@openfluke/isocard
Version: 
Isomorphic Three.js + Jolt Physics JSON scene runner for browser and server (Node/Bun).
1,156 lines • 88.4 kB
JavaScript
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