diff options
| author | Agando <roduccomm@gmail.com> | 2026-07-09 06:00:32 +0200 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-07-09 06:00:32 +0200 |
| commit | cf7461ffeb4f53f3f50c9c692a2e4fdcecf17e82 (patch) | |
| tree | e7780cd41271381cf108c9c81d71a3cb65cfd555 /modules/ext/three.js | |
| parent | ea4d09fb5ccba12ed774fcf7c913edfa1e6a851a (diff) | |
auto: 2026-07-09_06:00 changes
Diffstat (limited to 'modules/ext/three.js')
| -rw-r--r-- | modules/ext/three.js | 1411 |
1 files changed, 1330 insertions, 81 deletions
diff --git a/modules/ext/three.js b/modules/ext/three.js index d72afa1..2fc5284 100644 --- a/modules/ext/three.js +++ b/modules/ext/three.js @@ -1,7 +1,17 @@ -// newOS4 Three.js 3D Module (v4.5.82) +// newOS4 Three.js 3D Module (v4.5.86) // Commands: three <subcommand> [...] // Two modes: top panel (default) + fullscreen with terminal overlay // Lazy-loads Three.js from /downloads/ on first use. +// +// Features: +// - 3D Design: box, sphere, cylinder, cone, torus, torusKnot, text, particles, line +// - Transforms: position, rotation, scale, color, material, duplicate, group +// - Animations: rotate, bounce, float, orbit, keyframe, timeline +// - Avatars: load, pose, look-at, speak, emotion +// - Interaction: click, hover, follow, path +// - Import/Export: GLTF, GLB, HTML +// - Presets: solar system, grid, particle field +// - Mindmap 3D rendering class ThreeModule { constructor() { @@ -11,11 +21,12 @@ class ThreeModule { }; this.terminal = null; this._loaded = false; + this._loadingPromise = null; this._scene = null; this._camera = null; this._renderer = null; this._controls = null; - this._objects = []; + this._objects = new Map(); this._animId = null; this._container = null; this._closeBtn = null; @@ -29,6 +40,31 @@ class ThreeModule { this._savedInputStyles = null; this._savedHeaderStyles = null; this._escHandler = null; + this._animations = {}; // id -> { type, speed, active } + this._timeline = []; // [{ time, id, props }] + this._timelinePlaying = false; + this._timelineTime = 0; + this._clickHandlers = {}; // id -> command string + this._hoverHandlers = {}; // id -> { enterColor, leaveColor } + this._followTargets = {}; // id -> targetId + this._paths = {}; // id -> [{x,y,z}, ...] + this._pathProgress = {}; // id -> index + this._avatar = null; // current avatar object + this._avatarMood = 'neutral'; + this._speechBubble = null; + this._speechTimeout = null; + this._raycaster = null; + this._mouse = null; + this._clickableObjects = []; + this._clock = null; + this._animTime = 0; + this._importCache = {}; // URL -> loaded data + this._bloomEnabled = true; + this._composer = null; + this._transformControls = null; + this._selectedId = null; + this._undoStack = []; + this._redoStack = []; } requirements() { @@ -46,12 +82,17 @@ class ThreeModule { getHelpInfo() { return { - 'three': 'three <subcommand> [...] — 3D scene builder (Three.js). Subcommands: help, scene, fullscreen, hide, mode, add, camera, orbit, clear, list, remove, mindmap, export, preset, bgcolor. Run `three help` for full list.' + 'three': 'three <subcommand> [...] — 3D scene builder (Three.js). Run `three help` for full list.' }; } _listSubcommands() { - return ['help', 'scene', 'fullscreen', 'hide', 'mode', 'add', 'camera', 'orbit', 'clear', 'list', 'remove', 'mindmap', 'export', 'preset', 'bgcolor']; + return ['help', 'scene', 'fullscreen', 'hide', 'mode', 'add', 'camera', 'orbit', + 'pos', 'rotate', 'scale', 'color', 'material', 'duplicate', 'group', 'ungroup', + 'animate', 'stop', 'timeline', 'click', 'hover', 'follow', 'unfollow', 'path', + 'avatar', 'speak', 'emotion', + 'clear', 'list', 'remove', 'mindmap', 'export', 'import', 'preset', 'bgcolor', 'bloom', 'select', 'gizmo', 'undo', 'redo', 'rename', 'batch', + 'snapshot']; } registerCommands() { @@ -62,20 +103,27 @@ class ThreeModule { async _ensureLoaded() { if (this._loaded) return true; + if (this._loadingPromise) return this._loadingPromise; if (typeof THREE !== 'undefined') { this._THREE = THREE; this._loaded = true; return true; } + this._loadingPromise = this._doLoad(); + return this._loadingPromise; + } + async _doLoad() { this.terminal.print('📦 Loading Three.js (590KB)...', 'info'); try { await this._loadScript('downloads/three.min.js'); await this._loadScript('downloads/OrbitControls.js'); this._THREE = THREE; this._loaded = true; + this._loadingPromise = null; this.terminal.print('✅ Three.js loaded', 'success'); return true; } catch (e) { + this._loadingPromise = null; this.terminal.print('❌ Failed to load Three.js: ' + e.message, 'error'); return false; } @@ -85,8 +133,12 @@ class ThreeModule { return new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = url; - s.onload = () => { s.remove(); resolve(); }; - s.onerror = () => { s.remove(); reject(new Error('Failed to load ' + url)); }; + const timeout = setTimeout(() => { + s.remove(); + reject(new Error('Timeout loading ' + url)); + }, 30000); + s.onload = () => { clearTimeout(timeout); s.remove(); resolve(); }; + s.onerror = () => { clearTimeout(timeout); s.remove(); reject(new Error('Failed to load ' + url)); }; document.head.appendChild(s); }); } @@ -96,7 +148,6 @@ class ThreeModule { _createContainer() { if (this._container) return; - // Main container this._container = document.createElement('div'); this._container.id = 'three-canvas-container'; this._container.style.cssText = ` @@ -109,7 +160,6 @@ class ThreeModule { `; document.body.appendChild(this._container); - // Close button this._closeBtn = document.createElement('button'); this._closeBtn.id = 'three-close-btn'; this._closeBtn.textContent = '✕'; @@ -134,7 +184,6 @@ class ThreeModule { this._closeBtn.addEventListener('click', () => this._hideContainer()); document.body.appendChild(this._closeBtn); - // Escape key handler this._escHandler = (e) => { if (e.key === 'Escape' && this._visible) { this._hideContainer(); @@ -147,8 +196,6 @@ class ThreeModule { if (!this._container) return; this._mode = mode || this._mode; this._visible = true; - - // Show close button this._closeBtn.style.display = 'block'; if (this._mode === 'top') { @@ -161,7 +208,6 @@ class ThreeModule { } _applyTopPanel() { - // 3D container: top 50vh this._container.style.cssText = ` position: fixed; top: 0; left: 0; @@ -171,7 +217,6 @@ class ThreeModule { display: block; `; - // Terminal: push down const term = document.getElementById('terminal-container'); if (term) { if (!this._savedTerminalStyles) { @@ -181,7 +226,6 @@ class ThreeModule { term.style.height = '50vh'; } - // Input area: stays at bottom const inputArea = document.getElementById('input-area'); if (inputArea) { if (!this._savedInputStyles) { @@ -189,12 +233,10 @@ class ThreeModule { } } - // Resize renderer this._resizeRenderer(window.innerWidth, window.innerHeight / 2); } _applyFullscreen() { - // 3D container: full viewport this._container.style.cssText = ` position: fixed; top: 0; left: 0; @@ -204,7 +246,6 @@ class ThreeModule { display: block; `; - // Terminal: float as overlay const term = document.getElementById('terminal-container'); if (term) { if (!this._savedTerminalStyles) { @@ -227,7 +268,6 @@ class ThreeModule { `; } - // Input area: fixed at bottom const inputArea = document.getElementById('input-area'); if (inputArea) { if (!this._savedInputStyles) { @@ -245,7 +285,6 @@ class ThreeModule { `; } - // Header: hide const header = document.querySelector('header'); if (header) { if (!this._savedHeaderStyles) { @@ -297,7 +336,6 @@ class ThreeModule { this._container.style.pointerEvents = 'none'; if (this._closeBtn) this._closeBtn.style.display = 'none'; - // Pause animation (don't cancel — scene stays intact) if (this._animId) { cancelAnimationFrame(this._animId); this._animId = null; @@ -317,7 +355,26 @@ class ThreeModule { // ── Scene management ── _createScene() { + // Dispose existing GPU resources before creating new ones + if (this._renderer) { + this._stopLoop(); + if (this._controls) { + this._controls.dispose(); + this._controls = null; + } + if (this._webglLostHandler) { + this._renderer.domElement.removeEventListener('webglcontextlost', this._webglLostHandler); + } + if (this._webglRestoredHandler) { + this._renderer.domElement.removeEventListener('webglcontextrestored', this._webglRestoredHandler); + } + this._renderer.dispose(); + this._renderer = null; + if (this._container) this._container.innerHTML = ''; + } + this._disposeAllObjects(); const T = this._THREE; + this._scene = new T.Scene(); this._scene.background = new T.Color(this._backgroundColor); @@ -348,6 +405,9 @@ class ThreeModule { this._controls.minDistance = 2; this._controls.maxDistance = 500; + // TransformControls gizmo + this._initTransformControls(); + // Lights const ambient = new T.AmbientLight(0x404060); this._scene.add(ambient); @@ -363,7 +423,7 @@ class ThreeModule { const grid = new T.GridHelper(30, 20, 0x00ff88, 0x333355); this._scene.add(grid); - // Resize handler — remove old first to prevent leak + // Resize handler if (window._threeResizeHandler) { window.removeEventListener('resize', window._threeResizeHandler); } @@ -374,21 +434,242 @@ class ThreeModule { } else { this._resizeRenderer(window.innerWidth, window.innerHeight); } + if (this._composer) { + this._composer.setSize( + this._mode === 'top' ? window.innerWidth : window.innerWidth, + this._mode === 'top' ? window.innerHeight / 2 : window.innerHeight + ); + } } }; window.addEventListener('resize', window._threeResizeHandler); - this._objects = []; + // Raycaster for click/hover + this._raycaster = new T.Raycaster(); + this._mouse = new T.Vector2(); + this._clock = new T.Clock(); + + // Bloom post-processing + this._initBloom(); + + // Click/mousemove event listeners for interaction + const dom = this._renderer.domElement; + dom.style.pointerEvents = 'auto'; + dom.addEventListener('click', (event) => { + if (!this._raycaster || !this._camera || this._clickableObjects.length === 0) return; + const rect = dom.getBoundingClientRect(); + this._mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; + this._mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; + this._raycaster.setFromCamera(this._mouse, this._camera); + const meshes = this._clickableObjects.map(id => { + const obj = this._objects.get(id); + return obj ? obj.mesh : null; + }).filter(Boolean); + const intersects = this._raycaster.intersectObjects(meshes, true); + if (intersects.length > 0) { + // Walk up to find the tracked object + let hit = intersects[0].object; + const entry = this._objects.find(o => o.mesh === hit); + if (entry && this._clickHandlers[entry.id]) { + this.terminal.executeCommand(this._clickHandlers[entry.id]); + } + } + }); + dom.addEventListener('mousemove', (event) => { + if (!this._raycaster || !this._camera || Object.keys(this._hoverHandlers).length === 0) return; + const rect = dom.getBoundingClientRect(); + this._mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; + this._mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; + this._raycaster.setFromCamera(this._mouse, this._camera); + const meshes = this._objects.filter(o => this._hoverHandlers[o.id]).map(o => o.mesh).filter(Boolean); + const intersects = this._raycaster.intersectObjects(meshes, true); + // Reset all hovered objects to leave color + for (const [hid, h] of Object.entries(this._hoverHandlers)) { + const ho = this._objects.get(hid); + if (ho && ho.mesh && ho.mesh.material && ho.mesh.material.color) { + ho.mesh.material.color.setHex(h.origColor != null ? h.origColor : 0x818cf8); + } + } + if (intersects.length > 0) { + let hit = intersects[0].object; + const entry = this._objects.find(o => o.mesh === hit); + if (entry && this._hoverHandlers[entry.id]) { + try { + hit.material.color.set(this._hoverHandlers[entry.id].enterColor); + } catch (e) {} + } + } + }); + + this._objects = new Map(); this._idCounter = 0; + this._animations = {}; + this._clickHandlers = {}; + this._hoverHandlers = {}; + this._followTargets = {}; + this._paths = {}; + this._pathProgress = {}; + this._clickableObjects = []; + this._avatar = null; + this._speechBubble = null; + } + + async _initTransformControls() { + try { + if (typeof THREE.TransformControls === 'undefined') { + await this._loadScript('downloads/TransformControls.js'); + } + const T = this._THREE; + this._transformControls = new T.TransformControls(this._camera, this._renderer.domElement); + this._transformControls.addEventListener('dragging-changed', (event) => { + this._controls.enabled = !event.value; + }); + this._scene.add(this._transformControls); + } catch (e) { + this.terminal?.print('TransformControls not available: ' + e.message, 'warning'); + this._transformControls = null; + } + } + + async _initBloom() { + try { + if (typeof THREE.EffectComposer === 'undefined') { + await this._loadScript('downloads/CopyShader.js'); + await this._loadScript('downloads/EffectComposer.js'); + await this._loadScript('downloads/ShaderPass.js'); + await this._loadScript('downloads/RenderPass.js'); + await this._loadScript('downloads/LuminosityHighPassShader.js'); + await this._loadScript('downloads/UnrealBloomPass.js'); + } + const T = this._THREE; + this._composer = new T.EffectComposer(this._renderer); + this._composer.addPass(new T.RenderPass(this._scene, this._camera)); + const bloomPass = new T.UnrealBloomPass( + new T.Vector2(window.innerWidth, window.innerHeight), + 0.2, 0.1, 0.05 + ); + this._composer.addPass(bloomPass); + } catch (e) { + this.terminal?.print('Bloom not available: ' + e.message, 'warning'); + this._composer = null; + } } _startLoop() { if (this._animId) cancelAnimationFrame(this._animId); + this._animTime = 0; const loop = () => { this._animId = requestAnimationFrame(loop); + const delta = Math.min(this._clock ? this._clock.getDelta() : 0.016, 0.1); + this._animTime += delta; + if (this._controls) this._controls.update(); + + // ── Animations ── + for (const [id, anim] of Object.entries(this._animations)) { + if (!anim.active) continue; + const obj = this._objects.get(id); + if (!obj || !obj.mesh) continue; + const m = obj.mesh; + switch (anim.type) { + case 'rotate': + m.rotation.y += anim.speed * delta * 2; + break; + case 'bounce': + m.position.y = anim.baseY + Math.sin(this._animTime * anim.speed * 2) * anim.height; + break; + case 'float': + m.position.y = anim.baseY + Math.sin(this._animTime * anim.speed * 2) * anim.height; + m.rotation.z = Math.sin(this._animTime * anim.speed) * 0.1; + break; + case 'ease': { + const t = (this._animTime * anim.speed) % 1; + const e = this._easeInOut(t); + m.position.y = anim.baseY + e * anim.height; + break; + } + case 'elastic': { + const t = (this._animTime * anim.speed) % 1; + const e = this._easeElastic(t); + m.position.y = anim.baseY + e * anim.height; + break; + } + case 'bounce-ease': { + const t = (this._animTime * anim.speed) % 1; + const e = this._easeBounce(t); + m.position.y = anim.baseY + e * anim.height; + break; + } + case 'orbit': { + const d = anim; + d.angle = (d.angle || 0) + d.speed * delta; + m.position.x = Math.cos(d.angle) * (d.orbit || 1); + m.position.z = Math.sin(d.angle) * (d.orbit || 1); + break; + } + } + } + + // ── Follow targets ── + for (const [id, targetId] of Object.entries(this._followTargets)) { + const obj = this._objects.get(id); + const target = this._objects.get(targetId); + if (obj && target && obj.mesh && target.mesh) { + obj.mesh.position.lerp(target.mesh.position, 0.05); + } + } + + // ── Path following ── + for (const [id, path] of Object.entries(this._paths)) { + const obj = this._objects.get(id); + if (!obj || !obj.mesh || path.length < 2) continue; + const prog = this._pathProgress[id] || 0; + const next = Math.min(prog + 1, path.length - 1); + const p0 = path[prog]; + const p1 = path[next]; + const t = 0.05; + obj.mesh.position.x += (p0.x - obj.mesh.position.x) * t; + obj.mesh.position.y += (p0.y - obj.mesh.position.y) * t; + obj.mesh.position.z += (p0.z - obj.mesh.position.z) * t; + const dist = obj.mesh.position.distanceTo(new T.Vector3(p0.x, p0.y, p0.z)); + if (dist < 0.1) { + this._pathProgress[id] = next; + if (next >= path.length - 1) this._pathProgress[id] = 0; // loop + } + } + + // ── Avatar idle animation ── + if (this._avatar && this._avatarMood !== 'talking') { + this._avatar.position.y += Math.sin(this._animTime * 0.2) * 0.002; + } + + // ── Timeline ── + if (this._timelinePlaying) { + this._timelineTime += delta; + for (const key of this._timeline) { + if (key.time <= this._timelineTime && !key._applied) { + key._applied = true; + const obj = this._objects.get(key.id); + if (obj && obj.mesh) { + if (key.pos) obj.mesh.position.set(key.pos[0], key.pos[1], key.pos[2]); + if (key.rot) obj.mesh.rotation.set(key.rot[0], key.rot[1], key.rot[2]); + if (key.scale) obj.mesh.scale.set(key.scale[0], key.scale[1], key.scale[2]); + } + } + } + // Check if timeline ended + const lastTime = this._timeline.length > 0 ? this._timeline[this._timeline.length - 1].time : 0; + if (this._timelineTime > lastTime) { + this._timelinePlaying = false; + } + } + if (this._renderer && this._scene && this._camera) { - this._renderer.render(this._scene, this._camera); + if (this._composer && this._bloomEnabled) { + this._composer.render(); + } else { + this._renderer.render(this._scene, this._camera); + } } }; loop(); @@ -406,7 +687,6 @@ class ThreeModule { destroy() { this._stopLoop(); this._hideContainer(); - // Remove event listeners if (window._threeResizeHandler) { window.removeEventListener('resize', window._threeResizeHandler); window._threeResizeHandler = null; @@ -425,9 +705,7 @@ class ThreeModule { this._renderer.dispose(); this._renderer = null; } - // Dispose all GPU resources this._disposeAllObjects(); - // Remove DOM elements if (this._container) { this._container.remove(); this._container = null; @@ -441,10 +719,11 @@ class ThreeModule { this._controls = null; this._loaded = false; this._visible = false; + this._importCache = {}; } _disposeAllObjects() { - for (const obj of this._objects) { + for (const obj of this._objects.values()) { if (this._scene) this._scene.remove(obj.mesh); if (obj.mesh.geometry) obj.mesh.geometry.dispose(); if (obj.mesh.material) { @@ -455,7 +734,7 @@ class ThreeModule { }); } } - this._objects = []; + this._objects = new Map(); this._idCounter = 0; } @@ -475,7 +754,8 @@ class ThreeModule { mesh.castShadow = true; const id = this._genId(); this._scene.add(mesh); - this._objects.push({ mesh, id, label: 'Box ' + id }); + this._objects.set(id, { mesh, id, label: 'Box ' + id }); + this._pushUndo('add', { mesh, id, label: 'Box ' + id }); this.terminal.print(`Added box (${id}) at origin`, 'success'); return id; } @@ -490,11 +770,47 @@ class ThreeModule { mesh.castShadow = true; const id = this._genId(); this._scene.add(mesh); - this._objects.push({ mesh, id, label: 'Sphere ' + id }); + this._objects.set(id, { mesh, id, label: 'Sphere ' + id }); + this._pushUndo('add', { mesh, id, label: 'Sphere ' + id }); this.terminal.print(`Added sphere (${id}) at origin`, 'success'); return id; } + _addCylinder(args) { + const T = this._THREE; + const radiusTop = parseFloat(args[0]) || 0.5; + const radiusBottom = parseFloat(args[1]) || 0.5; + const height = parseFloat(args[2]) || 1.5; + const color = args[3] || '#fbbf24'; + const geo = new T.CylinderGeometry(radiusTop, radiusBottom, height, 24); + const mat = new T.MeshStandardMaterial({ color, metalness: 0.3, roughness: 0.5 }); + const mesh = new T.Mesh(geo, mat); + mesh.castShadow = true; + const id = this._genId(); + this._scene.add(mesh); + this._objects.set(id, { mesh, id, label: 'Cylinder ' + id }); + this._pushUndo('add', { mesh, id, label: 'Cylinder ' + id }); + this.terminal.print(`Added cylinder (${id}) at origin`, 'success'); + return id; + } + + _addCone(args) { + const T = this._THREE; + const radius = parseFloat(args[0]) || 0.6; + const height = parseFloat(args[1]) || 1.5; + const color = args[2] || '#fb7185'; + const geo = new T.ConeGeometry(radius, height, 24); + const mat = new T.MeshStandardMaterial({ color, metalness: 0.2, roughness: 0.5 }); + const mesh = new T.Mesh(geo, mat); + mesh.castShadow = true; + const id = this._genId(); + this._scene.add(mesh); + this._objects.set(id, { mesh, id, label: 'Cone ' + id }); + this._pushUndo('add', { mesh, id, label: 'Cone ' + id }); + this.terminal.print(`Added cone (${id}) at origin`, 'success'); + return id; + } + _addTorus(args) { const T = this._THREE; const radius = parseFloat(args[0]) || 1; @@ -506,11 +822,29 @@ class ThreeModule { mesh.castShadow = true; const id = this._genId(); this._scene.add(mesh); - this._objects.push({ mesh, id, label: 'Torus ' + id }); + this._objects.set(id, { mesh, id, label: 'Torus ' + id }); + this._pushUndo('add', { mesh, id, label: 'Torus ' + id }); this.terminal.print(`Added torus (${id}) at origin`, 'success'); return id; } + _addTorusKnot(args) { + const T = this._THREE; + const radius = parseFloat(args[0]) || 0.8; + const tube = parseFloat(args[1]) || 0.25; + const color = args[2] || '#22d3ee'; + const geo = new T.TorusKnotGeometry(radius, tube, 64, 8, 2, 3); + const mat = new T.MeshStandardMaterial({ color, metalness: 0.5, roughness: 0.2 }); + const mesh = new T.Mesh(geo, mat); + mesh.castShadow = true; + const id = this._genId(); + this._scene.add(mesh); + this._objects.set(id, { mesh, id, label: 'TorusKnot ' + id }); + this._pushUndo('add', { mesh, id, label: 'TorusKnot ' + id }); + this.terminal.print(`Added torus knot (${id}) at origin`, 'success'); + return id; + } + _addText(args) { if (!args[0]) return this.terminal.print('Usage: three add text <text> [color]', 'warning'); const T = this._THREE; @@ -532,7 +866,8 @@ class ThreeModule { sprite.scale.set(4, 1, 1); const id = this._genId(); this._scene.add(sprite); - this._objects.push({ mesh: sprite, id, label: 'Text: ' + text }); + this._objects.set(id, { mesh: sprite, id, label: 'Text: ' + text }); + this._pushUndo('add', { mesh: sprite, id, label: 'Text: ' + text }); this.terminal.print(`Added text "${text}" (${id})`, 'success'); return id; } @@ -553,7 +888,8 @@ class ThreeModule { const points = new T.Points(geo, mat); const id = this._genId(); this._scene.add(points); - this._objects.push({ mesh: points, id, label: 'Particles (' + count + ')' }); + this._objects.set(id, { mesh: points, id, label: 'Particles (' + count + ')' }); + this._pushUndo('add', { mesh: points, id, label: 'Particles (' + count + ')' }); this.terminal.print(`Added ${count} particles (${id})`, 'success'); return id; } @@ -573,27 +909,592 @@ class ThreeModule { const line = new T.Line(geo, mat); const id = this._genId(); this._scene.add(line); - this._objects.push({ mesh: line, id, label: 'Line ' + id }); + this._objects.set(id, { mesh: line, id, label: 'Line ' + id }); + this._pushUndo('add', { mesh: line, id, label: 'Line ' + id }); this.terminal.print(`Added line (${id})`, 'success'); return id; } - // ── Position helpers ── + // ── Transforms ── _setPosition(id, x, y, z) { - const obj = this._objects.find(o => o.id === id); + const obj = this._objects.get(id); if (!obj) return this.terminal.print('Object not found: ' + id, 'error'); obj.mesh.position.set(x, y, z); this.terminal.print(`Moved ${id} to (${x}, ${y}, ${z})`, 'success'); } _setRotation(id, rx, ry, rz) { - const obj = this._objects.find(o => o.id === id); + const obj = this._objects.get(id); if (!obj) return this.terminal.print('Object not found: ' + id, 'error'); obj.mesh.rotation.set(rx, ry, rz); this.terminal.print(`Rotated ${id}`, 'success'); } + _setScale(id, sx, sy, sz) { + const obj = this._objects.get(id); + if (!obj) return this.terminal.print('Object not found: ' + id, 'error'); + obj.mesh.scale.set(sx, sy, sz); + this.terminal.print(`Scaled ${id} to (${sx}, ${sy}, ${sz})`, 'success'); + } + + _setColor(id, colorHex) { + const obj = this._objects.get(id); + if (!obj) return this.terminal.print('Object not found: ' + id, 'error'); + if (!obj.mesh.material || !obj.mesh.material.color) return this.terminal.print('Object has no color property', 'warning'); + const T = this._THREE; + try { + obj.mesh.material.color.set(colorHex); + this.terminal.print(`Set ${id} color to ${colorHex}`, 'success'); + } catch (e) { + this.terminal.print('Invalid color: ' + colorHex, 'warning'); + } + } + + _setMaterial(id, key, value) { + const obj = this._objects.get(id); + if (!obj) return this.terminal.print('Object not found: ' + id, 'error'); + if (!obj.mesh.material) return this.terminal.print('Object has no material', 'warning'); + const validKeys = ['metalness', 'roughness', 'opacity', 'emissiveIntensity']; + if (!validKeys.includes(key)) return this.terminal.print('Valid keys: ' + validKeys.join(', '), 'warning'); + const num = parseFloat(value); + if (isNaN(num) || num < 0 || num > 1) return this.terminal.print('Value must be 0-1', 'warning'); + obj.mesh.material[key] = num; + if (key === 'opacity') { + obj.mesh.material.transparent = num < 1; + } + obj.mesh.material.needsUpdate = true; + this.terminal.print(`Set ${id} ${key} to ${num}`, 'success'); + } + + _duplicateObject(id) { + const obj = this._objects.get(id); + if (!obj) return this.terminal.print('Object not found: ' + id, 'error'); + const T = this._THREE; + let newMesh; + if (obj.mesh.type === 'Line' || obj.mesh.type === 'LineSegments') { + const geo = obj.mesh.geometry.clone(); + const mat = obj.mesh.material.clone(); + newMesh = new T.Line(geo, mat); + newMesh.position.copy(obj.mesh.position); + newMesh.position.x += 1.5; + } else if (obj.mesh.type === 'Sprite') { + const mat = obj.mesh.material.clone(); + newMesh = new T.Sprite(mat); + newMesh.position.copy(obj.mesh.position); + newMesh.position.x += 1.5; + newMesh.scale.copy(obj.mesh.scale); + } else if (obj.mesh.type === 'Points') { + const geo = obj.mesh.geometry.clone(); + const mat = obj.mesh.material.clone(); + newMesh = new T.Points(geo, mat); + newMesh.position.copy(obj.mesh.position); + newMesh.position.x += 1.5; + } else if (obj.mesh.geometry && obj.mesh.material) { + const geo = obj.mesh.geometry.clone(); + const mat = obj.mesh.material.clone(); + newMesh = new T.Mesh(geo, mat); + newMesh.position.copy(obj.mesh.position); + newMesh.position.x += 1.5; + newMesh.rotation.copy(obj.mesh.rotation); + newMesh.scale.copy(obj.mesh.scale); + newMesh.castShadow = true; + } else { + return this.terminal.print('Cannot duplicate this object type', 'warning'); + } + const newId = this._genId(); + this._scene.add(newMesh); + this._objects.set(newId, { mesh: newMesh, id: newId, label: obj.label + ' (copy)' }); + this.terminal.print(`Duplicated ${id} → ${newId}`, 'success'); + return newId; + } + + _groupObjects(ids) { + const T = this._THREE; + const group = new T.Group(); + const moved = []; + for (const id of ids) { + if (!this._objects.has(id)) { this.terminal.print('Object not found: ' + id, 'warning'); continue; } + const obj = this._objects.get(id); + this._scene.remove(obj.mesh); + group.add(obj.mesh); + this._objects.delete(id); + moved.push(id); + } + this._scene.add(group); + const groupId = this._genId(); + this._objects.set(groupId, { mesh: group, id: groupId, label: 'Group (' + moved.join(',') + ')', _isGroup: true }); + this.terminal.print(`Grouped ${moved.length} objects as ${groupId}`, 'success'); + return groupId; + } + + _ungroupObject(id) { + const obj = this._objects.get(id); + if (!obj) return this.terminal.print('Object not found: ' + id, 'error'); + if (!obj._isGroup) return this.terminal.print('Not a group: ' + id, 'warning'); + const T = this._THREE; + const group = obj.mesh; + const children = [...group.children]; + const newIds = []; + for (const child of children) { + group.remove(child); + this._scene.add(child); + const newId = this._genId(); + this._objects.set(newId, { mesh: child, id: newId, label: 'Ungrouped ' + newId }); + newIds.push(newId); + } + this._scene.remove(group); + this._objects.delete(id); + this.terminal.print(`Ungrouped ${id} → ${newIds.length} objects`, 'success'); + return newIds; + } + + // ── Animations ── + + _pushUndo(action, data) { + this._undoStack.push({ action, data, timestamp: Date.now() }); + if (this._undoStack.length > 50) this._undoStack.shift(); + this._redoStack = []; + } + + _undo() { + if (this._undoStack.length === 0) return this.terminal.print('Nothing to undo', 'info'); + const entry = this._undoStack.pop(); + this._redoStack.push(entry); + if (entry.action === 'remove') { + // Re-add the object + const obj = entry.data; + this._scene.add(obj.mesh); + this._objects.set(obj.id, obj); + this.terminal.print('Undo: restored ' + obj.id, 'success'); + } else if (entry.action === 'add') { + // Remove the object + const obj = entry.data; + this._scene.remove(obj.mesh); + this._objects.delete(obj.id); + this.terminal.print('Undo: removed ' + obj.id, 'success'); + } else if (entry.action === 'clear') { + // Restore all objects + for (const obj of entry.data) { + this._scene.add(obj.mesh); + this._objects.set(obj.id, obj); + } + this.terminal.print('Undo: restored scene', 'success'); + } else if (entry.action === 'transform') { + const { id, pos, rot, scale } = entry.data; + const obj = this._objects.get(id); + if (obj) { + if (pos) obj.mesh.position.set(pos[0], pos[1], pos[2]); + if (rot) obj.mesh.rotation.set(rot[0], rot[1], rot[2]); + if (scale) obj.mesh.scale.set(scale[0], scale[1], scale[2]); + this.terminal.print('Undo: reverted ' + id, 'success'); + } + } + } + + _redo() { + if (this._redoStack.length === 0) return this.terminal.print('Nothing to redo', 'info'); + const entry = this._redoStack.pop(); + this._undoStack.push(entry); + if (entry.action === 'add') { + const obj = entry.data; + this._scene.add(obj.mesh); + this._objects.set(obj.id, obj); + this.terminal.print('Redo: restored ' + obj.id, 'success'); + } else if (entry.action === 'remove') { + const obj = entry.data; + this._scene.remove(obj.mesh); + this._objects.delete(obj.id); + this.terminal.print('Redo: removed ' + obj.id, 'success'); + } else if (entry.action === 'clear') { + for (const obj of entry.data) { + this._scene.remove(obj.mesh); + this._objects.delete(obj.id); + } + this.terminal.print('Redo: cleared scene', 'success'); + } + } + + _renameObject(id, newName) { + const obj = this._objects.get(id); + if (!obj) return this.terminal.print('Object not found: ' + id, 'error'); + obj.label = newName; + this.terminal.print(`Renamed ${id} to "${newName}"`, 'success'); + } + + _selectObject(id) { + if (!this._transformControls) return this.terminal.print('TransformControls not loaded', 'warning'); + if (!id) { + this._transformControls.detach(); + this._selectedId = null; + this.terminal.print('Deselected', 'info'); + return; + } + const obj = this._objects.get(id); + if (!obj) return this.terminal.print('Object not found: ' + id, 'error'); + this._transformControls.attach(obj.mesh); + this._selectedId = id; + this.terminal.print('Selected ' + id, 'success'); + } + + // Easing functions + _easeInOut(t) { return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; } + _easeOut(t) { return 1 - Math.pow(1 - t, 3); } + _easeElastic(t) { return Math.pow(2, -10 * t) * Math.sin((t - 0.075) * (2 * Math.PI) / 0.3) + 1; } + _easeBounce(t) { + const n1 = 7.5625, d1 = 2.75; + if (t < 1 / d1) return n1 * t * t; + else if (t < 2 / d1) return n1 * (t -= 1.5 / d1) * t + 0.75; + else if (t < 2.5 / d1) return n1 * (t -= 2.25 / d1) * t + 0.9375; + else return n1 * (t -= 2.625 / d1) * t + 0.984375; + } + + _animateObject(args) { + if (args.length < 2) return this.terminal.print('Usage: three animate <id> rotate|bounce|float|orbit|ease|elastic|bounce-ease [params] [easing]', 'warning'); + const id = args[0]; + const type = args[1]; + const easing = args[args.length - 1] === 'ease' || args[args.length - 1] === 'elastic' || args[args.length - 1] === 'bounce' ? args.pop() : 'linear'; + const obj = this._objects.get(id); + if (!obj) return this.terminal.print('Object not found: ' + id, 'error'); + + const anim = { type, speed: 1, active: true, baseY: obj.mesh.position.y, height: 0.5, angle: 0, orbit: 2, easing }; + if (type === 'orbit') { + anim.orbit = parseFloat(args[2]) || 2; + anim.speed = parseFloat(args[3]) || 0.5; + } else if (type === 'bounce' || type === 'float') { + anim.height = parseFloat(args[2]) || 0.5; + anim.speed = parseFloat(args[3]) || 1; + } else { + anim.speed = parseFloat(args[2]) || 1; + } + this._animations[id] = anim; + this.terminal.print(`▶️ Animated ${id}: ${type} (speed: ${anim.speed})`, 'success'); + } + + _stopAnimation(args) { + const id = args[0]; + if (!id) { + this._animations = {}; + this.terminal.print('All animations stopped', 'info'); + return; + } + if (this._animations[id]) { + delete this._animations[id]; + this.terminal.print(`Stopped animation for ${id}`, 'info'); + } else { + this.terminal.print('No animation for ' + id, 'warning'); + } + } + + // ── Timeline ── + + _timelineCommand(args) { + if (args.length === 0) return this.terminal.print('Usage: three timeline add|play|pause|stop|clear|list', 'warning'); + const sub = args[0]; + const rest = args.slice(1); + switch (sub) { + case 'add': { + if (rest.length < 5) return this.terminal.print('Usage: three timeline add <id> <time_sec> <x> <y> <z>', 'warning'); + const id = rest[0]; + const time = parseFloat(rest[1]); + if (isNaN(time)) return this.terminal.print('Time must be a number', 'warning'); + const x = parseFloat(rest[2]), y = parseFloat(rest[3]), z = parseFloat(rest[4]); + this._timeline.push({ id, time, pos: [x, y, z], _applied: false }); + this._timeline.sort((a, b) => a.time - b.time); + this.terminal.print(`Keyframe added: ${id} @ ${time}s → (${x}, ${y}, ${z})`, 'success'); + break; + } + case 'play': + this._timeline.forEach(k => k._applied = false); + this._timelinePlaying = true; + this._timelineTime = 0; + this.terminal.print('▶️ Timeline playing', 'success'); + break; + case 'pause': + this._timelinePlaying = false; + this.terminal.print('⏸ Timeline paused', 'info'); + break; + case 'stop': + this._timelinePlaying = false; + this._timelineTime = 0; + this._timeline.forEach(k => k._applied = false); + this.terminal.print('⏹ Timeline stopped', 'info'); + break; + case 'clear': + this._timeline = []; + this._timelinePlaying = false; + this._timelineTime = 0; + this.terminal.print('Timeline cleared', 'info'); + break; + case 'list': + if (this._timeline.length === 0) return this.terminal.print('No keyframes', 'info'); + this.terminal.print('=== Timeline (' + this._timeline.length + ' keyframes) ===', 'header'); + for (const k of this._timeline) { + this.terminal.print(` ${k.id} @ ${k.time}s → (${k.pos[0]}, ${k.pos[1]}, ${k.pos[2]})`, 'info'); + } + break; + default: + this.terminal.print('Unknown: ' + sub + '. Try: add, play, pause, stop, clear, list', 'warning'); + } + } + + // ── Interaction ── + + _setClickHandler(args) { + if (args.length < 2) return this.terminal.print('Usage: three click <id> <command...>', 'warning'); + const id = args[0]; + const cmd = args.slice(1).join(' '); + const obj = this._objects.get(id); + if (!obj) return this.terminal.print('Object not found: ' + id, 'error'); + this._clickHandlers[id] = cmd; + if (!this._clickableObjects.includes(id)) this._clickableObjects.push(id); + this.terminal.print(`Click handler set: ${id} → "${cmd}"`, 'success'); + } + + _setHoverHandler(args) { + if (args.length < 3) return this.terminal.print('Usage: three hover <id> <enterColor> <leaveColor>', 'warning'); + const id = args[0]; + const obj = this._objects.get(id); + if (!obj) return this.terminal.print('Object not found: ' + id, 'error'); + this._hoverHandlers[id] = { enterColor: args[1], leaveColor: args[2], origColor: obj.mesh.material && obj.mesh.material.color ? obj.mesh.material.color.getHex() : null }; + this.terminal.print(`Hover set: ${id}`, 'success'); + } + + _setFollow(id, targetId) { + const obj = this._objects.get(id); + const target = this._objects.get(targetId); + if (!obj) return this.terminal.print('Object not found: ' + id, 'error'); + if (!target) return this.terminal.print('Target not found: ' + targetId, 'error'); + this._followTargets[id] = targetId; + this.terminal.print(`${id} now follows ${targetId}`, 'success'); + } + + _unfollow(id) { + if (this._followTargets[id]) { + delete this._followTargets[id]; + this.terminal.print(`${id} stopped following`, 'info'); + } else { + this.terminal.print('Not following: ' + id, 'warning'); + } + } + + _setPath(args) { + if (args.length < 4) return this.terminal.print('Usage: three path <id> <x> <y> <z> [more points...]', 'warning'); + const id = args[0]; + const obj = this._objects.get(id); + if (!obj) return this.terminal.print('Object not found: ' + id, 'error'); + const coords = args.slice(1).map(parseFloat); + const points = []; + for (let i = 0; i < coords.length; i += 3) { + if (coords.length - i < 3) break; + points.push({ x: coords[i], y: coords[i+1], z: coords[i+2] }); + } + if (points.length < 2) return this.terminal.print('Need at least 2 points (6 coordinates)', 'warning'); + this._paths[id] = points; + this._pathProgress[id] = 0; + this.terminal.print(`Path set for ${id}: ${points.length} waypoints`, 'success'); + } + + // ── Avatars ── + + _createAvatar(args) { + const T = this._THREE; + const name = args[0] || 'assistant'; + const color = args[1] || '#00ff88'; + + // Remove existing avatar + if (this._avatar) { + this._scene.remove(this._avatar); + this._avatar = null; + } + if (this._speechBubble) { + this._scene.remove(this._speechBubble); + this._speechBubble = null; + } + + const group = new T.Group(); + + // Body (rounded cylinder) + const bodyGeo = new T.CylinderGeometry(0.6, 0.7, 1.2, 12); + const bodyMat = new T.MeshStandardMaterial({ color, metalness: 0.3, roughness: 0.4, emissive: color, emissiveIntensity: 0.1 }); + const body = new T.Mesh(bodyGeo, bodyMat); + body.position.y = 0.6; + body.castShadow = true; + group.add(body); + + // Head (sphere) + const headGeo = new T.SphereGeometry(0.4, 16, 16); + const headMat = new T.MeshStandardMaterial({ color: 0xcccccc, metalness: 0.1, roughness: 0.3 }); + const head = new T.Mesh(headGeo, headMat); + head.position.y = 1.4; + head.castShadow = true; + group.add(head); + + // Eyes (two small spheres) + const eyeMat = new T.MeshStandardMaterial({ color: color, emissive: color, emissiveIntensity: 0.5 }); + const eyeGeo = new T.SphereGeometry(0.08, 8, 8); + const leftEye = new T.Mesh(eyeGeo, eyeMat); + leftEye.position.set(-0.15, 1.5, 0.35); + group.add(leftEye); + const rightEye = new T.Mesh(eyeGeo, eyeMat); + rightEye.position.set(0.15, 1.5, 0.35); + group.add(rightEye); + + // Arms (thin cylinders) + const armMat = new T.MeshStandardMaterial({ color: 0xaaaaaa, metalness: 0.2, roughness: 0.5 }); + const armGeo = new T.CylinderGeometry(0.06, 0.06, 0.6, 6); + const leftArm = new T.Mesh(armGeo, armMat); + leftArm.position.set(-0.7, 0.8, 0); + leftArm.rotation.z = 0.2; + group.add(leftArm); + const rightArm = new T.Mesh(armGeo, armMat); + rightArm.position.set(0.7, 0.8, 0); + rightArm.rotation.z = -0.2; + group.add(rightArm); + + group.position.set(0, 0, 0); + this._scene.add(group); + this._avatar = group; + this._avatarMood = 'neutral'; + + // Store body parts for animation + group.userData = { body, head, leftEye, rightEye, leftArm, rightArm, bodyMat, headMat }; + + this.terminal.print(`🤖 Avatar "${name}" created`, 'success'); + return group; + } + + _avatarPose(pose) { + if (!this._avatar) return this.terminal.print('No avatar. Create one with three avatar <name>', 'warning'); + const parts = this._avatar.userData; + if (!parts) return; + + switch (pose) { + case 'idle': + parts.leftArm.rotation.z = 0.2; + parts.rightArm.rotation.z = -0.2; + break; + case 'wave': + parts.rightArm.rotation.z = -1.5; + break; + case 'think': + parts.leftArm.rotation.z = 0.8; + parts.rightArm.rotation.z = -0.8; + parts.head.rotation.x = -0.2; + break; + case 'point': + parts.rightArm.rotation.z = -0.5; + parts.rightArm.rotation.x = -0.8; + break; + case 'cross': + parts.leftArm.rotation.z = 0.8; + parts.rightArm.rotation.z = -0.8; + break; + default: + this.terminal.print('Poses: idle, wave, think, point, cross', 'warning'); + } + } + + _avatarLookAt(x, y, z) { + if (!this._avatar) return this.terminal.print('No avatar', 'warning'); + const parts = this._avatar.userData; + if (!parts || !parts.head) return; + const dx = x - this._avatar.position.x; + const dy = y - (this._avatar.position.y + 1.4); + const dz = z - this._avatar.position.z; + parts.head.rotation.x = Math.atan2(dy, Math.sqrt(dx*dx + dz*dz)) * 0.5; + parts.head.rotation.y = Math.atan2(dx, dz) * 0.5; + } + + _avatarSpeak(text) { + if (!this._avatar) return this.terminal.print('No avatar', 'warning'); + const T = this._THREE; + + // Remove old speech bubble + if (this._speechBubble) { + if (this._speechBubble.material) { + if (this._speechBubble.material.map) this._speechBubble.material.map.dispose(); + this._speechBubble.material.dispose(); + } + this._speechBubble.removeFromParent(); + this._speechBubble = null; + } + if (this._speechTimeout) clearTimeout(this._speechTimeout); + + const canvas = document.createElement('canvas'); + canvas.width = 512; + canvas.height = 128; + const ctx = canvas.getContext('2d'); + ctx.fillStyle = 'rgba(0,0,0,0.8)'; + ctx.roundRect ? ctx.roundRect(10, 10, 492, 108, 16) : ctx.fillRect(10, 10, 492, 108); + ctx.fill(); + ctx.font = '24px monospace'; + ctx.fillStyle = '#00ff88'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + const lines = this._wrapText(ctx, text, 450); + lines.forEach((line, i) => { + ctx.fillText(line, 256, 40 + i * 30); + }); + + const texture = new T.CanvasTexture(canvas); + const mat = new T.SpriteMaterial({ map: texture, transparent: true, depthTest: false }); + const sprite = new T.Sprite(mat); + sprite.position.set(0, 2.5, 0); + sprite.scale.set(3, 0.75, 1); + this._avatar.add(sprite); + this._speechBubble = sprite; + this._avatarMood = 'talking'; + + // Auto-clear talking state after text duration + setTimeout(() => { + if (this._avatarMood === 'talking') this._avatarMood = 'neutral'; + }, Math.max(2000, text.length * 100)); + } + + _wrapText(ctx, text, maxWidth) { + const words = text.split(' '); + const lines = []; + let line = ''; + for (const word of words) { + const test = line ? line + ' ' + word : word; + if (ctx.measureText(test).width > maxWidth && line) { + lines.push(line); + line = word; + } else { + line = test; + } + } + if (line) lines.push(line); + return lines.length > 0 ? lines : ['']; + } + + _avatarEmotion(emotion) { + if (!this._avatar) return this.terminal.print('No avatar', 'warning'); + const parts = this._avatar.userData; + if (!parts) return; + this._avatarMood = emotion; + + const colors = { + happy: 0x00ff88, + sad: 0x4488ff, + angry: 0xff4444, + confused: 0xffaa00, + thinking: 0xaa88ff, + neutral: 0x00ff88 + }; + const color = colors[emotion] || 0x00ff88; + if (parts.bodyMat) { + parts.bodyMat.color.setHex(color); + parts.bodyMat.emissive.setHex(color); + } + if (parts.leftEye && parts.rightEye) { + parts.leftEye.material.color.setHex(color); + parts.rightEye.material.color.setHex(color); + } + this.terminal.print(`Avatar emotion: ${emotion}`, 'success'); + } + // ── 3D Mindmap ── _renderMindmap(args) { @@ -639,7 +1540,6 @@ class ThreeModule { }; layout(root, null, 0, 0, Math.PI * 1.5); - const nodeMeshes = []; for (const n of nodes) { const radius = n.depth === 0 ? 0.6 : 0.3 + (1 / (n.depth + 1)) * 0.4; const geo = new T.SphereGeometry(radius, 16, 16); @@ -652,8 +1552,7 @@ class ThreeModule { mesh.castShadow = true; mesh.userData = { id: n.id, text: n.text, depth: n.depth }; this._scene.add(mesh); - nodeMeshes.push(mesh); - this._objects.push({ mesh, id: this._genId(), label: 'Mindmap: ' + n.text }); + const _mmid = this._genId(); this._objects.set(_mmid, { mesh, id: _mmid, label: 'Mindmap: ' + n.text }); // Text label sprite const canvas = document.createElement('canvas'); @@ -670,7 +1569,7 @@ class ThreeModule { sprite.position.set(n.pos.x, n.pos.y + radius + 0.5, n.pos.z); sprite.scale.set(2.5, 0.5, 1); this._scene.add(sprite); - this._objects.push({ mesh: sprite, id: this._genId(), label: 'Label: ' + n.text }); + const _slid = this._genId(); this._objects.set(_slid, { mesh: sprite, id: _slid, label: 'Label: ' + n.text }); } for (const e of edges) { @@ -685,11 +1584,14 @@ class ThreeModule { const mat = new T.LineBasicMaterial({ color: 0x444466, transparent: true, opacity: 0.5 }); const line = new T.Line(geo, mat); this._scene.add(line); - this._objects.push({ mesh: line, id: this._genId(), label: 'Edge' }); + const _eid = this._genId(); this._objects.set(_eid, { mesh: line, id: _eid, label: 'Edge' }); } + // Auto-frame camera + const nodeMeshes = Array.from(this._objects.values()).filter(o => o.mesh.type === 'Mesh').map(o => o.mesh); if (nodeMeshes.length > 0) { - const box = new T.Box3().setFromObjects(nodeMeshes); + const box = new T.Box3(); + for (const m of nodeMeshes) box.expandByObject(m); const center = box.getCenter(new T.Vector3()); const size = box.getSize(new T.Vector3()); const maxDim = Math.max(size.x, size.y, size.z); @@ -714,7 +1616,7 @@ class ThreeModule { const sunMat = new T.MeshStandardMaterial({ color: 0xffaa00, emissive: 0xff6600, emissiveIntensity: 0.5 }); const sun = new T.Mesh(sunGeo, sunMat); this._scene.add(sun); - this._objects.push({ mesh: sun, id: this._genId(), label: 'Sun' }); + const _sid = this._genId(); this._objects.set(_sid, { mesh: sun, id: _sid, label: 'Sun' }); const planets = [ { name: 'Mercury', radius: 0.15, orbit: 2.5, color: 0xaaaaaa, speed: 0.02 }, @@ -731,7 +1633,8 @@ class ThreeModule { mesh.position.x = p.orbit; mesh.userData = { orbit: p.orbit, speed: p.speed, angle: Math.random() * Math.PI * 2 }; this._scene.add(mesh); - this._objects.push({ mesh, id: this._genId(), label: p.name, _animate: true, _data: mesh.userData }); + const _pid = this._genId(); this._objects.set(_pid, { mesh, id: _pid, label: p.name }); + this._animations[_pid] = { type: 'orbit', speed: p.speed, active: true, angle: Math.random() * Math.PI * 2, orbit: p.orbit }; const ringPoints = []; for (let i = 0; i <= 64; i++) { @@ -742,7 +1645,7 @@ class ThreeModule { const ringMat = new T.LineBasicMaterial({ color: 0x333355, transparent: true, opacity: 0.3 }); const ring = new T.Line(ringGeo, ringMat); this._scene.add(ring); - this._objects.push({ mesh: ring, id: this._genId(), label: 'Orbit: ' + p.name }); + const _rid = this._genId(); this._objects.set(_rid, { mesh: ring, id: _rid, label: 'Orbit: ' + p.name }); } this.terminal.print('🌌 Solar system preset loaded', 'success'); } @@ -754,20 +1657,104 @@ class ThreeModule { const T = this._THREE; const size = parseInt(args[0]) || 5; const spacing = parseFloat(args[1]) || 2; - + const count = (size * 2 + 1) ** 2; + + const sharedGeo = new T.BoxGeometry(0.3, 0.3, 0.3); + const sharedMat = new T.MeshStandardMaterial({ emissiveIntensity: 0.1 }); + const instancedMesh = new T.InstancedMesh(sharedGeo, sharedMat, count); + const dummy = new T.Object3D(); + const color = new T.Color(); + let idx = 0; for (let x = -size; x <= size; x++) { for (let z = -size; z <= size; z++) { - const geo = new T.BoxGeometry(0.3, 0.3, 0.3); const hue = ((x + size) / (size * 2)) * 0.6 + 0.5; - const color = new T.Color().setHSL(hue, 0.8, 0.5); - const mat = new T.MeshStandardMaterial({ color, emissive: color, emissiveIntensity: 0.1 }); - const mesh = new T.Mesh(geo, mat); - mesh.position.set(x * spacing, 0, z * spacing); - this._scene.add(mesh); - this._objects.push({ mesh, id: this._genId(), label: `Grid (${x},${z})` }); + color.setHSL(hue, 0.8, 0.5); + dummy.position.set(x * spacing, 0, z * spacing); + dummy.updateMatrix(); + instancedMesh.setMatrixAt(idx, dummy.matrix); + instancedMesh.setColorAt(idx, color); + idx++; } } - this.terminal.print(`📐 Grid preset: ${(size*2+1)**2} cubes`, 'success'); + instancedMesh.instanceMatrix.needsUpdate = true; + instancedMesh.instanceColor.needsUpdate = true; + instancedMesh.castShadow = true; + this._scene.add(instancedMesh); + const _gid = this._genId(); this._objects.set(_gid, { mesh: instancedMesh, id: _gid, label: 'Grid' }); + this.terminal.print(`📐 Grid preset: ${count} cubes (1 draw call)`, 'success'); + } + + _presetParticleField(args) { + if (!this._scene) this._createScene(); + this._showContainer(this._mode); + this._clearScene(); + const T = this._THREE; + const count = parseInt(args[0]) || 2000; + const spread = parseFloat(args[1]) || 30; + + const geo = new T.BufferGeometry(); + const positions = new Float32Array(count * 3); + const colors = new Float32Array(count * 3); + for (let i = 0; i < count; i++) { + positions[i*3] = (Math.random() - 0.5) * spread; + positions[i*3+1] = (Math.random() - 0.5) * spread * 0.5; + positions[i*3+2] = (Math.random() - 0.5) * spread; + const c = new T.Color().setHSL(Math.random() * 0.3 + 0.4, 0.8, 0.5); + colors[i*3] = c.r; colors[i*3+1] = c.g; colors[i*3+2] = c.b; + } + geo.setAttribute('position', new T.BufferAttribute(positions, 3)); + geo.setAttribute('color', new T.BufferAttribute(colors, 3)); + const mat = new T.PointsMaterial({ + size: 0.2, vertexColors: true, transparent: true, opacity: 0.8, + blending: T.AdditiveBlending, sizeAttenuation: true + }); + const points = new T.Points(geo, mat); + this._scene.add(points); + const _pfid = this._genId(); this._objects.set(_pfid, { mesh: points, id: _pfid, label: 'Particle Field' }); + this.terminal.print(`✨ Particle field: ${count} stars`, 'success'); + } + + // ── Import ── + + async _importModel(args) { + if (!args[0]) return this.terminal.print('Usage: three import <url>', 'warning'); + const url = args[0]; + + // Check cache + if (this._importCache[url]) { + const cached = this._importCache[url]; + const newMesh = cached.clone(); + this._scene.add(newMesh); + const id = this._genId(); + this._objects.set(id, { mesh: newMesh, id, label: 'Imported: ' + url.split('/').pop() }); + this.terminal.print(`Loaded cached model (${id})`, 'success'); + return id; + } + + this.terminal.print('📥 Loading model from ' + url + '...', 'info'); + try { + // Load GLTFLoader if not already loaded + if (typeof THREE.GLTFLoader === 'undefined') { + await this._loadScript('downloads/GLTFLoader.js'); + } + const T = this._THREE; + const loader = new T.GLTFLoader(); + const gltf = await new Promise((resolve, reject) => { + loader.load(url, resolve, undefined, reject); + }); + const model = gltf.scene || gltf; + if (!this._scene) this._createScene(); + if (!this._visible) this._showContainer(this._mode); + this._scene.add(model); + const id = this._genId(); + this._objects.set(id, { mesh: model, id, label: 'Imported: ' + url.split('/').pop() }); + this._importCache[url] = model; + this.terminal.print('📦 Model imported (' + id + ')', 'success'); + return id; + } catch (e) { + this.terminal.print('❌ Import failed: ' + e.message, 'error'); + return null; + } } // ── Export ── @@ -775,11 +1762,11 @@ class ThreeModule { _export(args) { if (!this._scene) return this.terminal.print('No scene to export', 'warning'); const rawName = args[0] || 'three-scene'; - const name = rawName.replace(/[<>&"']/g, function(c) { return {'<':'<','>':'>','&':'&','"':'"',"'":'''}[c]; }); + const name = rawName.replace(/[^a-zA-Z0-9_-]/g, '_'); const T = this._THREE; const objects = []; - for (const obj of this._objects) { + for (const obj of this._objects.values()) { const m = obj.mesh; if (m.type === 'Mesh' || m.type === 'Points' || m.type === 'Sprite' || m.type === 'Line') { const hexColor = m.material && m.material.color ? m.material.color.getHex() : null; @@ -894,22 +1881,56 @@ class ThreeModule { _clearScene() { if (!this._scene) return; + const savedObjects = Array.from(this._objects.values()); + this._pushUndo('clear', savedObjects); this._disposeAllObjects(); + this._animations = {}; + this._clickHandlers = {}; + this._hoverHandlers = {}; + this._followTargets = {}; + this._paths = {}; + this._pathProgress = {}; + this._clickableObjects = []; + this._timeline = []; + this._timelinePlaying = false; + this._timelineTime = 0; + if (this._avatar) { + this._avatar.traverse((child) => { + if (child.geometry) child.geometry.dispose(); + if (child.material) { + if (child.material.map) child.material.map.dispose(); + child.material.dispose(); + } + }); + this._scene.remove(this._avatar); + this._avatar = null; + } + if (this._speechBubble) { + if (this._speechBubble.material) { + if (this._speechBubble.material.map) this._speechBubble.material.map.dispose(); + this._speechBubble.material.dispose(); + } + this._speechBubble = null; + } + if (this._speechTimeout) clearTimeout(this._speechTimeout); + this._speechTimeout = null; } _listObjects() { - if (this._objects.length === 0) return this.terminal.print('No objects in scene', 'info'); - this.terminal.print(`=== 3D Objects (${this._objects.length}) ===`, 'header'); - for (const obj of this._objects) { + if (this._objects.size === 0) return this.terminal.print('No objects in scene', 'info'); + this.terminal.print(`=== 3D Objects (${this._objects.size}) ===`, 'header'); + for (const obj of this._objects.values()) { const pos = obj.mesh.position; - this.terminal.print(` ${obj.id} ${obj.label} @ (${pos.x.toFixed(1)}, ${pos.y.toFixed(1)}, ${pos.z.toFixed(1)})`); + const anim = this._animations[obj.id] ? ' ▶️' : ''; + const click = this._clickHandlers[obj.id] ? ' 👆' : ''; + const follow = this._followTargets[obj.id] ? ' 🔗' : ''; + this.terminal.print(` ${obj.id} ${obj.label} @ (${pos.x.toFixed(1)}, ${pos.y.toFixed(1)}, ${pos.z.toFixed(1)})${anim}${click}${follow}`); } } _removeObject(id) { - const idx = this._objects.findIndex(o => o.id === id); - if (idx === -1) return this.terminal.print('Object not found: ' + id, 'error'); - const obj = this._objects[idx]; + const obj = this._objects.get(id); + if (!obj) return this.terminal.print('Object not found: ' + id, 'error'); this._scene.remove(obj.mesh); if (obj.mesh.geometry) obj.mesh.geometry.dispose(); if (obj.mesh.material) { @@ -919,19 +1940,42 @@ class ThreeModule { m.dispose(); }); } - this._objects.splice(idx, 1); + this._objects.delete(id); + delete this._animations[id]; + delete this._clickHandlers[id]; + delete this._hoverHandlers[id]; + delete this._followTargets[id]; + delete this._paths[id]; + delete this._pathProgress[id]; + this._clickableObjects = this._clickableObjects.filter(c => c !== id); this.terminal.print(`Removed ${id}`, 'success'); } _setBgColor(args) { if (!args[0]) return this.terminal.print('Usage: three bgcolor <hex> (e.g. #0a0a14)', 'warning'); - const color = parseInt(args[0].replace('#', ''), 16); - if (isNaN(color)) return this.terminal.print('Invalid color', 'warning'); + const colorStr = String(args[0] || '').replace('#', ''); + const color = parseInt(colorStr, 16); + if (isNaN(color) || colorStr.length < 3) return this.terminal.print('Invalid color', 'warning'); this._backgroundColor = color; if (this._scene) this._scene.background = new this._THREE.Color(color); this.terminal.print(`Background set to ${args[0]}`, 'success'); } + _takeSnapshot() { + if (!this._renderer || !this._scene) return this.terminal.print('No scene to capture', 'warning'); + try { + this._renderer.render(this._scene, this._camera); + const dataUrl = this._renderer.domElement.toDataURL('image/png'); + const link = document.createElement('a'); + link.download = 'three-snapshot-' + Date.now() + '.png'; + link.href = dataUrl; + link.click(); + this.terminal.print('📸 Snapshot saved', 'success'); + } catch (e) { + this.terminal.print('Snapshot failed: ' + e.message, 'error'); + } + } + // ── Main dispatch ── async _dispatch(args) { @@ -973,24 +2017,29 @@ class ThreeModule { case 'add': { const type = rest[0]; const params = rest.slice(1); - if (!type) return this.terminal.print('Usage: three add box|sphere|torus|text|particles|line [params...]', 'warning'); + if (!type) return this.terminal.print('Usage: three add box|sphere|cylinder|cone|torus|torusknot|text|particles|line [params...]', 'warning'); if (!this._scene) this._createScene(); if (!this._visible) this._showContainer(this._mode); switch (type) { case 'box': this._addBox(params); break; case 'sphere': this._addSphere(params); break; + case 'cylinder': this._addCylinder(params); break; + case 'cone': this._addCone(params); break; case 'torus': this._addTorus(params); break; + case 'torusknot': this._addTorusKnot(params); break; case 'text': this._addText(params); break; case 'particles': this._addParticles(params); break; case 'line': this._addLine(params); break; - default: this.terminal.print('Unknown type: ' + type + '. Try: box, sphere, torus, text, particles, line', 'warning'); + default: this.terminal.print('Unknown type: ' + type + '. Try: box, sphere, cylinder, cone, torus, torusknot, text, particles, line', 'warning'); } break; } case 'camera': { if (rest.length < 3) return this.terminal.print('Usage: three camera <x> <y> <z>', 'warning'); if (!this._camera) return this.terminal.print('No scene. Create one with three scene first.', 'warning'); - this._camera.position.set(parseFloat(rest[0]), parseFloat(rest[1]), parseFloat(rest[2])); + const cx = parseFloat(rest[0]), cy = parseFloat(rest[1]), cz = parseFloat(rest[2]); + if ([cx, cy, cz].some(v => isNaN(v))) return this.terminal.print('Invalid number in camera position', 'warning'); + this._camera.position.set(cx, cy, cz); this._controls.update(); this.terminal.print(`Camera moved to (${rest[0]}, ${rest[1]}, ${rest[2]})`, 'success'); break; @@ -1002,6 +2051,110 @@ class ThreeModule { this.terminal.print(`Orbit controls: ${this._orbitEnabled ? 'ON' : 'OFF'}`, 'info'); break; } + case 'pos': { + if (rest.length < 4) return this.terminal.print('Usage: three pos <id> <x> <y> <z>', 'warning'); + const px = parseFloat(rest[1]), py = parseFloat(rest[2]), pz = parseFloat(rest[3]); + if ([px, py, pz].some(v => isNaN(v))) return this.terminal.print('Invalid number in position', 'warning'); + this._setPosition(rest[0], px, py, pz); + break; + } + case 'rotate': { + if (rest.length < 4) return this.terminal.print('Usage: three rotate <id> <x> <y> <z> (radians)', 'warning'); + const rx = parseFloat(rest[1]), ry = parseFloat(rest[2]), rz = parseFloat(rest[3]); + if ([rx, ry, rz].some(v => isNaN(v))) return this.terminal.print('Invalid number in rotation', 'warning'); + this._setRotation(rest[0], rx, ry, rz); + break; + } + case 'scale': { + if (rest.length < 4) return this.terminal.print('Usage: three scale <id> <x> <y> <z>', 'warning'); + const sx = parseFloat(rest[1]), sy = parseFloat(rest[2]), sz = parseFloat(rest[3]); + if ([sx, sy, sz].some(v => isNaN(v))) return this.terminal.print('Invalid number in scale', 'warning'); + this._setScale(rest[0], sx, sy, sz); + break; + } + case 'color': { + if (rest.length < 2) return this.terminal.print('Usage: three color <id> <hex>', 'warning'); + this._setColor(rest[0], rest[1]); + break; + } + case 'material': { + if (rest.length < 3) return this.terminal.print('Usage: three material <id> <key> <value>', 'warning'); + this._setMaterial(rest[0], rest[1], rest[2]); + break; + } + case 'duplicate': { + if (!rest[0]) return this.terminal.print('Usage: three duplicate <id>', 'warning'); + this._duplicateObject(rest[0]); + break; + } + case 'group': { + if (rest.length < 2) return this.terminal.print('Usage: three group <id1> <id2> ...', 'warning'); + this._groupObjects(rest); + break; + } + case 'ungroup': { + if (!rest[0]) return this.terminal.print('Usage: three ungroup <id>', 'warning'); + this._ungroupObject(rest[0]); + break; + } + case 'animate': { + this._animateObject(rest); + break; + } + case 'stop': { + this._stopAnimation(rest); + break; + } + case 'timeline': { + this._timelineCommand(rest); + break; + } + case 'click': { + this._setClickHandler(rest); + break; + } + case 'hover': { + this._setHoverHandler(rest); + break; + } + case 'follow': { + if (rest.length < 2) return this.terminal.print('Usage: three follow <id> <target-id>', 'warning'); + this._setFollow(rest[0], rest[1]); + break; + } + case 'unfollow': { + if (!rest[0]) return this.terminal.print('Usage: three unfollow <id>', 'warning'); + this._unfollow(rest[0]); + break; + } + case 'path': { + this._setPath(rest); + break; + } + case 'avatar': { + this._createAvatar(rest); + break; + } + case 'pose': { + if (!rest[0]) return this.terminal.print('Usage: three pose idle|wave|think|point|cross', 'warning'); + this._avatarPose(rest[0]); + break; + } + case 'look': { + if (rest.length < 3) return this.terminal.print('Usage: three look <x> <y> <z>', 'warning'); + this._avatarLookAt(parseFloat(rest[0]), parseFloat(rest[1]), parseFloat(rest[2])); + break; + } + case 'speak': { + if (!rest[0]) return this.terminal.print('Usage: three speak <text...>', 'warning'); + this._avatarSpeak(rest.join(' ')); + break; + } + case 'emotion': { + if (!rest[0]) return this.terminal.print('Usage: three emotion happy|sad|angry|confused|thinking|neutral', 'warning'); + this._avatarEmotion(rest[0]); + break; + } case 'clear': this._clearScene(); this.terminal.print('Scene cleared', 'success'); @@ -1013,39 +2166,95 @@ class ThreeModule { if (!rest[0]) return this.terminal.print('Usage: three remove <id>', 'warning'); this._removeObject(rest[0]); break; - case 'pos': - if (rest.length < 4) return this.terminal.print('Usage: three pos <id> <x> <y> <z>', 'warning'); - this._setPosition(rest[0], parseFloat(rest[1]), parseFloat(rest[2]), parseFloat(rest[3])); - break; case 'mindmap': this._renderMindmap(rest); break; case 'export': this._export(rest); break; + case 'import': + await this._importModel(rest); + break; case 'preset': { const preset = rest[0] || 'solar'; if (!this._scene) this._createScene(); if (!this._visible) this._showContainer(this._mode); if (preset === 'solar') this._presetSolarSystem(); else if (preset === 'grid') this._presetGrid(rest.slice(1)); - else this.terminal.print('Presets: solar, grid', 'warning'); + else if (preset === 'particles' || preset === 'stars') this._presetParticleField(rest.slice(1)); + else this.terminal.print('Presets: solar, grid, particles', 'warning'); break; } case 'bgcolor': this._setBgColor(rest); break; + case 'bloom': + this._bloomEnabled = !this._bloomEnabled; + this.terminal.print('Bloom ' + (this._bloomEnabled ? 'ON' : 'OFF'), 'success'); + break; + case 'select': + this._selectObject(rest[0]); + break; + case 'undo': + this._undo(); + break; + case 'redo': + this._redo(); + break; + case 'rename': + if (rest.length < 2) return this.terminal.print('Usage: three rename <id> <newName>', 'warning'); + this._renameObject(rest[0], rest.slice(1).join(' ')); + break; + case 'batch': { + const sub = rest[0]; + const target = rest[1]; + const ids = target === 'all' ? Array.from(this._objects.keys()) : [target]; + if (sub === 'color' && rest[2]) { + for (const bid of ids) { + const bo = this._objects.get(bid); + if (bo && bo.mesh.material && bo.mesh.material.color) { + bo.mesh.material.color.set(rest[2]); + } + } + this.terminal.print(`Batch color ${rest[2]} applied to ${ids.length} objects`, 'success'); + } else if (sub === 'remove') { + for (const bid of ids) this._removeObject(bid); + this.terminal.print(`Batch removed ${ids.length} objects`, 'success'); + } else if (sub === 'animate' && rest[2]) { + for (const bid of ids) this._animateObject([bid, rest[2], rest[3] || '1']); + this.terminal.print(`Batch animated ${ids.length} objects`, 'success'); + } else if (sub === 'stop') { + for (const bid of ids) { delete this._animations[bid]; } + this.terminal.print(`Batch stopped ${ids.length} objects`, 'success'); + } else { + this.terminal.print('Usage: three batch color|remove|animate|stop <id|all> [params]', 'warning'); + } + break; + } + case 'gizmo': + if (!this._transformControls) return this.terminal.print('TransformControls not loaded', 'warning'); + const gmode = rest[0] || 'translate'; + if (['translate', 'rotate', 'scale'].includes(gmode)) { + this._transformControls.setMode(gmode); + this.terminal.print('Gizmo mode: ' + gmode, 'success'); + } else { + this.terminal.print('Modes: translate, rotate, scale', 'warning'); + } + break; case 'hide': this._hideContainer(); this.terminal.print('3D view closed. Use three scene or three fullscreen to reopen.', 'info'); break; + case 'snapshot': + this._takeSnapshot(); + break; default: this.terminal.print('Unknown subcommand: ' + sub + '. Try: three help', 'warning'); } } _printHelp() { - this.terminal.print('=== Three.js 3D Module (v4.5.82) ===', 'header'); + this.terminal.print('=== Three.js 3D Module (v4.5.86) ===', 'header'); this.terminal.print('', ''); this.terminal.print(' ── Scene ──', 'info'); this.terminal.print(' three scene Open 3D in top panel (default)'); @@ -1054,31 +2263,66 @@ class ThreeModule { this.terminal.print(' three hide Close 3D view (or press Esc)'); this.terminal.print(' three clear Remove all objects'); this.terminal.print(' three bgcolor <hex> Set background color (e.g. #0a0a14)'); + this.terminal.print(' three snapshot Save PNG screenshot'); this.terminal.print('', ''); this.terminal.print(' ── Objects ──', 'info'); this.terminal.print(' three add box [size] [color] Add a box'); this.terminal.print(' three add sphere [r] [color] Add a sphere'); + this.terminal.print(' three add cylinder [rt] [rb] [h] [c] Add a cylinder'); + this.terminal.print(' three add cone [r] [h] [c] Add a cone'); this.terminal.print(' three add torus [r] [tube] [c] Add a torus'); + this.terminal.print(' three add torusknot [r] [tube] [c] Add a torus knot'); this.terminal.print(' three add text <text> [color] Add 2D text sprite'); this.terminal.print(' three add particles [n] [c] Add particle system'); this.terminal.print(' three add line x1 y1 z1 x2 y2 z2 [color] Add a line'); this.terminal.print(' three list List all objects'); this.terminal.print(' three remove <id> Remove an object'); + this.terminal.print(' three duplicate <id> Clone an object'); + this.terminal.print(' three group <id1> <id2> ... Group objects together'); + this.terminal.print(' three ungroup <id> Ungroup a group'); + this.terminal.print('', ''); + this.terminal.print(' ── Transforms ──', 'info'); this.terminal.print(' three pos <id> <x> <y> <z> Move an object'); + this.terminal.print(' three rotate <id> <x> <y> <z> Rotate (radians)'); + this.terminal.print(' three scale <id> <x> <y> <z> Scale an object'); + this.terminal.print(' three color <id> <hex> Change object color'); + this.terminal.print(' three material <id> <k> <v> Change material (metalness, roughness, opacity)'); this.terminal.print('', ''); this.terminal.print(' ── Camera & Controls ──', 'info'); this.terminal.print(' three camera <x> <y> <z> Set camera position'); this.terminal.print(' three orbit Toggle orbit controls'); this.terminal.print('', ''); + this.terminal.print(' ── Animations ──', 'info'); + this.terminal.print(' three animate <id> rotate|bounce|float|orbit [speed] Animate object'); + this.terminal.print(' three stop [id] Stop animation(s)'); + this.terminal.print(' three timeline add <id> <t> <x> <y> <z> Add keyframe'); + this.terminal.print(' three timeline play|pause|stop|clear|list Control timeline'); + this.terminal.print('', ''); + this.terminal.print(' ── Interaction ──', 'info'); + this.terminal.print(' three click <id> <cmd...> Make object clickable (runs command)'); + this.terminal.print(' three hover <id> <enter> <leave> Hover highlight colors'); + this.terminal.print(' three follow <id> <target> Make object follow another'); + this.terminal.print(' three unfollow <id> Stop following'); + this.terminal.print(' three path <id> <x> <y> <z> [more...] Set waypoint path'); + this.terminal.print('', ''); + this.terminal.print(' ── Avatars ──', 'info'); + this.terminal.print(' three avatar [name] [color] Create an AI avatar'); + this.terminal.print(' three pose idle|wave|think|point|cross Set avatar pose'); + this.terminal.print(' three look <x> <y> <z> Avatar looks at point'); + this.terminal.print(' three speak <text...> Show speech bubble'); + this.terminal.print(' three emotion happy|sad|angry|confused|thinking|neutral'); + this.terminal.print('', ''); this.terminal.print(' ── Mindmap ──', 'info'); this.terminal.print(' three mindmap [name] Render current mindmap in 3D'); this.terminal.print('', ''); this.terminal.print(' ── Presets ──', 'info'); this.terminal.print(' three preset solar Solar system'); this.terminal.print(' three preset grid [n] [spc] Color grid of cubes'); + this.terminal.print(' three preset particles [n] [spread] Star field'); this.terminal.print('', ''); - this.terminal.print(' ── Export ──', 'info'); + this.terminal.print(' ── Import/Export ──', 'info'); this.terminal.print(' three export [name] Export scene as standalone HTML'); + this.terminal.print(' three import <url> Import 3D model (GLTF/GLB)'); this.terminal.print('', ''); this.terminal.print(' ── AI Tools ──', 'info'); this.terminal.print(' The AI can create 3D scenes via create_3d_scene tool.'); @@ -1136,6 +2380,11 @@ class ThreeModule { results.push('torus'); } + if (desc.includes('avatar') || desc.includes('character') || desc.includes('robot')) { + this._createAvatar(['assistant', '#00ff88']); + results.push('avatar'); + } + if (desc.includes('solar') || desc.includes('planet')) { this._presetSolarSystem(); return 'Created solar system preset'; @@ -1147,10 +2396,10 @@ class ThreeModule { } if (results.length === 0) { - this._addBox(['1', '#818cf8']); - this._addSphere(['0.6', '#34d399']); - this._setPosition('obj_1', -2, 0, 0); - this._setPosition('obj_2', 2, 0, 0); + const id1 = this._addBox(['1', '#818cf8']); + const id2 = this._addSphere(['0.6', '#34d399']); + this._setPosition(id1, -2, 0, 0); + this._setPosition(id2, 2, 0, 0); results.push('default scene'); } |