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:
parent
8a92b95990
commit
d945e414d7
6
3parties/three.min.js
vendored
Normal file
6
3parties/three.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -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
264
bricks/scene3d.js
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
var bricks = window.bricks || {};
|
||||||
|
/*
|
||||||
|
* scene3d.js — bricks3d 核心 widget:Scene3D
|
||||||
|
*
|
||||||
|
* 后端声明式驱动:后端用 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.js(r149 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);
|
||||||
61
examples/scene3d_demo.html
Normal file
61
examples/scene3d_demo.html
Normal 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>
|
||||||
Loading…
x
Reference in New Issue
Block a user