@simarena/viewport 0.0.1-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +40 -0
- package/src/compiler/threejs/index.ts +389 -0
- package/src/index.ts +13 -0
- package/src/parser/threejs/index.ts +84 -0
- package/src/sync.ts +189 -0
- package/src/viewport.ts +78 -0
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@simarena/viewport",
|
|
3
|
+
"version": "0.0.1-alpha.0",
|
|
4
|
+
"description": "SimArena viewport — Three.js rendering, body sync, camera, WebSocket streaming",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"files": ["src"],
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"source": "./src/index.ts",
|
|
11
|
+
"import": "./src/index.ts",
|
|
12
|
+
"types": "./src/index.ts"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"main": "./src/index.ts",
|
|
16
|
+
"scripts": {
|
|
17
|
+
"check": "tsc --noEmit"
|
|
18
|
+
},
|
|
19
|
+
"keywords": ["simarena", "viewport", "three.js", "robotics", "simulation", "websocket"],
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "https://github.com/simarena/viewport"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@simarena/format": "0.0.1-alpha.0",
|
|
26
|
+
"three": "^0.183.2"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/three": "^0.183.1",
|
|
30
|
+
"typescript": "^6.0.2"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"three": "^0.170.0"
|
|
34
|
+
},
|
|
35
|
+
"peerDependenciesMeta": {
|
|
36
|
+
"three": {
|
|
37
|
+
"optional": true
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import { STLLoader } from 'three/addons/loaders/STLLoader.js';
|
|
3
|
+
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
|
|
4
|
+
import { MTLLoader } from 'three/addons/loaders/MTLLoader.js';
|
|
5
|
+
import type { SceneDoc, DocEntity, MaterialDef, MeshDef, TextureDef, LightComponent, CameraComponent } from '@simarena/format';
|
|
6
|
+
import type { Geom } from '@simarena/format';
|
|
7
|
+
import type { Vec3, Vec4 } from '@simarena/format';
|
|
8
|
+
|
|
9
|
+
const stlLoader = new STLLoader();
|
|
10
|
+
const geoCache = new Map<string, THREE.BufferGeometry>();
|
|
11
|
+
|
|
12
|
+
async function loadSTL(url: string): Promise<THREE.BufferGeometry> {
|
|
13
|
+
const hit = geoCache.get(url);
|
|
14
|
+
if (hit) return hit.clone();
|
|
15
|
+
const geo = await stlLoader.loadAsync(url);
|
|
16
|
+
geo.computeVertexNormals();
|
|
17
|
+
geoCache.set(url, geo);
|
|
18
|
+
return geo.clone();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const objGroupCache = new Map<string, THREE.Group>();
|
|
22
|
+
const mtlCreatorCache = new Map<string, MTLLoader.MaterialCreator | null>();
|
|
23
|
+
|
|
24
|
+
async function loadMTL(url: string): Promise<MTLLoader.MaterialCreator | null> {
|
|
25
|
+
if (mtlCreatorCache.has(url)) return mtlCreatorCache.get(url)!;
|
|
26
|
+
try {
|
|
27
|
+
const dir = url.substring(0, url.lastIndexOf('/') + 1);
|
|
28
|
+
const loader = new MTLLoader();
|
|
29
|
+
loader.setResourcePath(dir);
|
|
30
|
+
const creator = await loader.loadAsync(url);
|
|
31
|
+
creator.preload();
|
|
32
|
+
mtlCreatorCache.set(url, creator);
|
|
33
|
+
return creator;
|
|
34
|
+
} catch {
|
|
35
|
+
mtlCreatorCache.set(url, null);
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const texLoader = new THREE.TextureLoader();
|
|
41
|
+
const texCache = new Map<string, THREE.Texture>();
|
|
42
|
+
|
|
43
|
+
async function loadTex(url: string): Promise<THREE.Texture> {
|
|
44
|
+
const hit = texCache.get(url);
|
|
45
|
+
if (hit) return hit;
|
|
46
|
+
const tex = await texLoader.loadAsync(url);
|
|
47
|
+
tex.colorSpace = THREE.SRGBColorSpace;
|
|
48
|
+
texCache.set(url, tex);
|
|
49
|
+
return tex;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function loadOBJGroup(url: string): Promise<THREE.Group> {
|
|
53
|
+
const mtlUrl = url.replace(/\.(obj|OBJ)$/, '.mtl');
|
|
54
|
+
const creator = await loadMTL(mtlUrl);
|
|
55
|
+
const cacheKey = url + (creator ? ':mtl' : '');
|
|
56
|
+
const hit = objGroupCache.get(cacheKey);
|
|
57
|
+
if (hit) return hit.clone() as THREE.Group;
|
|
58
|
+
|
|
59
|
+
const loader = new OBJLoader();
|
|
60
|
+
if (creator) loader.setMaterials(creator);
|
|
61
|
+
const grp = await loader.loadAsync(url);
|
|
62
|
+
|
|
63
|
+
grp.traverse((c) => {
|
|
64
|
+
if (c instanceof THREE.Mesh) {
|
|
65
|
+
const old = (Array.isArray(c.material) ? c.material[0] : c.material) as THREE.MeshPhongMaterial;
|
|
66
|
+
c.material = new THREE.MeshStandardMaterial({
|
|
67
|
+
map: old?.map ?? null,
|
|
68
|
+
color: old?.map ? new THREE.Color(1, 1, 1) : (old?.color ?? new THREE.Color(0xaaaaaa)),
|
|
69
|
+
metalness: 0.0,
|
|
70
|
+
roughness: 0.8
|
|
71
|
+
});
|
|
72
|
+
c.castShadow = true;
|
|
73
|
+
c.receiveShadow = true;
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
objGroupCache.set(cacheKey, grp);
|
|
77
|
+
return grp.clone() as THREE.Group;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function stdMat(color?: string, opacity?: number): THREE.MeshStandardMaterial {
|
|
81
|
+
const c = color ? new THREE.Color(color) : new THREE.Color(0x6b7280);
|
|
82
|
+
const a = opacity ?? 1;
|
|
83
|
+
return new THREE.MeshStandardMaterial({ color: c, transparent: a < 1, opacity: a, metalness: 0.1, roughness: 0.6 });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function renderGeoms(entity: DocEntity, all: Geom[]): Geom[] {
|
|
87
|
+
if (!entity.visual) return all;
|
|
88
|
+
if (!entity.visual.groups?.length) return all;
|
|
89
|
+
return all.filter(g => entity.visual!.groups!.includes(g.group ?? ''));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function buildGeo(g: Geom): THREE.BufferGeometry | null {
|
|
93
|
+
switch (g.type) {
|
|
94
|
+
case 'box': {
|
|
95
|
+
const [w, h, d] = g.size;
|
|
96
|
+
return new THREE.BoxGeometry(w, h, d);
|
|
97
|
+
}
|
|
98
|
+
case 'sphere': return new THREE.SphereGeometry(g.radius, 32, 32);
|
|
99
|
+
case 'cylinder': {
|
|
100
|
+
const geo = new THREE.CylinderGeometry(g.radius, g.radius, g.height, 32);
|
|
101
|
+
geo.rotateX(Math.PI / 2);
|
|
102
|
+
return geo;
|
|
103
|
+
}
|
|
104
|
+
case 'capsule': {
|
|
105
|
+
const geo = new THREE.CapsuleGeometry(g.radius, g.height, 16, 32);
|
|
106
|
+
geo.rotateX(Math.PI / 2);
|
|
107
|
+
return geo;
|
|
108
|
+
}
|
|
109
|
+
case 'plane': {
|
|
110
|
+
const [w, d] = g.size ?? [10, 10];
|
|
111
|
+
return new THREE.PlaneGeometry(w * 2, d * 2);
|
|
112
|
+
}
|
|
113
|
+
default: return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function applyT(
|
|
118
|
+
obj: THREE.Object3D,
|
|
119
|
+
t?: { pos?: Vec3; rot?: Vec3; quat?: Vec4; scale?: number }
|
|
120
|
+
): void {
|
|
121
|
+
if (!t) return;
|
|
122
|
+
if (t.pos) obj.position.set(t.pos[0], t.pos[1], t.pos[2]);
|
|
123
|
+
if (t.quat) {
|
|
124
|
+
const [w, x, y, z] = t.quat;
|
|
125
|
+
obj.quaternion.set(x, y, z, w).normalize();
|
|
126
|
+
} else if (t.rot) {
|
|
127
|
+
const D = Math.PI / 180;
|
|
128
|
+
obj.rotation.set(t.rot[0] * D, t.rot[1] * D, t.rot[2] * D);
|
|
129
|
+
}
|
|
130
|
+
if (t.scale !== undefined) obj.scale.setScalar(t.scale);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function resolveMat(g: Geom, matMap: Map<string, MaterialDef>): THREE.MeshStandardMaterial {
|
|
134
|
+
const opacity = g.opacity ?? 1;
|
|
135
|
+
if (g.material && matMap.has(g.material)) {
|
|
136
|
+
const def = matMap.get(g.material)!;
|
|
137
|
+
if (def.rgba) {
|
|
138
|
+
const alpha = g.opacity ?? def.rgba[3] ?? 1;
|
|
139
|
+
return new THREE.MeshStandardMaterial({
|
|
140
|
+
color: new THREE.Color(def.rgba[0], def.rgba[1], def.rgba[2]),
|
|
141
|
+
transparent: alpha < 1, opacity: alpha,
|
|
142
|
+
metalness: def.metalness ?? 0.1, roughness: def.roughness ?? 0.6
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
if (def.color) return stdMat(def.color, opacity);
|
|
146
|
+
}
|
|
147
|
+
if (g.color) return stdMat(g.color, opacity);
|
|
148
|
+
return new THREE.MeshStandardMaterial({ color: 0x888888, metalness: 0.1, roughness: 0.6 });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function buildLightComponent(lc: LightComponent): THREE.Light | null {
|
|
152
|
+
const color = lc.color ? new THREE.Color(lc.color) : new THREE.Color(0xffffff);
|
|
153
|
+
const intensity = lc.intensity ?? 1;
|
|
154
|
+
switch (lc.type) {
|
|
155
|
+
case 'point': {
|
|
156
|
+
const l = new THREE.PointLight(color, intensity, lc.range ?? 0);
|
|
157
|
+
l.castShadow = lc.castShadow ?? false;
|
|
158
|
+
return l;
|
|
159
|
+
}
|
|
160
|
+
case 'directional': {
|
|
161
|
+
const l = new THREE.DirectionalLight(color, intensity);
|
|
162
|
+
if (lc.dir) l.position.set(-lc.dir[0], -lc.dir[1], -lc.dir[2]).normalize().multiplyScalar(5);
|
|
163
|
+
l.castShadow = lc.castShadow ?? false;
|
|
164
|
+
return l;
|
|
165
|
+
}
|
|
166
|
+
case 'spot': {
|
|
167
|
+
const l = new THREE.SpotLight(color, intensity, lc.range ?? 0, lc.angle ? lc.angle * Math.PI / 180 : Math.PI / 6);
|
|
168
|
+
l.castShadow = lc.castShadow ?? false;
|
|
169
|
+
return l;
|
|
170
|
+
}
|
|
171
|
+
case 'ambient': return new THREE.AmbientLight(color, intensity);
|
|
172
|
+
case 'hemisphere': {
|
|
173
|
+
const g = lc.groundColor ? new THREE.Color(lc.groundColor) : new THREE.Color(0x545454);
|
|
174
|
+
return new THREE.HemisphereLight(color, g, intensity);
|
|
175
|
+
}
|
|
176
|
+
default: return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function buildMeshUrlMap(meshes: MeshDef[], basePath: string): Map<string, string> {
|
|
181
|
+
const map = new Map<string, string>();
|
|
182
|
+
for (const m of meshes) {
|
|
183
|
+
const url = m.file.startsWith('/') || m.file.startsWith('http') ? m.file : basePath + m.file;
|
|
184
|
+
map.set(m.name, url);
|
|
185
|
+
}
|
|
186
|
+
return map;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function buildMatMap(materials: MaterialDef[]): Map<string, MaterialDef> {
|
|
190
|
+
const map = new Map<string, MaterialDef>();
|
|
191
|
+
for (const m of materials) map.set(m.name, m);
|
|
192
|
+
return map;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function buildTextureUrlMap(textures: TextureDef[], basePath: string): Map<string, string> {
|
|
196
|
+
const map = new Map<string, string>();
|
|
197
|
+
for (const t of textures) {
|
|
198
|
+
const url = t.file.startsWith('/') || t.file.startsWith('http') ? t.file : basePath + t.file;
|
|
199
|
+
map.set(t.name, url);
|
|
200
|
+
}
|
|
201
|
+
return map;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
interface BuildCtx {
|
|
205
|
+
bodies: Map<string, THREE.Object3D>;
|
|
206
|
+
entityRoots: Map<string, THREE.Object3D>;
|
|
207
|
+
meshUrls: Map<string, string>;
|
|
208
|
+
textureUrls: Map<string, string>;
|
|
209
|
+
materials: Map<string, MaterialDef>;
|
|
210
|
+
threeScene: THREE.Scene;
|
|
211
|
+
deferredParents: Array<{ group: THREE.Group; parentPath: string }>;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function applyTexture(mat: THREE.MeshStandardMaterial, g: Geom, ctx: BuildCtx): Promise<void> {
|
|
215
|
+
if (!g.material) return;
|
|
216
|
+
const def = ctx.materials.get(g.material);
|
|
217
|
+
if (!def?.map) return;
|
|
218
|
+
const texUrl = ctx.textureUrls.get(def.map);
|
|
219
|
+
if (!texUrl) return;
|
|
220
|
+
try {
|
|
221
|
+
mat.map = await loadTex(texUrl);
|
|
222
|
+
mat.color.set(0xffffff);
|
|
223
|
+
mat.needsUpdate = true;
|
|
224
|
+
} catch { /* skip */ }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function buildGeom(g: Geom, parent: THREE.Group, ctx: BuildCtx): Promise<void> {
|
|
228
|
+
if (g.type === 'mesh') {
|
|
229
|
+
const url = ctx.meshUrls.get(g.mesh);
|
|
230
|
+
if (!url) { console.warn(`[threejs] mesh not in catalog: "${g.mesh}"`); return; }
|
|
231
|
+
try {
|
|
232
|
+
if (/\.(obj|OBJ)$/.test(url)) {
|
|
233
|
+
const grp = await loadOBJGroup(url);
|
|
234
|
+
if (g.material || g.color) {
|
|
235
|
+
const mat = resolveMat(g, ctx.materials);
|
|
236
|
+
await applyTexture(mat, g, ctx);
|
|
237
|
+
grp.traverse((c) => { if (c instanceof THREE.Mesh) { c.material = mat.clone(); c.castShadow = true; c.receiveShadow = true; } });
|
|
238
|
+
} else {
|
|
239
|
+
grp.traverse((c) => { if (c instanceof THREE.Mesh) { c.castShadow = true; c.receiveShadow = true; } });
|
|
240
|
+
}
|
|
241
|
+
applyT(grp, { pos: g.pos, rot: g.rot, quat: g.quat });
|
|
242
|
+
parent.add(grp);
|
|
243
|
+
} else {
|
|
244
|
+
const mat = resolveMat(g, ctx.materials);
|
|
245
|
+
await applyTexture(mat, g, ctx);
|
|
246
|
+
const geo = await loadSTL(url);
|
|
247
|
+
geo.computeVertexNormals();
|
|
248
|
+
const mesh = new THREE.Mesh(geo, mat);
|
|
249
|
+
mesh.castShadow = true; mesh.receiveShadow = true;
|
|
250
|
+
applyT(mesh, { pos: g.pos, rot: g.rot, quat: g.quat });
|
|
251
|
+
parent.add(mesh);
|
|
252
|
+
}
|
|
253
|
+
} catch (e) { console.error(`[threejs] failed to load mesh "${g.mesh}" from ${url}`, e); }
|
|
254
|
+
} else {
|
|
255
|
+
const geo = buildGeo(g);
|
|
256
|
+
if (!geo) return;
|
|
257
|
+
const mat = resolveMat(g, ctx.materials);
|
|
258
|
+
await applyTexture(mat, g, ctx);
|
|
259
|
+
geo.computeVertexNormals();
|
|
260
|
+
const mesh = new THREE.Mesh(geo, mat);
|
|
261
|
+
mesh.castShadow = true; mesh.receiveShadow = true;
|
|
262
|
+
applyT(mesh, { pos: g.pos, rot: g.rot, quat: g.quat });
|
|
263
|
+
parent.add(mesh);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function buildCameraIcon(cc: CameraComponent, parent: THREE.Group): void {
|
|
268
|
+
const mat = new THREE.MeshStandardMaterial({ color: 0x00bcd4, metalness: 0.3, roughness: 0.5 });
|
|
269
|
+
const body = new THREE.Mesh(new THREE.BoxGeometry(0.10, 0.065, 0.075), mat);
|
|
270
|
+
body.castShadow = true;
|
|
271
|
+
|
|
272
|
+
const lensMat = new THREE.MeshStandardMaterial({ color: 0x007a9e });
|
|
273
|
+
const lens = new THREE.Mesh(new THREE.CylinderGeometry(0.03, 0.02, 0.05, 8), lensMat);
|
|
274
|
+
lens.rotation.x = Math.PI / 2;
|
|
275
|
+
lens.position.z = 0.06;
|
|
276
|
+
|
|
277
|
+
const fov = (cc.fov ?? 50) * Math.PI / 180;
|
|
278
|
+
const aspect = (cc.resolution?.[0] ?? 256) / (cc.resolution?.[1] ?? 256);
|
|
279
|
+
const near = cc.near ?? 0.1;
|
|
280
|
+
const far = Math.min(cc.far ?? 100, 2);
|
|
281
|
+
const hNear = Math.tan(fov / 2) * near;
|
|
282
|
+
const wNear = hNear * aspect;
|
|
283
|
+
const hFar = Math.tan(fov / 2) * far;
|
|
284
|
+
const wFar = hFar * aspect;
|
|
285
|
+
|
|
286
|
+
const pts = [
|
|
287
|
+
new THREE.Vector3(-wNear, -hNear, near), new THREE.Vector3( wNear, -hNear, near),
|
|
288
|
+
new THREE.Vector3( wNear, -hNear, near), new THREE.Vector3( wNear, hNear, near),
|
|
289
|
+
new THREE.Vector3( wNear, hNear, near), new THREE.Vector3(-wNear, hNear, near),
|
|
290
|
+
new THREE.Vector3(-wNear, hNear, near), new THREE.Vector3(-wNear, -hNear, near),
|
|
291
|
+
new THREE.Vector3(-wFar, -hFar, far), new THREE.Vector3( wFar, -hFar, far),
|
|
292
|
+
new THREE.Vector3( wFar, -hFar, far), new THREE.Vector3( wFar, hFar, far),
|
|
293
|
+
new THREE.Vector3( wFar, hFar, far), new THREE.Vector3(-wFar, hFar, far),
|
|
294
|
+
new THREE.Vector3(-wFar, hFar, far), new THREE.Vector3(-wFar, -hFar, far),
|
|
295
|
+
new THREE.Vector3(0, 0, 0), new THREE.Vector3(-wFar, -hFar, far),
|
|
296
|
+
new THREE.Vector3(0, 0, 0), new THREE.Vector3( wFar, -hFar, far),
|
|
297
|
+
new THREE.Vector3(0, 0, 0), new THREE.Vector3( wFar, hFar, far),
|
|
298
|
+
new THREE.Vector3(0, 0, 0), new THREE.Vector3(-wFar, hFar, far),
|
|
299
|
+
];
|
|
300
|
+
const geo = new THREE.BufferGeometry().setFromPoints(pts);
|
|
301
|
+
const lines = new THREE.LineSegments(geo, new THREE.LineBasicMaterial({ color: 0x00bcd4, opacity: 0.6, transparent: true }));
|
|
302
|
+
lines.userData['isCameraIcon'] = true;
|
|
303
|
+
|
|
304
|
+
parent.add(body);
|
|
305
|
+
parent.add(lens);
|
|
306
|
+
parent.add(lines);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function buildEntity(entity: DocEntity, parentObject: THREE.Object3D, ctx: BuildCtx): Promise<void> {
|
|
310
|
+
const grp = new THREE.Group();
|
|
311
|
+
grp.name = entity.name;
|
|
312
|
+
applyT(grp, entity.transform);
|
|
313
|
+
ctx.bodies.set(entity.name, grp);
|
|
314
|
+
|
|
315
|
+
const parentRef = entity.relation?.parent;
|
|
316
|
+
if (parentRef) {
|
|
317
|
+
ctx.deferredParents.push({ group: grp, parentPath: parentRef });
|
|
318
|
+
} else {
|
|
319
|
+
parentObject.add(grp);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
for (const g of renderGeoms(entity, entity.geoms ?? [])) await buildGeom(g, grp, ctx);
|
|
323
|
+
|
|
324
|
+
if (entity.light) {
|
|
325
|
+
const l = buildLightComponent(entity.light);
|
|
326
|
+
if (l) {
|
|
327
|
+
if (entity.transform?.pos) l.position.set(...entity.transform.pos);
|
|
328
|
+
grp.add(l);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (entity.camera) {
|
|
333
|
+
buildCameraIcon(entity.camera, grp);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export interface ViewerScene {
|
|
338
|
+
bodies: Map<string, THREE.Object3D>;
|
|
339
|
+
entityRoots: Map<string, THREE.Object3D>;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export async function buildScene(doc: SceneDoc, threeScene: THREE.Scene, basePath = ''): Promise<ViewerScene> {
|
|
343
|
+
const bodies = new Map<string, THREE.Object3D>();
|
|
344
|
+
const entityRoots = new Map<string, THREE.Object3D>();
|
|
345
|
+
const deferredParents: Array<{ group: THREE.Group; parentPath: string }> = [];
|
|
346
|
+
|
|
347
|
+
const ctx: BuildCtx = {
|
|
348
|
+
bodies, entityRoots,
|
|
349
|
+
meshUrls: buildMeshUrlMap(doc.meshes ?? [], basePath),
|
|
350
|
+
textureUrls: buildTextureUrlMap(doc.textures ?? [], basePath),
|
|
351
|
+
materials: buildMatMap(doc.materials ?? []),
|
|
352
|
+
threeScene, deferredParents
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
for (const entity of doc.scene ?? []) {
|
|
356
|
+
await buildEntity(entity, threeScene, ctx);
|
|
357
|
+
if (!entity.relation) {
|
|
358
|
+
const root = bodies.get(entity.name);
|
|
359
|
+
if (root) entityRoots.set(entity.name, root);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
for (const { group, parentPath } of deferredParents) {
|
|
364
|
+
const target = bodies.get(parentPath);
|
|
365
|
+
if (target) {
|
|
366
|
+
group.removeFromParent();
|
|
367
|
+
target.add(group);
|
|
368
|
+
} else {
|
|
369
|
+
console.warn(`[threejs] parent ref unresolved: "${parentPath}" for "${group.name}"`);
|
|
370
|
+
threeScene.add(group);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return { bodies, entityRoots };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function createGeomMesh(g: Geom, color?: string): THREE.Mesh | null {
|
|
378
|
+
const geo = buildGeo(g);
|
|
379
|
+
if (!geo) return null;
|
|
380
|
+
geo.computeVertexNormals();
|
|
381
|
+
const mat = new THREE.MeshStandardMaterial({
|
|
382
|
+
color: color ?? g.color ?? '#6b7280',
|
|
383
|
+
roughness: 0.6, metalness: 0.1
|
|
384
|
+
});
|
|
385
|
+
const mesh = new THREE.Mesh(geo, mat);
|
|
386
|
+
mesh.castShadow = true;
|
|
387
|
+
mesh.receiveShadow = true;
|
|
388
|
+
return mesh;
|
|
389
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { buildScene, createGeomMesh, buildLightComponent } from './compiler/threejs/index.js';
|
|
2
|
+
export type { ViewerScene } from './compiler/threejs/index.js';
|
|
3
|
+
|
|
4
|
+
export { parseThreeScene } from './parser/threejs/index.js';
|
|
5
|
+
export type { ThreeEntity, ParseOptions, PrimitiveKind } from './parser/threejs/index.js';
|
|
6
|
+
|
|
7
|
+
export { createViewport } from './viewport.js';
|
|
8
|
+
export type { Viewport, ViewportOpts } from './viewport.js';
|
|
9
|
+
|
|
10
|
+
export { buildBodyIdx, applyTransforms, flattenBodies, restoreBodies } from './sync.js';
|
|
11
|
+
export type { BodySnapshot } from './sync.js';
|
|
12
|
+
export { loadBodies, connectSim } from './sync.js';
|
|
13
|
+
export type { BodyInfo, LoadBodiesOpts, LoadBodiesResult, SimOpts, SimHandle } from './sync.js';
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import type { SceneDoc, DocEntity, Physics, Joint } from '@simarena/format';
|
|
3
|
+
import type { Vec3, Geom } from '@simarena/format';
|
|
4
|
+
|
|
5
|
+
export type PrimitiveKind = 'box' | 'sphere' | 'cylinder' | 'capsule' | 'plane';
|
|
6
|
+
|
|
7
|
+
export interface ThreeEntity {
|
|
8
|
+
name: string;
|
|
9
|
+
obj: THREE.Object3D;
|
|
10
|
+
kind: 'primitive' | 'robot';
|
|
11
|
+
geom?: PrimitiveKind;
|
|
12
|
+
size?: Vec3;
|
|
13
|
+
color?: string;
|
|
14
|
+
asset?: string;
|
|
15
|
+
physics?: { dynamic?: boolean; mass?: number; friction?: number };
|
|
16
|
+
joint?: { type: 'free' | 'hinge' | 'slide' | 'fixed' };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ParseOptions {
|
|
20
|
+
name?: string;
|
|
21
|
+
gravity?: Vec3;
|
|
22
|
+
timestep?: number;
|
|
23
|
+
substeps?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function buildGeom(kind: PrimitiveKind | undefined, size: Vec3, color?: string): Geom {
|
|
27
|
+
const [a, b, c] = size;
|
|
28
|
+
switch (kind) {
|
|
29
|
+
case 'sphere': return { type: 'sphere', radius: a, color, group: 'default' };
|
|
30
|
+
case 'cylinder': return { type: 'cylinder', radius: a, height: b, color, group: 'default' };
|
|
31
|
+
case 'capsule': return { type: 'capsule', radius: a, height: b, color, group: 'default' };
|
|
32
|
+
case 'plane': return { type: 'plane', size: [a, b], color, group: 'default' };
|
|
33
|
+
default: return { type: 'box', size: [a, b, c] as Vec3, color, group: 'default' };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function parseThreeScene(entities: ThreeEntity[], opts: ParseOptions = {}): SceneDoc {
|
|
38
|
+
const scene: DocEntity[] = entities.map(e => {
|
|
39
|
+
const pos = e.obj.position.toArray() as Vec3;
|
|
40
|
+
const q = e.obj.quaternion;
|
|
41
|
+
const hasRot = Math.abs(q.x) + Math.abs(q.y) + Math.abs(q.z) > 1e-6;
|
|
42
|
+
const quat = hasRot ? [q.w, q.x, q.y, q.z] as [number,number,number,number] : undefined;
|
|
43
|
+
const transform = { pos, ...(quat ? { quat } : {}) };
|
|
44
|
+
|
|
45
|
+
if (e.kind === 'robot' && e.asset) {
|
|
46
|
+
const scl = (e.obj.scale.x + e.obj.scale.y + e.obj.scale.z) / 3;
|
|
47
|
+
return {
|
|
48
|
+
name: e.name,
|
|
49
|
+
asset: e.asset,
|
|
50
|
+
transform: { ...transform, ...(Math.abs(scl - 1) > 0.001 ? { scale: scl } : {}) }
|
|
51
|
+
} satisfies DocEntity;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const dyn = e.physics?.dynamic ?? false;
|
|
55
|
+
const physics: Physics = {
|
|
56
|
+
dynamic: dyn,
|
|
57
|
+
...(e.physics?.mass !== undefined ? { mass: e.physics.mass } : {}),
|
|
58
|
+
...(e.physics?.friction !== undefined ? { friction: e.physics.friction } : {})
|
|
59
|
+
};
|
|
60
|
+
const joint: Joint | undefined = e.joint
|
|
61
|
+
? { type: e.joint.type }
|
|
62
|
+
: dyn ? { type: 'free' } : undefined;
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
name: e.name,
|
|
66
|
+
transform,
|
|
67
|
+
geoms: [buildGeom(e.geom, e.size ?? [0.1, 0.1, 0.1], e.color)],
|
|
68
|
+
physics,
|
|
69
|
+
collider: {},
|
|
70
|
+
...(joint ? { joint } : {})
|
|
71
|
+
} satisfies DocEntity;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
_format: 'simarena/scene@1.0',
|
|
76
|
+
name: opts.name ?? 'Untitled Scene',
|
|
77
|
+
sim: {
|
|
78
|
+
timestep: opts.timestep ?? 0.002,
|
|
79
|
+
gravity: opts.gravity ?? [0, 0, -9.81],
|
|
80
|
+
substeps: opts.substeps ?? 4
|
|
81
|
+
},
|
|
82
|
+
scene
|
|
83
|
+
};
|
|
84
|
+
}
|
package/src/sync.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
|
3
|
+
|
|
4
|
+
// ── Body index ─────────────────────────────────────────────────────────────────
|
|
5
|
+
// Maps entity names → frame indices (matches the order bodies appear in sim state).
|
|
6
|
+
|
|
7
|
+
export function buildBodyIdx(names: string[]): Map<string, number> {
|
|
8
|
+
const m = new Map<string, number>();
|
|
9
|
+
names.forEach((n, i) => m.set(n, i));
|
|
10
|
+
return m;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// ── Apply world-space transforms ───────────────────────────────────────────────
|
|
14
|
+
// Works for any sim source: WASM worker, WebSocket, cloud.
|
|
15
|
+
// xpos: flat Float32Array of positions [x0,y0,z0, x1,y1,z1, ...]
|
|
16
|
+
// xquat: flat Float32Array of quaternions in wxyz order [w0,x0,y0,z0, ...]
|
|
17
|
+
// Bodies not in the map are skipped automatically.
|
|
18
|
+
|
|
19
|
+
export function applyTransforms(
|
|
20
|
+
xpos: Float32Array,
|
|
21
|
+
xquat: Float32Array,
|
|
22
|
+
bodyIdx: Map<string, number>,
|
|
23
|
+
bodies: Map<string, THREE.Object3D>
|
|
24
|
+
): void {
|
|
25
|
+
for (const [name, idx] of bodyIdx) {
|
|
26
|
+
const obj = bodies.get(name);
|
|
27
|
+
if (!obj) continue;
|
|
28
|
+
const p = idx * 3, q = idx * 4;
|
|
29
|
+
obj.position.set(xpos[p]!, xpos[p + 1]!, xpos[p + 2]!);
|
|
30
|
+
// MuJoCo wxyz → Three.js xyzw
|
|
31
|
+
obj.quaternion.set(xquat[q + 1]!, xquat[q + 2]!, xquat[q + 3]!, xquat[q]!);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Hierarchy flattening ───────────────────────────────────────────────────────
|
|
36
|
+
// World-space transforms (from any sim) require bodies to be direct children
|
|
37
|
+
// of the scene root. Flatten before sync, restore after.
|
|
38
|
+
|
|
39
|
+
export interface BodySnapshot {
|
|
40
|
+
parent: THREE.Object3D;
|
|
41
|
+
pos: THREE.Vector3;
|
|
42
|
+
quat: THREE.Quaternion;
|
|
43
|
+
scale: THREE.Vector3;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function flattenBodies(
|
|
47
|
+
bodies: Map<string, THREE.Object3D>,
|
|
48
|
+
scene: THREE.Scene
|
|
49
|
+
): Map<string, BodySnapshot> {
|
|
50
|
+
const saved = new Map<string, BodySnapshot>();
|
|
51
|
+
for (const [name, obj] of bodies) {
|
|
52
|
+
saved.set(name, {
|
|
53
|
+
parent: obj.parent ?? scene,
|
|
54
|
+
pos: obj.position.clone(),
|
|
55
|
+
quat: obj.quaternion.clone(),
|
|
56
|
+
scale: obj.scale.clone(),
|
|
57
|
+
});
|
|
58
|
+
if (obj.parent !== scene) scene.attach(obj);
|
|
59
|
+
}
|
|
60
|
+
return saved;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function restoreBodies(
|
|
64
|
+
flatBodies: Map<string, THREE.Object3D>,
|
|
65
|
+
saved: Map<string, BodySnapshot>
|
|
66
|
+
): void {
|
|
67
|
+
for (const [name, obj] of flatBodies) {
|
|
68
|
+
const snap = saved.get(name);
|
|
69
|
+
if (snap) {
|
|
70
|
+
snap.parent.add(obj);
|
|
71
|
+
obj.position.copy(snap.pos);
|
|
72
|
+
obj.quaternion.copy(snap.quat);
|
|
73
|
+
obj.scale.copy(snap.scale);
|
|
74
|
+
}
|
|
75
|
+
obj.matrixWorldAutoUpdate = true;
|
|
76
|
+
obj.matrixWorldNeedsUpdate = true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── HTTP body loading ──────────────────────────────────────────────────────────
|
|
81
|
+
// Fetches GLBs from the Python viewport server (or any compatible server).
|
|
82
|
+
// Returns Map<name, Object3D> keyed by entity name, matching SceneDoc entity names.
|
|
83
|
+
|
|
84
|
+
export interface BodyInfo {
|
|
85
|
+
id: number;
|
|
86
|
+
name: string;
|
|
87
|
+
fixed?: boolean;
|
|
88
|
+
size?: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface LoadBodiesOpts {
|
|
92
|
+
onProgress?: (loaded: number, total: number) => void;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const loader = new GLTFLoader();
|
|
96
|
+
|
|
97
|
+
export interface LoadBodiesResult {
|
|
98
|
+
bodies: Map<string, THREE.Object3D>;
|
|
99
|
+
manifest: BodyInfo[];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function loadBodies(
|
|
103
|
+
httpBase: string,
|
|
104
|
+
parent: THREE.Object3D,
|
|
105
|
+
opts: LoadBodiesOpts = {}
|
|
106
|
+
): Promise<LoadBodiesResult> {
|
|
107
|
+
const base = httpBase.replace(/\/$/, '');
|
|
108
|
+
const res = await fetch(`${base}/bodies`);
|
|
109
|
+
const manifest: BodyInfo[] = await res.json();
|
|
110
|
+
const bodies = new Map<string, THREE.Object3D>();
|
|
111
|
+
|
|
112
|
+
await Promise.all(manifest.map(b =>
|
|
113
|
+
new Promise<void>(resolve => {
|
|
114
|
+
loader.load(`${base}/body/${b.id}.glb`, gltf => {
|
|
115
|
+
const mesh = gltf.scene;
|
|
116
|
+
mesh.traverse(c => {
|
|
117
|
+
if ((c as THREE.Mesh).isMesh) {
|
|
118
|
+
(c as THREE.Mesh).castShadow = true;
|
|
119
|
+
(c as THREE.Mesh).receiveShadow = true;
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
parent.add(mesh);
|
|
123
|
+
bodies.set(b.name, mesh);
|
|
124
|
+
opts.onProgress?.(bodies.size, manifest.length);
|
|
125
|
+
resolve();
|
|
126
|
+
});
|
|
127
|
+
})
|
|
128
|
+
));
|
|
129
|
+
|
|
130
|
+
return { bodies, manifest };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── WebSocket sim transport ────────────────────────────────────────────────────
|
|
134
|
+
// Connects to any sim server streaming binary frames.
|
|
135
|
+
// Wire protocol: [xpos: nbody*3 floats | xquat: nbody*4 floats]
|
|
136
|
+
// Buffer length must be divisible by 7 (nbody*7 = pos+quat per body).
|
|
137
|
+
|
|
138
|
+
export interface SimOpts {
|
|
139
|
+
onFps?: (fps: number) => void;
|
|
140
|
+
onParticles?: (positions: Float32Array) => void;
|
|
141
|
+
onConnect?: () => void;
|
|
142
|
+
onError?: (msg: string) => void;
|
|
143
|
+
onClose?: () => void;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface SimHandle {
|
|
147
|
+
close(): void;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function connectSim(
|
|
151
|
+
wsUrl: string,
|
|
152
|
+
nbody: number,
|
|
153
|
+
onFrame: (xpos: Float32Array, xquat: Float32Array) => void,
|
|
154
|
+
opts: SimOpts = {}
|
|
155
|
+
): SimHandle {
|
|
156
|
+
const ws = new WebSocket(wsUrl);
|
|
157
|
+
ws.binaryType = 'arraybuffer';
|
|
158
|
+
|
|
159
|
+
let frames = 0;
|
|
160
|
+
let lastTick = performance.now();
|
|
161
|
+
|
|
162
|
+
ws.onopen = () => opts.onConnect?.();
|
|
163
|
+
|
|
164
|
+
ws.onmessage = e => {
|
|
165
|
+
const buf = new Float32Array(e.data as ArrayBuffer);
|
|
166
|
+
const xpos = buf.subarray(0, nbody * 3);
|
|
167
|
+
const xquat = buf.subarray(nbody * 3, nbody * 7);
|
|
168
|
+
onFrame(xpos, xquat);
|
|
169
|
+
|
|
170
|
+
// Optional particle data appended after transforms
|
|
171
|
+
if (buf.length > nbody * 7 && opts.onParticles) {
|
|
172
|
+
const rest = buf.subarray(nbody * 7);
|
|
173
|
+
if (rest.length % 3 === 0) opts.onParticles(rest);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
frames++;
|
|
177
|
+
const now = performance.now();
|
|
178
|
+
if (now - lastTick >= 1000) {
|
|
179
|
+
opts.onFps?.(frames);
|
|
180
|
+
frames = 0;
|
|
181
|
+
lastTick = now;
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
ws.onerror = () => opts.onError?.('WebSocket error');
|
|
186
|
+
ws.onclose = () => opts.onClose?.();
|
|
187
|
+
|
|
188
|
+
return { close: () => ws.close() };
|
|
189
|
+
}
|
package/src/viewport.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|
3
|
+
|
|
4
|
+
export interface ViewportOpts {
|
|
5
|
+
fov?: number;
|
|
6
|
+
near?: number;
|
|
7
|
+
far?: number;
|
|
8
|
+
pos?: [number, number, number];
|
|
9
|
+
target?: [number, number, number];
|
|
10
|
+
grid?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Viewport {
|
|
14
|
+
scene: THREE.Scene;
|
|
15
|
+
camera: THREE.PerspectiveCamera;
|
|
16
|
+
renderer: THREE.WebGLRenderer;
|
|
17
|
+
orbit: OrbitControls;
|
|
18
|
+
grid: THREE.GridHelper | null;
|
|
19
|
+
resize(): void;
|
|
20
|
+
dispose(): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createViewport(
|
|
24
|
+
canvas: HTMLCanvasElement,
|
|
25
|
+
container: HTMLElement,
|
|
26
|
+
opts: ViewportOpts = {}
|
|
27
|
+
): Viewport {
|
|
28
|
+
const { fov = 60, near = 0.1, far = 100, pos = [1.5, -1.5, 1.5], target = [0, 0, 0.3], grid = 2 } = opts;
|
|
29
|
+
|
|
30
|
+
THREE.Object3D.DEFAULT_UP.set(0, 0, 1);
|
|
31
|
+
|
|
32
|
+
const scene = new THREE.Scene();
|
|
33
|
+
|
|
34
|
+
const camera = new THREE.PerspectiveCamera(fov, container.clientWidth / container.clientHeight, near, far);
|
|
35
|
+
camera.position.set(...pos);
|
|
36
|
+
camera.lookAt(...target);
|
|
37
|
+
|
|
38
|
+
const gl = canvas.getContext('webgl2');
|
|
39
|
+
const renderer = new THREE.WebGLRenderer({ canvas, context: gl || undefined, antialias: true });
|
|
40
|
+
renderer.setSize(container.clientWidth, container.clientHeight);
|
|
41
|
+
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
42
|
+
renderer.shadowMap.enabled = true;
|
|
43
|
+
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
44
|
+
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
45
|
+
renderer.toneMappingExposure = 1.0;
|
|
46
|
+
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
47
|
+
|
|
48
|
+
const orbit = new OrbitControls(camera, renderer.domElement);
|
|
49
|
+
orbit.enableDamping = true;
|
|
50
|
+
orbit.dampingFactor = 0.05;
|
|
51
|
+
orbit.target.set(...target);
|
|
52
|
+
orbit.update();
|
|
53
|
+
|
|
54
|
+
let gridHelper: THREE.GridHelper | null = null;
|
|
55
|
+
if (grid > 0) {
|
|
56
|
+
gridHelper = new THREE.GridHelper(grid * 10, 20, 0x444444, 0x222222);
|
|
57
|
+
gridHelper.rotation.x = Math.PI / 2;
|
|
58
|
+
scene.add(gridHelper);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function resize() {
|
|
62
|
+
const w = container.clientWidth, h = container.clientHeight;
|
|
63
|
+
camera.aspect = w / h;
|
|
64
|
+
camera.updateProjectionMatrix();
|
|
65
|
+
renderer.setSize(w, h);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const obs = new ResizeObserver(resize);
|
|
69
|
+
obs.observe(container);
|
|
70
|
+
|
|
71
|
+
function dispose() {
|
|
72
|
+
obs.disconnect();
|
|
73
|
+
renderer.dispose();
|
|
74
|
+
orbit.dispose();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { scene, camera, renderer, orbit, grid: gridHelper, resize, dispose };
|
|
78
|
+
}
|