@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.
- package/package.json +24 -18
- package/src/index.ts +40 -10
- package/src/sensors/contact.ts +124 -0
- package/src/sensors/directions.ts +103 -0
- package/src/sensors/index.ts +4 -0
- package/src/sensors/lidar.ts +120 -0
- package/src/sync.ts +128 -124
- package/src/types.ts +83 -0
- package/src/viewer/builder.ts +492 -0
- package/src/viewer/scene.ts +253 -0
- package/src/viewer/types.ts +193 -0
- package/src/viewport.ts +71 -58
- package/src/compiler/threejs/index.ts +0 -389
- package/src/parser/threejs/index.ts +0 -84
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import { LidarViz } from "../sensors/index.js";
|
|
3
|
+
import { applyTransforms, buildBodyIdx } from "../sync.js";
|
|
4
|
+
import type { SimScene as SimSceneMsg } from "../types.js";
|
|
5
|
+
import { ContactViz } from "../sensors/contact.js";
|
|
6
|
+
import type { SimCamera, VisualScene } from "./types.js";
|
|
7
|
+
|
|
8
|
+
/** The subset of state that SimScene needs for per-frame visualization. */
|
|
9
|
+
export interface VizState {
|
|
10
|
+
showContacts: boolean;
|
|
11
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
12
|
+
contacts: any[];
|
|
13
|
+
sensordata: Record<string, number[]> | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* SimScene — Three.js renderer for simulation visualization.
|
|
18
|
+
*
|
|
19
|
+
* Lifecycle:
|
|
20
|
+
* 1. new SimScene(scene)
|
|
21
|
+
* 2. setBodies(map) — register body objects
|
|
22
|
+
* 3. init(msg, visual) — build bodyIdx, proxy meshes, lidars, thumb cameras
|
|
23
|
+
* 4. Per frame: applyFrame() → updateViz() → renderThumbnails()
|
|
24
|
+
* 5. dispose()
|
|
25
|
+
*/
|
|
26
|
+
export class SimScene {
|
|
27
|
+
static readonly THUMB_W = 160;
|
|
28
|
+
static readonly THUMB_H = 100;
|
|
29
|
+
|
|
30
|
+
#scene: THREE.Scene;
|
|
31
|
+
#bodies: Map<string, THREE.Object3D> = new Map();
|
|
32
|
+
#bodyIdx: Map<string, number> = new Map();
|
|
33
|
+
#proxies: THREE.Object3D[] = [];
|
|
34
|
+
#lidars: LidarViz[] = [];
|
|
35
|
+
#contactViz: ContactViz;
|
|
36
|
+
#thumbCams: {
|
|
37
|
+
name: string;
|
|
38
|
+
cam: THREE.PerspectiveCamera;
|
|
39
|
+
entityName?: string;
|
|
40
|
+
lookAtName?: string;
|
|
41
|
+
}[] = [];
|
|
42
|
+
#cameras: SimCamera[] = [];
|
|
43
|
+
#thumbTarget: THREE.WebGLRenderTarget | null = null;
|
|
44
|
+
|
|
45
|
+
/** Config list — updated on init(). */
|
|
46
|
+
thumbConfigs: { key: string; label: string }[] = [];
|
|
47
|
+
|
|
48
|
+
constructor(scene: THREE.Scene) {
|
|
49
|
+
this.#scene = scene;
|
|
50
|
+
this.#contactViz = new ContactViz(scene);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Body management ────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
setBodies(bodies: Map<string, THREE.Object3D>): void {
|
|
56
|
+
this.#bodies = bodies;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
get bodies(): Map<string, THREE.Object3D> {
|
|
60
|
+
return this.#bodies;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
init(msg: SimSceneMsg, visual: VisualScene): void {
|
|
66
|
+
this.#bodyIdx.clear();
|
|
67
|
+
buildBodyIdx(msg.bodies.map((b) => b.name)).forEach((v, k) => this.#bodyIdx.set(k, v));
|
|
68
|
+
this.#cameras = visual.cameras;
|
|
69
|
+
this.#initThumbCams(visual.cameras);
|
|
70
|
+
this.#createProxies(visual);
|
|
71
|
+
this.#createLidars(visual);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Per-frame updates ──────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
applyFrame(xpos: Float32Array, xquat: Float32Array): void {
|
|
77
|
+
applyTransforms(xpos, xquat, this.#bodyIdx, this.#bodies);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
updateViz(state: VizState): void {
|
|
81
|
+
this.#syncThumbCams();
|
|
82
|
+
this.#contactViz.visible = state.showContacts;
|
|
83
|
+
this.#contactViz.update(state.contacts as Parameters<ContactViz["update"]>[0]);
|
|
84
|
+
for (const viz of this.#lidars) viz.update(state.sensordata?.[viz.name] ?? null);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Thumbnail cameras ──────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
renderThumbnails(
|
|
90
|
+
renderer: THREE.WebGLRenderer,
|
|
91
|
+
ctxs: (CanvasRenderingContext2D | null)[],
|
|
92
|
+
running: boolean,
|
|
93
|
+
time: number,
|
|
94
|
+
): void {
|
|
95
|
+
if (!running || !this.#thumbCams.length) return;
|
|
96
|
+
if (Math.round(time * 50) % 3 !== 0) return;
|
|
97
|
+
const W = SimScene.THUMB_W;
|
|
98
|
+
const H = SimScene.THUMB_H;
|
|
99
|
+
const target = this.#getThumbTarget();
|
|
100
|
+
const prev = renderer.outputColorSpace;
|
|
101
|
+
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
102
|
+
for (let i = 0; i < this.#thumbCams.length; i++) {
|
|
103
|
+
const ctx2d = ctxs[i];
|
|
104
|
+
if (!ctx2d) continue;
|
|
105
|
+
renderer.setRenderTarget(target);
|
|
106
|
+
renderer.render(this.#scene, this.#thumbCams[i]!.cam);
|
|
107
|
+
renderer.setRenderTarget(null);
|
|
108
|
+
const buf = new Uint8Array(W * H * 4);
|
|
109
|
+
renderer.readRenderTargetPixels(target, 0, 0, W, H, buf);
|
|
110
|
+
const img = ctx2d.createImageData(W, H);
|
|
111
|
+
for (let y = 0; y < H; y++) {
|
|
112
|
+
const src = (H - 1 - y) * W * 4;
|
|
113
|
+
img.data.set(buf.subarray(src, src + W * 4), y * W * 4);
|
|
114
|
+
}
|
|
115
|
+
ctx2d.putImageData(img, 0, 0);
|
|
116
|
+
}
|
|
117
|
+
renderer.outputColorSpace = prev;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
getThumbCam(key: string): THREE.PerspectiveCamera | null {
|
|
121
|
+
return this.#thumbCams.find((c) => c.name === key)?.cam ?? null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
getThumbConfig(key: string): SimCamera | null {
|
|
125
|
+
return this.#cameras.find((c) => c.name === key) ?? null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Dispose ────────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
dispose(): void {
|
|
131
|
+
for (const viz of this.#lidars) {
|
|
132
|
+
this.#scene.remove(viz.group);
|
|
133
|
+
viz.dispose();
|
|
134
|
+
}
|
|
135
|
+
this.#lidars.length = 0;
|
|
136
|
+
|
|
137
|
+
for (const obj of this.#proxies) {
|
|
138
|
+
this.#scene.remove(obj);
|
|
139
|
+
obj.traverse((c) => {
|
|
140
|
+
if (c instanceof THREE.Mesh) {
|
|
141
|
+
c.geometry.dispose();
|
|
142
|
+
(c.material as THREE.Material).dispose();
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
this.#proxies.length = 0;
|
|
147
|
+
|
|
148
|
+
this.#contactViz.dispose();
|
|
149
|
+
this.#thumbCams = [];
|
|
150
|
+
this.#cameras = [];
|
|
151
|
+
this.thumbConfigs = [];
|
|
152
|
+
this.#thumbTarget?.dispose();
|
|
153
|
+
this.#thumbTarget = null;
|
|
154
|
+
this.#bodyIdx.clear();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Private ────────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
#getThumbTarget(): THREE.WebGLRenderTarget {
|
|
160
|
+
if (!this.#thumbTarget) {
|
|
161
|
+
this.#thumbTarget = new THREE.WebGLRenderTarget(SimScene.THUMB_W, SimScene.THUMB_H, {
|
|
162
|
+
minFilter: THREE.LinearFilter,
|
|
163
|
+
magFilter: THREE.LinearFilter,
|
|
164
|
+
format: THREE.RGBAFormat,
|
|
165
|
+
type: THREE.UnsignedByteType,
|
|
166
|
+
});
|
|
167
|
+
this.#thumbTarget.texture.colorSpace = THREE.SRGBColorSpace;
|
|
168
|
+
}
|
|
169
|
+
return this.#thumbTarget;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
#syncThumbCams(): void {
|
|
173
|
+
for (const tc of this.#thumbCams) {
|
|
174
|
+
if (!tc.entityName) continue;
|
|
175
|
+
const obj = this.#bodies.get(tc.entityName);
|
|
176
|
+
if (obj) {
|
|
177
|
+
tc.cam.position.copy(obj.position);
|
|
178
|
+
tc.cam.quaternion.copy(obj.quaternion);
|
|
179
|
+
}
|
|
180
|
+
if (tc.lookAtName) {
|
|
181
|
+
const target = this.#bodies.get(tc.lookAtName);
|
|
182
|
+
if (target) tc.cam.lookAt(target.position);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
#initThumbCams(cameras: SimCamera[]): void {
|
|
188
|
+
const W = SimScene.THUMB_W;
|
|
189
|
+
const H = SimScene.THUMB_H;
|
|
190
|
+
this.#thumbCams = cameras.map((c) => {
|
|
191
|
+
const cam = new THREE.PerspectiveCamera(c.fov, W / H, 0.1, 100);
|
|
192
|
+
cam.position.set(...c.position);
|
|
193
|
+
cam.lookAt(...c.lookAt);
|
|
194
|
+
return { name: c.name, cam, entityName: c.entityName, lookAtName: c.lookAtName };
|
|
195
|
+
});
|
|
196
|
+
this.thumbConfigs = this.#thumbCams.map((c) => ({ key: c.name, label: c.name }));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
#createProxies(visual: VisualScene): void {
|
|
200
|
+
for (const body of visual.bodies) {
|
|
201
|
+
if (body.kind !== "rigid") continue;
|
|
202
|
+
if (this.#bodies.has(body.name)) continue;
|
|
203
|
+
const grp = new THREE.Group();
|
|
204
|
+
grp.position.set(...body.pos);
|
|
205
|
+
if (body.quat) {
|
|
206
|
+
const [w, x, y, z] = body.quat;
|
|
207
|
+
grp.quaternion.set(x, y, z, w);
|
|
208
|
+
}
|
|
209
|
+
for (const geom of body.geoms) {
|
|
210
|
+
if (geom.type === "box") {
|
|
211
|
+
const [sx, sy, sz] = geom.size as [number, number, number];
|
|
212
|
+
const mesh = new THREE.Mesh(
|
|
213
|
+
new THREE.BoxGeometry(sx, sy, sz),
|
|
214
|
+
new THREE.MeshStandardMaterial({
|
|
215
|
+
color: 0x1f1f3e,
|
|
216
|
+
roughness: 0.7,
|
|
217
|
+
metalness: 0.2,
|
|
218
|
+
transparent: true,
|
|
219
|
+
opacity: 0.9,
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
grp.add(mesh);
|
|
223
|
+
}
|
|
224
|
+
// TODO: add sphere, cylinder, capsule, mesh (GLB) handling
|
|
225
|
+
}
|
|
226
|
+
if (grp.children.length > 0) {
|
|
227
|
+
this.#scene.add(grp);
|
|
228
|
+
this.#bodies.set(body.name, grp);
|
|
229
|
+
this.#proxies.push(grp);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
#createLidars(visual: VisualScene): void {
|
|
235
|
+
for (const s of visual.sensors) {
|
|
236
|
+
if (s.type !== "lidar") continue;
|
|
237
|
+
const body = this.#bodies.get(s.parentBody ?? s.name);
|
|
238
|
+
if (!body) continue;
|
|
239
|
+
const ch = s.channels ?? 1;
|
|
240
|
+
const viz = new LidarViz(s.name, body, {
|
|
241
|
+
pattern: ch > 1 ? "3d" : "2d",
|
|
242
|
+
channels: ch,
|
|
243
|
+
horizontalSamples: s.horizontalSamples ?? 120,
|
|
244
|
+
horizontalFov: s.horizontalFov ?? [-180, 180],
|
|
245
|
+
verticalFov: s.verticalFov as [number, number] | undefined,
|
|
246
|
+
range: s.range ?? [0.1, 10],
|
|
247
|
+
height: 0.3,
|
|
248
|
+
});
|
|
249
|
+
this.#scene.add(viz.group);
|
|
250
|
+
this.#lidars.push(viz);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VisualScene — engine-agnostic visual description of a scene.
|
|
3
|
+
*
|
|
4
|
+
* Designed to be consumed by any renderer (Three.js, wgpu-rs headless, etc.).
|
|
5
|
+
* Produced by @simarena/format (SceneDoc → VisualScene) or any sim engine
|
|
6
|
+
* (MuJoCo, Genesis, Newton, USD, ...).
|
|
7
|
+
*
|
|
8
|
+
* Asset tables (materials, textures, meshes) deduplicate shared resources.
|
|
9
|
+
* GeomDesc references them by name; renderers build GPU resources once per
|
|
10
|
+
* unique material/mesh and batch draw calls accordingly.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// ── Asset tables ──────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/** PBR material — engine-agnostic, all values 0-1. */
|
|
16
|
+
export interface MaterialDesc {
|
|
17
|
+
color?: [number, number, number, number]; // RGBA 0-1
|
|
18
|
+
roughness?: number;
|
|
19
|
+
metalness?: number;
|
|
20
|
+
texture?: string; // ref to VisualScene.textures
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ── Geometry ──────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Geometry primitive.
|
|
27
|
+
* size convention: box=[w,h,d] | sphere=[r] | cylinder=[r,h] | capsule=[r,h] | plane=[w,d] | mesh=[]
|
|
28
|
+
*/
|
|
29
|
+
export interface GeomDesc {
|
|
30
|
+
type: string; // 'box' | 'sphere' | 'capsule' | 'cylinder' | 'plane' | 'mesh'
|
|
31
|
+
size: number[];
|
|
32
|
+
pos?: [number, number, number];
|
|
33
|
+
rot?: [number, number, number]; // Euler degrees XYZ
|
|
34
|
+
quat?: [number, number, number, number]; // WXYZ
|
|
35
|
+
material?: string | MaterialDesc; // name ref OR inline MaterialDesc
|
|
36
|
+
mesh?: string; // name ref to VisualScene.meshes
|
|
37
|
+
meshUrl?: string; // direct URL (convenience; prefer mesh ref)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── Bodies ────────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/** Rigid body — updated per frame via transform (pos + quat). */
|
|
43
|
+
export interface RigidBodyDesc {
|
|
44
|
+
kind: "rigid";
|
|
45
|
+
name: string;
|
|
46
|
+
pos: [number, number, number];
|
|
47
|
+
rot?: [number, number, number];
|
|
48
|
+
quat?: [number, number, number, number];
|
|
49
|
+
geoms: GeomDesc[];
|
|
50
|
+
parent?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Deformable body — updated per frame via per-vertex positions. */
|
|
54
|
+
export interface SoftBodyDesc {
|
|
55
|
+
kind: "soft";
|
|
56
|
+
name: string;
|
|
57
|
+
/** Total vertex count — pre-allocate GPU buffers. */
|
|
58
|
+
vertexCount: number;
|
|
59
|
+
/** Initial topology mesh (GLB/OBJ). Triangle indices stay fixed. */
|
|
60
|
+
mesh?: string; // ref to VisualScene.meshes
|
|
61
|
+
meshUrl?: string; // direct URL fallback
|
|
62
|
+
/** Inline triangle indices (alternative to mesh ref). */
|
|
63
|
+
indices?: number[];
|
|
64
|
+
material?: string | MaterialDesc;
|
|
65
|
+
doubleSided?: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Bone-driven mesh — GPU skeletal animation. One draw call; efficient for articulated robots. */
|
|
69
|
+
export interface SkinnedBodyDesc {
|
|
70
|
+
kind: "skinned";
|
|
71
|
+
name: string;
|
|
72
|
+
/** GLB with embedded skeleton and skin weights. */
|
|
73
|
+
mesh?: string;
|
|
74
|
+
meshUrl?: string;
|
|
75
|
+
/** Number of bones — pre-allocate bone transform buffer. */
|
|
76
|
+
boneCount: number;
|
|
77
|
+
material?: string | MaterialDesc;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** GPU-instanced copies of the same shape. One draw call for `count` copies. */
|
|
81
|
+
export interface InstancedBodyDesc {
|
|
82
|
+
kind: "instanced";
|
|
83
|
+
name: string;
|
|
84
|
+
/** Number of instances. */
|
|
85
|
+
count: number;
|
|
86
|
+
/** Shared geometry for all instances. */
|
|
87
|
+
geom: GeomDesc;
|
|
88
|
+
/** Per-instance color overrides (optional). */
|
|
89
|
+
colors?: [number, number, number, number][];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type BodyDesc = RigidBodyDesc | SoftBodyDesc | SkinnedBodyDesc | InstancedBodyDesc;
|
|
93
|
+
|
|
94
|
+
// ── Lights ────────────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
export type LightType = "point" | "directional" | "spot" | "ambient" | "hemisphere";
|
|
97
|
+
|
|
98
|
+
export interface LightDesc {
|
|
99
|
+
type: LightType;
|
|
100
|
+
name: string;
|
|
101
|
+
pos?: [number, number, number];
|
|
102
|
+
color?: [number, number, number]; // RGB 0-1
|
|
103
|
+
groundColor?: [number, number, number]; // hemisphere bottom color
|
|
104
|
+
intensity?: number;
|
|
105
|
+
castShadow?: boolean;
|
|
106
|
+
dir?: [number, number, number];
|
|
107
|
+
range?: number;
|
|
108
|
+
angle?: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Cameras ───────────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
export interface SimCamera {
|
|
114
|
+
name: string;
|
|
115
|
+
fov: number;
|
|
116
|
+
position: [number, number, number];
|
|
117
|
+
lookAt: [number, number, number];
|
|
118
|
+
entityName?: string;
|
|
119
|
+
lookAtName?: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Sensors ───────────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
export interface SensorDesc {
|
|
125
|
+
name: string;
|
|
126
|
+
type: string; // 'lidar' | 'camera' | ...
|
|
127
|
+
parentBody?: string;
|
|
128
|
+
channels?: number;
|
|
129
|
+
horizontalSamples?: number;
|
|
130
|
+
horizontalFov?: [number, number];
|
|
131
|
+
verticalFov?: [number, number];
|
|
132
|
+
range?: [number, number];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Dynamic elements ──────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
/** Point cloud — MPM particles, granular, fluid, debug markers. */
|
|
138
|
+
export interface ParticleDesc {
|
|
139
|
+
name: string;
|
|
140
|
+
material?: string | MaterialDesc;
|
|
141
|
+
size?: number;
|
|
142
|
+
sizeAttenuation?: boolean;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Gaussian Splats ───────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 3D Gaussian Splat scene — photorealistic environments from NeRF/3DGS reconstruction.
|
|
149
|
+
* Rendered via @sparkjsdev/spark (Three.js-integrated Gaussian rasterizer).
|
|
150
|
+
* Splat data is loaded from a .ply, .splat, or .spz file.
|
|
151
|
+
*/
|
|
152
|
+
export interface GaussianSplatDesc {
|
|
153
|
+
name: string;
|
|
154
|
+
/** URL to .ply / .splat / .spz file. */
|
|
155
|
+
url?: string;
|
|
156
|
+
/** Name ref to meshes table. */
|
|
157
|
+
splat?: string;
|
|
158
|
+
pos?: [number, number, number];
|
|
159
|
+
quat?: [number, number, number, number];
|
|
160
|
+
scale?: number;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── Environment ───────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
export interface EnvironmentDesc {
|
|
166
|
+
/** Background color [R,G,B] 0-1 or HDR/EXR URL. */
|
|
167
|
+
background?: [number, number, number] | string;
|
|
168
|
+
fog?: { color: [number, number, number]; near: number; far: number };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── Root ──────────────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
export interface VisualScene {
|
|
174
|
+
// ── Shared asset tables ──────────────────────────────────────────────────
|
|
175
|
+
/** name → PBR material. Geoms reference by name for GPU batching. */
|
|
176
|
+
materials?: Record<string, MaterialDesc>;
|
|
177
|
+
/** name → texture URL. Materials reference by name. */
|
|
178
|
+
textures?: Record<string, string>;
|
|
179
|
+
/** name → mesh URL (.stl/.obj/.glb). Geoms reference by name. */
|
|
180
|
+
meshes?: Record<string, string>;
|
|
181
|
+
|
|
182
|
+
// ── Scene graph ──────────────────────────────────────────────────────────
|
|
183
|
+
/** Rigid + soft bodies. Discriminate with body.kind. */
|
|
184
|
+
bodies: BodyDesc[];
|
|
185
|
+
lights: LightDesc[];
|
|
186
|
+
cameras: SimCamera[];
|
|
187
|
+
sensors: SensorDesc[];
|
|
188
|
+
/** Per-frame point cloud channels (particles, fluid, granular). */
|
|
189
|
+
particles?: ParticleDesc[];
|
|
190
|
+
/** Gaussian Splat environments — loaded via @sparkjsdev/spark. */
|
|
191
|
+
splats?: GaussianSplatDesc[];
|
|
192
|
+
environment?: EnvironmentDesc;
|
|
193
|
+
}
|
package/src/viewport.ts
CHANGED
|
@@ -1,78 +1,91 @@
|
|
|
1
|
-
import * as THREE from
|
|
2
|
-
import { OrbitControls } from
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
|
3
3
|
|
|
4
4
|
export interface ViewportOpts {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
fov?: number;
|
|
6
|
+
near?: number;
|
|
7
|
+
far?: number;
|
|
8
|
+
pos?: [number, number, number];
|
|
9
|
+
target?: [number, number, number];
|
|
10
|
+
grid?: number;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export interface Viewport {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
21
|
}
|
|
22
22
|
|
|
23
23
|
export function createViewport(
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
canvas: HTMLCanvasElement,
|
|
25
|
+
container: HTMLElement,
|
|
26
|
+
opts: ViewportOpts = {},
|
|
27
27
|
): Viewport {
|
|
28
|
-
|
|
28
|
+
const {
|
|
29
|
+
fov = 60,
|
|
30
|
+
near = 0.1,
|
|
31
|
+
far = 100,
|
|
32
|
+
pos = [1.5, -1.5, 1.5],
|
|
33
|
+
target = [0, 0, 0.3],
|
|
34
|
+
grid = 2,
|
|
35
|
+
} = opts;
|
|
29
36
|
|
|
30
|
-
|
|
37
|
+
THREE.Object3D.DEFAULT_UP.set(0, 0, 1);
|
|
31
38
|
|
|
32
|
-
|
|
39
|
+
const scene = new THREE.Scene();
|
|
33
40
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
const camera = new THREE.PerspectiveCamera(
|
|
42
|
+
fov,
|
|
43
|
+
container.clientWidth / container.clientHeight,
|
|
44
|
+
near,
|
|
45
|
+
far,
|
|
46
|
+
);
|
|
47
|
+
camera.position.set(...pos);
|
|
48
|
+
camera.lookAt(...target);
|
|
37
49
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
50
|
+
const gl = canvas.getContext("webgl2");
|
|
51
|
+
const renderer = new THREE.WebGLRenderer({ canvas, context: gl || undefined, antialias: true });
|
|
52
|
+
renderer.setSize(container.clientWidth, container.clientHeight);
|
|
53
|
+
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
54
|
+
renderer.shadowMap.enabled = true;
|
|
55
|
+
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
56
|
+
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
57
|
+
renderer.toneMappingExposure = 1.0;
|
|
58
|
+
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
47
59
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
60
|
+
const orbit = new OrbitControls(camera, renderer.domElement);
|
|
61
|
+
orbit.enableDamping = true;
|
|
62
|
+
orbit.dampingFactor = 0.05;
|
|
63
|
+
orbit.target.set(...target);
|
|
64
|
+
orbit.update();
|
|
53
65
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
66
|
+
let gridHelper: THREE.GridHelper | null = null;
|
|
67
|
+
if (grid > 0) {
|
|
68
|
+
gridHelper = new THREE.GridHelper(grid * 10, 20, 0x444444, 0x222222);
|
|
69
|
+
gridHelper.rotation.x = Math.PI / 2;
|
|
70
|
+
scene.add(gridHelper);
|
|
71
|
+
}
|
|
60
72
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
73
|
+
function resize() {
|
|
74
|
+
const w = container.clientWidth,
|
|
75
|
+
h = container.clientHeight;
|
|
76
|
+
camera.aspect = w / h;
|
|
77
|
+
camera.updateProjectionMatrix();
|
|
78
|
+
renderer.setSize(w, h);
|
|
79
|
+
}
|
|
67
80
|
|
|
68
|
-
|
|
69
|
-
|
|
81
|
+
const obs = new ResizeObserver(resize);
|
|
82
|
+
obs.observe(container);
|
|
70
83
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
84
|
+
function dispose() {
|
|
85
|
+
obs.disconnect();
|
|
86
|
+
renderer.dispose();
|
|
87
|
+
orbit.dispose();
|
|
88
|
+
}
|
|
76
89
|
|
|
77
|
-
|
|
90
|
+
return { scene, camera, renderer, orbit, grid: gridHelper, resize, dispose };
|
|
78
91
|
}
|