aboutsummaryrefslogtreecommitdiff
path: root/modules/ext
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-07-09 06:00:32 +0200
committerAgando <roduccomm@gmail.com>2026-07-09 06:00:32 +0200
commitcf7461ffeb4f53f3f50c9c692a2e4fdcecf17e82 (patch)
treee7780cd41271381cf108c9c81d71a3cb65cfd555 /modules/ext
parentea4d09fb5ccba12ed774fcf7c913edfa1e6a851a (diff)
auto: 2026-07-09_06:00 changes
Diffstat (limited to 'modules/ext')
-rw-r--r--modules/ext/critical-ext-bundle.js315
-rw-r--r--modules/ext/shell.js315
-rw-r--r--modules/ext/skill-pack-core.js298
-rw-r--r--modules/ext/three.js1411
-rw-r--r--modules/ext/tutorial.js6
-rw-r--r--modules/ext/voice.js91
6 files changed, 2089 insertions, 347 deletions
diff --git a/modules/ext/critical-ext-bundle.js b/modules/ext/critical-ext-bundle.js
index 3c5d54b..2230498 100644
--- a/modules/ext/critical-ext-bundle.js
+++ b/modules/ext/critical-ext-bundle.js
@@ -186,7 +186,18 @@ class ShellModule {
if (!rawInput.trim()) return;
this.markDirty();
this.printUserPrompt(rawInput);
-
+
+ // Save to command history (preserved from original executeCommand)
+ const cmdRaw = rawInput.trim();
+ if (this.history.length === 0 || this.history[this.history.length - 1] !== cmdRaw) {
+ this.history.push(cmdRaw);
+ }
+ if (this.history.length > this._historyCap) {
+ this.history = this.history.slice(-this._historyCap);
+ }
+ this.historyIndex = this.history.length;
+ this._saveCommandHistory();
+
// For simple single-word commands without special chars, skip shell parsing
if (/^[a-zA-Z0-9._\/-]+$/.test(rawInput.trim()) && !rawInput.includes(' ') && !rawInput.includes('$')) {
try {
@@ -196,19 +207,20 @@ class ShellModule {
return;
}
}
-
+
// Substitute variables in the raw input
const substituted = self._substituteVars(rawInput);
// Split on ; first (sequence)
const seqParts = self._splitSequence(substituted);
for (const seqPart of seqParts) {
if (!seqPart.trim()) continue;
- // Split on && and ||
+ // Split on && and || — operator now attached to the FOLLOWING segment
const chainParts = self._splitChain(seqPart.trim());
let lastOk = true;
for (const chainPart of chainParts) {
if (!chainPart.cmd.trim()) continue;
const { cmd: chainCmd, operator } = chainPart;
+ // Check the operator that PRECEDES this command
if (operator === '&&' && !lastOk) continue;
if (operator === '||' && lastOk) continue;
// Check for redirects
@@ -219,28 +231,40 @@ class ShellModule {
lastOk = redirectInfo.exitOk;
} else {
// Normal or piped command
- const pipeParts = self.terminal.splitPipe(chainCmd);
+ const pipeParts = this.splitPipe(chainCmd);
if (pipeParts.length > 1) {
- await self.terminal.executePipeChain(pipeParts);
+ await this.executePipeChain(pipeParts);
} else {
- // Single command
+ // Single command — check for shell built-ins first
const parts = chainCmd.split(/\s+/);
const cmd = parts[0].toLowerCase();
const args = parts.slice(1);
- try {
- await this.security.checkAndRun(chainCmd);
- } catch(e) {
- this.terminal && this.terminal.print ? this.terminal.print('Error: ' + e.message, 'error') : console.error(e);
+ if (self.commands[cmd]) {
+ try {
+ this.markDirty();
+ await self.commands[cmd](args);
+ lastOk = true;
+ } catch(e) {
+ this.print('Error: ' + e.message, 'error');
+ lastOk = false;
+ }
+ } else {
+ try {
+ await this.security.checkAndRun(chainCmd);
+ lastOk = true;
+ } catch(e) {
+ this.print('Error: ' + e.message, 'error');
+ lastOk = false;
+ }
}
}
- lastOk = true;
}
}
}
};
this.terminal._shellModule = this;
- // Modify pipe chain to read _pipeInput for commands that support it
+ // Modify pipe chain to add pipe-aware injection and security checks
const origPipeChain = this.terminal.executePipeChain.bind(this.terminal);
this.terminal.executePipeChain = async function(pipeParts) {
// Write pipe input from previous segment as temp file for next segment
@@ -251,6 +275,22 @@ class ShellModule {
const parts = cmdText.split(/\s+/);
const cmd = parts[0].toLowerCase();
let args = parts.slice(1);
+
+ // Security gate — replicate the original executePipeChain checks
+ const cls = this.security._classify(cmdText);
+ if (cls.destructive && this.security.level > 0) {
+ this.print(`⚠️ Security: Destructive command "${cmd}" blocked in pipe at level ${this.security.level}`, 'error');
+ return;
+ }
+ if (cls.system && this.security.level > 0) {
+ this.print(`⚠️ Security: System command "${cmd}" blocked in pipe at level ${this.security.level}`, 'error');
+ return;
+ }
+ if (!cls.known && this.security.level >= 2) {
+ this.print(`⚠️ Security: Unknown command "${cmd}" blocked in pipe (paranoid mode)`, 'error');
+ return;
+ }
+
this._pipeInput = pipeInput;
if (pipeInput !== null) {
@@ -265,16 +305,17 @@ class ShellModule {
}
}
- if (i < pipeParts.length - 1) {
- pipeInput = await this.capturePipeOutput(cmd, args);
- } else {
- await this.dispatchCommand(cmd, args);
- }
-
- // Clean up temp file
- if (this.fs.readFile('__pipebuf__') !== null) {
- this.fs.root.content['__pipebuf__'] = undefined;
- delete this.fs.root.content['__pipebuf__'];
+ try {
+ if (i < pipeParts.length - 1) {
+ pipeInput = await this.capturePipeOutput(cmd, args);
+ } else {
+ await this.dispatchCommand(cmd, args);
+ }
+ } finally {
+ // Clean up temp file
+ if (this.fs.readFile('__pipebuf__') !== null) {
+ delete this.fs.root.content['__pipebuf__'];
+ }
}
}
this._pipeInput = null;
@@ -307,10 +348,12 @@ class ShellModule {
_splitChain(input) {
// Split on && and ||, preserving operator info
+ // Operator is attached to the FOLLOWING segment (the one it guards)
const parts = [];
let current = '';
let inQuote = false;
let quoteChar = '';
+ let pendingOp = null; // operator from previous split, applies to next segment
let i = 0;
while (i < input.length) {
const ch = input[i];
@@ -319,16 +362,18 @@ class ShellModule {
} else if (ch === quoteChar && inQuote) {
inQuote = false; quoteChar = ''; current += ch; i++;
} else if (!inQuote && input.substring(i, i+2) === '&&') {
- parts.push({ cmd: current.trim(), operator: '&&' });
+ parts.push({ cmd: current.trim(), operator: pendingOp });
+ pendingOp = '&&';
current = ''; i += 2;
} else if (!inQuote && input.substring(i, i+2) === '||') {
- parts.push({ cmd: current.trim(), operator: '||' });
+ parts.push({ cmd: current.trim(), operator: pendingOp });
+ pendingOp = '||';
current = ''; i += 2;
} else {
current += ch; i++;
}
}
- parts.push({ cmd: current.trim(), operator: null });
+ parts.push({ cmd: current.trim(), operator: pendingOp });
return parts;
}
@@ -351,7 +396,7 @@ class ShellModule {
inQuote = false; quoteChar = ''; current += ch; i++;
} else if (!inQuote) {
if (input.substring(i, i+2) === '>>') {
- if (current.trim()) parts.push(current.trim());
+ if (current.trim()) { parts.push(current.trim()); current = ''; }
hasRedirect = true;
redirectOp = '>>';
append = true;
@@ -366,7 +411,7 @@ class ShellModule {
if (i < input.length && (input[i] === '>' || input[i] === '<')) continue;
break;
} else if (input.substring(i, i+1) === '>') {
- if (current.trim()) parts.push(current.trim());
+ if (current.trim()) { parts.push(current.trim()); current = ''; }
hasRedirect = true;
redirectOp = '>';
append = false;
@@ -380,7 +425,7 @@ class ShellModule {
if (i < input.length && (input[i] === '>' || input[i] === '<')) continue;
break;
} else if (input.substring(i, i+1) === '<') {
- if (current.trim()) parts.push(current.trim());
+ if (current.trim()) { parts.push(current.trim()); current = ''; }
hasRedirect = true;
redirectOp = '<';
i += 1;
@@ -421,28 +466,38 @@ class ShellModule {
const inputContent = fs.readFile(redirectFile);
if (inputContent === null) {
this.terminal.print(`shell: ${redirectFile}: No such file`, 'error');
+ redirectInfo.exitOk = false;
return;
}
this.terminal._pipeInput = inputContent;
- // Execute the command with pipe input available
- const parts = cmdText.split(/\s+/);
- const cmd = parts[0].toLowerCase();
- const args = parts.slice(1);
- // If command reads files, inject pipe content as temp file
- if (this._isPipeAware(cmd)) {
- fs.writeFile('__pipebuf__', inputContent);
- const newArgs = [...args];
- if (!newArgs.some(a => !a.startsWith('-'))) {
- newArgs.push('__pipebuf__');
- }
- await this.terminal.dispatchCommand(cmd, newArgs);
- if (fs.readFile('__pipebuf__') !== null) {
- delete fs.root.content['__pipebuf__'];
+ try {
+ const parts = cmdText.split(/\s+/);
+ const cmd = parts[0].toLowerCase();
+ const args = parts.slice(1);
+ // If command reads files, inject pipe content as temp file
+ if (this._isPipeAware(cmd)) {
+ fs.writeFile('__pipebuf__', inputContent);
+ const newArgs = [...args];
+ if (!newArgs.some(a => !a.startsWith('-'))) {
+ newArgs.push('__pipebuf__');
+ }
+ try {
+ await this.terminal.dispatchCommand(cmd, newArgs);
+ } finally {
+ if (fs.readFile('__pipebuf__') !== null) {
+ delete fs.root.content['__pipebuf__'];
+ }
+ }
+ } else {
+ await this.terminal.dispatchCommand(cmd, args);
}
- } else {
- await this.terminal.dispatchCommand(cmd, args);
+ redirectInfo.exitOk = true;
+ } catch(e) {
+ this.terminal.print('Error: ' + e.message, 'error');
+ redirectInfo.exitOk = false;
+ } finally {
+ this.terminal._pipeInput = null;
}
- this.terminal._pipeInput = null;
return;
}
@@ -450,42 +505,57 @@ class ShellModule {
const originalPrint = this.terminal.print.bind(this.terminal);
let output = '';
this.terminal.print = (text, type) => { output += text + '\n'; };
+ let cmdOk = true;
- // Check for pipes within the command before redirect
- const pipeParts = this.terminal.splitPipe(cmdText);
- if (pipeParts.length > 1) {
- // Execute pipe chain manually to capture all output
- let pipeInput = null;
- for (let i = 0; i < pipeParts.length; i++) {
- const pText = pipeParts[i];
- const pParts = pText.split(/\s+/);
- const pCmd = pParts[0].toLowerCase();
- const pArgs = pParts.slice(1);
- if (pipeInput !== null) {
- fs.writeFile('__pipebuf__', pipeInput);
- }
- if (i < pipeParts.length - 1) {
- const captPrint = this.terminal.print.bind(this.terminal);
- let capt = '';
- this.terminal.print = (t, ty) => { capt += t + '\n'; };
- await this.terminal.dispatchCommand(pCmd, pArgs);
- this.terminal.print = captPrint;
- pipeInput = capt.trim();
- } else {
- await this.terminal.dispatchCommand(pCmd, pArgs);
- }
- if (fs.readFile('__pipebuf__') !== null) {
- delete fs.root.content['__pipebuf__'];
+ try {
+ // Check for pipes within the command before redirect
+ const pipeParts = this.terminal.splitPipe(cmdText);
+ if (pipeParts.length > 1) {
+ // Execute pipe chain manually to capture all output
+ let pipeInput = null;
+ for (let i = 0; i < pipeParts.length; i++) {
+ const pText = pipeParts[i];
+ const pParts = pText.split(/\s+/);
+ const pCmd = pParts[0].toLowerCase();
+ const pArgs = pParts.slice(1);
+ if (pipeInput !== null) {
+ fs.writeFile('__pipebuf__', pipeInput);
+ }
+ if (i < pipeParts.length - 1) {
+ const captPrint = this.terminal.print.bind(this.terminal);
+ let capt = '';
+ this.terminal.print = (t, ty) => { capt += t + '\n'; };
+ try {
+ await this.terminal.dispatchCommand(pCmd, pArgs);
+ } finally {
+ this.terminal.print = captPrint;
+ }
+ pipeInput = capt.trim();
+ } else {
+ await this.terminal.dispatchCommand(pCmd, pArgs);
+ }
+ if (fs.readFile('__pipebuf__') !== null) {
+ delete fs.root.content['__pipebuf__'];
+ }
}
+ } else {
+ const parts = cmdText.split(/\s+/);
+ const cmd = parts[0].toLowerCase();
+ const args = parts.slice(1);
+ await this.terminal.dispatchCommand(cmd, args);
}
- } else {
- const parts = cmdText.split(/\s+/);
- const cmd = parts[0].toLowerCase();
- const args = parts.slice(1);
- await this.terminal.dispatchCommand(cmd, args);
+ } catch(e) {
+ cmdOk = false;
+ // Restore print before showing the error
+ this.terminal.print = originalPrint;
+ this.terminal.print('Error: ' + e.message, 'error');
+ redirectInfo.exitOk = false;
+ return;
+ } finally {
+ // Always restore print — critical: without this, terminal is permanently broken
+ this.terminal.print = originalPrint;
}
- this.terminal.print = originalPrint;
const captured = output.trimEnd();
if (captured) {
@@ -503,6 +573,7 @@ class ShellModule {
this.terminal.print(`Redirected (empty) to ${redirectFile}`, 'success');
}
}
+ redirectInfo.exitOk = cmdOk;
}
_isPipeAware(cmd) {
@@ -622,54 +693,66 @@ class ShellModule {
const content = this.terminal.fs.readFile(path);
if (content === null) return this.terminal.print(`script: ${path}: No such file`, 'error');
this._scriptDepth++;
- this.terminal.print(`Running script: ${path}`, 'info');
- const lines = content.split('\n');
- let lineNum = 0;
- for (const rawLine of lines) {
- lineNum++;
- const line = rawLine.trim();
- if (!line || line.startsWith('#')) continue;
- // Run each line through the shell (by calling executeCommand directly)
- // We need to manually call the wrapped executeCommand
- this.terminal.print(`[${path}:${lineNum}] ${line}`, 'command-prompt');
- // Parse inline: handle pipe, redirect, chain, sequence
- const substituted = this._substituteVars(line);
- const seqParts = this._splitSequence(substituted);
- for (const seqPart of seqParts) {
- if (!seqPart.trim()) continue;
- const chainParts = this._splitChain(seqPart.trim());
- let lastOk = true;
- for (const chainPart of chainParts) {
- if (!chainPart.cmd.trim()) continue;
- if (chainPart.operator === '&&' && !lastOk) continue;
- if (chainPart.operator === '||' && lastOk) continue;
- const redirectInfo = this._extractRedirects(chainPart.cmd);
- if (redirectInfo.hasRedirect) {
- await this._executeWithRedirect(redirectInfo);
- lastOk = redirectInfo.exitOk;
- } else {
- const pipeParts = this.terminal.splitPipe(chainPart.cmd);
- if (pipeParts.length > 1) {
- await this.terminal.executePipeChain(pipeParts);
+ try {
+ this.terminal.print(`Running script: ${path}`, 'info');
+ const lines = content.split('\n');
+ let lineNum = 0;
+ for (const rawLine of lines) {
+ lineNum++;
+ const line = rawLine.trim();
+ if (!line || line.startsWith('#')) continue;
+ this.terminal.print(`[${path}:${lineNum}] ${line}`, 'command-prompt');
+ const substituted = this._substituteVars(line);
+ const seqParts = this._splitSequence(substituted);
+ for (const seqPart of seqParts) {
+ if (!seqPart.trim()) continue;
+ const chainParts = this._splitChain(seqPart.trim());
+ let lastOk = true;
+ for (const chainPart of chainParts) {
+ if (!chainPart.cmd.trim()) continue;
+ if (chainPart.operator === '&&' && !lastOk) continue;
+ if (chainPart.operator === '||' && lastOk) continue;
+ const redirectInfo = this._extractRedirects(chainPart.cmd);
+ if (redirectInfo.hasRedirect) {
+ await this._executeWithRedirect(redirectInfo);
+ lastOk = redirectInfo.exitOk;
} else {
- const parts = chainPart.cmd.split(/\s+/);
- const cmd = parts[0].toLowerCase();
- const args = parts.slice(1);
- // Check if it's our built-in command
- if (this.commands[cmd]) {
- this.terminal.markDirty();
- await this.commands[cmd](args);
+ const pipeParts = this.terminal.splitPipe(chainPart.cmd);
+ if (pipeParts.length > 1) {
+ await this.terminal.executePipeChain(pipeParts);
} else {
- await this.terminal.security.checkAndRun(chainPart.cmd);
+ const parts = chainPart.cmd.split(/\s+/);
+ const cmd = parts[0].toLowerCase();
+ const args = parts.slice(1);
+ // Check if it's a shell built-in command
+ if (this.commands[cmd]) {
+ try {
+ this.terminal.markDirty();
+ await this.commands[cmd](args);
+ lastOk = true;
+ } catch(e) {
+ this.terminal.print('Error: ' + e.message, 'error');
+ lastOk = false;
+ }
+ } else {
+ try {
+ await this.terminal.security.checkAndRun(chainPart.cmd);
+ lastOk = true;
+ } catch(e) {
+ this.terminal.print('Error: ' + e.message, 'error');
+ lastOk = false;
+ }
+ }
}
}
- lastOk = true;
}
}
}
+ this.terminal.print(`Script ${path} completed (${lineNum} lines)`, 'success');
+ } finally {
+ // Always decrement — even on error or thrown exception
+ this._scriptDepth--;
}
- this._scriptDepth--;
- this.terminal.print(`Script ${path} completed (${lineNum} lines)`, 'success');
}
}
if (typeof window.webx11 !== 'undefined' && window.webx11) {
diff --git a/modules/ext/shell.js b/modules/ext/shell.js
index 55d66de..80bc841 100644
--- a/modules/ext/shell.js
+++ b/modules/ext/shell.js
@@ -50,7 +50,18 @@ class ShellModule {
if (!rawInput.trim()) return;
this.markDirty();
this.printUserPrompt(rawInput);
-
+
+ // Save to command history (preserved from original executeCommand)
+ const cmdRaw = rawInput.trim();
+ if (this.history.length === 0 || this.history[this.history.length - 1] !== cmdRaw) {
+ this.history.push(cmdRaw);
+ }
+ if (this.history.length > this._historyCap) {
+ this.history = this.history.slice(-this._historyCap);
+ }
+ this.historyIndex = this.history.length;
+ this._saveCommandHistory();
+
// For simple single-word commands without special chars, skip shell parsing
if (/^[a-zA-Z0-9._\/-]+$/.test(rawInput.trim()) && !rawInput.includes(' ') && !rawInput.includes('$')) {
try {
@@ -60,19 +71,20 @@ class ShellModule {
return;
}
}
-
+
// Substitute variables in the raw input
const substituted = self._substituteVars(rawInput);
// Split on ; first (sequence)
const seqParts = self._splitSequence(substituted);
for (const seqPart of seqParts) {
if (!seqPart.trim()) continue;
- // Split on && and ||
+ // Split on && and || — operator now attached to the FOLLOWING segment
const chainParts = self._splitChain(seqPart.trim());
let lastOk = true;
for (const chainPart of chainParts) {
if (!chainPart.cmd.trim()) continue;
const { cmd: chainCmd, operator } = chainPart;
+ // Check the operator that PRECEDES this command
if (operator === '&&' && !lastOk) continue;
if (operator === '||' && lastOk) continue;
// Check for redirects
@@ -83,28 +95,40 @@ class ShellModule {
lastOk = redirectInfo.exitOk;
} else {
// Normal or piped command
- const pipeParts = self.terminal.splitPipe(chainCmd);
+ const pipeParts = this.splitPipe(chainCmd);
if (pipeParts.length > 1) {
- await self.terminal.executePipeChain(pipeParts);
+ await this.executePipeChain(pipeParts);
} else {
- // Single command
+ // Single command — check for shell built-ins first
const parts = chainCmd.split(/\s+/);
const cmd = parts[0].toLowerCase();
const args = parts.slice(1);
- try {
- await this.security.checkAndRun(chainCmd);
- } catch(e) {
- this.terminal && this.terminal.print ? this.terminal.print('Error: ' + e.message, 'error') : console.error(e);
+ if (self.commands[cmd]) {
+ try {
+ this.markDirty();
+ await self.commands[cmd](args);
+ lastOk = true;
+ } catch(e) {
+ this.print('Error: ' + e.message, 'error');
+ lastOk = false;
+ }
+ } else {
+ try {
+ await this.security.checkAndRun(chainCmd);
+ lastOk = true;
+ } catch(e) {
+ this.print('Error: ' + e.message, 'error');
+ lastOk = false;
+ }
}
}
- lastOk = true;
}
}
}
};
this.terminal._shellModule = this;
- // Modify pipe chain to read _pipeInput for commands that support it
+ // Modify pipe chain to add pipe-aware injection and security checks
const origPipeChain = this.terminal.executePipeChain.bind(this.terminal);
this.terminal.executePipeChain = async function(pipeParts) {
// Write pipe input from previous segment as temp file for next segment
@@ -115,6 +139,22 @@ class ShellModule {
const parts = cmdText.split(/\s+/);
const cmd = parts[0].toLowerCase();
let args = parts.slice(1);
+
+ // Security gate — replicate the original executePipeChain checks
+ const cls = this.security._classify(cmdText);
+ if (cls.destructive && this.security.level > 0) {
+ this.print(`⚠️ Security: Destructive command "${cmd}" blocked in pipe at level ${this.security.level}`, 'error');
+ return;
+ }
+ if (cls.system && this.security.level > 0) {
+ this.print(`⚠️ Security: System command "${cmd}" blocked in pipe at level ${this.security.level}`, 'error');
+ return;
+ }
+ if (!cls.known && this.security.level >= 2) {
+ this.print(`⚠️ Security: Unknown command "${cmd}" blocked in pipe (paranoid mode)`, 'error');
+ return;
+ }
+
this._pipeInput = pipeInput;
if (pipeInput !== null) {
@@ -129,16 +169,17 @@ class ShellModule {
}
}
- if (i < pipeParts.length - 1) {
- pipeInput = await this.capturePipeOutput(cmd, args);
- } else {
- await this.dispatchCommand(cmd, args);
- }
-
- // Clean up temp file
- if (this.fs.readFile('__pipebuf__') !== null) {
- this.fs.root.content['__pipebuf__'] = undefined;
- delete this.fs.root.content['__pipebuf__'];
+ try {
+ if (i < pipeParts.length - 1) {
+ pipeInput = await this.capturePipeOutput(cmd, args);
+ } else {
+ await this.dispatchCommand(cmd, args);
+ }
+ } finally {
+ // Clean up temp file
+ if (this.fs.readFile('__pipebuf__') !== null) {
+ delete this.fs.root.content['__pipebuf__'];
+ }
}
}
this._pipeInput = null;
@@ -171,10 +212,12 @@ class ShellModule {
_splitChain(input) {
// Split on && and ||, preserving operator info
+ // Operator is attached to the FOLLOWING segment (the one it guards)
const parts = [];
let current = '';
let inQuote = false;
let quoteChar = '';
+ let pendingOp = null; // operator from previous split, applies to next segment
let i = 0;
while (i < input.length) {
const ch = input[i];
@@ -183,16 +226,18 @@ class ShellModule {
} else if (ch === quoteChar && inQuote) {
inQuote = false; quoteChar = ''; current += ch; i++;
} else if (!inQuote && input.substring(i, i+2) === '&&') {
- parts.push({ cmd: current.trim(), operator: '&&' });
+ parts.push({ cmd: current.trim(), operator: pendingOp });
+ pendingOp = '&&';
current = ''; i += 2;
} else if (!inQuote && input.substring(i, i+2) === '||') {
- parts.push({ cmd: current.trim(), operator: '||' });
+ parts.push({ cmd: current.trim(), operator: pendingOp });
+ pendingOp = '||';
current = ''; i += 2;
} else {
current += ch; i++;
}
}
- parts.push({ cmd: current.trim(), operator: null });
+ parts.push({ cmd: current.trim(), operator: pendingOp });
return parts;
}
@@ -215,7 +260,7 @@ class ShellModule {
inQuote = false; quoteChar = ''; current += ch; i++;
} else if (!inQuote) {
if (input.substring(i, i+2) === '>>') {
- if (current.trim()) parts.push(current.trim());
+ if (current.trim()) { parts.push(current.trim()); current = ''; }
hasRedirect = true;
redirectOp = '>>';
append = true;
@@ -230,7 +275,7 @@ class ShellModule {
if (i < input.length && (input[i] === '>' || input[i] === '<')) continue;
break;
} else if (input.substring(i, i+1) === '>') {
- if (current.trim()) parts.push(current.trim());
+ if (current.trim()) { parts.push(current.trim()); current = ''; }
hasRedirect = true;
redirectOp = '>';
append = false;
@@ -244,7 +289,7 @@ class ShellModule {
if (i < input.length && (input[i] === '>' || input[i] === '<')) continue;
break;
} else if (input.substring(i, i+1) === '<') {
- if (current.trim()) parts.push(current.trim());
+ if (current.trim()) { parts.push(current.trim()); current = ''; }
hasRedirect = true;
redirectOp = '<';
i += 1;
@@ -285,28 +330,38 @@ class ShellModule {
const inputContent = fs.readFile(redirectFile);
if (inputContent === null) {
this.terminal.print(`shell: ${redirectFile}: No such file`, 'error');
+ redirectInfo.exitOk = false;
return;
}
this.terminal._pipeInput = inputContent;
- // Execute the command with pipe input available
- const parts = cmdText.split(/\s+/);
- const cmd = parts[0].toLowerCase();
- const args = parts.slice(1);
- // If command reads files, inject pipe content as temp file
- if (this._isPipeAware(cmd)) {
- fs.writeFile('__pipebuf__', inputContent);
- const newArgs = [...args];
- if (!newArgs.some(a => !a.startsWith('-'))) {
- newArgs.push('__pipebuf__');
- }
- await this.terminal.dispatchCommand(cmd, newArgs);
- if (fs.readFile('__pipebuf__') !== null) {
- delete fs.root.content['__pipebuf__'];
+ try {
+ const parts = cmdText.split(/\s+/);
+ const cmd = parts[0].toLowerCase();
+ const args = parts.slice(1);
+ // If command reads files, inject pipe content as temp file
+ if (this._isPipeAware(cmd)) {
+ fs.writeFile('__pipebuf__', inputContent);
+ const newArgs = [...args];
+ if (!newArgs.some(a => !a.startsWith('-'))) {
+ newArgs.push('__pipebuf__');
+ }
+ try {
+ await this.terminal.dispatchCommand(cmd, newArgs);
+ } finally {
+ if (fs.readFile('__pipebuf__') !== null) {
+ delete fs.root.content['__pipebuf__'];
+ }
+ }
+ } else {
+ await this.terminal.dispatchCommand(cmd, args);
}
- } else {
- await this.terminal.dispatchCommand(cmd, args);
+ redirectInfo.exitOk = true;
+ } catch(e) {
+ this.terminal.print('Error: ' + e.message, 'error');
+ redirectInfo.exitOk = false;
+ } finally {
+ this.terminal._pipeInput = null;
}
- this.terminal._pipeInput = null;
return;
}
@@ -314,42 +369,57 @@ class ShellModule {
const originalPrint = this.terminal.print.bind(this.terminal);
let output = '';
this.terminal.print = (text, type) => { output += text + '\n'; };
+ let cmdOk = true;
- // Check for pipes within the command before redirect
- const pipeParts = this.terminal.splitPipe(cmdText);
- if (pipeParts.length > 1) {
- // Execute pipe chain manually to capture all output
- let pipeInput = null;
- for (let i = 0; i < pipeParts.length; i++) {
- const pText = pipeParts[i];
- const pParts = pText.split(/\s+/);
- const pCmd = pParts[0].toLowerCase();
- const pArgs = pParts.slice(1);
- if (pipeInput !== null) {
- fs.writeFile('__pipebuf__', pipeInput);
- }
- if (i < pipeParts.length - 1) {
- const captPrint = this.terminal.print.bind(this.terminal);
- let capt = '';
- this.terminal.print = (t, ty) => { capt += t + '\n'; };
- await this.terminal.dispatchCommand(pCmd, pArgs);
- this.terminal.print = captPrint;
- pipeInput = capt.trim();
- } else {
- await this.terminal.dispatchCommand(pCmd, pArgs);
- }
- if (fs.readFile('__pipebuf__') !== null) {
- delete fs.root.content['__pipebuf__'];
+ try {
+ // Check for pipes within the command before redirect
+ const pipeParts = this.terminal.splitPipe(cmdText);
+ if (pipeParts.length > 1) {
+ // Execute pipe chain manually to capture all output
+ let pipeInput = null;
+ for (let i = 0; i < pipeParts.length; i++) {
+ const pText = pipeParts[i];
+ const pParts = pText.split(/\s+/);
+ const pCmd = pParts[0].toLowerCase();
+ const pArgs = pParts.slice(1);
+ if (pipeInput !== null) {
+ fs.writeFile('__pipebuf__', pipeInput);
+ }
+ if (i < pipeParts.length - 1) {
+ const captPrint = this.terminal.print.bind(this.terminal);
+ let capt = '';
+ this.terminal.print = (t, ty) => { capt += t + '\n'; };
+ try {
+ await this.terminal.dispatchCommand(pCmd, pArgs);
+ } finally {
+ this.terminal.print = captPrint;
+ }
+ pipeInput = capt.trim();
+ } else {
+ await this.terminal.dispatchCommand(pCmd, pArgs);
+ }
+ if (fs.readFile('__pipebuf__') !== null) {
+ delete fs.root.content['__pipebuf__'];
+ }
}
+ } else {
+ const parts = cmdText.split(/\s+/);
+ const cmd = parts[0].toLowerCase();
+ const args = parts.slice(1);
+ await this.terminal.dispatchCommand(cmd, args);
}
- } else {
- const parts = cmdText.split(/\s+/);
- const cmd = parts[0].toLowerCase();
- const args = parts.slice(1);
- await this.terminal.dispatchCommand(cmd, args);
+ } catch(e) {
+ cmdOk = false;
+ // Restore print before showing the error
+ this.terminal.print = originalPrint;
+ this.terminal.print('Error: ' + e.message, 'error');
+ redirectInfo.exitOk = false;
+ return;
+ } finally {
+ // Always restore print — critical: without this, terminal is permanently broken
+ this.terminal.print = originalPrint;
}
- this.terminal.print = originalPrint;
const captured = output.trimEnd();
if (captured) {
@@ -367,6 +437,7 @@ class ShellModule {
this.terminal.print(`Redirected (empty) to ${redirectFile}`, 'success');
}
}
+ redirectInfo.exitOk = cmdOk;
}
_isPipeAware(cmd) {
@@ -486,54 +557,66 @@ class ShellModule {
const content = this.terminal.fs.readFile(path);
if (content === null) return this.terminal.print(`script: ${path}: No such file`, 'error');
this._scriptDepth++;
- this.terminal.print(`Running script: ${path}`, 'info');
- const lines = content.split('\n');
- let lineNum = 0;
- for (const rawLine of lines) {
- lineNum++;
- const line = rawLine.trim();
- if (!line || line.startsWith('#')) continue;
- // Run each line through the shell (by calling executeCommand directly)
- // We need to manually call the wrapped executeCommand
- this.terminal.print(`[${path}:${lineNum}] ${line}`, 'command-prompt');
- // Parse inline: handle pipe, redirect, chain, sequence
- const substituted = this._substituteVars(line);
- const seqParts = this._splitSequence(substituted);
- for (const seqPart of seqParts) {
- if (!seqPart.trim()) continue;
- const chainParts = this._splitChain(seqPart.trim());
- let lastOk = true;
- for (const chainPart of chainParts) {
- if (!chainPart.cmd.trim()) continue;
- if (chainPart.operator === '&&' && !lastOk) continue;
- if (chainPart.operator === '||' && lastOk) continue;
- const redirectInfo = this._extractRedirects(chainPart.cmd);
- if (redirectInfo.hasRedirect) {
- await this._executeWithRedirect(redirectInfo);
- lastOk = redirectInfo.exitOk;
- } else {
- const pipeParts = this.terminal.splitPipe(chainPart.cmd);
- if (pipeParts.length > 1) {
- await this.terminal.executePipeChain(pipeParts);
+ try {
+ this.terminal.print(`Running script: ${path}`, 'info');
+ const lines = content.split('\n');
+ let lineNum = 0;
+ for (const rawLine of lines) {
+ lineNum++;
+ const line = rawLine.trim();
+ if (!line || line.startsWith('#')) continue;
+ this.terminal.print(`[${path}:${lineNum}] ${line}`, 'command-prompt');
+ const substituted = this._substituteVars(line);
+ const seqParts = this._splitSequence(substituted);
+ for (const seqPart of seqParts) {
+ if (!seqPart.trim()) continue;
+ const chainParts = this._splitChain(seqPart.trim());
+ let lastOk = true;
+ for (const chainPart of chainParts) {
+ if (!chainPart.cmd.trim()) continue;
+ if (chainPart.operator === '&&' && !lastOk) continue;
+ if (chainPart.operator === '||' && lastOk) continue;
+ const redirectInfo = this._extractRedirects(chainPart.cmd);
+ if (redirectInfo.hasRedirect) {
+ await this._executeWithRedirect(redirectInfo);
+ lastOk = redirectInfo.exitOk;
} else {
- const parts = chainPart.cmd.split(/\s+/);
- const cmd = parts[0].toLowerCase();
- const args = parts.slice(1);
- // Check if it's our built-in command
- if (this.commands[cmd]) {
- this.terminal.markDirty();
- await this.commands[cmd](args);
+ const pipeParts = this.terminal.splitPipe(chainPart.cmd);
+ if (pipeParts.length > 1) {
+ await this.terminal.executePipeChain(pipeParts);
} else {
- await this.terminal.security.checkAndRun(chainPart.cmd);
+ const parts = chainPart.cmd.split(/\s+/);
+ const cmd = parts[0].toLowerCase();
+ const args = parts.slice(1);
+ // Check if it's a shell built-in command
+ if (this.commands[cmd]) {
+ try {
+ this.terminal.markDirty();
+ await this.commands[cmd](args);
+ lastOk = true;
+ } catch(e) {
+ this.terminal.print('Error: ' + e.message, 'error');
+ lastOk = false;
+ }
+ } else {
+ try {
+ await this.terminal.security.checkAndRun(chainPart.cmd);
+ lastOk = true;
+ } catch(e) {
+ this.terminal.print('Error: ' + e.message, 'error');
+ lastOk = false;
+ }
+ }
}
}
- lastOk = true;
}
}
}
+ this.terminal.print(`Script ${path} completed (${lineNum} lines)`, 'success');
+ } finally {
+ // Always decrement — even on error or thrown exception
+ this._scriptDepth--;
}
- this._scriptDepth--;
- this.terminal.print(`Script ${path} completed (${lineNum} lines)`, 'success');
}
}
if (typeof window.webx11 !== 'undefined' && window.webx11) {
diff --git a/modules/ext/skill-pack-core.js b/modules/ext/skill-pack-core.js
index 455a7f4..f204e70 100644
--- a/modules/ext/skill-pack-core.js
+++ b/modules/ext/skill-pack-core.js
@@ -35,6 +35,10 @@ class SkillPackCoreModule {
'skill-export': 'skill-export <name> — Export a skill as JSON',
'skill-import': 'skill-import <json> — Import a skill from JSON string',
'skill-sync': 'skill-sync — Rebuild skills index from /skills/ directory',
+ 'learn-skill': 'learn-skill <topic> — Reflect on topic, draft a skill from experience',
+ 'skill-validate': 'skill-validate <name> — Validate skill structure and get confidence score',
+ 'skill-audit': 'skill-audit — Audit skills for stale, duplicate, and broken references',
+ 'skill-restore': 'skill-restore <name> — Restore an archived skill',
'skill-webdav-sync': 'skill-webdav-sync — Push all skills to WebDAV as JSON files',
};
}
@@ -528,6 +532,11 @@ class SkillPackCoreModule {
this.commands['skill-export'] = (args) => this._cmdSkillExport(args);
this.commands['skill-import'] = (args) => this._cmdSkillImport(args);
this.commands['skill-sync'] = () => this._cmdSkillSync();
+ // v4.5.90: Agent Skills — learn, validate, audit, restore
+ this.commands['learn-skill'] = (args) => this._cmdLearnSkill(args);
+ this.commands['skill-validate'] = (args) => this._cmdSkillValidate(args);
+ this.commands['skill-audit'] = () => this._cmdSkillAudit();
+ this.commands['skill-restore'] = (args) => this._cmdSkillRestore(args);
this.commands['skill-webdav-sync'] = () => this._cmdSkillWebdavSync();
}
// ── Skill Commands ──
@@ -574,6 +583,9 @@ class SkillPackCoreModule {
this.terminal.print(line, '');
}
this.terminal.print(`=== End ${name} ===`, 'info');
+ // v4.5.90: Record skill use for decay tracking
+ const mg = this.terminal?.moduleManager?.modules?.['MemoryGraphModule'];
+ if (mg && typeof mg.recordSkillUse === 'function') mg.recordSkillUse(name, true);
}
_cmdSkillCreate(args) {
@@ -846,6 +858,292 @@ class SkillPackCoreModule {
}
this._seeded = true;
}
+
+ // ── v4.5.90: Agent Skills ──
+
+ _cmdLearnSkill(args) {
+ if (!args || args.length === 0) return this.terminal.print('Usage: learn-skill <topic>', 'warning');
+ const topic = args.join(' ');
+ this.terminal.print('🧠 Reflecting on: ' + topic + '...', 'info');
+
+ // 1. Pull context from memgraph
+ const mg = this.terminal?.moduleManager?.modules?.['MemoryGraphModule'];
+ let graphNodes = [];
+ if (mg) {
+ graphNodes = mg.findNodes(topic) || [];
+ this.terminal.print(' 📊 Found ' + graphNodes.length + ' graph nodes matching topic', 'info');
+ }
+
+ // 2. Pull recent sessions
+ const fs = this.terminal.fs;
+ let sessionInsights = [];
+ try {
+ const memDir = fs.readDir('/memory/') || [];
+ // v4.5.90b fix: readDir returns objects, filter to .json files (P0 from review)
+ const recent = memDir.filter(function(e) { return e.type === 'file' && e.name.endsWith('.json'); }).slice(-3);
+ for (const f of recent) {
+ const data = fs.fsReadJSON('/memory/' + f.name);
+ if (data && data.summary) sessionInsights.push(data.summary);
+ }
+ } catch (_) {}
+ this.terminal.print(' 📅 Loaded ' + sessionInsights.length + ' recent sessions', 'info');
+
+ // 3. Pull existing skills
+ const index = fs.fsReadJSON('/skills/index.json') || {};
+ const skillNames = Object.keys(index).filter(function(n) {
+ return n.toLowerCase().includes(topic.toLowerCase().split(' ')[0]);
+ });
+ this.terminal.print(' 📚 Found ' + skillNames.length + ' related skills', 'info');
+
+ // 4. Draft skill
+ const draft = this._draftSkill(topic, graphNodes, sessionInsights, skillNames);
+ this.terminal.print('', '');
+ this.terminal.print('📝 Draft skill:', 'header');
+ const lines = draft.split('\n');
+ for (const line of lines) this.terminal.print(line, '');
+ this.terminal.print('', '');
+
+ // 5. Validate
+ const validation = this._validateSkillContent(draft);
+ if (validation.errors.length > 0) {
+ this.terminal.print('❌ Validation errors:', 'error');
+ for (const e of validation.errors) this.terminal.print(' • ' + e, 'error');
+ this.terminal.print('Fix the issues and use skill-create to save manually.', 'info');
+ return;
+ }
+ this.terminal.print('✅ Validation passed. Confidence: ' + validation.confidence.toFixed(2), 'success');
+
+ // 6. Propose
+ this.terminal.print('Ship this skill? Use: skill-create ' + topic.replace(/\s+/g, '-') + ' "<desc>" "<content>"', 'info');
+ this.terminal.print('Or discard and continue.', 'text-muted');
+ }
+
+ _draftSkill(topic, graphNodes, sessions, existingSkills) {
+ var lines = [];
+ lines.push('---');
+ lines.push('name: ' + topic.toLowerCase().replace(/\s+/g, '-'));
+ lines.push('description: ' + topic + ' — auto-drafted from agent experience');
+ lines.push('tags: [auto-learned, ' + topic.toLowerCase().split(' ')[0] + ']');
+ lines.push('---');
+ lines.push('');
+ lines.push('# ' + topic);
+ lines.push('');
+ lines.push('## Steps');
+ lines.push('');
+ // Generate steps from graph node patterns
+ var stepNum = 1;
+ if (graphNodes && graphNodes.length > 0) {
+ for (var n of graphNodes.slice(0, 5)) {
+ lines.push(stepNum + '. ' + n.label + ' (type: ' + n.type + ', uses: ' + (n.access_count || 1) + ')');
+ stepNum++;
+ }
+ } else {
+ lines.push('1. Identify the core ' + topic + ' workflow');
+ lines.push('2. Check for existing tools and modules');
+ lines.push('3. Execute the task step by step');
+ lines.push('4. Record results in memory graph');
+ }
+ if (sessions && sessions.length > 0) {
+ lines.push('');
+ lines.push('## Insights from Recent Sessions');
+ for (var s of sessions.slice(0, 3)) {
+ lines.push('- ' + s.substring(0, 100));
+ }
+ }
+ if (existingSkills && existingSkills.length > 0) {
+ lines.push('');
+ lines.push('## Related Skills');
+ for (var sk of existingSkills) lines.push('- skill-view ' + sk);
+ }
+ lines.push('');
+ lines.push('## Pitfalls');
+ lines.push('- This skill was auto-drafted — validate before relying on it');
+ return lines.join('\n');
+ }
+
+ _cmdSkillValidate(args) {
+ if (!args || args.length === 0) return this.terminal.print('Usage: skill-validate <name>', 'warning');
+ const name = args[0].replace(/\.md$/, '');
+ const fs = this.terminal.fs;
+ const content = fs.readFile('/skills/' + name + '.md');
+ if (!content) return this.terminal.print('Skill not found: ' + name, 'error');
+
+ const result = this._validateSkillContent(content);
+ this.terminal.print('📊 Validation: ' + name, 'header');
+ if (result.errors.length === 0) {
+ this.terminal.print(' ✅ Valid', 'success');
+ } else {
+ this.terminal.print(' ❌ ' + result.errors.length + ' error(s):', 'error');
+ for (var e of result.errors) this.terminal.print(' • ' + e, 'error');
+ }
+ if (result.warnings.length > 0) {
+ this.terminal.print(' ⚠️ ' + result.warnings.length + ' warning(s):', 'warning');
+ for (var w of result.warnings) this.terminal.print(' • ' + w, 'warning');
+ }
+ this.terminal.print(' 📈 Confidence: ' + result.confidence.toFixed(2), 'info');
+ }
+
+ _validateSkillContent(content) {
+ var errors = [];
+ var warnings = [];
+ var confidence = 0;
+
+ // v4.5.90b fix: Normalize CRLF to LF (P2 from review)
+ content = content.replace(/\r\n/g, '\n');
+
+ // Check YAML frontmatter
+ var fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
+ if (!fmMatch) {
+ errors.push('Missing YAML frontmatter (--- ... ---)');
+ } else {
+ var fm = fmMatch[1];
+ if (!fm.match(/^name:\s*\S+/m)) errors.push('Missing "name" field in frontmatter');
+ if (!fm.match(/^description:\s*\S+/m)) errors.push('Missing "description" field in frontmatter');
+ var nameMatch = fm.match(/^name:\s*(\S+)/m);
+ if (nameMatch && !nameMatch[1].match(/^[a-z0-9-]+$/)) {
+ errors.push('Name must be kebab-case (lowercase, hyphens, numbers only)');
+ }
+ var tagsMatch = fm.match(/^tags:\s*\[(.+)\]/m);
+ if (tagsMatch && !tagsMatch[1].trim()) warnings.push('Tags field is empty');
+ }
+
+ // Check for ## Steps heading
+ if (!content.match(/##\s+Steps/i)) {
+ errors.push('Missing "## Steps" heading');
+ } else {
+ // Check for at least 1 numbered step
+ var steps = content.match(/^\d+\.\s+/gm);
+ if (!steps || steps.length === 0) errors.push('No numbered steps found under ## Steps');
+ }
+
+ // Check broken skill-view references
+ var refMatches = content.match(/skill-view\s+(\S+)/g) || [];
+ var fs = this.terminal.fs;
+ for (var ref of refMatches) {
+ var refName = ref.replace(/skill-view\s+/, '').replace(/\.md$/, '');
+ if (fs && !fs.readFile('/skills/' + refName + '.md')) {
+ warnings.push('Broken skill-view reference: ' + refName);
+ }
+ }
+
+ // v4.5.90b fix: Confidence = success_rate × (uses / (uses + 5)) — saturates at ~5 perfect uses, discriminates better (P2 from review)
+ var index = fs ? (fs.fsReadJSON('/skills/index.json') || {}) : {};
+ var fmName = fmMatch ? fmMatch[1].match(/^name:\s*(\S+)/m) : null;
+ var skillName = fmName ? fmName[1] : '';
+ var stats = index[skillName] || {};
+ var uses = stats.useCount || 0;
+ var successes = stats.successCount || 0;
+ if (uses > 0) {
+ var successRate = successes / uses;
+ confidence = successRate * (uses / (uses + 5));
+ }
+
+ return { valid: errors.length === 0, errors: errors, warnings: warnings, confidence: Math.min(confidence, 1.0) };
+ }
+
+ _cmdSkillAudit() {
+ const fs = this.terminal.fs;
+ const index = fs.fsReadJSON('/skills/index.json') || {};
+ const allNames = Object.keys(index);
+ const now = Date.now();
+
+ var stale = [];
+ var duplicates = [];
+ var brokenRefs = 0;
+
+ // 1. Stale check
+ const mg = this.terminal?.moduleManager?.modules?.['MemoryGraphModule'];
+ var staleSkills = [];
+ if (mg && typeof mg.getStaleSkills === 'function') {
+ staleSkills = mg.getStaleSkills();
+ }
+ for (var s of staleSkills) {
+ if (!index[s.name] || index[s.name].immune) continue;
+ stale.push(s.name + ' (' + s.daysUnused + 'd unused)');
+ }
+
+ // 2. Duplicate check (simple Levenshtein on name)
+ const auditCap = 200;
+ const auditNames = allNames.slice(0, auditCap);
+ for (var i = 0; i < auditNames.length; i++) {
+ for (var j = i + 1; j < auditNames.length; j++) {
+ if (this._levenshtein(auditNames[i], auditNames[j]) <= 2) {
+ duplicates.push(auditNames[i] + ' ≈ ' + auditNames[j]);
+ }
+ }
+ }
+ if (allNames.length > auditCap) {
+ warnings.push('Duplicate check only scanned first ' + auditCap + ' of ' + allNames.length + ' skills');
+ }
+
+ // 3. Reference check
+ for (var name of allNames) {
+ var content = fs.readFile('/skills/' + name + '.md');
+ if (!content) continue;
+ var refs = content.match(/skill-view\s+(\S+)/g) || [];
+ for (var ref of refs) {
+ var refName = ref.replace(/skill-view\s+/, '').replace(/\.md$/, '');
+ if (!fs.readFile('/skills/' + refName + '.md')) brokenRefs++;
+ }
+ }
+
+ var healthy = allNames.length - stale.length;
+ this.terminal.print('📊 Skill Audit', 'header');
+ this.terminal.print('────────────────────────────────', 'text-muted');
+ this.terminal.print('Total skills: ' + allNames.length, 'info');
+ this.terminal.print('Healthy: ' + healthy, 'success');
+ this.terminal.print('Stale: ' + stale.length + (stale.length > 0 ? ' (' + stale.join(', ') + ')' : ''), stale.length > 0 ? 'warning' : 'info');
+ this.terminal.print('Duplicates: ' + duplicates.length + (duplicates.length > 0 ? ' (' + duplicates.join(', ') + ')' : ''), duplicates.length > 0 ? 'warning' : 'info');
+ this.terminal.print('Broken refs: ' + brokenRefs, brokenRefs > 0 ? 'error' : 'info');
+ this.terminal.print('', '');
+
+ if (stale.length > 0 || duplicates.length > 0 || brokenRefs > 0) {
+ this.terminal.print('Suggestions:', 'info');
+ for (var st of stale) this.terminal.print('• Archive ' + st, 'warning');
+ for (var dup of duplicates) this.terminal.print('• Merge ' + dup, 'warning');
+ if (brokenRefs > 0) this.terminal.print('• Fix ' + brokenRefs + ' broken reference(s)', 'warning');
+ } else {
+ this.terminal.print('✅ All skills healthy', 'success');
+ }
+ }
+
+ _cmdSkillRestore(args) {
+ if (!args || args.length === 0) return this.terminal.print('Usage: skill-restore <name>', 'warning');
+ const name = args[0].replace(/\.md$/, '');
+ const fs = this.terminal.fs;
+ if (!fs.readDir('/skills/.archive/')) return this.terminal.print('No archive directory found', 'error');
+ const content = fs.readFile('/skills/.archive/' + name + '.md');
+ if (!content) return this.terminal.print('Skill not found in archive: ' + name, 'error');
+ // v4.5.90b fix: Don't overwrite a live skill (P1 from review)
+ const existing = fs.readFile('/skills/' + name + '.md');
+ if (existing) return this.terminal.print('Skill "' + name + '" already exists live. Use skill-patch or delete it first.', 'warning');
+ fs.writeFile('/skills/' + name + '.md', content);
+ fs.deleteFile('/skills/.archive/' + name + '.md');
+ const index = fs.fsReadJSON('/skills/index.json') || {};
+ index[name] = index[name] || {};
+ index[name].lastUsed = new Date().toISOString();
+ index[name].useCount = index[name].useCount || 0;
+ fs.fsWriteJSON('/skills/index.json', index);
+ this.terminal.print('✅ Restored: ' + name, 'success');
+ }
+
+ _levenshtein(a, b) {
+ if (a.length === 0) return b.length;
+ if (b.length === 0) return a.length;
+ var matrix = [];
+ for (var i = 0; i <= b.length; i++) matrix[i] = [i];
+ for (var j = 0; j <= a.length; j++) matrix[0][j] = j;
+ for (var i = 1; i <= b.length; i++) {
+ for (var j = 1; j <= a.length; j++) {
+ if (b.charAt(i - 1) === a.charAt(j - 1)) {
+ matrix[i][j] = matrix[i - 1][j - 1];
+ } else {
+ matrix[i][j] = Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1);
+ }
+ }
+ }
+ return matrix[b.length][a.length];
+ }
}
if (typeof window.webx11 !== 'undefined' && window.webx11) {
webx11.moduleManager.registerModule(new SkillPackCoreModule(), 'https://webx11.duckdns.org/spaces/newos4/modules/ext/skill-pack-core.js');
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');
}
diff --git a/modules/ext/tutorial.js b/modules/ext/tutorial.js
index af4f4fa..e080efd 100644
--- a/modules/ext/tutorial.js
+++ b/modules/ext/tutorial.js
@@ -280,8 +280,4 @@ class TutorialModule {
setTimeout(() => el.remove(), 5000);
}
}
-}
-
-if (typeof window.webx11 !== 'undefined' && window.webx11) {
- webx11.moduleManager.registerModule(new TutorialModule(), 'https://webx11.duckdns.org/spaces/newos4/modules/ext/tutorial.js');
-}
+} \ No newline at end of file
diff --git a/modules/ext/voice.js b/modules/ext/voice.js
index b243ace..284d6f3 100644
--- a/modules/ext/voice.js
+++ b/modules/ext/voice.js
@@ -9,9 +9,29 @@ class VoiceModule {
this._lastError = '';
this._sttEndpoint = '/vmai-api/stt'; // VaultMind STT via nginx proxy
this._recordingDuration = 10000; // 10s auto-stop
+ this._voices = [];
+ this._autoSpeak = false;
+ }
+ init(terminal) {
+ this.terminal = terminal;
+ // Pre-load TTS voices (Chrome loads async)
+ if (typeof speechSynthesis !== 'undefined') {
+ this._voices = speechSynthesis.getVoices() || [];
+ if (this._voices.length === 0) {
+ var self = this;
+ speechSynthesis.onvoiceschanged = function() {
+ self._voices = speechSynthesis.getVoices() || [];
+ };
+ }
+ }
+ }
+ _getBestVoice() {
+ var voices = this._voices.length > 0 ? this._voices : (typeof speechSynthesis !== 'undefined' ? speechSynthesis.getVoices() : []);
+ return voices.find(function(v) { return v.lang && v.lang.startsWith('en') && v.name.includes('Google'); })
+ || voices.find(function(v) { return v.lang && v.lang.startsWith('en'); })
+ || (voices.length > 0 ? voices[0] : null);
}
requirements() {
- // MediaRecorder + getUserMedia work on ALL modern browsers
const hasMic = typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia;
const hasRecorder = typeof MediaRecorder !== 'undefined';
if (hasMic && hasRecorder) {
@@ -23,26 +43,20 @@ class VoiceModule {
return { met: false, missing };
}
getHelpInfo() {
- return { 'voice': 'voice [stop] — Start voice recording (Whisper STT), or type "voice stop" to cancel' };
+ return { 'voice': 'voice [stop] — Start voice recording (Whisper STT), or type "voice stop" to cancel', 'speak': 'speak [text] — Text-to-speech. No args = toggle auto-speak for AI responses' };
}
registerCommands() {
this.commands['voice'] = (args) => this.handle(args);
this.commands['speak'] = (args) => this.speak(args);
}
- getHelpInfo() {
- return { 'voice': 'voice [stop] — Start voice recording (Whisper STT), or type "voice stop" to cancel', 'speak': 'speak [text] — Text-to-speech. No args = toggle auto-speak for AI responses' };
- }
-
speak(args) {
const text = args.join(' ').trim();
if (!text) {
- // If no text, check if speechSynthesis is available
if (typeof speechSynthesis === 'undefined') {
this.terminal.print('🔊 TTS not supported in this browser', 'error');
return;
}
- // Toggle auto-speak mode
this._autoSpeak = !this._autoSpeak;
this.terminal.print(this._autoSpeak ? '🔊 Auto-speak ON — AI responses will be spoken' : '🔊 Auto-speak OFF', 'info');
return;
@@ -51,40 +65,59 @@ class VoiceModule {
this.terminal.print('🔊 TTS not supported in this browser', 'error');
return;
}
+ this._doSpeak(text);
+ this.terminal.print('🔊 Speaking: "' + text.substring(0, 80) + (text.length > 80 ? '...' : '') + '"', 'info');
+ }
+
+ _doSpeak(text) {
+ var self = this;
speechSynthesis.cancel();
- const utter = new SpeechSynthesisUtterance(text);
+ var utter = new SpeechSynthesisUtterance(text);
utter.rate = parseFloat(localStorage.getItem('webx11_tts_rate') || '1.0');
utter.pitch = 1.0;
utter.volume = 1.0;
- // Try to use a good English voice
- const voices = speechSynthesis.getVoices();
- const enVoice = voices.find(v => v.lang.startsWith('en') && v.name.includes('Google')) || voices.find(v => v.lang.startsWith('en'));
- if (enVoice) utter.voice = enVoice;
- utter.onstart = () => this._updateButton(true);
- utter.onend = () => this._updateButton(false);
- utter.onerror = () => this._updateButton(false);
- speechSynthesis.speak(utter);
- this.terminal.print('🔊 Speaking: "' + text.substring(0, 80) + (text.length > 80 ? '...' : '') + '"', 'info');
+ var voice = this._getBestVoice();
+ if (voice) utter.voice = voice;
+ utter.onstart = function() { self._updateButton(true); };
+ utter.onend = function() { self._updateButton(false); };
+ utter.onerror = function() { self._updateButton(false); };
+ // If voices not loaded yet, wait for voiceschanged with 2s timeout
+ if (this._voices.length === 0 && typeof speechSynthesis !== 'undefined') {
+ var spoken = false;
+ var onReady = function() {
+ if (spoken) return;
+ spoken = true;
+ var v = self._getBestVoice();
+ if (v) utter.voice = v;
+ speechSynthesis.cancel();
+ speechSynthesis.speak(utter);
+ speechSynthesis.onvoiceschanged = null;
+ };
+ speechSynthesis.onvoiceschanged = onReady;
+ setTimeout(function() {
+ if (!spoken) {
+ spoken = true;
+ speechSynthesis.onvoiceschanged = null;
+ speechSynthesis.cancel();
+ speechSynthesis.speak(utter);
+ }
+ }, 2000);
+ } else {
+ speechSynthesis.speak(utter);
+ }
}
speakText(text) {
- // Called by AI assistant to auto-speak responses
if (!this._autoSpeak || typeof speechSynthesis === 'undefined') return;
speechSynthesis.cancel();
- // Strip markdown/code blocks for cleaner speech
const clean = text.replace(/```[\s\S]*?```/g, ' code block ').replace(/[*_#|`>]/g, '').replace(/\n+/g, '. ').trim();
if (clean.length > 500) {
- // Split long text into chunks
const chunks = clean.match(/.{1,500}/g);
- chunks.forEach(c => {
- const u = new SpeechSynthesisUtterance(c);
- u.rate = parseFloat(localStorage.getItem('webx11_tts_rate') || '1.0');
- speechSynthesis.speak(u);
- });
+ for (var i = 0; i < chunks.length; i++) {
+ this._doSpeak(chunks[i]);
+ }
} else {
- const u = new SpeechSynthesisUtterance(clean);
- u.rate = parseFloat(localStorage.getItem('webx11_tts_rate') || '1.0');
- speechSynthesis.speak(u);
+ this._doSpeak(clean);
}
}
handle(args) {