feat(scene3d): Scene3D 接入脚本引擎 + 按需注入 three.js + 自写环绕相机

- engine.js/inherit.js 以 UMD 双分支引入(node 测试照跑 + 浏览器拼进 bricks.js)
- Scene3D 新增本地逻辑引擎(独立世界):描述符 properties/events 喂 SceneRuntime,
  点击→tap 注入、每帧 step 推进、transform/visible 回填 three.js
- three.min.js 按需注入(非 3D 页面不背 608KB)
- 自写环绕相机:单指旋转/双指缩放/双指平移(three.js r148+ 移除 UMD OrbitControls)
- 新增 API:set_entity_transform / set_paused / reset_runtime
- 已验证:门铃示例(定时+条件+点击+moveTo+属性变化)、相机旋转全链路
This commit is contained in:
yumoqing 2026-08-27 15:41:51 +08:00
parent d945e414d7
commit 406ed6f17a
4 changed files with 924 additions and 42 deletions

View File

@ -13,7 +13,7 @@ SOURCES=" page_data_loader.js factory.js uitypesdef.js utils.js uitype.js \
line.js pie.js bar.js gobang.js period.js iconbarpage.js \
keypress.js asr.js webspeech.js countdown.js progressbar.js \
qaframe.js svg.js videoplayer.js scatter.js radar.js kline.js \
heatmap.js map.js qr.js textfiles.js agent_input.js agent.js api_doc.js flipcard.js carousel.js draggable.js resourcebrowser.js scene3d.js "
heatmap.js map.js qr.js textfiles.js agent_input.js agent.js api_doc.js flipcard.js carousel.js draggable.js resourcebrowser.js inherit.js engine.js scene3d.js "
echo ${SOURCES}
cat ${SOURCES} > ../dist/bricks.js
# uglifyjs --compress --mangle -- ../dist/bricks.js > ../dist/bricks.min.js

545
bricks/engine.js Normal file
View File

@ -0,0 +1,545 @@
/*
* engine.js 脚本引擎解释器原型
* 实现脚本引擎设计规范v1.0 的类型系统 / 表达式求值 / 事件 / 动作 / 执行模型 / 错误语义
* 纯同步确定性的离散事件仿真无任何外部依赖可用 node 直接运行
*
* 渲染 / 音频 / 变换动画在原型中为桩记录日志逻辑语义事件顺序属性变化检测
* wait 挂起控制流类型检查终止性错误码全部真实实现
*
* 实体继承加载时按实体继承设计规范§3 求解有效定义inherit.js扁平化注入
* 脚本引擎运行时不感知继承
*/
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory(require('./inherit'));
} else {
root.bricks = root.bricks || {};
root.bricks.ScriptEngine = factory(root.bricks.ScriptInherit);
}
}(typeof self !== 'undefined' ? self : this, function (inherit) {
const resolveAll = inherit.resolveAll;
// ────────────────────────────── 类型系统§1──────────────────────────────
function typeOf(v) {
if (v === null) return 'null';
if (typeof v === 'number') return 'number';
if (typeof v === 'boolean') return 'boolean';
if (typeof v === 'string') return 'string';
if (v && typeof v === 'object' && '$e' in v) return 'entity';
return 'unknown';
}
// ────────────────────────────── 错误§8───────────────────────────────
class ScriptError extends Error {
constructor(code, message, loc) {
super(`[${code}] ${message}` + (loc ? ` (${loc})` : ''));
this.code = code;
this.loc = loc;
}
}
const E_TYPE = 'E-TYPE', E_REF = 'E-REF', E_LOOP = 'E-LOOP',
E_DEPTH = 'E-DEPTH', E_BUDGET = 'E-BUDGET', E_DIV0 = 'E-DIV0';
// ────────────────────────────── 表达式求值§3───────────────────────────
// 运行时上下文:求值表达式所需的作用域与运行时句柄
function makeCtx(rt, entity, message) {
return { rt, entity, scene: rt.scene, global: rt.global, message };
}
function resolveVar(ctx, node) {
const scope = node.scope;
if (scope === 'global') return ctx.global.vars;
if (scope === 'scene') return ctx.scene.vars;
if (scope === 'entity') {
if (!ctx.entity) throw new ScriptError(E_REF, '实体作用域变量在场景级脚本不可用', node.name);
return ctx.entity.vars;
}
// auto实体 → 场景 → 全局(遮蔽规则 §2.3
if (ctx.entity && node.name in ctx.entity.vars) return ctx.entity.vars;
if (node.name in ctx.scene.vars) return ctx.scene.vars;
return ctx.global.vars;
}
function getProp(ctx, target, name) {
const holder = target === 'self' ? ctx.entity
: target === 'scene' ? ctx.scene
: ctx.rt.entities[target];
if (!holder) throw new ScriptError(E_REF, `引用的实体不存在: ${target}`, name);
const p = holder.properties[name];
if (p === undefined) throw new ScriptError(E_REF, `属性不存在: ${name}`, name);
return p.value;
}
function evalExpr(node, ctx, depth = 0) {
if (depth > 64) throw new ScriptError(E_DEPTH, '表达式嵌套超限');
const k = node.k;
if (k === 'lit') return node.v;
if (k === 'var') {
const vars = resolveVar(ctx, node);
if (!(node.name in vars)) throw new ScriptError(E_REF, `变量未定义: ${node.name}`, node.name);
return vars[node.name];
}
if (k === 'prop') return getProp(ctx, node.target, node.name);
if (k === 'unary') {
const x = evalExpr(node.x, ctx, depth + 1);
if (node.op === '-') { if (typeOf(x) !== 'number') throw new ScriptError(E_TYPE, '一元 - 要求 number'); return -x; }
if (node.op === '!') { if (typeOf(x) !== 'boolean') throw new ScriptError(E_TYPE, '一元 ! 要求 boolean'); return !x; }
}
if (k === 'bin') return evalBin(node, ctx, depth);
if (k === 'call') {
const fn = BUILTINS[node.fn];
if (!fn) throw new ScriptError(E_REF, `未知函数: ${node.fn}`);
const args = (node.args || []).map(a => evalExpr(a, ctx, depth + 1));
return fn(args, ctx);
}
throw new ScriptError(E_TYPE, `未知表达式节点: ${k}`);
}
function evalBin(node, ctx, depth) {
const op = node.op;
if (op === '&&' || op === '||') {
const l = evalExpr(node.l, ctx, depth + 1);
if (typeOf(l) !== 'boolean') throw new ScriptError(E_TYPE, `&&/|| 操作数须为 boolean实为 ${typeOf(l)}`);
if (op === '&&') return l === false ? false : (evalExpr(node.r, ctx, depth + 1) === true);
return l === true ? true : (evalExpr(node.r, ctx, depth + 1) === true);
}
const l = evalExpr(node.l, ctx, depth + 1);
const r = evalExpr(node.r, ctx, depth + 1);
const tl = typeOf(l), tr = typeOf(r);
if (op === '+') {
if (tl === 'number' && tr === 'number') return l + r;
if (tl === 'string' && tr === 'string') return l + r;
throw new ScriptError(E_TYPE, `+ 要求两侧同为 number 或同为 string实为 ${tl}/${tr}`);
}
if (['-', '*', '/', '%'].includes(op)) {
if (tl !== 'number' || tr !== 'number') throw new ScriptError(E_TYPE, `${op} 要求两侧为 number`);
if (op === '-' ) return l - r;
if (op === '*' ) return l * r;
if (op === '/' ) return l / r;
if (r === 0) throw new ScriptError(E_DIV0, '取模除数为 0');
return l % r;
}
if (['>', '<', '>=', '<='].includes(op)) {
if (tl !== 'number' || tr !== 'number') throw new ScriptError(E_TYPE, `${op} 要求两侧为 number`);
if (op === '>') return l > r;
if (op === '<') return l < r;
if (op === '>=') return l >= r;
return l <= r;
}
if (op === '==' || op === '!=') {
if (tl !== tr) throw new ScriptError(E_TYPE, `==/!= 要求两侧类型相同,实为 ${tl}/${tr}`);
const eq = tl === 'entity' ? (l.$e === r.$e) : (l === r);
return op === '==' ? eq : !eq;
}
throw new ScriptError(E_TYPE, `未知运算符: ${op}`);
}
// ────────────────────────────── 真值表§1.3)─────────────────────────────
function truthy(v) {
const t = typeOf(v);
if (t === 'boolean') return v;
if (t === 'number') return v !== 0;
if (t === 'string') return v.length > 0;
if (t === 'entity') return true; // 非 null 实体恒真
if (t === 'null') return false;
return false;
}
// ────────────────────────────── 内建函数§3.5 封闭集合)───────────────────
const BUILTINS = {
abs: ([x]) => { needNum(x); return Math.abs(x); },
min: ([a, b]) => { needNum(a); needNum(b); return Math.min(a, b); },
max: ([a, b]) => { needNum(a); needNum(b); return Math.max(a, b); },
floor: ([x]) => { needNum(x); return Math.floor(x); },
ceil: ([x]) => { needNum(x); return Math.ceil(x); },
round: ([x]) => { needNum(x); return Math.round(x); },
random: ([a, b]) => { needNum(a); needNum(b); return a + Math.random() * (b - a); },
randomInt: ([a, b]) => { needNum(a); needNum(b); return Math.floor(a + Math.random() * (b - a + 1)); },
toNumber: ([s]) => { if (typeOf(s) !== 'string') throw new ScriptError(E_TYPE, 'toNumber 要求 string'); const n = Number(s); if (Number.isNaN(n)) throw new ScriptError(E_TYPE, `无法解析为数字: ${s}`); return n; },
toString: ([x]) => { const t = typeOf(x); return t === 'entity' ? x.$e : t === 'null' ? '' : String(x); },
len: ([s]) => { if (typeOf(s) !== 'string') throw new ScriptError(E_TYPE, 'len 要求 string'); return [...s].length; },
contains: ([s, sub]) => { if (typeOf(s) !== 'string' || typeOf(sub) !== 'string') throw new ScriptError(E_TYPE, 'contains 要求两个 string'); return s.includes(sub); },
compare: ([a, b]) => { if (typeOf(a) !== 'string' || typeOf(b) !== 'string') throw new ScriptError(E_TYPE, 'compare 要求两个 string'); return a < b ? -1 : a > b ? 1 : 0; },
now: (_args, ctx) => ctx.rt.now(),
exists: ([e]) => { if (typeOf(e) !== 'entity') throw new ScriptError(E_TYPE, 'exists 要求 entity'); return !!e.$e; },
getProp: ([e, name]) => { if (typeOf(e) !== 'entity') throw new ScriptError(E_TYPE, 'getProp 第一个参数须为 entity'); return getProp({ entity: null, rt: null }, e.$e, name); },
};
function needNum(x) { if (typeOf(x) !== 'number') throw new ScriptError(E_TYPE, `要求 number实为 ${typeOf(x)}`); }
// ────────────────────────────── 运行时§7───────────────────────────────
class SceneRuntime {
constructor(sceneDef, opts = {}) {
this.opts = opts;
this.T = 0; // 单调时钟(秒)
this.log = []; // 日志(音频/消息/变换桩 + 事件触发)
this.eventCount = 0;
this.budget = opts.budget || 100000;
this.frameDT = opts.frameDT || 0.1; // 仿真帧步长(秒)
this.halted = false;
// 作用域容器
this.global = { vars: {} };
this.scene = { id: sceneDef.id, properties: buildProps(sceneDef.properties), vars: {}, events: sceneDef.events || [] };
this.entities = {}; // id -> entity
this.entityOrder = []; // 创建顺序§7.2 确定性)
// 事件索引
this.watchers = new Map(); // "target::prop" -> [eventRef]
this.intervals = []; // {event, nextFire, count, ref}
this.pendingPC = new Set(); // onPropertyChange 合并入队§7.5.4
// 队列与唤醒
this.readyQueue = []; // FIFO 任务
this.wakeups = []; // {time, task, ref}
// 继承求解《实体继承设计规范》§5.1):场景加载时对所有实体求解一次,
// 模板实体过滤(不注册运行时),其余以扁平化定义注入
const raw = sceneDef.entities || [];
const resolved = resolveAll(raw); // 非法继承在此抛 InheritError保存期校验
const resolvedList = raw
.filter(e => !e.template) // §5.4 模板实体不注册
.map(e => resolved[e.id]);
this._buildEntities(resolvedList);
this._indexEvents();
this._fireInitEvents();
}
now() { return this.T; }
_log(kind, msg) { this.log.push({ t: this.T, kind, msg }); }
_buildEntities(list) {
for (const e of list) {
this.entities[e.id] = {
id: e.id, name: e.name, type: e.type || 'model',
asset_ref: e.asset_ref || null, // 继承求解后的有效外观§3.6
properties: buildProps(e.properties),
vars: {},
transform: e.transform ? {
pos: (e.transform.pos || [0,0,0]).slice(),
rot: (e.transform.rot || [0,0,0]).slice(),
scale: (e.transform.scale || [1,1,1]).slice(),
} : { pos: [0,0,0], rot: [0,0,0], scale: [1,1,1] },
visible: true, events: e.events || [],
};
this.entityOrder.push(e.id);
}
}
_indexEvents() {
const reg = (holder, ev) => {
const ref = { holder, ev };
if (ev.type === 'onInterval') {
this.intervals.push({ event: ev, holder, nextFire: (ev.params.startDelay || 0), count: 0, ref });
}
if (ev.type === 'onPropertyChange') {
const p = ev.params;
// 归一化 target"self" → 持有者实体 id / "scene";否则用字面量实体 id
const t = p.target === 'self' ? (holder === this.scene ? 'scene' : holder.id) : p.target;
const key = `${t}::${p.property}`;
if (!this.watchers.has(key)) this.watchers.set(key, []);
this.watchers.get(key).push(ref);
}
};
// 场景事件先注册§7.2 顺序scene 优先)
for (const ev of this.scene.events) reg(this.scene, ev);
for (const eid of this.entityOrder) for (const ev of this.entities[eid].events) reg(this.entities[eid], ev);
}
_fireInitEvents() {
// onStartscene 先,实体按创建顺序
const starts = [];
for (const ev of this.scene.events) if (ev.type === 'onStart') starts.push({ holder: this.scene, ev });
for (const eid of this.entityOrder) for (const ev of this.entities[eid].events) if (ev.type === 'onStart') starts.push({ holder: this.entities[eid], ev });
for (const s of starts) this._enqueue(s.holder, s.ev, null);
for (const ev of this.scene.events) if (ev.type === 'onSceneLoad') this._enqueue(this.scene, ev, null);
}
// 目标解析:'self' | 'scene' | 实体ID
_holder(target, curEntity) {
if (target === 'self') { if (!curEntity) throw new ScriptError(E_REF, 'self 在场景级脚本不可用'); return curEntity; }
if (target === 'scene') return this.scene;
const h = this.entities[target];
if (!h) throw new ScriptError(E_REF, `实体不存在: ${target}`);
return h;
}
_enqueue(holder, ev, message) {
const task = { holder, ev, message, gen: null, eventId: ev.id };
this.readyQueue.push(task);
if (ev.type === 'onPropertyChange') this.pendingPC.add(ev.id);
this._log('enqueue', `event=${ev.type}#${ev.id} 入队 (holder=${holder === this.scene ? 'scene' : holder.id})`);
}
// ── 主循环 ──
run(maxSeconds) {
const end = maxSeconds;
while (this.T < end - 1e-9 && !this.halted) {
this.T += this.frameDT;
this._tick();
}
return { log: this.log, scene: this.scene, entities: this.entities, global: this.global, eventCount: this.eventCount };
}
// 逐帧推进(浏览器实时模式):推进 dt 秒并执行一次 tick。
// 与 run 的区别dt 为真实墙钟增量(非固定 frameDT供 requestAnimationFrame 每帧调用。
step(dt) {
if (this.halted) return;
this.T += dt;
this._tick();
}
_tick() {
// ① 到期定时器(确定性顺序已在 _indexEvents 保证scene 先、实体按创建顺序、声明顺序)
for (const it of this.intervals) {
const p = it.event.params;
if (this.T + 1e-9 >= it.nextFire) {
const max = p.maxCount;
if (max != null && it.count >= max) continue;
let guardPass = true;
if (p.guard) {
const ctx = makeCtx(this, it.holder === this.scene ? null : it.holder, null);
guardPass = truthy(evalExpr(p.guard, ctx));
}
if (guardPass) { this._enqueue(it.holder, it.event, null); it.count++; }
if (p.loop) it.nextFire += p.interval; else it.nextFire = Infinity;
}
}
// ② 到期 wait 唤醒
const due = this.wakeups.filter(w => this.T + 1e-9 >= w.time);
this.wakeups = this.wakeups.filter(w => this.T + 1e-9 < w.time);
for (const w of due) this.readyQueue.push(w.task);
// ③ 处理就绪队列
this._processQueue();
}
_processQueue() {
while (this.readyQueue.length && !this.halted) {
const task = this.readyQueue.shift();
if (task.ev.type === 'onPropertyChange') this.pendingPC.delete(task.eventId);
if (!task.gen) {
const holder = task.holder;
const ctx = makeCtx(this, holder === this.scene ? null : holder, task.message);
task.gen = runActions(task.ev.actions || [], ctx, this, task);
}
this.eventCount++;
if (this.eventCount > this.budget) {
this._log('error', `E-BUDGET 事件预算超限(${this.budget})`);
this.halted = true;
break;
}
// 驱动该任务直到挂起(wait)或结束
let r;
while (true) {
try {
r = task.gen.next();
} catch (e) {
if (e && e.$sceneSwitch) { this._log('scene', `场景切换: ${e.$sceneSwitch}`); this.halted = true; break; }
if (e instanceof ScriptError) { this._log('error', `${e.code} ${e.message}`); }
else this._log('error', `未捕获异常: ${e && e.message}`);
break; // 任务终止§8.3:仅终止当前任务)
}
if (r.done) break;
if (r.value && r.value.wait !== undefined) {
this.wakeups.push({ time: this.T + r.value.wait, task });
this._log('wait', `任务挂起 ${r.value.wait}s将在 T=${(this.T + r.value.wait).toFixed(3)} 恢复`);
break; // 让出,处理下一就绪任务
}
}
}
}
// ── 属性写入(含变化检测 §7.5)──
_writeProp(holder, name, value, srcEventId) {
const p = holder.properties[name];
if (p === undefined) throw new ScriptError(E_REF, `属性不存在: ${name}`);
if (typeOf(value) !== p.type) throw new ScriptError(E_TYPE, `属性 ${name} 类型应为 ${p.type},实为 ${typeOf(value)}`);
const v1 = p.value;
if (p.type === 'entity' ? (v1 && v1.$e) === (value && value.$e) : v1 === value) return; // 值未变
p.value = value;
this._log('prop', `${holder.id}.${name} = ${fmtVal(value)}`);
this._onPropChange(holder, name);
}
_onPropChange(holder, name) {
const target = holder === this.scene ? 'scene' : holder.id;
const key = `${target}::${name}`;
const refs = this.watchers.get(key) || [];
for (const ref of refs) {
if (this.pendingPC.has(ref.ev.id)) continue; // 合并同一事件已有待处理任务§7.5.4
let pass = true;
if (ref.ev.params.condition) {
const ctx = makeCtx(this, ref.holder === this.scene ? null : ref.holder, null);
pass = truthy(evalExpr(ref.ev.params.condition, ctx));
}
if (pass) this._enqueue(ref.holder, ref.ev, null);
}
}
// ── 对外触发接口(供测试 onTap/onMessage/onAudioEnd──
tap(entityId) {
const h = this.entities[entityId];
if (!h) throw new ScriptError(E_REF, `实体不存在: ${entityId}`);
for (const ev of h.events) if (ev.type === 'onTap') this._enqueue(h, ev, null);
}
sendMessage(name, payload, source) {
const deliver = (holder) => {
for (const ev of holder.events) {
if (ev.type !== 'onMessage') continue;
if (ev.params.name !== name) continue;
const src = ev.params.source || 'any';
if (src !== 'any' && src !== source) continue;
this._enqueue(holder, ev, { name, source, payload: payload || null });
}
};
deliver(this.scene);
for (const eid of this.entityOrder) deliver(this.entities[eid]);
}
}
function buildProps(def) {
const out = {};
for (const [name, spec] of Object.entries(def || {})) {
const t = spec.type || 'string';
const raw = spec.default !== undefined ? spec.default : spec.value; // 兼容 {default} 与 {value}
out[name] = { type: t, value: raw != null ? (t === 'entity' ? { $e: raw } : raw) : null };
}
return out;
}
function fmtVal(v) {
const t = typeOf(v);
if (t === 'entity') return `@${v.$e || 'null'}`;
if (t === 'string') return `"${v}"`;
if (t === 'null') return 'null';
return String(v);
}
// ────────────────────────────── 动作解释generator§6──────────────────
function* runActions(actions, ctx, rt, task) {
for (const a of actions) yield* runAction(a, ctx, rt, task);
}
function* runAction(a, ctx, rt, task) {
const p = a.params || {};
switch (a.type) {
case 'if': {
if (truthy(evalExpr(p.condition, ctx))) yield* runActions(p.then || [], ctx, rt, task);
else {
let matched = false;
for (const ei of (p.elseif || [])) {
if (truthy(evalExpr(ei.condition, ctx))) { yield* runActions(ei.actions || [], ctx, rt, task); matched = true; break; }
}
if (!matched && p.else) yield* runActions(p.else, ctx, rt, task);
}
return;
}
case 'while': {
let it = 0;
while (truthy(evalExpr(p.condition, ctx))) {
if (++it > p.maxIterations) throw new ScriptError(E_LOOP, `while 迭代超过 maxIterations=${p.maxIterations}`);
yield* runActions(p.body || [], ctx, rt, task);
}
return;
}
case 'for': {
const from = num(evalExpr(p.from, ctx));
const to = num(evalExpr(p.to, ctx));
const step = num(evalExpr(p.step, ctx));
if (step === 0) throw new ScriptError(E_TYPE, 'for 的 step 不能为 0');
const staticCap = Math.floor(Math.abs((to - from) / step)) + 1;
const cap = p.maxIterations != null ? Math.min(staticCap, p.maxIterations) : staticCap;
const vars = ctx.entity ? ctx.entity.vars : ctx.scene.vars; // 循环变量:实体上下则实体作用域,否则场景作用域
let v = from, it = 0;
while ((step > 0 ? v <= to : v >= to) && it < cap) {
vars[p.var] = v;
yield* runActions(p.body || [], ctx, rt, task);
v += step; it++;
}
return;
}
case 'wait': {
const d = num(evalExpr(p.duration, ctx));
yield { wait: d };
return;
}
case 'setVariable': {
const holder = p.scope === 'global' ? ctx.global : p.scope === 'scene' ? ctx.scene : ctx.entity;
if (!holder) throw new ScriptError(E_REF, 'setVariable 目标作用域不可用');
holder.vars[p.name] = evalExpr(p.value, ctx);
rt._log('var', `${p.scope}.${p.name} = ${fmtVal(holder.vars[p.name])}`);
return;
}
case 'setProperty': {
const h = rt._holder(p.target, ctx.entity);
rt._writeProp(h, p.name, evalExpr(p.value, ctx));
return;
}
case 'changeProperty': {
const h = rt._holder(p.target, ctx.entity);
const cur = h.properties[p.name];
if (!cur) throw new ScriptError(E_REF, `属性不存在: ${p.name}`);
if (cur.type !== 'number') throw new ScriptError(E_TYPE, `changeProperty 要求 number 属性: ${p.name}`);
rt._writeProp(h, p.name, cur.value + num(evalExpr(p.delta, ctx)));
return;
}
case 'playAudio': {
rt._log('audio', `▶ play ${p.audio} vol=${p.volume ?? 1} loop=${p.loop ?? false}`);
const dur = (rt.opts.audioDurations || {})[p.audio];
if (dur && !p.loop) {
// 桩:自然播完触发 onAudioEnd
const fire = () => { for (const [eid, h] of Object.entries(rt.entities)) for (const ev of h.events) if (ev.type === 'onAudioEnd' && ev.params.audio === p.audio) rt._enqueue(h, ev, null); };
rt.wakeups.push({ time: rt.T + dur, task: { holder: ctx.entity || ctx.scene, ev: { type: '__audioEnd', id: 'audio_end_' + p.audio, actions: [] }, message: null, gen: null, _post: fire } });
}
return;
}
case 'stopAudio': rt._log('audio', `■ stop ${p.audio}`); return;
case 'pauseAudio': rt._log('audio', `⏸ pause ${p.audio}`); return;
case 'resumeAudio': rt._log('audio', `▶ resume ${p.audio}`); return;
case 'moveTo': case 'rotateTo': case 'scaleTo': {
const h = rt._holder(p.target, ctx.entity);
if (a.type === 'moveTo') h.transform.pos = p.position && p.position.entity ? rt.entities[p.position.entity].transform.pos.slice() : [p.position.x, p.position.y, p.position.z];
if (a.type === 'rotateTo') h.transform.rot = [p.rotation.x, p.rotation.y, p.rotation.z];
if (a.type === 'scaleTo') h.transform.scale = Array.isArray(p.scale) ? p.scale : [p.scale, p.scale, p.scale];
rt._log('transform', `${a.type} ${h.id}${JSON.stringify(h.transform.pos)} dur=${p.duration ?? 0} easing=${p.easing ?? 'linear'}`);
return;
}
case 'lookAt': {
const h = rt._holder(p.target, ctx.entity);
rt._log('transform', `lookAt ${h.id}${JSON.stringify(p.position)}`);
return;
}
case 'show': case 'hide': {
const h = rt._holder(p.target, ctx.entity);
h.visible = (a.type === 'show');
rt._log('prop', `${h.id}.visible = ${h.visible}`);
return;
}
case 'switchScene': throw { $sceneSwitch: p.scene };
case 'reloadScene': throw { $sceneSwitch: '<reload>' };
case 'sendMessage': {
const src = ctx.entity ? ctx.entity.id : 'scene';
rt.sendMessage(p.name, p.payload, src);
rt._log('msg', `sendMessage "${p.name}" from ${src}`);
return;
}
default:
throw new ScriptError(E_TYPE, `未知动作类型: ${a.type}`);
}
}
function num(x) { if (typeOf(x) !== 'number') throw new ScriptError(E_TYPE, `要求 number实为 ${typeOf(x)}`); return x; }
return { SceneRuntime, evalExpr, truthy, typeOf, ScriptError, makeCtx, E_TYPE, E_REF, E_LOOP, E_DEPTH, E_BUDGET, E_DIV0 };
}));

134
bricks/inherit.js Normal file
View File

@ -0,0 +1,134 @@
/*
* inherit.js 实体继承设计规范§3有效定义求解的实现
*
* 原型式单继承沿 parent 链自根向叶合并属性定义与事件集
* 求解产出扁平化定义脚本引擎不感知继承规范 P1
* 所有非法结构抛 InheritError错误码见规范 §6.1对应保存期校验
*/
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory();
} else {
root.bricks = root.bricks || {};
root.bricks.ScriptInherit = factory();
}
}(typeof self !== 'undefined' ? self : this, function () {
const E_INH_CYCLE = 'E-INH-CYCLE';
const E_INH_DEPTH = 'E-INH-DEPTH';
const E_INH_NOSUCHPARENT = 'E-INH-NOSUCHPARENT';
const E_INH_TYPE = 'E-INH-TYPE';
const E_INH_DUPID = 'E-INH-DUPID';
const E_INH_NOSUCHEVENT = 'E-INH-NOSUCHEVENT';
const E_INH_CONFLICT = 'E-INH-CONFLICT';
class InheritError extends Error {
constructor(code, message) {
super(`[${code}] ${message}`);
this.code = code;
}
}
// 属性定义的默认值:规范用 {type, default},兼容旧格式 {type, value}
function defDefault(def) {
return def.default !== undefined ? def.default : def.value;
}
// §3.4 继承链校验:环 / 深度(≤32含自身) / 父存在性
function validateChain(e, all) {
const seen = new Set();
let cur = e, depth = 0;
for (;;) {
if (seen.has(cur.id)) {
throw new InheritError(E_INH_CYCLE, `继承环: ${[...seen, cur.id].join(' → ')}`);
}
seen.add(cur.id);
depth++;
if (depth > 32) throw new InheritError(E_INH_DEPTH, `继承链深度超过 32实体 ${e.id}`);
if (!cur.parent) break;
const p = all[cur.parent];
if (!p) throw new InheritError(E_INH_NOSUCHPARENT, `父实体不存在: ${cur.parent}(引用自 ${cur.id}`);
cur = p;
}
}
// §3.2 属性求解(自根向叶;覆盖只改默认值,类型必须一致)
function resolveProperties(e, all) {
if (!e.parent) {
const out = {};
for (const [k, v] of Object.entries(e.properties || {})) out[k] = JSON.parse(JSON.stringify(v));
return out;
}
const base = resolveProperties(all[e.parent], all);
for (const [name, def] of Object.entries(e.properties || {})) {
if (name in base) {
const bt = base[name].type || 'string';
const dt = def.type || 'string';
if (bt !== dt) {
throw new InheritError(E_INH_TYPE, `实体 ${e.id} 覆盖属性 "${name}" 类型不匹配:继承类型 ${bt},覆盖类型 ${dt}`);
}
base[name] = { type: bt, default: defDefault(def) }; // 类型不变,只覆盖默认值
} else {
base[name] = JSON.parse(JSON.stringify(def)); // 新增
}
}
return base;
}
// §3.3 事件求解(自根向叶;同 id 原位覆盖;禁用移除)
// 依赖 JS Map 语义set 已存在的 key 保持原插入位置 → 精确实现 §5.3「覆盖原位替换,不挪到末尾」
function resolveEvents(e, all) {
const own = e.events || [];
const ownIds = new Set();
for (const ev of own) {
if (ownIds.has(ev.id)) throw new InheritError(E_INH_DUPID, `实体 ${e.id} 自身事件 id 重复: ${ev.id}`);
ownIds.add(ev.id);
}
let map;
if (!e.parent) {
map = new Map(own.map(ev => [ev.id, ev]));
} else {
map = resolveEvents(all[e.parent], all);
for (const ev of own) map.set(ev.id, ev); // 覆盖(原位)或新增
for (const id of (e.disabled_events || [])) {
if (ownIds.has(id)) throw new InheritError(E_INH_CONFLICT, `实体 ${e.id}: 事件 ${id} 既覆盖又禁用`);
if (!map.has(id)) throw new InheritError(E_INH_NOSUCHEVENT, `实体 ${e.id} 禁用了不存在的继承事件: ${id}`);
map.delete(id);
}
}
return map;
}
// §3.6 外观求解:沿链取第一个非 null 的 asset_ref
function resolveAsset(e, all) {
let cur = e;
while (cur) {
if (cur.asset_ref) return cur.asset_ref;
cur = cur.parent ? all[cur.parent] : null;
}
return null;
}
// §3.1 求解入口:对全部实体先校验后求解,返回 id → 扁平化定义
function resolveAll(list) {
const all = {};
for (const e of list) all[e.id] = e;
const out = {};
for (const e of list) {
validateChain(e, all);
out[e.id] = {
...e,
asset_ref: resolveAsset(e, all),
properties: resolveProperties(e, all),
events: [...resolveEvents(e, all).values()],
};
}
return out;
}
return {
resolveAll, validateChain, resolveProperties, resolveEvents, resolveAsset,
InheritError,
E_INH_CYCLE, E_INH_DEPTH, E_INH_NOSUCHPARENT, E_INH_TYPE, E_INH_DUPID, E_INH_NOSUCHEVENT, E_INH_CONFLICT,
};
}));

View File

@ -2,25 +2,36 @@ var bricks = window.bricks || {};
/*
* scene3d.js bricks3d 核心 widgetScene3D
*
* 后端声明式驱动后端用 JSON 描述符声明场景实体/变换/颜色/相机
* Scene3D three.js 本地渲染点击实体 dispatch('entity_tapped', {entity_id, entity_name})
* 走标准 binds urlwidget/script回调后端
* 三层职责
* 1. 渲染 three.js 把后端下发的场景描述符实体/变换/颜色/相机渲染出来
* 2. 输入上报点击实体 dispatch('entity_tapped', {entity_id, entity_name}) binds 回传后端
* 3. 本地逻辑独立世界描述符若带 properties/events则用 bricks.ScriptEngine.SceneRuntime
* 在浏览器里本地跑脚本引擎每帧 step() 推进 transform/visible 状态回填 three.js
*
* 架构独立世界 / 共享世界
* - 独立世界数据全下前端本地跑引擎本文件第 3 层职责
* - 共享世界后端权威世界副本前端只渲染 + 上报输入后端推送状态更新本文件第 1/2 层职责
*
* options全部 get_ 前缀避开 opts_set_style option实例拷贝冲突
* scene : 内联场景描述符对象
* scene_url : 后端数据端点优先于 scene返回场景描述符
* get_bgcolor : 背景色默认 0x141420
* get_grid : 是否显示地面网格默认 true
* get_run_local : 是否本地跑脚本引擎独立世界=true共享世界/纯渲染=false默认 false
* get_three_url : three.js URL默认 /bricks/3parties/three.min.js按需注入用
*
* 场景描述符格式脚本引擎设计规范实体子集对齐
* 场景描述符格式渲染子集 + 逻辑子集统一
* {
* "camera": {"position": [x,y,z], "fov": 50},
* "entities": [
* {"id": "door", "name": "门", "type": "box",
* "transform": {"pos": [x,y,z], "rot": [deg,deg,deg], "scale": [x,y,z]},
* "appearance": {"color": "#3b82f6"}},
* {"id": "ball", "type": "sphere", ...}
* ]
* "appearance": {"color": "#3b82f6"},
* "properties": {"hasVisitor": {"type":"boolean","value":true}}, // 逻辑子集(可选)
* "events": [ ... ] // 逻辑子集(可选)
* }
* ],
* "events": [ ... ] // 场景级事件(可选)
* }
*
* dispatch 的事件
@ -28,11 +39,14 @@ var bricks = window.bricks || {};
* scene_ready {entity_count}
* scene_error {message}
*
* three.js 依赖dist/3parties/three.min.jsr149 UMD 全局版
* three.js 依赖/bricks/3parties/three.min.jsr149 UMD 全局版构造时按需注入
* 活动对象scene/camera/renderer/mesh只存实例私有字段绝不进 opts/描述符
* JSON.stringify 循环引用 bricks 已知坑
*/
// 全局 three.js 加载 Promise多实例共享避免重复注入 script
bricks._s3_three_promise = bricks._s3_three_promise || null;
bricks.Scene3D = class extends bricks.VBox {
constructor(opts) {
opts.width = opts.width || '100%';
@ -41,6 +55,7 @@ bricks.Scene3D = class extends bricks.VBox {
this.dom_element.style.display = 'flex';
this.dom_element.style.flexDirection = 'column';
this.dom_element.style.overflow = 'hidden';
this.dom_element.style.touchAction = 'none'; // 移动端手势:禁用浏览器默认缩放/滚动
// three.js 活动对象 —— 私有字段,不进 opts
this._s3_scene = null;
this._s3_camera = null;
@ -50,15 +65,35 @@ bricks.Scene3D = class extends bricks.VBox {
this._s3_raycaster = null;
this._s3_pointer = null;
this._s3_ready = false;
// 引擎(独立世界本地逻辑)
this._s3_runtime = null; // SceneRuntime 实例
this._s3_last_t = 0; // 上一帧时间戳step 用)
this._s3_paused = false;
// 相机控制状态
this._s3_ctrl = { rotX: 0, rotY: 0, dist: 12, targetX: 0, targetY: 1, targetZ: 0, lastPX: 0, lastPY: 0, pinchDist: 0, mode: 'rotate' };
this._s3_cam_pos = [8, 6, 10]; // 当前相机位置(环绕球坐标计算)
schedule_once(this.s3_build.bind(this), 0.1);
}
// ── three.js 按需注入 ──
s3_load_three() {
if (typeof THREE !== 'undefined') return Promise.resolve(THREE);
if (bricks._s3_three_promise) return bricks._s3_three_promise;
var url = this.opts.get_three_url || '/bricks/3parties/three.min.js';
bricks._s3_three_promise = new Promise(function (resolve, reject) {
var s = document.createElement('script');
s.src = url;
s.onload = function () { if (typeof THREE !== 'undefined') resolve(THREE); else reject(new Error('THREE 未定义')); };
s.onerror = function () { bricks._s3_three_promise = null; reject(new Error('three.js 加载失败: ' + url)); };
document.head.appendChild(s);
});
return bricks._s3_three_promise;
}
async s3_build() {
if (typeof THREE === 'undefined') {
this.s3_show_error('three.js 未加载(需引入 /bricks/3parties/three.min.js');
this.dispatch('scene_error', {message: 'THREE undefined'});
return;
}
try { await this.s3_load_three(); }
catch (e) { this.s3_show_error(e.message); this.dispatch('scene_error', { message: String(e) }); return; }
var desc = null;
if (this.opts.scene_url) {
try {
@ -66,7 +101,7 @@ bricks.Scene3D = class extends bricks.VBox {
desc = await jc.get(this.opts.scene_url);
} catch (e) {
this.s3_show_error('场景数据加载失败: ' + this.opts.scene_url);
this.dispatch('scene_error', {message: String(e)});
this.dispatch('scene_error', { message: String(e) });
return;
}
} else {
@ -79,14 +114,15 @@ bricks.Scene3D = class extends bricks.VBox {
try {
this.s3_init_gl(desc);
this.s3_build_entities(desc.entities || []);
this.s3_bind_events();
this.s3_bind_input(desc);
this.s3_start_runtime(desc);
this.s3_loop();
this._s3_ready = true;
this.dispatch('scene_ready', {entity_count: (desc.entities || []).length});
this.dispatch('scene_ready', { entity_count: (desc.entities || []).length });
} catch (e) {
console.error('Scene3D build error', e);
this.s3_show_error('场景构建失败: ' + e.message);
this.dispatch('scene_error', {message: String(e)});
this.dispatch('scene_error', { message: String(e) });
}
}
@ -101,11 +137,18 @@ bricks.Scene3D = class extends bricks.VBox {
var cam = desc.camera || {};
var camera = new THREE.PerspectiveCamera(cam.fov || 50, w / h, 0.1, 1000);
this._s3_camera = camera; // 先赋值s3_apply_camera 依赖它
var p = cam.position || [8, 6, 10];
camera.position.set(p[0], p[1], p[2]);
camera.lookAt(0, 1, 0);
this._s3_cam_pos = p.slice();
// 初始化环绕球参数(由初始相机位置反推)
var dx = p[0], dy = p[1] - 1, dz = p[2]; // 目标点默认 [0,1,0]
var dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
this._s3_ctrl.dist = dist || 12;
this._s3_ctrl.rotY = Math.atan2(dx, dz); // 方位角(绕 Y
this._s3_ctrl.rotX = Math.asin(Math.max(-1, Math.min(1, dy / (dist || 1)))); // 俯仰角
this.s3_apply_camera();
var renderer = new THREE.WebGLRenderer({antialias: true});
var renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(w, h);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
el.appendChild(renderer.domElement);
@ -130,10 +173,121 @@ bricks.Scene3D = class extends bricks.VBox {
this._s3_raycaster = new THREE.Raycaster();
this._s3_pointer = new THREE.Vector2();
// 容器尺寸变化 → 跟随bricks.resize_observer 会向本元素派发 resize 事件)
this.bind('resize', this.s3_on_resize.bind(this));
}
// ── 环绕相机:单指旋转 / 双指缩放 / 双指平移 ──
s3_apply_camera() {
var c = this._s3_ctrl;
var cy = Math.cos(c.rotX), sy = Math.sin(c.rotX);
var cx = Math.cos(c.rotY), sx = Math.sin(c.rotY);
var x = c.targetX + c.dist * cy * sx;
var y = c.targetY + c.dist * sy;
var z = c.targetZ + c.dist * cy * cx;
this._s3_camera.position.set(x, y, z);
this._s3_camera.lookAt(c.targetX, c.targetY, c.targetZ);
this._s3_cam_pos = [x, y, z];
}
s3_bind_input(desc) {
var self = this;
var cv = this._s3_renderer.domElement;
this._s3_pointers = {}; // pointerId → {x, y}
var downX = 0, downY = 0; // 单指按下位置(点击判定用)
var downCount = 0;
cv.addEventListener('pointerdown', function (ev) {
cv.setPointerCapture && cv.setPointerCapture(ev.pointerId);
self._s3_pointers[ev.pointerId] = { x: ev.clientX, y: ev.clientY };
var n = Object.keys(self._s3_pointers).length;
if (n === 1) {
downX = ev.clientX; downY = ev.clientY; downCount = 1;
self._s3_ctrl.pinchDist = 0;
self._s3_ctrl.pinchMid = null;
} else if (n === 2) {
// 进入双指:记录初始距离与中点
var ids = Object.keys(self._s3_pointers);
var p1 = self._s3_pointers[ids[0]], p2 = self._s3_pointers[ids[1]];
self._s3_ctrl.pinchDist = Math.hypot(p1.x - p2.x, p1.y - p2.y);
self._s3_ctrl.pinchMid = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
}
});
cv.addEventListener('pointermove', function (ev) {
if (!(ev.pointerId in self._s3_pointers)) return;
self._s3_pointers[ev.pointerId] = { x: ev.clientX, y: ev.clientY };
var ids = Object.keys(self._s3_pointers);
if (ids.length === 2) {
var p1 = self._s3_pointers[ids[0]], p2 = self._s3_pointers[ids[1]];
var d = Math.hypot(p1.x - p2.x, p1.y - p2.y);
if (self._s3_ctrl.pinchDist > 0) {
var ratio = self._s3_ctrl.pinchDist / d;
self._s3_ctrl.dist = Math.max(1.5, Math.min(80, self._s3_ctrl.dist * ratio));
}
self._s3_ctrl.pinchDist = d;
var mx = (p1.x + p2.x) / 2, my = (p1.y + p2.y) / 2;
if (self._s3_ctrl.pinchMid) {
self._s3_pan(mx - self._s3_ctrl.pinchMid.x, my - self._s3_ctrl.pinchMid.y);
}
self._s3_ctrl.pinchMid = { x: mx, y: my };
} else if (ids.length === 1) {
var dx = ev.clientX - self._s3_ctrl.lastPX;
var dy = ev.clientY - self._s3_ctrl.lastPY;
self._s3_ctrl.rotY -= dx * 0.005;
self._s3_ctrl.rotX += dy * 0.005;
self._s3_ctrl.rotX = Math.max(-1.4, Math.min(1.4, self._s3_ctrl.rotX));
self.s3_apply_camera();
}
self._s3_ctrl.lastPX = ev.clientX;
self._s3_ctrl.lastPY = ev.clientY;
});
cv.addEventListener('pointerup', function (ev) {
delete self._s3_pointers[ev.pointerId];
self._s3_ctrl.lastPX = ev.clientX;
self._s3_ctrl.lastPY = ev.clientY;
self._s3_ctrl.pinchDist = 0;
self._s3_ctrl.pinchMid = null;
downCount = 0;
});
cv.addEventListener('pointercancel', function (ev) {
delete self._s3_pointers[ev.pointerId];
self._s3_ctrl.pinchDist = 0;
self._s3_ctrl.pinchMid = null;
});
// 点击拾取(与拖拽区分:位移小于阈值才算点击)
cv.addEventListener('click', function (ev) {
if (Math.abs(ev.clientX - downX) > 6 || Math.abs(ev.clientY - downY) > 6) return; // 拖拽不算点击
if (!self._s3_ready) return;
var rect = cv.getBoundingClientRect();
self._s3_pointer.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
self._s3_pointer.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
self._s3_raycaster.setFromCamera(self._s3_pointer, self._s3_camera);
var hits = self._s3_raycaster.intersectObjects(Object.values(self._s3_meshes), false);
if (hits.length > 0) {
var ud = hits[0].object.userData;
if (self._s3_runtime) self._s3_runtime.tap(ud.entity_id); // 独立世界:注入引擎
self.dispatch('entity_tapped', { entity_id: ud.entity_id, entity_name: ud.entity_name });
}
});
}
s3_pan(dxpx, dypx) {
var c = this._s3_ctrl;
var scale = c.dist * 0.0012; // 平移速度随距离
var sy = Math.sin(c.rotX), cy = Math.cos(c.rotX);
var sx = Math.sin(c.rotY), cx = Math.cos(c.rotY);
// 相机右向量(水平,绕 Y
var rx = cx, rz = -sx;
// 相机上向量(简化)
var upx = -sy * sx, upy = cy, upz = -sy * cx;
c.targetX += (-rx * dxpx - upx * dypx) * scale;
c.targetY += (-upy * dypx) * scale;
c.targetZ += (-rz * dxpx - upz * dypx) * scale;
this.s3_apply_camera();
}
s3_build_entities(list) {
for (var i = 0; i < list.length; i++) {
var e = list[i];
@ -164,14 +318,14 @@ bricks.Scene3D = class extends bricks.VBox {
case 'cylinder':
geo = new THREE.CylinderGeometry(0.5, 0.5, 1, 24);
break;
case 'model': // glTF 占位初版用盒子代替GLTFLoader 为下一步
case 'model': // glTF 占位初版用盒子代替GLTFLoader 为后续
geo = new THREE.BoxGeometry(1, 1, 1);
break;
default:
console.warn('Scene3D: 未知实体类型', e.type, e.id);
return null;
}
var mat = new THREE.MeshStandardMaterial({color: new THREE.Color(color)});
var mat = new THREE.MeshStandardMaterial({ color: new THREE.Color(color) });
var mesh = new THREE.Mesh(geo, mat);
mesh.position.set(pos[0], pos[1], pos[2]);
mesh.rotation.set(
@ -183,28 +337,55 @@ bricks.Scene3D = class extends bricks.VBox {
return mesh;
}
s3_bind_events() {
var self = this;
var cv = this._s3_renderer.domElement;
cv.style.cursor = 'pointer';
cv.addEventListener('click', function(ev) {
if (!self._s3_ready) return;
var rect = cv.getBoundingClientRect();
self._s3_pointer.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
self._s3_pointer.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
self._s3_raycaster.setFromCamera(self._s3_pointer, self._s3_camera);
var hits = self._s3_raycaster.intersectObjects(Object.values(self._s3_meshes), false);
if (hits.length > 0) {
var ud = hits[0].object.userData;
self.dispatch('entity_tapped', {entity_id: ud.entity_id, entity_name: ud.entity_name});
}
});
// ── 本地逻辑引擎(独立世界)──
s3_start_runtime(desc) {
if (!this.opts.get_run_local) return;
if (!bricks.ScriptEngine || !bricks.ScriptEngine.SceneRuntime) {
this.s3_show_error('脚本引擎未加载bricks.ScriptEngine 缺失)');
return;
}
var hasLogic = (desc.events && desc.events.length) ||
(desc.entities || []).some(function (e) { return (e.events && e.events.length) || (e.properties && Object.keys(e.properties).length); });
if (!hasLogic) return; // 纯渲染,不启动引擎
try {
this._s3_runtime = new bricks.ScriptEngine.SceneRuntime(desc, { frameDT: 0.05, budget: 100000 });
this._s3_last_t = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
} catch (e) {
console.error('Scene3D 引擎启动失败', e);
this.dispatch('scene_error', { message: '引擎启动失败: ' + e.message });
}
}
s3_sync_from_runtime() {
if (!this._s3_runtime) return;
var ents = this._s3_runtime.entities;
for (var id in this._s3_meshes) {
var mesh = this._s3_meshes[id];
var re = ents[id];
if (!re) continue;
var tr = re.transform;
mesh.position.set(tr.pos[0], tr.pos[1], tr.pos[2]);
mesh.rotation.set(
THREE.MathUtils.degToRad(tr.rot[0]),
THREE.MathUtils.degToRad(tr.rot[1]),
THREE.MathUtils.degToRad(tr.rot[2])
);
mesh.scale.set(tr.scale[0], tr.scale[1], tr.scale[2]);
mesh.visible = re.visible;
}
}
s3_loop() {
var self = this;
function tick() {
self._s3_raf = requestAnimationFrame(tick);
if (self._s3_runtime && !self._s3_paused) {
var now = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
var dt = (now - self._s3_last_t) / 1000;
self._s3_last_t = now;
if (dt > 0 && dt < 0.5) self._s3_runtime.step(dt);
self.s3_sync_from_runtime();
}
if (self._s3_renderer && self._s3_scene && self._s3_camera) {
self._s3_renderer.render(self._s3_scene, self._s3_camera);
}
@ -221,7 +402,7 @@ bricks.Scene3D = class extends bricks.VBox {
this._s3_renderer.setSize(w, h);
}
// 公共 API设置实体颜色后端描述符更新或前端联动均可调用)
// 公共 API设置实体颜色后端状态更新或前端联动均可调用)
set_entity_color(entity_id, color) {
var m = this._s3_meshes[entity_id];
if (m && m.material) m.material.color.set(color);
@ -233,8 +414,29 @@ bricks.Scene3D = class extends bricks.VBox {
if (m) m.visible = !!visible;
}
// 公共 API设置实体变换共享世界后端推送状态用
set_entity_transform(entity_id, transform) {
var m = this._s3_meshes[entity_id];
if (!m || !transform) return;
var pos = transform.pos, rot = transform.rot, scl = transform.scale;
if (pos) m.position.set(pos[0], pos[1], pos[2]);
if (rot) m.rotation.set(THREE.MathUtils.degToRad(rot[0]), THREE.MathUtils.degToRad(rot[1]), THREE.MathUtils.degToRad(rot[2]));
if (scl) m.scale.set(scl[0], scl[1], scl[2]);
}
// 公共 API暂停/继续本地引擎
set_paused(paused) { this._s3_paused = !!paused; }
// 公共 API重置本地引擎重新加载描述符逻辑
reset_runtime(desc) {
if (this._s3_runtime && desc) {
this._s3_runtime = new bricks.ScriptEngine.SceneRuntime(desc, { frameDT: 0.05, budget: 100000 });
this._s3_last_t = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
}
}
s3_show_error(msg) {
var t = new bricks.Text({text: '⚠ ' + msg, color: '#f87171', padding: '12px'});
var t = new bricks.Text({ text: '⚠ ' + msg, color: '#f87171', padding: '12px' });
this.add_widget(t);
}
@ -249,6 +451,7 @@ bricks.Scene3D = class extends bricks.VBox {
if (m.material) m.material.dispose();
}
this._s3_meshes = {};
this._s3_runtime = null;
if (this._s3_renderer) {
this._s3_renderer.dispose();
if (this._s3_renderer.domElement && this._s3_renderer.domElement.parentNode) {