UNPKG

@openfluke/isocard

Version:

Isomorphic Three.js + Jolt Physics JSON scene runner for browser and server (Node/Bun).

1,156 lines 88.4 kB
export class IsoCard { constructor(container, deps, opts = {}) { // Scene state this.isPreview = false; this.isServer = false; this.sceneConfig = {}; this.layers = {}; this.objects = []; // Selection / input this.selectedHelper = null; // Physics this.jolt = null; this.physicsSystem = null; this.bodyInterface = null; this.jInterface = null; this.dynamicObjects = []; this.isPhysicsRunning = false; this.time = 0; this.savedTransforms = new Map(); this.constraints = []; // Gravity & attractors this.gravityType = "uniform"; this.gravityStrength = 1000; this.attractors = []; // Camera save/lock this.cameraLocked = false; this.savedCameraState = null; this.actionsPerSecond = 1; this.lastActionTime = 0; // inject libs this.THREE = deps.THREE; this.OrbitControls = deps.OrbitControls ?? this.THREE.OrbitControls ?? window.OrbitControls; this.Stats = deps.Stats ?? window.Stats; this.jolt = deps.jolt ?? null; this.loadJolt = deps.joltInit ?? (deps.loadJolt ? () => deps.loadJolt("standard") : undefined) ?? (window.loadJolt ? () => window.loadJolt("standard") : undefined); if (!this.THREE) throw new Error("THREE.js not provided"); this.container = container; this.isPreview = !!opts.isPreview; this.isServer = !!opts.isServer; // base scene this.scene = new this.THREE.Scene(); this.gravityCenter = new this.THREE.Vector3(0, 0, 0); this.attractors = []; // default scene config this.sceneConfig = { background: null, fog: null, environment: null, gravity: { type: "uniform", vector: [0, -9.81, 0] }, camera: { position: [5, 5, 5], lookAt: [0, 0, 0], fov: 75, near: 0.1, far: 1000, locked: false, orbitTarget: [0, 0, 0], orbitEnabled: true, }, }; this.layers = { main: { visible: true, opacity: 1.0 } }; this.clock = new this.THREE.Clock(); this.dynamicObjects = []; if (!this.isServer) { // dimensions let width = container?.clientWidth || window.innerWidth; let height = container?.clientHeight || window.innerHeight; let aspect = width / height; if (width <= 0 || height <= 0 || !isFinite(aspect)) { width = window.innerWidth; height = window.innerHeight; aspect = width / height; } // camera/renderer this.camera = new this.THREE.PerspectiveCamera(75, aspect, 0.1, 1000); this.renderer = new this.THREE.WebGLRenderer({ antialias: true }); this.renderer.setSize(width, height); container.appendChild(this.renderer.domElement); // controls (prefer injected class) if (this.OrbitControls) { this.controls = new this.OrbitControls(this.camera, this.renderer.domElement); } this.camera.position.set(5, 5, 5); this.camera.lookAt(0, 0, 0); this.camera.updateProjectionMatrix(); // stats (optional) if (!this.isPreview && this.Stats) { this.stats = new this.Stats(); container.appendChild(this.stats.dom); } // input / picking this.selectedHelper = null; this.raycaster = new this.THREE.Raycaster(); this.mouse = new this.THREE.Vector2(); setTimeout(() => { this.renderer.domElement.addEventListener("click", this.onDocumentClick.bind(this), { capture: true }); }, 100); // resize this.resizeObserver = new ResizeObserver(this.onResize.bind(this)); this.resizeObserver.observe(container); window.addEventListener("resize", this.onResize.bind(this)); } // binders used elsewhere this.renderSync = this.renderSync.bind(this); } setOnSelectCallback(cb) { this.onSelectCallback = cb; } setOnObjectsChangeCallback(cb) { this.onObjectsChangeCallback = cb; } onDocumentClick(event) { if (this.isPreview) return; console.log("Click detected on canvas"); event.preventDefault(); const rect = this.renderer.domElement.getBoundingClientRect(); console.log("Canvas rect:", rect); this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; console.log("Mouse coords:", this.mouse.x, this.mouse.y); this.raycaster.setFromCamera(this.mouse, this.camera); const checkableObjects = this.objects .filter((o) => { const layer = o.config.layer || "main"; return (this.layers[layer] && this.layers[layer].visible && o.config.enabled !== false); }) .map((o) => o.threeObj); console.log("Checkable objects:", checkableObjects.length); const intersects = this.raycaster.intersectObjects(checkableObjects, true); console.log("Intersects:", intersects.length); if (intersects.length > 0) { let object = intersects[0].object; console.log("Intersected object:", object); while (object && !this.objects.some((o) => o.threeObj === object)) { object = object.parent; } if (object) { const found = this.objects.find((o) => o.threeObj === object); console.log("Found object:", found); if (found && this.onSelectCallback) { console.log("Calling onSelectCallback with ID:", found.id); this.onSelectCallback(found.id); } } } } selectObject(id) { if (this.selectedHelper) { this.scene.remove(this.selectedHelper); this.selectedHelper = null; } const item = this.objects.find((o) => o.id === id); if (item && item.threeObj.isMesh) { this.selectedHelper = new this.THREE.BoxHelper(item.threeObj, 0xffff00); this.scene.add(this.selectedHelper); } } addObject(config) { try { let addedObj = null; // Default to enabled if (config.enabled === undefined) { config.enabled = true; } // Default to main layer if (!config.layer) { config.layer = "main"; } // Create layer if it doesn't exist if (!this.layers[config.layer]) { this.layers[config.layer] = { visible: true, opacity: 0.5 }; } if (config.type === "scene") { if (config.background !== undefined) { this.scene.background = new this.THREE.Color(config.background); this.sceneConfig.background = config.background; } if (config.fog) { this.scene.fog = new this.THREE.Fog(config.fog.color || 0xffffff, config.fog.near || 1, config.fog.far || 1000); this.sceneConfig.fog = config.fog; } if (config.gravity) { this.setGravityConfig(config.gravity); } if (config.camera) { this.setCameraConfig(config.camera); } if (this.onObjectsChangeCallback) this.onObjectsChangeCallback(); return null; } else if (config.type === "light") { let light; switch (config.lightType) { case "directional": light = new this.THREE.DirectionalLight(config.color || 0xffffff, config.intensity || 1); if (config.castShadow) light.castShadow = true; break; case "ambient": light = new this.THREE.AmbientLight(config.color || 0x404040); break; case "point": light = new this.THREE.PointLight(config.color || 0xffffff, config.intensity || 1, config.distance || 100, config.decay || 1); break; case "spot": light = new this.THREE.SpotLight(config.color || 0xffffff, config.intensity || 1, config.distance || 100, config.angle || Math.PI / 3, config.penumbra || 0, config.decay || 1); break; case "hemisphere": light = new this.THREE.HemisphereLight(config.skyColor || 0xffffff, config.groundColor || 0x444444, config.intensity || 1); break; default: console.warn("Unsupported light type:", config.lightType); return null; } if (config.pos) light.position.fromArray(config.pos); if (config.target && light.target) { light.target.position.fromArray(config.target); this.scene.add(light.target); } addedObj = light; } else if (config.type === "helper") { let helper; switch (config.helperType) { case "grid": helper = new this.THREE.GridHelper(config.size || 10, config.divisions || 10, config.colorCenterLine || 0x444444, config.colorGrid || 0x888888); break; case "axes": helper = new this.THREE.AxesHelper(config.size || 5); break; default: console.warn("Unsupported helper type:", config.helperType); return null; } if (config.pos) helper.position.fromArray(config.pos); addedObj = helper; } else if (config.type === "mesh" || !config.type) { const geometry = this.createGeometry(config.shape || {}); if (!geometry) return null; const material = config.material ? this.createMaterialFromObj(config.material) : this.createMaterialFromObj({ type: "phong", color: config.color || 0xffffff, }); if (!material) return null; // Apply layer-based opacity const layerOpacity = this.layers[config.layer].opacity; if (layerOpacity < 1) { material.transparent = true; material.opacity = material.opacity * layerOpacity; } const mesh = new this.THREE.Mesh(geometry, material); if (config.pos) mesh.position.fromArray(config.pos); // Handle rotation properly - convert euler angles from degrees to radians if (config.euler) { const eulerRad = config.euler.map((deg) => (deg * Math.PI) / 180); mesh.rotation.set(eulerRad[0], eulerRad[1], eulerRad[2]); } else if (config.rot) { // Support legacy rot as quaternion array if (config.rot.length === 4) { mesh.quaternion.fromArray(config.rot); } else { // If rot is euler angles in radians mesh.rotation.set(config.rot[0], config.rot[1], config.rot[2]); } } if (config.scale) mesh.scale.fromArray(config.scale); // Apply enabled state mesh.visible = config.enabled && this.layers[config.layer].visible; // Cast/receive shadows if enabled if (config.castShadow) mesh.castShadow = true; if (config.receiveShadow) mesh.receiveShadow = true; addedObj = mesh; if (config.shape?.type === "sphere" && config.physics?.motionType === "static" && config.physics?.gravityStrength) { this.attractors.push({ position: addedObj.position.clone(), strength: config.physics.gravityStrength, }); console.log("Added attractor at", addedObj.position, "with strength", config.physics.gravityStrength); } } else if (config.type === "group") { const group = new this.THREE.Group(); if (config.pos) group.position.fromArray(config.pos); if (config.euler) { const eulerRad = config.euler.map((deg) => (deg * Math.PI) / 180); group.rotation.set(eulerRad[0], eulerRad[1], eulerRad[2]); } if (config.scale) group.scale.fromArray(config.scale); addedObj = group; } else { console.warn("Unsupported object type:", config.type); return null; } if (addedObj) { //const id = this.objects.length; const id = config.name; const newConfig = { ...config }; if (!newConfig.name) newConfig.name = `${config.shape?.type || config.type || "object"} ${id}`; // Store layer info addedObj.userData.layer = config.layer; addedObj.userData.enabled = config.enabled; //console.log(this.objects); this.objects.push({ id, threeObj: addedObj, config: newConfig }); this.scene.add(addedObj); if (this.onObjectsChangeCallback) this.onObjectsChangeCallback(); return id; } } catch (err) { console.error("Error adding object:", err); return null; } } replaceObject(id, newConfig) { const index = this.objects.findIndex((o) => o.id === id); if (index === -1) return false; const oldObj = this.objects[index]; // Remove old object from scene this.scene.remove(oldObj.threeObj); // Dispose of old geometry and materials if (oldObj.threeObj.geometry) oldObj.threeObj.geometry.dispose(); if (oldObj.threeObj.material) { if (Array.isArray(oldObj.threeObj.material)) { oldObj.threeObj.material.forEach((m) => m.dispose()); } else { oldObj.threeObj.material.dispose(); } } // Remove from objects array temporarily this.objects.splice(index, 1); // Add new object with same ID const tempObjects = this.objects; this.objects = this.objects.slice(0, index); // Create new object let addedObj = null; // Preserve the ID and ensure proper defaults if (newConfig.enabled === undefined) { newConfig.enabled = true; } if (!newConfig.layer) { newConfig.layer = "main"; } // Create the appropriate object type if (newConfig.type === "light") { let light; switch (newConfig.lightType) { case "directional": light = new this.THREE.DirectionalLight(newConfig.color || 0xffffff, newConfig.intensity || 1); if (newConfig.castShadow) light.castShadow = true; break; case "ambient": light = new this.THREE.AmbientLight(newConfig.color || 0x404040); break; case "point": light = new this.THREE.PointLight(newConfig.color || 0xffffff, newConfig.intensity || 1, newConfig.distance || 100, newConfig.decay || 1); break; case "spot": light = new this.THREE.SpotLight(newConfig.color || 0xffffff, newConfig.intensity || 1, newConfig.distance || 100, newConfig.angle || Math.PI / 3, newConfig.penumbra || 0, newConfig.decay || 1); break; case "hemisphere": light = new this.THREE.HemisphereLight(newConfig.skyColor || 0xffffff, newConfig.groundColor || 0x444444, newConfig.intensity || 1); break; default: console.warn("Unsupported light type:", newConfig.lightType); this.objects = [...this.objects, ...tempObjects.slice(index)]; return false; } if (newConfig.pos) light.position.fromArray(newConfig.pos); if (newConfig.target && light.target) { light.target.position.fromArray(newConfig.target); this.scene.add(light.target); } addedObj = light; } else if (newConfig.type === "helper") { let helper; switch (newConfig.helperType) { case "grid": helper = new this.THREE.GridHelper(newConfig.size || 10, newConfig.divisions || 10, newConfig.colorCenterLine || 0x444444, newConfig.colorGrid || 0x888888); break; case "axes": helper = new this.THREE.AxesHelper(newConfig.size || 5); break; default: console.warn("Unsupported helper type:", newConfig.helperType); this.objects = [...this.objects, ...tempObjects.slice(index)]; return false; } if (newConfig.pos) helper.position.fromArray(newConfig.pos); addedObj = helper; } else if (newConfig.type === "mesh" || !newConfig.type) { const geometry = this.createGeometry(newConfig.shape || {}); if (!geometry) { this.objects = [...this.objects, ...tempObjects.slice(index)]; return false; } const material = newConfig.material ? this.createMaterialFromObj(newConfig.material) : this.createMaterialFromObj({ type: "phong", color: newConfig.color || 0xffffff, }); if (!material) { geometry.dispose(); this.objects = [...this.objects, ...tempObjects.slice(index)]; return false; } // Apply layer-based opacity if (this.layers[newConfig.layer]) { const layerOpacity = this.layers[newConfig.layer].opacity; if (layerOpacity < 1) { material.transparent = true; material.opacity = material.opacity * layerOpacity; } } const mesh = new this.THREE.Mesh(geometry, material); if (newConfig.pos) mesh.position.fromArray(newConfig.pos); // Handle rotation properly - convert euler angles from degrees to radians if (newConfig.euler) { const eulerRad = newConfig.euler.map((deg) => (deg * Math.PI) / 180); mesh.rotation.set(eulerRad[0], eulerRad[1], eulerRad[2]); } else if (newConfig.rot) { if (newConfig.rot.length === 4) { mesh.quaternion.fromArray(newConfig.rot); } else { mesh.rotation.set(newConfig.rot[0], newConfig.rot[1], newConfig.rot[2]); } } if (newConfig.scale) mesh.scale.fromArray(newConfig.scale); // Apply enabled state and layer visibility mesh.visible = newConfig.enabled && (!this.layers[newConfig.layer] || this.layers[newConfig.layer].visible); if (newConfig.castShadow) mesh.castShadow = true; if (newConfig.receiveShadow) mesh.receiveShadow = true; addedObj = mesh; } else if (newConfig.type === "group") { const group = new this.THREE.Group(); if (newConfig.pos) group.position.fromArray(newConfig.pos); if (newConfig.euler) { const eulerRad = newConfig.euler.map((deg) => (deg * Math.PI) / 180); group.rotation.set(eulerRad[0], eulerRad[1], eulerRad[2]); } if (newConfig.scale) group.scale.fromArray(newConfig.scale); addedObj = group; } if (addedObj) { // Store layer info addedObj.userData.layer = newConfig.layer; addedObj.userData.enabled = newConfig.enabled; // Preserve the name or create new one if (!newConfig.name) { newConfig.name = `${newConfig.shape?.type || newConfig.type || "object"} ${id}`; } // Re-insert at the same position with the same ID this.objects.push({ id, threeObj: addedObj, config: newConfig }); this.objects = [...this.objects, ...tempObjects.slice(index)]; this.scene.add(addedObj); // Re-select if it was selected if (this.selectedHelper) { this.selectObject(id); } if (this.onObjectsChangeCallback) this.onObjectsChangeCallback(); return true; } // If failed, restore the objects array this.objects = [...this.objects, ...tempObjects.slice(index)]; return false; } updateObject(id, updates) { const item = this.objects.find((o) => o.id === id); if (!item) return; Object.assign(item.config, updates); const obj = item.threeObj; if (updates.pos) obj.position.fromArray(item.config.pos); // Handle rotation properly - convert euler angles from degrees to radians if (updates.euler) { const eulerRad = item.config.euler.map((deg) => (deg * Math.PI) / 180); obj.rotation.set(eulerRad[0], eulerRad[1], eulerRad[2]); } else if (updates.rot) { // Support legacy rot as quaternion array if (item.config.rot.length === 4) { obj.quaternion.fromArray(item.config.rot); } else { // If rot is euler angles in radians obj.rotation.set(item.config.rot[0], item.config.rot[1], item.config.rot[2]); } } if (updates.scale) obj.scale.fromArray(item.config.scale); // Handle layer changes if (updates.layer !== undefined) { obj.userData.layer = updates.layer; if (!this.layers[updates.layer]) { this.layers[updates.layer] = { visible: true, opacity: 0.5 }; } // Update visibility based on new layer obj.visible = item.config.enabled !== false && this.layers[updates.layer].visible; // Update opacity if it's a mesh if (obj.isMesh && obj.material) { const layerOpacity = this.layers[updates.layer].opacity; if (layerOpacity < 1) { obj.material.transparent = true; obj.material.opacity = (item.config.material?.opacity || 1) * layerOpacity; } else { obj.material.opacity = item.config.material?.opacity || 1; } } } // Handle enabled state changes if (updates.enabled !== undefined) { obj.userData.enabled = updates.enabled; const layer = item.config.layer || "main"; obj.visible = updates.enabled && this.layers[layer].visible; } const recreateGeo = "shape" in updates; const recreateMat = "material" in updates; if (recreateGeo && obj.isMesh) { const newGeo = this.createGeometry(item.config.shape || {}); if (newGeo) { obj.geometry.dispose(); obj.geometry = newGeo; } } if (recreateMat && obj.isMesh) { const newMat = this.createMaterialFromObj(item.config.material || { type: "phong" }); if (newMat) { // Apply layer opacity const layer = item.config.layer || "main"; const layerOpacity = this.layers[layer].opacity; if (layerOpacity < 1) { newMat.transparent = true; newMat.opacity = newMat.opacity * layerOpacity; } obj.material.dispose(); obj.material = newMat; } } // For lights if (item.config.type === "light") { if (updates.color !== undefined) obj.color.set(item.config.color); if (updates.intensity !== undefined) obj.intensity = item.config.intensity; } if (this.onObjectsChangeCallback) this.onObjectsChangeCallback(); } removeObject(id) { const index = this.objects.findIndex((o) => o.id === id); if (index > -1) { const obj = this.objects[index].threeObj; this.scene.remove(obj); // Dispose of geometry and materials if (obj.geometry) obj.geometry.dispose(); if (obj.material) { if (Array.isArray(obj.material)) { obj.material.forEach((m) => m.dispose()); } else { obj.material.dispose(); } } if (this.selectedHelper && this.objects[index].threeObj === this.selectedHelper.object) { this.scene.remove(this.selectedHelper); this.selectedHelper = null; } this.objects.splice(index, 1); if (this.onObjectsChangeCallback) this.onObjectsChangeCallback(); } } setLayerVisibility(layerId, visible) { if (!this.layers[layerId]) { this.layers[layerId] = { visible: true, opacity: 0.5 }; } this.layers[layerId].visible = visible; // Update all objects in this layer this.objects.forEach((obj) => { const objLayer = obj.config.layer || "main"; if (objLayer === layerId) { obj.threeObj.visible = visible && obj.config.enabled !== false; } }); } updateLayerOpacity(layerId, opacity) { if (!this.layers[layerId]) { this.layers[layerId] = { visible: true, opacity: 0.5 }; } this.layers[layerId].opacity = opacity; // Update all mesh materials in this layer this.objects.forEach((obj) => { const objLayer = obj.config.layer || "main"; if (objLayer === layerId && obj.threeObj.isMesh && obj.threeObj.material) { const baseMaterialOpacity = obj.config.material?.opacity || 1; if (opacity < 1) { obj.threeObj.material.transparent = true; obj.threeObj.material.opacity = baseMaterialOpacity * opacity; } else { obj.threeObj.material.opacity = baseMaterialOpacity; if (baseMaterialOpacity === 1) { obj.threeObj.material.transparent = false; } } } }); } /** * Set camera configuration */ setCameraConfig(config) { if (!this.camera || !this.controls) return; // Update stored config this.sceneConfig.camera = { ...this.sceneConfig.camera, ...config }; // Apply position if (config.position) { this.camera.position.set(...config.position); } // Apply lookAt if (config.lookAt) { this.camera.lookAt(...config.lookAt); } // Apply FOV if (config.fov !== undefined) { this.camera.fov = config.fov; this.camera.updateProjectionMatrix(); } // Apply near/far planes if (config.near !== undefined) { this.camera.near = config.near; this.camera.updateProjectionMatrix(); } if (config.far !== undefined) { this.camera.far = config.far; this.camera.updateProjectionMatrix(); } // Apply orbit controls target if (config.orbitTarget && this.controls) { this.controls.target.set(...config.orbitTarget); this.controls.update(); } // Apply orbit controls enabled state if (config.orbitEnabled !== undefined && this.controls) { this.controls.enabled = config.orbitEnabled && !this.cameraLocked; } // Apply lock state if (config.locked !== undefined) { this.setCameraLocked(config.locked); } } /** * Get current camera configuration */ getCameraConfig() { if (!this.camera || !this.controls) { return this.sceneConfig.camera; } return { position: this.camera.position.toArray(), lookAt: this.controls.target.toArray(), fov: this.camera.fov, near: this.camera.near, far: this.camera.far, locked: this.cameraLocked, orbitTarget: this.controls.target.toArray(), orbitEnabled: this.controls.enabled, }; } /** * Lock or unlock camera controls */ setCameraLocked(locked) { this.cameraLocked = locked; if (this.controls) { this.controls.enabled = !locked && this.sceneConfig.camera.orbitEnabled; } this.sceneConfig.camera.locked = locked; } /** * Check if camera is locked */ isCameraLocked() { return this.cameraLocked; } /** * Save current camera state */ saveCameraState() { if (!this.camera || !this.controls) return; this.savedCameraState = { position: this.camera.position.clone(), rotation: this.camera.rotation.clone(), orbitTarget: this.controls.target.clone(), }; } /** * Restore saved camera state */ restoreCameraState() { if (!this.savedCameraState || !this.camera || !this.controls) return; this.camera.position.copy(this.savedCameraState.position); this.camera.rotation.copy(this.savedCameraState.rotation); this.controls.target.copy(this.savedCameraState.orbitTarget); this.controls.update(); } /** * Look at specific object */ lookAtObject(objectId) { const obj = this.objects.find((o) => o.id === objectId); if (!obj || !obj.threeObj) return; const box = new this.THREE.Box3().setFromObject(obj.threeObj); const center = box.getCenter(new this.THREE.Vector3()); const size = box.getSize(new this.THREE.Vector3()).length(); // Position camera to view the object this.camera.position.set(center.x + size * 1.5, center.y + size * 1.5, center.z + size * 1.5); this.camera.lookAt(center); if (this.controls) { this.controls.target = center; this.controls.update(); } // Update stored config this.sceneConfig.camera.position = this.camera.position.toArray(); this.sceneConfig.camera.lookAt = center.toArray(); this.sceneConfig.camera.orbitTarget = center.toArray(); } /** * Animate camera to position */ animateCameraTo(targetPosition, targetLookAt, duration = 1000) { if (!this.camera || !this.controls) return; const startPosition = this.camera.position.clone(); const startTarget = this.controls.target.clone(); const endPosition = new this.THREE.Vector3(...targetPosition); const endTarget = new this.THREE.Vector3(...targetLookAt); const startTime = Date.now(); const animate = () => { const elapsed = Date.now() - startTime; const progress = Math.min(elapsed / duration, 1); // Use easing function const eased = 1 - Math.pow(1 - progress, 3); // Cubic ease-out this.camera.position.lerpVectors(startPosition, endPosition, eased); this.controls.target.lerpVectors(startTarget, endTarget, eased); this.controls.update(); if (progress < 1) { requestAnimationFrame(animate); } else { // Update stored config this.sceneConfig.camera.position = targetPosition; this.sceneConfig.camera.lookAt = targetLookAt; this.sceneConfig.camera.orbitTarget = targetLookAt; } }; animate(); } exportScene() { const sceneData = []; // Export scene config including camera if (this.sceneConfig.background !== null || this.sceneConfig.camera) { sceneData.push({ type: "scene", background: this.sceneConfig.background, fog: this.sceneConfig.fog, gravity: this.sceneConfig.gravity, camera: this.getCameraConfig(), // Get current camera state }); } // ... rest of existing exportScene code ... this.objects.forEach((obj) => { const config = { ...obj.config }; if (obj.threeObj.isMesh || obj.threeObj.isGroup) { config.pos = obj.threeObj.position.toArray(); config.euler = [ (obj.threeObj.rotation.x * 180) / Math.PI, (obj.threeObj.rotation.y * 180) / Math.PI, (obj.threeObj.rotation.z * 180) / Math.PI, ]; config.scale = obj.threeObj.scale.toArray(); } if (obj.config.physics) { config.physics = obj.config.physics; } sceneData.push(config); }); return sceneData; } interpretJSON(jsonString) { // Pre-process to convert hex 0x values to decimal jsonString = jsonString.replace(/:\s*0x([0-9a-fA-F]+)/g, (match, hex) => `: ${parseInt(hex, 16)}`); let sceneData; try { sceneData = JSON.parse(jsonString); } catch (e) { console.error("Invalid JSON:", e); return []; } if (!Array.isArray(sceneData)) { console.warn("JSON should be an array of objects"); return []; } // Store objects that need physics applied const physicsObjects = []; sceneData.forEach((config) => { const objId = this.addObject(config); // If this object has physics config and Jolt is initialized, store for later application if (objId !== null && config.physics && this.jolt && this.jInterface) { physicsObjects.push({ id: objId, physicsConfig: config.physics }); } }); // Apply physics to objects that had physics config if (physicsObjects.length > 0 && this.jolt && this.jInterface) { console.log(`Applying physics to ${physicsObjects.length} objects from loaded scene`); physicsObjects.forEach(({ id, physicsConfig }) => { this.convertObjectToDynamic(id, physicsConfig); }); } return this.objects.map((o) => o.threeObj); } async initializeScenePhysics() { if (!this.jolt || !this.jInterface) { console.log("Initializing Jolt physics..."); await this.setupJOLT(); } // Create floor this.createFloor(); // Apply physics to objects that have physics config this.objects.forEach((obj) => { if (obj.config.physics) { this.convertObjectToDynamic(obj.id, obj.config.physics); } }); console.log("Scene physics initialized"); } createGeometry(shape) { let geometry = null; switch (shape.type) { case "box": geometry = new this.THREE.BoxGeometry(shape.width || 1, shape.height || 1, shape.depth || 1, shape.widthSegments || 1, shape.heightSegments || 1, shape.depthSegments || 1); break; case "sphere": geometry = new this.THREE.SphereGeometry(shape.radius || 1, shape.widthSegments || 32, shape.heightSegments || 16, shape.phiStart || 0, shape.phiLength || Math.PI * 2, shape.thetaStart || 0, shape.thetaLength || Math.PI); break; case "plane": geometry = new this.THREE.PlaneGeometry(shape.width || 1, shape.height || 1, shape.widthSegments || 1, shape.heightSegments || 1); break; case "cylinder": geometry = new this.THREE.CylinderGeometry(shape.radiusTop || 1, shape.radiusBottom || 1, shape.height || 1, shape.radialSegments || 32, shape.heightSegments || 1, shape.openEnded || false, shape.thetaStart || 0, shape.thetaLength || Math.PI * 2); break; case "cone": geometry = new this.THREE.ConeGeometry(shape.radius || 1, shape.height || 1, shape.radialSegments || 32, shape.heightSegments || 1, shape.openEnded || false, shape.thetaStart || 0, shape.thetaLength || Math.PI * 2); break; case "torus": geometry = new this.THREE.TorusGeometry(shape.radius || 1, shape.tube || 0.4, shape.radialSegments || 16, shape.tubularSegments || 100, shape.arc || Math.PI * 2); break; case "circle": geometry = new this.THREE.CircleGeometry(shape.radius || 1, shape.segments || 32, shape.thetaStart || 0, shape.thetaLength || Math.PI * 2); break; case "ring": geometry = new this.THREE.RingGeometry(shape.innerRadius || 0.5, shape.outerRadius || 1, shape.thetaSegments || 32, shape.phiSegments || 1, shape.thetaStart || 0, shape.thetaLength || Math.PI * 2); break; case "dodecahedron": geometry = new this.THREE.DodecahedronGeometry(shape.radius || 1, shape.detail || 0); break; case "icosahedron": geometry = new this.THREE.IcosahedronGeometry(shape.radius || 1, shape.detail || 0); break; case "octahedron": geometry = new this.THREE.OctahedronGeometry(shape.radius || 1, shape.detail || 0); break; case "tetrahedron": geometry = new this.THREE.TetrahedronGeometry(shape.radius || 1, shape.detail || 0); break; case "torusknot": geometry = new this.THREE.TorusKnotGeometry(shape.radius || 1, shape.tube || 0.4, shape.tubularSegments || 64, shape.radialSegments || 8, shape.p || 2, shape.q || 3); break; case "capsule": geometry = new this.THREE.CapsuleGeometry(shape.radius || 0.5, shape.height || 1, shape.capSegments || 16, shape.radialSegments || 32); break; default: console.warn("Unsupported shape:", shape.type); return null; } return geometry; } createMaterialFromObj(materialObj) { let material = null; const type = materialObj.type?.toLowerCase() || "phong"; const commonProps = { side: materialObj.side !== undefined ? materialObj.side : this.THREE.FrontSide, transparent: materialObj.transparent || false, opacity: materialObj.opacity !== undefined ? materialObj.opacity : 1, wireframe: materialObj.wireframe || false, visible: materialObj.visible !== undefined ? materialObj.visible : true, map: materialObj.map || null, }; switch (type) { case "basic": material = new this.THREE.MeshBasicMaterial({ color: materialObj.color || 0xffffff, ...commonProps, }); break; case "lambert": material = new this.THREE.MeshLambertMaterial({ color: materialObj.color || 0xffffff, emissive: materialObj.emissive || 0x000000, ...commonProps, }); break; case "phong": material = new this.THREE.MeshPhongMaterial({ color: materialObj.color || 0xffffff, specular: materialObj.specular || 0x111111, shininess: materialObj.shininess || 30, emissive: materialObj.emissive || 0x000000, ...commonProps, }); break; case "standard": material = new this.THREE.MeshStandardMaterial({ color: materialObj.color || 0xffffff, roughness: materialObj.roughness !== undefined ? materialObj.roughness : 0.5, metalness: materialObj.metalness !== undefined ? materialObj.metalness : 0.5, emissive: materialObj.emissive || 0x000000, envMapIntensity: materialObj.envMapIntensity || 1, ...commonProps, }); break; case "physical": material = new this.THREE.MeshPhysicalMaterial({ color: materialObj.color || 0xffffff, roughness: materialObj.roughness !== undefined ? materialObj.roughness : 0.5, metalness: materialObj.metalness !== undefined ? materialObj.metalness : 0.5, emissive: materialObj.emissive || 0x000000, clearcoat: materialObj.clearcoat || 0, clearcoatRoughness: materialObj.clearcoatRoughness || 0, sheen: materialObj.sheen || 0, ...commonProps, }); break; case "toon": material = new this.THREE.MeshToonMaterial({ color: materialObj.color || 0xffffff, gradientMap: materialObj.gradientMap || null, ...commonProps, }); break; case "normal": material = new this.THREE.MeshNormalMaterial({ ...commonProps, }); break; case "depth": material = new this.THREE.MeshDepthMaterial({ ...commonProps, }); break; case "linebasic": material = new this.THREE.LineBasicMaterial({ color: materialObj.color || 0xffffff, linewidth: materialObj.linewidth || 1, ...commonProps, }); break; case "linedashed": material = new this.THREE.LineDashedMaterial({ color: materialObj.color || 0xffffff, linewidth: materialObj.linewidth || 1, scale: materialObj.scale || 1, dashSize: materialObj.dashSize || 3, gapSize: materialObj.gapSize || 1, ...commonProps, }); break; case "points": material = new this.THREE.PointsMaterial({ color: materialObj.color || 0xffffff, size: materialObj.size || 1, sizeAttenuation: materialObj.sizeAttenuation !== undefined ? materialObj.sizeAttenuation : true, ...commonProps, }); break; case "sprite": material = new this.THREE.SpriteMaterial({ color: materialObj.color || 0xffffff, ...commonProps, }); break; case "shadow": material = new this.THREE.ShadowMaterial({ ...commonProps, }); break; default: console.warn("Unsupported material type:", type); return null; } return material; } onResize() { const width = this.container.clientWidth; const height = this.container.clientHeight; console.log("Resizing to:", width, height); if (width > 0 && height > 0) { this.camera.aspect = width / height; this.camera.updateProjectionMatrix(); this.renderer.setSize(width, height); } else { console.warn("Skipping resize due to invalid dimensions:", width, height); } } update() { // Update animations or physics here } renderScene() { this.renderer.render(this.scene, this.camera); } animate() { //console.log("starting animate"); requestAnimationFrame(this.animate.bind(this)); // Only update physics if it's initialized AND running if (this.jInterface && this.isPhysicsRunning) { // Don't go below 30 Hz to prevent spiral of death var deltaTime = this.clock.getDelta(); deltaTime = Math.min(deltaTime, 1.0 / 30.0); if (this.gravityType === "radial") { const center = this.unwrapRVec3(this.gravityCenter); this.dynamicObjects.forEach((obj) => { if (obj.userData.body) { const body = obj.userData.body; const pos = body.GetPosition(); // RVec3 const dir = pos.Sub(center); // Vec3 (direction from center to body) const distSq = dir.LengthSq(); if (distSq > 0.0001) { // Avoid division by zero const dist = Math.sqrt(distSq); const unitDir = dir.Normalized(); // Vec3 towards center? Wait, dir is from center to body, so for attraction, negate const forceMag = this.gravityStrength / distSq; const invMass = body.GetMotionProperties().GetInverseMass(); const mass = invMass > 0 ? 1 / invMass : 0; if (mass > 0) { const force = unitDir.Mul(-forceMag * mass); // Negative for attraction (towards center) body.AddForce(force); } } } }); } // Step the physics world var numSteps = deltaTime > 1.0 / 55.0 ? 2 : 1; this.jInterface.Step(deltaTime, numSteps); // Update dynamic object transforms from physics for (let i = 0, il = this.dynamicObjects.length; i < il; i++) { let objThree = this.dynamicObjects[i]; let body = objThree.userData.body; if (body) { objThree.position.copy(this.wrapVec3(body.GetPosition())); objThree.quaternion.copy(this.wrapQuat(body.GetRotation())); } } // In the animate() method, add this code inside the if (this.jInterface && this.isPhysicsRunning) block, after this.time += deltaTime; const interval = 1 / this.actionsPerSecond; if (this.time - this.lastActionTime >= interval) { // this.applyPeriodicActions(); this.lastActionTime = this.time; } this.time += deltaTime; } if (!this.isServer) { this.controls.update(); } if (this.stats) this.stats.update(); if (this.selectedHelper) this.selectedHelper.update(); if (!this.isServer) { this.renderScene(); } } startAnimate() { this.animate(); } async setupJOLT() { if (this.jolt && this.jInterface && this.bodyInter