aboutsummaryrefslogtreecommitdiff
path: root/modules/ext/three.js
diff options
context:
space:
mode:
Diffstat (limited to 'modules/ext/three.js')
-rw-r--r--modules/ext/three.js1411
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 {'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;',"'":'&#39;'}[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');
}