diff options
| author | Agando <roduccomm@gmail.com> | 2026-07-02 06:00:40 +0200 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-07-02 06:00:40 +0200 |
| commit | ea4d09fb5ccba12ed774fcf7c913edfa1e6a851a (patch) | |
| tree | 2361e33e52cdb4e0880ed0c776ed75943b37d7cf /modules/ext/three.js | |
| parent | 8b3e3e4527772bf060984f98777a2cff8e88aa09 (diff) | |
auto: 2026-07-02_06:00 changes
Diffstat (limited to 'modules/ext/three.js')
| -rw-r--r-- | modules/ext/three.js | 1176 |
1 files changed, 1176 insertions, 0 deletions
diff --git a/modules/ext/three.js b/modules/ext/three.js new file mode 100644 index 0000000..d72afa1 --- /dev/null +++ b/modules/ext/three.js @@ -0,0 +1,1176 @@ +// newOS4 Three.js 3D Module (v4.5.82) +// Commands: three <subcommand> [...] +// Two modes: top panel (default) + fullscreen with terminal overlay +// Lazy-loads Three.js from /downloads/ on first use. + +class ThreeModule { + constructor() { + this.commands = {}; + this.commandsMeta = { + 'three': { argType: 'enum', enumFn: '_listSubcommands' }, + }; + this.terminal = null; + this._loaded = false; + this._scene = null; + this._camera = null; + this._renderer = null; + this._controls = null; + this._objects = []; + this._animId = null; + this._container = null; + this._closeBtn = null; + this._idCounter = 0; + this._orbitEnabled = true; + this._backgroundColor = 0x0a0a14; + this._THREE = null; + this._mode = 'top'; // 'top' or 'full' + this._visible = false; + this._savedTerminalStyles = null; + this._savedInputStyles = null; + this._savedHeaderStyles = null; + this._escHandler = null; + } + + requirements() { + const hasWebGL = (() => { + try { + const c = document.createElement('canvas'); + return !!(c.getContext('webgl') || c.getContext('experimental-webgl')); + } catch (e) { return false; } + })(); + if (!hasWebGL) { + return { met: false, missing: ['WebGL not supported in this browser'] }; + } + return { met: true, missing: [] }; + } + + 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.' + }; + } + + _listSubcommands() { + return ['help', 'scene', 'fullscreen', 'hide', 'mode', 'add', 'camera', 'orbit', 'clear', 'list', 'remove', 'mindmap', 'export', 'preset', 'bgcolor']; + } + + registerCommands() { + this.commands['three'] = (args) => this._dispatch(args); + } + + // ── Lazy load Three.js ── + + async _ensureLoaded() { + if (this._loaded) return true; + if (typeof THREE !== 'undefined') { + this._THREE = THREE; + this._loaded = true; + return true; + } + 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.terminal.print('✅ Three.js loaded', 'success'); + return true; + } catch (e) { + this.terminal.print('❌ Failed to load Three.js: ' + e.message, 'error'); + return false; + } + } + + _loadScript(url) { + 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)); }; + document.head.appendChild(s); + }); + } + + // ── Container management ── + + _createContainer() { + if (this._container) return; + + // Main container + this._container = document.createElement('div'); + this._container.id = 'three-canvas-container'; + this._container.style.cssText = ` + position: fixed; + top: 0; left: 0; + width: 100vw; + z-index: 400; + pointer-events: none; + display: none; + `; + document.body.appendChild(this._container); + + // Close button + this._closeBtn = document.createElement('button'); + this._closeBtn.id = 'three-close-btn'; + this._closeBtn.textContent = '✕'; + this._closeBtn.title = 'Close 3D view (Esc)'; + this._closeBtn.style.cssText = ` + position: fixed; + top: 12px; right: 12px; + z-index: 9999; + background: rgba(0,0,0,0.6); + color: #e0e0e0; + border: 1px solid #444; + border-radius: 6px; + padding: 6px 14px; + font-size: 16px; + cursor: pointer; + font-family: monospace; + display: none; + transition: background 0.15s; + `; + this._closeBtn.addEventListener('mouseenter', () => { this._closeBtn.style.background = 'rgba(200,50,50,0.7)'; }); + this._closeBtn.addEventListener('mouseleave', () => { this._closeBtn.style.background = 'rgba(0,0,0,0.6)'; }); + 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(); + } + }; + document.addEventListener('keydown', this._escHandler); + } + + _showContainer(mode) { + if (!this._container) return; + this._mode = mode || this._mode; + this._visible = true; + + // Show close button + this._closeBtn.style.display = 'block'; + + if (this._mode === 'top') { + this._applyTopPanel(); + } else { + this._applyFullscreen(); + } + + this._startLoop(); + } + + _applyTopPanel() { + // 3D container: top 50vh + this._container.style.cssText = ` + position: fixed; + top: 0; left: 0; + width: 100vw; height: 50vh; + z-index: 400; + pointer-events: auto; + display: block; + `; + + // Terminal: push down + const term = document.getElementById('terminal-container'); + if (term) { + if (!this._savedTerminalStyles) { + this._savedTerminalStyles = { height: term.style.height, marginTop: term.style.marginTop }; + } + term.style.marginTop = '50vh'; + term.style.height = '50vh'; + } + + // Input area: stays at bottom + const inputArea = document.getElementById('input-area'); + if (inputArea) { + if (!this._savedInputStyles) { + this._savedInputStyles = { position: inputArea.style.position, bottom: inputArea.style.bottom }; + } + } + + // Resize renderer + this._resizeRenderer(window.innerWidth, window.innerHeight / 2); + } + + _applyFullscreen() { + // 3D container: full viewport + this._container.style.cssText = ` + position: fixed; + top: 0; left: 0; + width: 100vw; height: 100vh; + z-index: 400; + pointer-events: auto; + display: block; + `; + + // Terminal: float as overlay + const term = document.getElementById('terminal-container'); + if (term) { + if (!this._savedTerminalStyles) { + this._savedTerminalStyles = { height: term.style.height, marginTop: term.style.marginTop }; + } + term.style.cssText = ` + position: fixed; + bottom: 60px; + left: 20px; + right: 20px; + max-height: 50vh; + z-index: 401; + background: rgba(0,0,0,0.7); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border-radius: 8px; + padding: 10px; + overflow-y: auto; + border: 1px solid #333; + `; + } + + // Input area: fixed at bottom + const inputArea = document.getElementById('input-area'); + if (inputArea) { + if (!this._savedInputStyles) { + this._savedInputStyles = { position: inputArea.style.position, bottom: inputArea.style.bottom }; + } + inputArea.style.cssText = ` + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 402; + background: rgba(0,0,0,0.85); + padding: 8px 12px; + border-top: 1px solid #333; + `; + } + + // Header: hide + const header = document.querySelector('header'); + if (header) { + if (!this._savedHeaderStyles) { + this._savedHeaderStyles = { display: header.style.display }; + } + header.style.display = 'none'; + } + + this._resizeRenderer(window.innerWidth, window.innerHeight); + } + + _restoreTerminal() { + const term = document.getElementById('terminal-container'); + if (term && this._savedTerminalStyles) { + term.style.cssText = ''; + term.style.height = this._savedTerminalStyles.height || ''; + term.style.marginTop = this._savedTerminalStyles.marginTop || ''; + term.style.position = ''; + term.style.bottom = ''; + term.style.left = ''; + term.style.right = ''; + term.style.maxHeight = ''; + term.style.zIndex = ''; + term.style.background = ''; + term.style.backdropFilter = ''; + term.style.borderRadius = ''; + term.style.padding = ''; + term.style.overflowY = ''; + term.style.border = ''; + } + + const inputArea = document.getElementById('input-area'); + if (inputArea && this._savedInputStyles) { + inputArea.style.cssText = ''; + inputArea.style.position = this._savedInputStyles.position || ''; + inputArea.style.bottom = this._savedInputStyles.bottom || ''; + } + + const header = document.querySelector('header'); + if (header && this._savedHeaderStyles) { + header.style.display = this._savedHeaderStyles.display || ''; + } + } + + _hideContainer() { + if (!this._container) return; + this._visible = false; + this._container.style.display = 'none'; + 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; + } + + this._restoreTerminal(); + } + + _resizeRenderer(w, h) { + if (this._renderer && this._camera) { + this._camera.aspect = w / h; + this._camera.updateProjectionMatrix(); + this._renderer.setSize(w, h); + } + } + + // ── Scene management ── + + _createScene() { + const T = this._THREE; + this._scene = new T.Scene(); + this._scene.background = new T.Color(this._backgroundColor); + + this._camera = new T.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 10000); + this._camera.position.set(0, 5, 15); + + this._createContainer(); + this._renderer = new T.WebGLRenderer({ antialias: true, alpha: false }); + this._renderer.setSize(window.innerWidth, window.innerHeight); + this._renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + this._renderer.shadowMap.enabled = true; + this._container.appendChild(this._renderer.domElement); + + // WebGL context loss handling + this._renderer.domElement.addEventListener('webglcontextlost', this._webglLostHandler = (e) => { + e.preventDefault(); + this._stopLoop(); + this.terminal?.print('⚠ WebGL context lost', 'warning'); + }); + this._renderer.domElement.addEventListener('webglcontextrestored', this._webglRestoredHandler = () => { + this._startLoop(); + this.terminal?.print('✅ WebGL context restored', 'success'); + }); + + this._controls = new T.OrbitControls(this._camera, this._renderer.domElement); + this._controls.enableDamping = true; + this._controls.dampingFactor = 0.08; + this._controls.minDistance = 2; + this._controls.maxDistance = 500; + + // Lights + const ambient = new T.AmbientLight(0x404060); + this._scene.add(ambient); + const dirLight = new T.DirectionalLight(0xffffff, 1); + dirLight.position.set(10, 20, 10); + dirLight.castShadow = true; + this._scene.add(dirLight); + const fillLight = new T.DirectionalLight(0x8888ff, 0.4); + fillLight.position.set(-10, 0, -10); + this._scene.add(fillLight); + + // Grid + const grid = new T.GridHelper(30, 20, 0x00ff88, 0x333355); + this._scene.add(grid); + + // Resize handler — remove old first to prevent leak + if (window._threeResizeHandler) { + window.removeEventListener('resize', window._threeResizeHandler); + } + window._threeResizeHandler = () => { + if (this._camera && this._renderer && this._visible) { + if (this._mode === 'top') { + this._resizeRenderer(window.innerWidth, window.innerHeight / 2); + } else { + this._resizeRenderer(window.innerWidth, window.innerHeight); + } + } + }; + window.addEventListener('resize', window._threeResizeHandler); + + this._objects = []; + this._idCounter = 0; + } + + _startLoop() { + if (this._animId) cancelAnimationFrame(this._animId); + const loop = () => { + this._animId = requestAnimationFrame(loop); + if (this._controls) this._controls.update(); + if (this._renderer && this._scene && this._camera) { + this._renderer.render(this._scene, this._camera); + } + }; + loop(); + } + + _stopLoop() { + if (this._animId) { + cancelAnimationFrame(this._animId); + this._animId = null; + } + } + + // ── Lifecycle ── + + destroy() { + this._stopLoop(); + this._hideContainer(); + // Remove event listeners + if (window._threeResizeHandler) { + window.removeEventListener('resize', window._threeResizeHandler); + window._threeResizeHandler = null; + } + if (this._escHandler) { + document.removeEventListener('keydown', this._escHandler); + this._escHandler = null; + } + if (this._renderer) { + 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; + } + // Dispose all GPU resources + this._disposeAllObjects(); + // Remove DOM elements + if (this._container) { + this._container.remove(); + this._container = null; + } + if (this._closeBtn) { + this._closeBtn.remove(); + this._closeBtn = null; + } + this._scene = null; + this._camera = null; + this._controls = null; + this._loaded = false; + this._visible = false; + } + + _disposeAllObjects() { + for (const obj of this._objects) { + if (this._scene) this._scene.remove(obj.mesh); + if (obj.mesh.geometry) obj.mesh.geometry.dispose(); + if (obj.mesh.material) { + const mats = Array.isArray(obj.mesh.material) ? obj.mesh.material : [obj.mesh.material]; + mats.forEach(m => { + if (m.map) m.map.dispose(); + m.dispose(); + }); + } + } + this._objects = []; + this._idCounter = 0; + } + + _genId() { + return 'obj_' + (++this._idCounter); + } + + // ── Object creation ── + + _addBox(args) { + const T = this._THREE; + const size = parseFloat(args[0]) || 1; + const color = args[1] || '#818cf8'; + const geo = new T.BoxGeometry(size, size, size); + const mat = new T.MeshStandardMaterial({ color, metalness: 0.3, roughness: 0.6 }); + const mesh = new T.Mesh(geo, mat); + mesh.castShadow = true; + const id = this._genId(); + this._scene.add(mesh); + this._objects.push({ mesh, id, label: 'Box ' + id }); + this.terminal.print(`Added box (${id}) at origin`, 'success'); + return id; + } + + _addSphere(args) { + const T = this._THREE; + const radius = parseFloat(args[0]) || 0.8; + const color = args[1] || '#34d399'; + const geo = new T.SphereGeometry(radius, 32, 32); + const mat = new T.MeshStandardMaterial({ color, metalness: 0.2, roughness: 0.4 }); + const mesh = new T.Mesh(geo, mat); + mesh.castShadow = true; + const id = this._genId(); + this._scene.add(mesh); + this._objects.push({ mesh, id, label: 'Sphere ' + id }); + this.terminal.print(`Added sphere (${id}) at origin`, 'success'); + return id; + } + + _addTorus(args) { + const T = this._THREE; + const radius = parseFloat(args[0]) || 1; + const tube = parseFloat(args[1]) || 0.3; + const color = args[2] || '#e879f9'; + const geo = new T.TorusGeometry(radius, tube, 16, 32); + const mat = new T.MeshStandardMaterial({ color, metalness: 0.4, roughness: 0.3 }); + const mesh = new T.Mesh(geo, mat); + mesh.castShadow = true; + const id = this._genId(); + this._scene.add(mesh); + this._objects.push({ mesh, id, label: 'Torus ' + id }); + this.terminal.print(`Added torus (${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; + const text = args[0]; + const color = args[1] || '#22d3ee'; + const canvas = document.createElement('canvas'); + canvas.width = 256; + canvas.height = 64; + const ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, 256, 64); + ctx.font = 'bold 36px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = color; + ctx.fillText(text, 128, 32); + const texture = new T.CanvasTexture(canvas); + const mat = new T.SpriteMaterial({ map: texture, transparent: true }); + const sprite = new T.Sprite(mat); + sprite.scale.set(4, 1, 1); + const id = this._genId(); + this._scene.add(sprite); + this._objects.push({ mesh: sprite, id, label: 'Text: ' + text }); + this.terminal.print(`Added text "${text}" (${id})`, 'success'); + return id; + } + + _addParticles(args) { + const T = this._THREE; + const count = parseInt(args[0]) || 500; + const color = args[1] || '#00ff88'; + const geo = new T.BufferGeometry(); + const positions = new Float32Array(count * 3); + for (let i = 0; i < count * 3; i++) { + positions[i] = (Math.random() - 0.5) * 40; + } + geo.setAttribute('position', new T.BufferAttribute(positions, 3)); + const mat = new T.PointsMaterial({ + color, size: 0.15, transparent: true, opacity: 0.8, blending: T.AdditiveBlending + }); + 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.terminal.print(`Added ${count} particles (${id})`, 'success'); + return id; + } + + _addLine(args) { + const T = this._THREE; + if (args.length < 6) return this.terminal.print('Usage: three add line <x1> <y1> <z1> <x2> <y2> <z2> [color]', 'warning'); + const x1 = parseFloat(args[0]), y1 = parseFloat(args[1]), z1 = parseFloat(args[2]); + const x2 = parseFloat(args[3]), y2 = parseFloat(args[4]), z2 = parseFloat(args[5]); + const color = args[6] || '#00ff88'; + const points = [ + new T.Vector3(x1, y1, z1), + new T.Vector3(x2, y2, z2) + ]; + const geo = new T.BufferGeometry().setFromPoints(points); + const mat = new T.LineBasicMaterial({ color }); + 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.terminal.print(`Added line (${id})`, 'success'); + return id; + } + + // ── Position helpers ── + + _setPosition(id, x, y, z) { + const obj = this._objects.find(o => o.id === 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); + if (!obj) return this.terminal.print('Object not found: ' + id, 'error'); + obj.mesh.rotation.set(rx, ry, rz); + this.terminal.print(`Rotated ${id}`, 'success'); + } + + // ── 3D Mindmap ── + + _renderMindmap(args) { + const mm = this.terminal.moduleManager.modules['MindmapModule']; + if (!mm) return this.terminal.print('MindmapModule not loaded', 'error'); + const cur = mm._ensureMindmap(); + if (!cur) return this.terminal.print('No mindmap. Create one with mindmap new first.', 'warning'); + const root = cur.root; + + if (!this._scene) this._createScene(); + this._showContainer(this._mode); + this._clearScene(); + + const T = this._THREE; + const colorMap = { + red: '#fb7185', green: '#34d399', yellow: '#fbbf24', blue: '#818cf8', + cyan: '#22d3ee', magenta: '#e879f9', white: '#e0e0e0' + }; + + const nodes = []; + const edges = []; + + const layout = (node, parentPos, depth, angle, spread) => { + const pos = { x: 0, y: 0, z: 0 }; + if (!parentPos) { + pos.x = 0; pos.y = 4; pos.z = 0; + } else { + const radius = 2 + depth * 2.5; + pos.x = parentPos.x + Math.cos(angle) * radius; + pos.z = parentPos.z + Math.sin(angle) * radius; + pos.y = parentPos.y - 1.5; + } + nodes.push({ ...node, pos, depth, color: colorMap[node.color] || '#818cf8' }); + if (parentPos) edges.push({ from: parentPos, to: pos }); + if (node.children) { + const count = node.children.length; + const startAngle = angle - spread / 2; + node.children.forEach((child, i) => { + const childAngle = startAngle + (i / (count || 1)) * spread; + layout(child, pos, depth + 1, childAngle, Math.min(spread * 0.7, Math.PI)); + }); + } + }; + 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); + const mat = new T.MeshStandardMaterial({ + color: n.color, emissive: n.color, emissiveIntensity: 0.15, + metalness: 0.3, roughness: 0.5 + }); + const mesh = new T.Mesh(geo, mat); + mesh.position.set(n.pos.x, n.pos.y, n.pos.z); + 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 }); + + // Text label sprite + const canvas = document.createElement('canvas'); + canvas.width = 512; canvas.height = 96; + const ctx = canvas.getContext('2d'); + ctx.font = 'bold 36px monospace'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillStyle = n.color; + const label = n.text.length > 16 ? n.text.substring(0, 14) + '..' : n.text; + ctx.fillText(label, 256, 48); + const tex = new T.CanvasTexture(canvas); + const spriteMat = new T.SpriteMaterial({ map: tex, transparent: true, depthTest: false }); + const sprite = new T.Sprite(spriteMat); + 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 }); + } + + for (const e of edges) { + const pts = [ + new T.Vector3(e.from.x, e.from.y, e.from.z), + new T.Vector3((e.from.x + e.to.x) / 2, (e.from.y + e.to.y) / 2 - 0.5, (e.from.z + e.to.z) / 2), + new T.Vector3(e.to.x, e.to.y, e.to.z) + ]; + const curve = new T.QuadraticBezierCurve3(pts[0], pts[1], pts[2]); + const curvePoints = curve.getPoints(20); + const geo = new T.BufferGeometry().setFromPoints(curvePoints); + 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' }); + } + + if (nodeMeshes.length > 0) { + const box = new T.Box3().setFromObjects(nodeMeshes); + const center = box.getCenter(new T.Vector3()); + const size = box.getSize(new T.Vector3()); + const maxDim = Math.max(size.x, size.y, size.z); + const dist = maxDim * 1.5 + 5; + this._camera.position.set(center.x + dist * 0.5, center.y + dist * 0.3, center.z + dist); + this._controls.target.copy(center); + this._controls.update(); + } + + this.terminal.print(`🧊 3D mindmap rendered: ${nodes.length} nodes, ${edges.length} edges`, 'success'); + } + + // ── Presets ── + + _presetSolarSystem() { + if (!this._scene) this._createScene(); + this._showContainer(this._mode); + this._clearScene(); + const T = this._THREE; + + const sunGeo = new T.SphereGeometry(1.5, 32, 32); + 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 planets = [ + { name: 'Mercury', radius: 0.15, orbit: 2.5, color: 0xaaaaaa, speed: 0.02 }, + { name: 'Venus', radius: 0.25, orbit: 4, color: 0xffcc88, speed: 0.015 }, + { name: 'Earth', radius: 0.3, orbit: 5.5, color: 0x4488ff, speed: 0.01 }, + { name: 'Mars', radius: 0.2, orbit: 7, color: 0xff6644, speed: 0.008 }, + { name: 'Jupiter', radius: 0.6, orbit: 9, color: 0xddbb88, speed: 0.005 }, + { name: 'Saturn', radius: 0.5, orbit: 11, color: 0xeeddbb, speed: 0.003 }, + ]; + for (const p of planets) { + const geo = new T.SphereGeometry(p.radius, 16, 16); + const mat = new T.MeshStandardMaterial({ color: p.color }); + const mesh = new T.Mesh(geo, mat); + 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 ringPoints = []; + for (let i = 0; i <= 64; i++) { + const a = (i / 64) * Math.PI * 2; + ringPoints.push(new T.Vector3(Math.cos(a) * p.orbit, 0, Math.sin(a) * p.orbit)); + } + const ringGeo = new T.BufferGeometry().setFromPoints(ringPoints); + 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 }); + } + this.terminal.print('🌌 Solar system preset loaded', 'success'); + } + + _presetGrid(args) { + if (!this._scene) this._createScene(); + this._showContainer(this._mode); + this._clearScene(); + const T = this._THREE; + const size = parseInt(args[0]) || 5; + const spacing = parseFloat(args[1]) || 2; + + 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})` }); + } + } + this.terminal.print(`📐 Grid preset: ${(size*2+1)**2} cubes`, 'success'); + } + + // ── Export ── + + _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 T = this._THREE; + + const objects = []; + for (const obj of this._objects) { + 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; + objects.push({ + type: m.type, id: obj.id, label: obj.label, + position: m.position.toArray(), rotation: m.rotation.toArray(), scale: m.scale.toArray(), + color: hexColor + }); + } + } + + const cameraPos = this._camera ? this._camera.position.toArray() : [0, 5, 15]; + const bgColor = '#' + new T.Color(this._backgroundColor).getHexString(); + + const html = `<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>3D Scene: ${name}</title> + <script src="three.min.js"></script> + <script src="OrbitControls.js"></script> + <style> + * { margin: 0; padding: 0; box-sizing: border-box; } + body { background: ${bgColor}; overflow: hidden; font-family: monospace; } + #info { + position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); + color: #888; font-size: 12px; background: rgba(0,0,0,0.6); + padding: 8px 16px; border-radius: 8px; pointer-events: none; + border: 1px solid #333; + } + </style> +</head> +<body> + <div id="info">${name} — ${objects.length} objects — Drag to orbit, scroll to zoom</div> + <script> + const scene = new THREE.Scene(); + scene.background = new THREE.Color('${bgColor}'); + const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 10000); + camera.position.set(${cameraPos.join(', ')}); + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setSize(window.innerWidth, window.innerHeight); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + document.body.appendChild(renderer.domElement); + const controls = new THREE.OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + scene.add(new THREE.AmbientLight(0x404060)); + const dl = new THREE.DirectionalLight(0xffffff, 1); + dl.position.set(10, 20, 10); + scene.add(dl); + const fl = new THREE.DirectionalLight(0x8888ff, 0.4); + fl.position.set(-10, 0, -10); + scene.add(fl); + scene.add(new THREE.GridHelper(30, 20, 0x00ff88, 0x333355)); + const data = ${JSON.stringify(objects)}; + data.forEach(function(d) { + var mesh; + if (d.type === 'Mesh') { + var geo = new THREE.SphereGeometry(0.5, 16, 16); + var mat = new THREE.MeshStandardMaterial({ color: d.color != null ? d.color : 0x818cf8 }); + mesh = new THREE.Mesh(geo, mat); + } else if (d.type === 'Points') { + var geo = new THREE.BufferGeometry(); + var pos = new Float32Array(500 * 3); + for (var i = 0; i < 1500; i++) pos[i] = (Math.random() - 0.5) * 40; + geo.setAttribute('position', new THREE.BufferAttribute(pos, 3)); + var mat = new THREE.PointsMaterial({ color: d.color != null ? d.color : 0x00ff88, size: 0.15, transparent: true }); + mesh = new THREE.Points(geo, mat); + } else if (d.type === 'Sprite') { + var canvas = document.createElement('canvas'); + canvas.width = 512; canvas.height = 96; + var ctx = canvas.getContext('2d'); + ctx.font = 'bold 36px monospace'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillStyle = d.color != null ? '#' + d.color.toString(16).padStart(6, '0') : '#22d3ee'; + ctx.fillText(d.label || '?', 256, 48); + var tex = new THREE.CanvasTexture(canvas); + var mat = new THREE.SpriteMaterial({ map: tex, transparent: true }); + mesh = new THREE.Sprite(mat); + } else { return; } + mesh.position.set(d.position[0], d.position[1], d.position[2]); + if (d.rotation) mesh.rotation.set(d.rotation[0], d.rotation[1], d.rotation[2]); + if (d.scale) mesh.scale.set(d.scale[0], d.scale[1], d.scale[2]); + scene.add(mesh); + }); + function animate() { + requestAnimationFrame(animate); + controls.update(); + renderer.render(scene, camera); + } + animate(); + window.addEventListener('resize', function() { + camera.aspect = window.innerWidth / window.innerHeight; + camera.updateProjectionMatrix(); + renderer.setSize(window.innerWidth, window.innerHeight); + }); + </script> +</body> +</html>`; + + try { + const fs = this.terminal.fs; + fs.mkdirRecursive('/exports'); + fs.writeFile('/exports/' + name + '.html', html); + this.terminal.print(`💾 Exported to /exports/${name}.html (${(html.length / 1024).toFixed(1)}KB)`, 'success'); + } catch (e) { + this.terminal.print('Failed to save export: ' + e.message, 'error'); + } + } + + // ── Scene helpers ── + + _clearScene() { + if (!this._scene) return; + this._disposeAllObjects(); + } + + _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) { + const pos = obj.mesh.position; + this.terminal.print(` ${obj.id} ${obj.label} @ (${pos.x.toFixed(1)}, ${pos.y.toFixed(1)}, ${pos.z.toFixed(1)})`); + } + } + + _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]; + this._scene.remove(obj.mesh); + if (obj.mesh.geometry) obj.mesh.geometry.dispose(); + if (obj.mesh.material) { + const mats = Array.isArray(obj.mesh.material) ? obj.mesh.material : [obj.mesh.material]; + mats.forEach(m => { + if (m.map) m.map.dispose(); + m.dispose(); + }); + } + this._objects.splice(idx, 1); + 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'); + this._backgroundColor = color; + if (this._scene) this._scene.background = new this._THREE.Color(color); + this.terminal.print(`Background set to ${args[0]}`, 'success'); + } + + // ── Main dispatch ── + + async _dispatch(args) { + if (!args[0] || args[0] === 'help' || args[0] === '--help' || args[0] === '-h') { + this._printHelp(); + return; + } + + const loaded = await this._ensureLoaded(); + if (!loaded && args[0] !== 'help') return; + + const sub = args[0]; + const rest = args.slice(1); + + switch (sub) { + case 'scene': + if (!this._scene) this._createScene(); + this._mode = 'top'; + this._showContainer('top'); + this.terminal.print('🎨 3D scene — top panel mode. Use three fullscreen for full view, Esc to close.', 'success'); + break; + case 'fullscreen': + if (!this._scene) this._createScene(); + this._mode = 'full'; + this._showContainer('full'); + this.terminal.print('🎨 3D scene — fullscreen mode. Terminal floats on top. Esc to close.', 'success'); + break; + case 'mode': { + const mode = rest[0]; + if (!mode || (mode !== 'top' && mode !== 'full')) { + return this.terminal.print('Usage: three mode top|full (current: ' + this._mode + ')', 'warning'); + } + if (!this._scene) return this.terminal.print('No scene. Create one with three scene first.', 'warning'); + this._mode = mode; + this._showContainer(mode); + this.terminal.print(`Switched to ${mode} panel mode`, 'success'); + break; + } + 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 (!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 'torus': this._addTorus(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'); + } + 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])); + this._controls.update(); + this.terminal.print(`Camera moved to (${rest[0]}, ${rest[1]}, ${rest[2]})`, 'success'); + break; + } + case 'orbit': { + if (!this._controls) return this.terminal.print('No scene.', 'warning'); + this._orbitEnabled = !this._orbitEnabled; + this._controls.enabled = this._orbitEnabled; + this.terminal.print(`Orbit controls: ${this._orbitEnabled ? 'ON' : 'OFF'}`, 'info'); + break; + } + case 'clear': + this._clearScene(); + this.terminal.print('Scene cleared', 'success'); + break; + case 'list': + this._listObjects(); + break; + case 'remove': + 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 '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'); + break; + } + case 'bgcolor': + this._setBgColor(rest); + break; + case 'hide': + this._hideContainer(); + this.terminal.print('3D view closed. Use three scene or three fullscreen to reopen.', 'info'); + 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('', ''); + this.terminal.print(' ── Scene ──', 'info'); + this.terminal.print(' three scene Open 3D in top panel (default)'); + this.terminal.print(' three fullscreen Open 3D fullscreen with terminal overlay'); + this.terminal.print(' three mode top|full Switch mode without closing'); + 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('', ''); + 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 torus [r] [tube] [c] Add a torus'); + 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 pos <id> <x> <y> <z> Move an object'); + 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(' ── 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('', ''); + this.terminal.print(' ── Export ──', 'info'); + this.terminal.print(' three export [name] Export scene as standalone HTML'); + this.terminal.print('', ''); + this.terminal.print(' ── AI Tools ──', 'info'); + this.terminal.print(' The AI can create 3D scenes via create_3d_scene tool.'); + this.terminal.print(' The AI can render mindmaps in 3D via visualize_mindmap_3d tool.'); + } + + // ── AI Tool Interface ── + + async createSceneFromDescription(description) { + const loaded = await this._ensureLoaded(); + if (!loaded) return 'Failed to load Three.js'; + if (!this._scene) this._createScene(); + this._showContainer(this._mode); + this._clearScene(); + + const T = this._THREE; + const desc = description.toLowerCase(); + const results = []; + + const boxMatch = desc.match(/(\d+)?\s*box(?:es)?/); + if (boxMatch) { + const count = parseInt(boxMatch[1]) || 1; + for (let i = 0; i < count; i++) { + const id = this._addBox(['0.8', '#818cf8']); + const angle = (i / count) * Math.PI * 2; + this._setPosition(id, Math.cos(angle) * 3, 0, Math.sin(angle) * 3); + results.push('box ' + id); + } + } + + const sphereMatch = desc.match(/(\d+)?\s*sphere(?:s)?/); + if (sphereMatch) { + const count = parseInt(sphereMatch[1]) || 1; + for (let i = 0; i < count; i++) { + const id = this._addSphere(['0.6', '#34d399']); + const angle = (i / count) * Math.PI * 2 + 0.5; + this._setPosition(id, Math.cos(angle) * 3, 1, Math.sin(angle) * 3); + results.push('sphere ' + id); + } + } + + if (desc.includes('particles') || desc.includes('stars') || desc.includes('starfield')) { + this._addParticles(['800', '#00ff88']); + results.push('particles'); + } + + const textMatch = desc.match(/text["']?[:]?\s*["']?([^"']+)["']?/); + if (textMatch) { + this._addText([textMatch[1].trim(), '#22d3ee']); + results.push('text'); + } + + if (desc.includes('torus') || desc.includes('donut') || desc.includes('ring')) { + this._addTorus(['1.2', '0.3', '#e879f9']); + results.push('torus'); + } + + if (desc.includes('solar') || desc.includes('planet')) { + this._presetSolarSystem(); + return 'Created solar system preset'; + } + + if (desc.includes('grid')) { + this._presetGrid(['4', '2']); + return 'Created grid preset'; + } + + 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); + results.push('default scene'); + } + + return 'Created 3D scene with: ' + results.join(', '); + } + + async visualizeMindmap3D(name) { + const loaded = await this._ensureLoaded(); + if (!loaded) return 'Failed to load Three.js'; + const mm = this.terminal.moduleManager.modules['MindmapModule']; + if (!mm) return 'MindmapModule not loaded'; + const cur = mm._ensureMindmap(); + if (!cur) return 'No mindmap active. Create one first.'; + this._renderMindmap(name ? [name] : []); + const count = this._objects.filter(o => o.label.startsWith('Mindmap:')).length; + return `Rendered mindmap "${cur.root.text}" in 3D: ${count} nodes`; + } +} + +// Register +if (typeof window.webx11 !== 'undefined' && window.webx11) { + webx11.moduleManager.registerModule(new ThreeModule(), 'https://webx11.duckdns.org/spaces/newos4/modules/ext/three.js'); +} |