feat(scene3d): Scene3D widget — bricks3d 最小闭环(后端声明式场景描述符→three.js 本地渲染→entity_tapped 事件回传)

- scene3d.js: Scene3D widget(VBox 子类),支持 box/sphere/cylinder/model 实体
  内联 scene 或 scene_url 两种描述符来源;Raycaster 拾取→dispatch entity_tapped
  活动对象(scene/camera/renderer/mesh)只存实例私有字段,防 JSON.stringify 循环引用
  s3_dispose() 释放 WebGL 资源;set_entity_color/set_entity_visible 公共 API
- build.sh: scene3d.js 加入 SOURCES
- 3parties/three.min.js: r149 UMD 全局版(608KB,npmmirror 下载)
- examples/scene3d_demo.html: 演示页(4 实体场景 + scene_ready/entity_tapped binds)

验证(CDP 实测):场景就绪事件 点击拾取 entity_tapped→binds→状态栏更新
球点击变色 #f59e0b→#ef4444 全链路通过;干净重载 0 JS 异常
This commit is contained in:
yumoqing 2026-08-27 11:31:10 +08:00
parent 8a92b95990
commit d945e414d7
4 changed files with 332 additions and 1 deletions

6
3parties/three.min.js vendored Normal file

File diff suppressed because one or more lines are too long

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 \ line.js pie.js bar.js gobang.js period.js iconbarpage.js \
keypress.js asr.js webspeech.js countdown.js progressbar.js \ keypress.js asr.js webspeech.js countdown.js progressbar.js \
qaframe.js svg.js videoplayer.js scatter.js radar.js kline.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 " 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 "
echo ${SOURCES} echo ${SOURCES}
cat ${SOURCES} > ../dist/bricks.js cat ${SOURCES} > ../dist/bricks.js
# uglifyjs --compress --mangle -- ../dist/bricks.js > ../dist/bricks.min.js # uglifyjs --compress --mangle -- ../dist/bricks.js > ../dist/bricks.min.js

264
bricks/scene3d.js Normal file
View File

@ -0,0 +1,264 @@
var bricks = window.bricks || {};
/*
* scene3d.js bricks3d 核心 widgetScene3D
*
* 后端声明式驱动后端用 JSON 描述符声明场景实体/变换/颜色/相机
* Scene3D three.js 本地渲染点击实体 dispatch('entity_tapped', {entity_id, entity_name})
* 走标准 binds urlwidget/script回调后端
*
* options全部 get_ 前缀避开 opts_set_style option实例拷贝冲突
* scene : 内联场景描述符对象
* scene_url : 后端数据端点优先于 scene返回场景描述符
* get_bgcolor : 背景色默认 0x141420
* get_grid : 是否显示地面网格默认 true
*
* 场景描述符格式脚本引擎设计规范实体子集对齐
* {
* "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", ...}
* ]
* }
*
* dispatch 的事件
* entity_tapped {entity_id, entity_name}
* scene_ready {entity_count}
* scene_error {message}
*
* three.js 依赖dist/3parties/three.min.jsr149 UMD 全局版
* 活动对象scene/camera/renderer/mesh只存实例私有字段绝不进 opts/描述符
* JSON.stringify 循环引用 bricks 已知坑
*/
bricks.Scene3D = class extends bricks.VBox {
constructor(opts) {
opts.width = opts.width || '100%';
opts.height = opts.height || '100%';
super(opts);
this.dom_element.style.display = 'flex';
this.dom_element.style.flexDirection = 'column';
this.dom_element.style.overflow = 'hidden';
// three.js 活动对象 —— 私有字段,不进 opts
this._s3_scene = null;
this._s3_camera = null;
this._s3_renderer = null;
this._s3_meshes = {}; // entity_id → THREE.Mesh
this._s3_raf = 0;
this._s3_raycaster = null;
this._s3_pointer = null;
this._s3_ready = false;
schedule_once(this.s3_build.bind(this), 0.1);
}
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;
}
var desc = null;
if (this.opts.scene_url) {
try {
var jc = new bricks.HttpJson();
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)});
return;
}
} else {
desc = this.opts.scene || null;
}
if (!desc) {
this.s3_show_error('未提供场景(需要 scene 或 scene_url');
return;
}
try {
this.s3_init_gl(desc);
this.s3_build_entities(desc.entities || []);
this.s3_bind_events();
this.s3_loop();
this._s3_ready = true;
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)});
}
}
s3_init_gl(desc) {
var el = this.dom_element;
var w = el.clientWidth || 640;
var h = el.clientHeight || 400;
var bg = this.opts.get_bgcolor || 0x141420;
var scene = new THREE.Scene();
scene.background = new THREE.Color(bg);
var cam = desc.camera || {};
var camera = new THREE.PerspectiveCamera(cam.fov || 50, w / h, 0.1, 1000);
var p = cam.position || [8, 6, 10];
camera.position.set(p[0], p[1], p[2]);
camera.lookAt(0, 1, 0);
var renderer = new THREE.WebGLRenderer({antialias: true});
renderer.setSize(w, h);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
el.appendChild(renderer.domElement);
renderer.domElement.style.display = 'block';
// 光照
var amb = new THREE.AmbientLight(0xffffff, 0.55);
var dir = new THREE.DirectionalLight(0xffffff, 0.9);
dir.position.set(5, 10, 7);
scene.add(amb);
scene.add(dir);
// 地面网格(编辑态辅助)
if (this.opts.get_grid !== false) {
var grid = new THREE.GridHelper(20, 20, 0x444466, 0x2a2a40);
scene.add(grid);
}
this._s3_scene = scene;
this._s3_camera = camera;
this._s3_renderer = renderer;
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_build_entities(list) {
for (var i = 0; i < list.length; i++) {
var e = list[i];
var mesh = this.s3_make_mesh(e);
if (!mesh) continue;
mesh.userData.entity_id = e.id;
mesh.userData.entity_name = e.name || e.id;
this._s3_scene.add(mesh);
this._s3_meshes[e.id] = mesh;
}
}
s3_make_mesh(e) {
var t = e.transform || {};
var pos = t.pos || [0, 0, 0];
var rot = t.rot || [0, 0, 0];
var scl = t.scale || [1, 1, 1];
var color = (e.appearance && e.appearance.color) || '#8899aa';
var geo = null;
switch (e.type) {
case 'box':
geo = new THREE.BoxGeometry(1, 1, 1);
break;
case 'sphere':
geo = new THREE.SphereGeometry(0.5, 24, 16);
break;
case 'cylinder':
geo = new THREE.CylinderGeometry(0.5, 0.5, 1, 24);
break;
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 mesh = new THREE.Mesh(geo, mat);
mesh.position.set(pos[0], pos[1], pos[2]);
mesh.rotation.set(
THREE.MathUtils.degToRad(rot[0]),
THREE.MathUtils.degToRad(rot[1]),
THREE.MathUtils.degToRad(rot[2])
);
mesh.scale.set(scl[0], scl[1], scl[2]);
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_loop() {
var self = this;
function tick() {
self._s3_raf = requestAnimationFrame(tick);
if (self._s3_renderer && self._s3_scene && self._s3_camera) {
self._s3_renderer.render(self._s3_scene, self._s3_camera);
}
}
tick();
}
s3_on_resize() {
var el = this.dom_element;
var w = el.clientWidth, h = el.clientHeight;
if (!this._s3_renderer || w < 10 || h < 10) return;
this._s3_camera.aspect = w / h;
this._s3_camera.updateProjectionMatrix();
this._s3_renderer.setSize(w, h);
}
// 公共 API设置实体颜色后端描述符更新或前端联动均可调用
set_entity_color(entity_id, color) {
var m = this._s3_meshes[entity_id];
if (m && m.material) m.material.color.set(color);
}
// 公共 API设置实体显隐
set_entity_visible(entity_id, visible) {
var m = this._s3_meshes[entity_id];
if (m) m.visible = !!visible;
}
s3_show_error(msg) {
var t = new bricks.Text({text: '⚠ ' + msg, color: '#f87171', padding: '12px'});
this.add_widget(t);
}
// 释放 WebGL 资源clear_widgets 只移 DOM不释放 GL 上下文)
s3_dispose() {
if (this._s3_raf) cancelAnimationFrame(this._s3_raf);
this._s3_raf = 0;
var ids = Object.keys(this._s3_meshes);
for (var i = 0; i < ids.length; i++) {
var m = this._s3_meshes[ids[i]];
if (m.geometry) m.geometry.dispose();
if (m.material) m.material.dispose();
}
this._s3_meshes = {};
if (this._s3_renderer) {
this._s3_renderer.dispose();
if (this._s3_renderer.domElement && this._s3_renderer.domElement.parentNode) {
this._s3_renderer.domElement.parentNode.removeChild(this._s3_renderer.domElement);
}
this._s3_renderer = null;
}
this._s3_scene = null;
this._s3_camera = null;
this._s3_ready = false;
}
};
bricks.Factory.register('Scene3D', bricks.Scene3D);

View File

@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>bricks3d Scene3D 最小闭环演示</title>
<link rel="stylesheet" href="/bricks/css/bricks.css" />
<style>html,body{margin:0;height:100%;background:#0d0d14}</style>
</head>
<body>
<script src="/bricks/3parties/three.min.js"></script>
<script src="/bricks/bricks.js"></script>
<script>
// 后端声明式场景描述符(实际部署时由 .dspy 返回,走 scene_url
var demo_scene = {
"camera": {"position": [8, 6, 10], "fov": 50},
"entities": [
{"id": "door", "name": "门", "type": "box",
"transform": {"pos": [-3, 1, 0], "rot": [0, 30, 0], "scale": [1.2, 2, 0.3]},
"appearance": {"color": "#3b82f6"}},
{"id": "ball", "name": "铃铛球", "type": "sphere",
"transform": {"pos": [0, 0.6, 2], "rot": [0, 0, 0], "scale": [1.2, 1.2, 1.2]},
"appearance": {"color": "#f59e0b"}},
{"id": "pillar", "name": "立柱", "type": "cylinder",
"transform": {"pos": [3, 1, -1], "rot": [0, 0, 0], "scale": [0.8, 2, 0.8]},
"appearance": {"color": "#10b981"}},
{"id": "crate", "name": "木箱", "type": "model",
"transform": {"pos": [0.5, 0.5, -2.5], "rot": [0, 45, 0], "scale": [1, 1, 1]},
"appearance": {"color": "#a16207"}}
]
};
const opts = {
"widget": {
"widgettype": "VBox",
"options": {"width": "100%", "height": "100%", "css": "filler"},
"subwidgets": [
{"widgettype": "HBox",
"options": {"height": "44px", "alignItems": "center", "gap": "10px", "padding": "0 12px"},
"subwidgets": [
{"widgettype": "Text", "id": "status_label",
"options": {"text": "加载中...", "color": "#94a3b8", "halign": "left"}}
]},
{"widgettype": "Scene3D", "id": "scene",
"options": {"scene": demo_scene, "css": "filler",
"binds_demo_note": "entity_tapped 走 binds"}}
],
"binds": [
{"wid": "scene", "event": "scene_ready", "actiontype": "script", "target": "status_label",
"script": "this.set_text('✅ 场景就绪:' + params.entity_count + ' 个实体(点击任意实体)');"},
{"wid": "scene", "event": "entity_tapped", "actiontype": "script", "target": "status_label",
"script": "var sc=bricks.getWidgetById('scene',bricks.app);var colors=['#ef4444','#3b82f6','#f59e0b','#10b981','#a855f7'];var c=colors[Math.floor(Math.random()*colors.length)];sc.set_entity_color(params.entity_id,c);this.set_text('🖱 点击了「' + params.entity_name + '」→ 已变色entity_tapped 事件回传成功)');"},
{"wid": "scene", "event": "scene_error", "actiontype": "script", "target": "status_label",
"script": "this.set_text('❌ 场景错误:' + params.message);"}
]
}
};
const app = new bricks.App(opts);
app.run();
</script>
</body>
</html>