@simarena/viewport 0.0.1-alpha.0 → 0.0.1-alpha.1

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.
@@ -0,0 +1,492 @@
1
+ /**
2
+ * VisualScene → Three.js scene builder.
3
+ * Handles asset table resolution (materials, textures, meshes),
4
+ * rigid/soft body branching, particles, and environment.
5
+ */
6
+
7
+ import * as THREE from "three";
8
+ import { STLLoader } from "three/addons/loaders/STLLoader.js";
9
+ import { OBJLoader } from "three/addons/loaders/OBJLoader.js";
10
+ import { MTLLoader } from "three/addons/loaders/MTLLoader.js";
11
+ import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
12
+ import type { VisualScene, GeomDesc, LightDesc, MaterialDesc } from "./types.js";
13
+
14
+ // ── Asset caches ──────────────────────────────────────────────────────────────
15
+
16
+ const stlLoader = new STLLoader();
17
+ const gltfLoader = new GLTFLoader();
18
+ const geoCache = new Map<string, THREE.BufferGeometry>();
19
+ const objGroupCache = new Map<string, THREE.Group>();
20
+ const gltfGroupCache = new Map<string, THREE.Group>();
21
+ const mtlCreatorCache = new Map<string, MTLLoader.MaterialCreator | null>();
22
+ const texCache = new Map<string, THREE.Texture>();
23
+ const texLoader = new THREE.TextureLoader();
24
+
25
+ async function loadSTL(url: string): Promise<THREE.BufferGeometry> {
26
+ const hit = geoCache.get(url);
27
+ if (hit) return hit.clone();
28
+ const geo = await stlLoader.loadAsync(url);
29
+ geo.computeVertexNormals();
30
+ geoCache.set(url, geo);
31
+ return geo.clone();
32
+ }
33
+
34
+ async function loadMTL(url: string): Promise<MTLLoader.MaterialCreator | null> {
35
+ if (mtlCreatorCache.has(url)) return mtlCreatorCache.get(url)!;
36
+ try {
37
+ const dir = url.substring(0, url.lastIndexOf("/") + 1);
38
+ const loader = new MTLLoader();
39
+ loader.setResourcePath(dir);
40
+ const creator = await loader.loadAsync(url);
41
+ creator.preload();
42
+ mtlCreatorCache.set(url, creator);
43
+ return creator;
44
+ } catch {
45
+ mtlCreatorCache.set(url, null);
46
+ return null;
47
+ }
48
+ }
49
+
50
+ async function loadOBJGroup(url: string): Promise<THREE.Group> {
51
+ const mtlUrl = url.replace(/\.(obj|OBJ)$/, ".mtl");
52
+ const creator = await loadMTL(mtlUrl);
53
+ const cacheKey = url + (creator ? ":mtl" : "");
54
+ const hit = objGroupCache.get(cacheKey);
55
+ if (hit) return hit.clone() as THREE.Group;
56
+ const loader = new OBJLoader();
57
+ if (creator) loader.setMaterials(creator);
58
+ const grp = await loader.loadAsync(url);
59
+ grp.traverse((c) => {
60
+ if (c instanceof THREE.Mesh) {
61
+ const old = (
62
+ Array.isArray(c.material) ? c.material[0] : c.material
63
+ ) as THREE.MeshPhongMaterial;
64
+ c.material = new THREE.MeshStandardMaterial({
65
+ map: old?.map ?? null,
66
+ color: old?.map ? new THREE.Color(1, 1, 1) : (old?.color ?? new THREE.Color(0xaaaaaa)),
67
+ metalness: 0.0,
68
+ roughness: 0.8,
69
+ });
70
+ c.castShadow = true;
71
+ c.receiveShadow = true;
72
+ }
73
+ });
74
+ objGroupCache.set(cacheKey, grp);
75
+ return grp.clone() as THREE.Group;
76
+ }
77
+
78
+ async function loadGLTFGroup(url: string): Promise<THREE.Group> {
79
+ const hit = gltfGroupCache.get(url);
80
+ if (hit) return hit.clone() as THREE.Group;
81
+ const gltf = await gltfLoader.loadAsync(url);
82
+ const grp = gltf.scene;
83
+ grp.traverse((c) => {
84
+ if (c instanceof THREE.Mesh) {
85
+ c.castShadow = true;
86
+ c.receiveShadow = true;
87
+ }
88
+ });
89
+ gltfGroupCache.set(url, grp);
90
+ return grp.clone() as THREE.Group;
91
+ }
92
+
93
+ async function loadTex(url: string): Promise<THREE.Texture> {
94
+ const hit = texCache.get(url);
95
+ if (hit) return hit;
96
+ const tex = await texLoader.loadAsync(url);
97
+ tex.colorSpace = THREE.SRGBColorSpace;
98
+ texCache.set(url, tex);
99
+ return tex;
100
+ }
101
+
102
+ // ── Asset resolution ──────────────────────────────────────────────────────────
103
+
104
+ /** Resolve geom material — string ref OR inline MaterialDesc. */
105
+ function resolveMat(
106
+ mat: string | MaterialDesc | undefined,
107
+ visual: VisualScene,
108
+ ): MaterialDesc | undefined {
109
+ if (mat === undefined) return undefined;
110
+ if (typeof mat === "string") return visual.materials?.[mat];
111
+ return mat;
112
+ }
113
+
114
+ /** Resolve geom mesh URL — name ref → meshes table → URL, or direct meshUrl. */
115
+ function resolveMeshUrl(geom: GeomDesc, visual: VisualScene): string | undefined {
116
+ if (geom.mesh) return visual.meshes?.[geom.mesh];
117
+ return geom.meshUrl;
118
+ }
119
+
120
+ /** Resolve texture URL from materials table entry. */
121
+ function resolveTexUrl(mat: MaterialDesc | undefined, visual: VisualScene): string | undefined {
122
+ if (!mat?.texture) return undefined;
123
+ return visual.textures?.[mat.texture] ?? mat.texture;
124
+ }
125
+
126
+ // ── Material + geometry builders ──────────────────────────────────────────────
127
+
128
+ function buildMat(desc: MaterialDesc | undefined): THREE.MeshStandardMaterial {
129
+ const c = desc?.color;
130
+ const a = c ? (c[3] ?? 1) : 1;
131
+ return new THREE.MeshStandardMaterial({
132
+ color: c ? new THREE.Color(c[0], c[1], c[2]) : new THREE.Color(0x6b7280),
133
+ opacity: a,
134
+ transparent: a < 1,
135
+ roughness: desc?.roughness ?? 0.6,
136
+ metalness: desc?.metalness ?? 0.1,
137
+ });
138
+ }
139
+
140
+ async function applyTexture(mat: THREE.MeshStandardMaterial, texUrl?: string): Promise<void> {
141
+ if (!texUrl) return;
142
+ try {
143
+ mat.map = await loadTex(texUrl);
144
+ mat.color.set(0xffffff);
145
+ mat.needsUpdate = true;
146
+ } catch {
147
+ /* skip */
148
+ }
149
+ }
150
+
151
+ function buildGeo(geom: GeomDesc): THREE.BufferGeometry | null {
152
+ const s = geom.size;
153
+ switch (geom.type) {
154
+ case "box":
155
+ return new THREE.BoxGeometry(s[0] ?? 1, s[1] ?? 1, s[2] ?? 1);
156
+ case "sphere":
157
+ return new THREE.SphereGeometry(s[0] ?? 0.5, 32, 32);
158
+ case "cylinder": {
159
+ const geo = new THREE.CylinderGeometry(s[0] ?? 0.1, s[0] ?? 0.1, s[1] ?? 0.5, 32);
160
+ geo.rotateX(Math.PI / 2);
161
+ return geo;
162
+ }
163
+ case "capsule": {
164
+ const geo = new THREE.CapsuleGeometry(s[0] ?? 0.1, s[1] ?? 0.5, 16, 32);
165
+ geo.rotateX(Math.PI / 2);
166
+ return geo;
167
+ }
168
+ case "plane": {
169
+ const [w, d] = [s[0] ?? 10, s[1] ?? 10];
170
+ return new THREE.PlaneGeometry(w * 2, d * 2);
171
+ }
172
+ default:
173
+ return null;
174
+ }
175
+ }
176
+
177
+ function applyTransform(obj: THREE.Object3D, geom: GeomDesc): void {
178
+ if (geom.pos) obj.position.set(geom.pos[0], geom.pos[1], geom.pos[2]);
179
+ if (geom.quat) {
180
+ const [w, x, y, z] = geom.quat;
181
+ obj.quaternion.set(x, y, z, w).normalize();
182
+ } else if (geom.rot) {
183
+ const D = Math.PI / 180;
184
+ obj.rotation.set(geom.rot[0] * D, geom.rot[1] * D, geom.rot[2] * D);
185
+ }
186
+ }
187
+
188
+ async function buildGeomObject(
189
+ geom: GeomDesc,
190
+ parent: THREE.Group,
191
+ visual: VisualScene,
192
+ ): Promise<void> {
193
+ const meshUrl = resolveMeshUrl(geom, visual);
194
+ if (geom.type === "mesh" && meshUrl) {
195
+ const mat = buildMat(resolveMat(geom.material, visual));
196
+ await applyTexture(mat, resolveTexUrl(resolveMat(geom.material, visual), visual));
197
+ try {
198
+ if (/\.(glb|gltf)$/i.test(meshUrl)) {
199
+ const grp = await loadGLTFGroup(meshUrl);
200
+ applyTransform(grp, geom);
201
+ parent.add(grp);
202
+ } else if (/\.(obj|OBJ)$/.test(meshUrl)) {
203
+ const grp = await loadOBJGroup(meshUrl);
204
+ if (geom.material) {
205
+ grp.traverse((c) => {
206
+ if (c instanceof THREE.Mesh) {
207
+ c.material = mat.clone();
208
+ c.castShadow = true;
209
+ c.receiveShadow = true;
210
+ }
211
+ });
212
+ } else {
213
+ grp.traverse((c) => {
214
+ if (c instanceof THREE.Mesh) {
215
+ c.castShadow = true;
216
+ c.receiveShadow = true;
217
+ }
218
+ });
219
+ }
220
+ applyTransform(grp, geom);
221
+ parent.add(grp);
222
+ } else {
223
+ const geo = await loadSTL(meshUrl);
224
+ const mesh = new THREE.Mesh(geo, mat);
225
+ mesh.castShadow = true;
226
+ mesh.receiveShadow = true;
227
+ applyTransform(mesh, geom);
228
+ parent.add(mesh);
229
+ }
230
+ } catch (e) {
231
+ console.error(`[builder] mesh load failed: ${meshUrl}`, e);
232
+ }
233
+ } else {
234
+ const geo = buildGeo(geom);
235
+ if (!geo) return;
236
+ const matDesc = resolveMat(geom.material, visual);
237
+ const mat = buildMat(matDesc);
238
+ await applyTexture(mat, resolveTexUrl(matDesc, visual));
239
+ geo.computeVertexNormals();
240
+ const mesh = new THREE.Mesh(geo, mat);
241
+ mesh.castShadow = true;
242
+ mesh.receiveShadow = true;
243
+ applyTransform(mesh, geom);
244
+ parent.add(mesh);
245
+ }
246
+ }
247
+
248
+ function buildLight(desc: LightDesc): THREE.Light | null {
249
+ const color = desc.color
250
+ ? new THREE.Color(desc.color[0], desc.color[1], desc.color[2])
251
+ : new THREE.Color(0xffffff);
252
+ const intensity = desc.intensity ?? 1;
253
+ switch (desc.type) {
254
+ case "point": {
255
+ const l = new THREE.PointLight(color, intensity, desc.range ?? 0);
256
+ l.castShadow = desc.castShadow ?? false;
257
+ return l;
258
+ }
259
+ case "directional": {
260
+ const l = new THREE.DirectionalLight(color, intensity);
261
+ if (desc.dir)
262
+ l.position.set(-desc.dir[0], -desc.dir[1], -desc.dir[2]).normalize().multiplyScalar(5);
263
+ l.castShadow = desc.castShadow ?? false;
264
+ return l;
265
+ }
266
+ case "spot": {
267
+ const angle = desc.angle ? (desc.angle * Math.PI) / 180 : Math.PI / 6;
268
+ const l = new THREE.SpotLight(color, intensity, desc.range ?? 0, angle);
269
+ l.castShadow = desc.castShadow ?? false;
270
+ return l;
271
+ }
272
+ case "ambient":
273
+ return new THREE.AmbientLight(color, intensity);
274
+ case "hemisphere": {
275
+ const ground = desc.groundColor
276
+ ? new THREE.Color(desc.groundColor[0], desc.groundColor[1], desc.groundColor[2])
277
+ : new THREE.Color(0x545454);
278
+ return new THREE.HemisphereLight(color, ground, intensity);
279
+ }
280
+ default:
281
+ return null;
282
+ }
283
+ }
284
+
285
+ // ── Public sync helpers ───────────────────────────────────────────────────────
286
+
287
+ /**
288
+ * Synchronously build a Three.js mesh from a primitive GeomDesc.
289
+ * Returns null for 'mesh' type (use buildGeomMesh for async loading).
290
+ */
291
+ export function createPrimitiveMesh(geom: GeomDesc): THREE.Mesh | null {
292
+ if (geom.type === "mesh") return null;
293
+ const geo = buildGeo(geom);
294
+ if (!geo) return null;
295
+ geo.computeVertexNormals();
296
+ const mesh = new THREE.Mesh(
297
+ geo,
298
+ buildMat(typeof geom.material === "object" ? geom.material : undefined),
299
+ );
300
+ mesh.castShadow = true;
301
+ mesh.receiveShadow = true;
302
+ return mesh;
303
+ }
304
+
305
+ export function createLightFromDesc(desc: LightDesc): THREE.Light | null {
306
+ return buildLight(desc);
307
+ }
308
+
309
+ // ── Public API ────────────────────────────────────────────────────────────────
310
+
311
+ export interface BuiltScene {
312
+ /** All rigid body objects keyed by name. */
313
+ bodies: Map<string, THREE.Object3D>;
314
+ /** Root rigid bodies only (no parent). */
315
+ entityRoots: Map<string, THREE.Object3D>;
316
+ /** Particle point clouds keyed by name — update positions per frame. */
317
+ particles: Map<string, THREE.Points>;
318
+ /** Soft body meshes keyed by name — update vertex positions per frame. */
319
+ softBodies: Map<string, THREE.Mesh>;
320
+ /**
321
+ * Skinned meshes keyed by name — update bone transforms per frame.
322
+ * Requires @sparkjsdev/spark or Three.js SkinnedMesh support.
323
+ */
324
+ skinnedBodies: Map<string, THREE.SkinnedMesh>;
325
+ /**
326
+ * Instanced meshes keyed by name — update instanceMatrix per frame.
327
+ * One draw call for all instances.
328
+ */
329
+ instancedBodies: Map<string, THREE.InstancedMesh>;
330
+ }
331
+
332
+ /**
333
+ * Build a Three.js scene from a VisualScene.
334
+ * Resolves asset table references, handles rigid/soft bodies, particles, and environment.
335
+ */
336
+ export async function buildSceneFromVisual(
337
+ visual: VisualScene,
338
+ scene: THREE.Scene,
339
+ ): Promise<BuiltScene> {
340
+ const bodies = new Map<string, THREE.Object3D>();
341
+ const roots = new Map<string, THREE.Object3D>();
342
+ const particlesMap = new Map<string, THREE.Points>();
343
+ const softBodiesMap = new Map<string, THREE.Mesh>();
344
+ const skinnedBodiesMap = new Map<string, THREE.SkinnedMesh>();
345
+ const instancedBodiesMap = new Map<string, THREE.InstancedMesh>();
346
+ const deferred: Array<{ grp: THREE.Group; parentName: string }> = [];
347
+
348
+ // ── Apply environment ──────────────────────────────────────────────────────
349
+ if (visual.environment) {
350
+ const env = visual.environment;
351
+ if (env.background) {
352
+ if (Array.isArray(env.background)) {
353
+ scene.background = new THREE.Color(env.background[0], env.background[1], env.background[2]);
354
+ } else {
355
+ scene.background = new THREE.Color(env.background);
356
+ }
357
+ }
358
+ if (env.fog) {
359
+ const f = env.fog;
360
+ scene.fog = new THREE.Fog(new THREE.Color(f.color[0], f.color[1], f.color[2]), f.near, f.far);
361
+ }
362
+ }
363
+
364
+ // ── Build bodies ───────────────────────────────────────────────────────────
365
+ for (const body of visual.bodies) {
366
+ if (body.kind === "rigid") {
367
+ const grp = new THREE.Group();
368
+ grp.name = body.name;
369
+ grp.position.set(...body.pos);
370
+ if (body.quat) {
371
+ const [w, x, y, z] = body.quat;
372
+ grp.quaternion.set(x, y, z, w).normalize();
373
+ } else if (body.rot) {
374
+ const D = Math.PI / 180;
375
+ grp.rotation.set(body.rot[0] * D, body.rot[1] * D, body.rot[2] * D);
376
+ }
377
+ bodies.set(body.name, grp);
378
+ if (body.parent) {
379
+ deferred.push({ grp, parentName: body.parent });
380
+ } else {
381
+ scene.add(grp);
382
+ roots.set(body.name, grp);
383
+ }
384
+ for (const geom of body.geoms) await buildGeomObject(geom, grp, visual);
385
+ } else if (body.kind === "soft") {
386
+ const geo = new THREE.BufferGeometry();
387
+ const positions = new Float32Array(body.vertexCount * 3);
388
+ geo.setAttribute("position", new THREE.BufferAttribute(positions, 3));
389
+ geo.setAttribute(
390
+ "normal",
391
+ new THREE.BufferAttribute(new Float32Array(body.vertexCount * 3), 3),
392
+ );
393
+ if (body.indices) geo.setIndex(body.indices);
394
+ const matDesc = resolveMat(body.material, visual);
395
+ const mat = buildMat(matDesc);
396
+ if (body.doubleSided) mat.side = THREE.DoubleSide;
397
+ const mesh = new THREE.Mesh(geo, mat);
398
+ mesh.name = body.name;
399
+ mesh.frustumCulled = false;
400
+ scene.add(mesh);
401
+ softBodiesMap.set(body.name, mesh);
402
+ }
403
+ }
404
+
405
+ // ── Resolve rigid body hierarchy ───────────────────────────────────────────
406
+ const allNames = [...bodies.keys()];
407
+ for (const { grp, parentName } of deferred) {
408
+ const parent = bodies.get(parentName);
409
+ if (parent) {
410
+ parent.add(grp);
411
+ } else {
412
+ console.warn(
413
+ `[builder] unresolved parent "${parentName}" for "${grp.name}". Available: [${allNames.slice(0, 10).join(", ")}${allNames.length > 10 ? "..." : ""}]`,
414
+ );
415
+ scene.add(grp);
416
+ roots.set(grp.name, grp);
417
+ }
418
+ }
419
+
420
+ // ── Build lights ───────────────────────────────────────────────────────────
421
+ for (const desc of visual.lights) {
422
+ const light = buildLight(desc);
423
+ if (!light) continue;
424
+ if (desc.pos) light.position.set(...desc.pos);
425
+ scene.add(light);
426
+ }
427
+
428
+ // ── Build particle systems ─────────────────────────────────────────────────
429
+ for (const pd of visual.particles ?? []) {
430
+ const geo = new THREE.BufferGeometry();
431
+ geo.setAttribute("position", new THREE.BufferAttribute(new Float32Array(0), 3));
432
+ const matDesc = resolveMat(pd.material, visual);
433
+ const c = matDesc?.color ?? [0.2, 0.6, 0.95, 0.8];
434
+ const ptsMat = new THREE.PointsMaterial({
435
+ color: new THREE.Color(c[0], c[1], c[2]),
436
+ size: pd.size ?? 0.03,
437
+ transparent: (c[3] ?? 1) < 1,
438
+ opacity: c[3] ?? 1,
439
+ sizeAttenuation: pd.sizeAttenuation ?? true,
440
+ });
441
+ const pts = new THREE.Points(geo, ptsMat);
442
+ pts.name = pd.name;
443
+ pts.frustumCulled = false;
444
+ scene.add(pts);
445
+ particlesMap.set(pd.name, pts);
446
+ }
447
+
448
+ return {
449
+ bodies,
450
+ entityRoots: roots,
451
+ particles: particlesMap,
452
+ softBodies: softBodiesMap,
453
+ skinnedBodies: skinnedBodiesMap,
454
+ instancedBodies: instancedBodiesMap,
455
+ };
456
+ }
457
+
458
+ /**
459
+ * Build a single Three.js mesh from a GeomDesc.
460
+ * Useful for editor live-preview of individual geoms.
461
+ */
462
+ export async function buildGeomMesh(
463
+ geom: GeomDesc,
464
+ visual: VisualScene = { bodies: [], lights: [], cameras: [], sensors: [] },
465
+ ): Promise<THREE.Mesh | null> {
466
+ const meshUrl = resolveMeshUrl(geom, visual);
467
+ if (geom.type === "mesh") {
468
+ if (!meshUrl) return null;
469
+ const matDesc = resolveMat(geom.material, visual);
470
+ const mat = buildMat(matDesc);
471
+ await applyTexture(mat, resolveTexUrl(matDesc, visual));
472
+ try {
473
+ const geo = await loadSTL(meshUrl);
474
+ const mesh = new THREE.Mesh(geo, mat);
475
+ mesh.castShadow = true;
476
+ mesh.receiveShadow = true;
477
+ return mesh;
478
+ } catch {
479
+ return null;
480
+ }
481
+ }
482
+ const geo = buildGeo(geom);
483
+ if (!geo) return null;
484
+ geo.computeVertexNormals();
485
+ const matDesc = resolveMat(geom.material, visual);
486
+ const mat = buildMat(matDesc);
487
+ await applyTexture(mat, resolveTexUrl(matDesc, visual));
488
+ const mesh = new THREE.Mesh(geo, mat);
489
+ mesh.castShadow = true;
490
+ mesh.receiveShadow = true;
491
+ return mesh;
492
+ }