@volter/editor-threejs 0.5.57

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,251 @@
1
+ /**
2
+ * Typed registry for `Object3D.userData` keys used by the engine and editor.
3
+ *
4
+ * Three.js lets any code stash arbitrary values on `object.userData`, and over
5
+ * time the engine + editor accumulated ~20 stringly-typed keys that together
6
+ * form a *parallel object model* — meaning carried entirely in undocumented
7
+ * string literals. This module is the ONE documented place that lists every
8
+ * such key, its value type, and what it means. All engine/editor-internal
9
+ * `userData` access for these keys must go through {@link getUserData} /
10
+ * {@link setUserData} / {@link hasUserData} / {@link deleteUserData} so the key
11
+ * strings live in exactly one place and are type-checked at every call site.
12
+ * Structural readers may use the narrow `object-marks` accessors instead;
13
+ * this registry includes that module's canonical keys and value schema.
14
+ *
15
+ * NOTE: This covers engine/editor *internal* keys only. User game code in the
16
+ * example template may stash its own ad-hoc keys; those are out of scope.
17
+ *
18
+ * ## Key reference
19
+ *
20
+ * Serialization / identity (load-bearing — editor identity):
21
+ * - `entityId` — the entity's stable string id. Presence also marks an
22
+ * Object3D as a "real" entity (vs editor helper/env object).
23
+ *
24
+ * Runtime scene-query metadata (read by `scene-query.ts` and `scene-index.ts`):
25
+ * - `tags` — string[] tags for `queryByTag` and the live index.
26
+ * - `attributes` — per-object `Record<string, string|number|boolean>`
27
+ * of game-defined attributes (P2 `observe`). Written
28
+ * through `SceneIndex.setAttribute`, which emits an
29
+ * `attributechanged` signal; JSON-simple values only,
30
+ * so an attribute survives serialization unchanged.
31
+ *
32
+ * Runtime gameplay metadata:
33
+ * - `forward` — `[x,y,z]` model-space visual forward exported as
34
+ * ordinary glTF extras for asset-facing validation.
35
+ * - `navRole` — `'walkable' | 'obstacle'` navmesh role; collected at runtime.
36
+ * - `pivot` — `[x,y,z]` local-space pivot for rotate/scale-around-pivot.
37
+ * - `splineCurve` — resolved THREE curve for a spline-following object.
38
+ * - `_camera` — THREE.Camera owned by a camera entity (collected on load).
39
+ * - `_particleSystem` — three.quarks ParticleSystem (scene-sync registration + census).
40
+ * - `gaussianSplat` — native Spark splat metadata used for renderer discovery,
41
+ * inspector facts, bounds, and deterministic disposal.
42
+ *
43
+ * Animation (load-bearing — ED5 disposal contract):
44
+ * - `_animMixer` — THREE.AnimationMixer driving this subtree's clips.
45
+ * - `_animClips` — Map<string, AnimationClip> discovered on the GLTF.
46
+ * - `_availableClips` — string[] of clip names discovered on the GLTF
47
+ * (inspector dropdown; same names as `_animClips`' keys).
48
+ * - `_animationRuntime` — format-neutral live native mixer/action inspection.
49
+ *
50
+ * Disposal contract:
51
+ * - `__sharedGeometry` — `true` when a mesh's geometry is shared/cached and MUST
52
+ * NOT be disposed by per-object cleanup (P0.2 contract).
53
+ *
54
+ * Editor shading overrides (editor-only, see `scene-sync.ts`):
55
+ * - `__shadeOrig` — original material(s) stashed before a shading override.
56
+ * - `__shadeUnlit` — generated unlit material(s) for the unlit view mode.
57
+ *
58
+ * Unmodified-game ingestion (editor-only, see `editor/src/ingest/`):
59
+ * - `__ingest` — `true` on every Object3D minted by ingestion; routes
60
+ * edits to live mutation.
61
+ * - `__ingestNextId` — monotonic id counter stashed on the captured Scene
62
+ * root so re-reflects keep minting unique ids.
63
+ *
64
+ * Editor scene-graph tagging (editor-only):
65
+ * - `engineInternal` — `true` on engine-owned infrastructure in the game scene
66
+ * (particle BatchedRenderer, debug-draw + its subtree).
67
+ * The editor's play-mode hierarchy skips these so they
68
+ * don't show up as selectable "entities".
69
+ * - `authoringRoot` — explicit opt-in for a runtime descendant to become
70
+ * its own authoring object instead of a part of the
71
+ * nearest ancestor owner.
72
+ * - `authoringInstance` — source OID of the custom-component callsite that
73
+ * rendered this existing Object3D. Used to group
74
+ * implementation parts without adding wrappers.
75
+ * - `authoringLabel` — authored literal name (or component tag fallback)
76
+ * for that source-backed component instance.
77
+ * - `authoringComponent` — exact source component identity for portable-CSF
78
+ * association; unlike the label, never user-facing copy.
79
+ * - `authoringDocument` — generic reference to the project-tool document
80
+ * that owns this scene instance's authored asset.
81
+ * - `authoringHierarchyId`, `authoringHierarchyParentId`,
82
+ * `authoringHierarchyOrder` — source-owned semantic identity, parent identity,
83
+ * and sibling ordinal shared across native surfaces.
84
+ * - `editorHelper` — `true` for editor-only helper objects (gizmos, wireframes).
85
+ *
86
+ * Hierarchy-presentation convention (written by GAME code, read by the editor —
87
+ * see `../adapter/hierarchy-marks.ts`, which is the ONLY place these two are
88
+ * read/written from; the `vgai` prefix marks them as the HOST's namespace on a
89
+ * node a game owns, unlike every other key above, which the engine/editor also
90
+ * write):
91
+ * - `vgaiComponentRoot` — display name of the component instance this subtree
92
+ * IS. The node renders as one collapsed, expandable
93
+ * row named for it.
94
+ * - `vgaiBuiltInternal` — `true` on the ROOT of a subtree runtime code
95
+ * CONSTRUCTED rather than authored (skeleton bones,
96
+ * particle renderers). Subtree-scoped: everything
97
+ * below a marked node is built-internal too.
98
+ * - `editorHelperType` — which kind of helper (lights/particles/pivot/navmesh/...).
99
+ * - `editorIcon` — `true` for editor billboard icon sprites.
100
+ * - `skeletonVisible` — per-entity editor preference for its bone overlay.
101
+ * - `skeletonEnabled` — resolved visibility preference on a skeleton helper.
102
+ * - `envObject` — `true` for environment objects (ambient light, etc.).
103
+ * - `reflectionProbe` — live project-owned reflection probe projected from
104
+ * JSX props for renderer/editor integration.
105
+ * - `triggerVolume` — plain `{ radius }` a GAME writes on the node that IS
106
+ * a trigger volume, so the editor can draw its ring
107
+ * (see `../adapter/trigger-volume.ts`).
108
+ * - `constraints` — live project-owned spatial constraints projected
109
+ * into the shared Inspector and viewport.
110
+ * - `authoringSubject` — transient identity/inspection for an ecosystem-native
111
+ * subject represented by an Object3D proxy.
112
+ * - `splineControlPoint` — index of a spline control-point drag handle.
113
+ * - `vcDirIdx` — view-cube face direction index (0=+X,1=-X,2=+Y,...).
114
+ */
115
+
116
+ import type * as THREE from 'three';
117
+ import type { ParticleSystem } from 'three.quarks';
118
+ import type { ConstraintMark } from '../adapter/constraint';
119
+ import type { Object3DAuthoringSubjectMark } from '../adapter/object3d-authoring-subject';
120
+ import type { ReflectionProbeMark } from '../adapter/reflection-probe';
121
+ import type { TriggerVolumeMark } from '../adapter/trigger-volume';
122
+ import type { AnimationRuntimeInspection } from '../animation/runtime-inspection';
123
+ import { ObjectMarkKeys, type ObjectMarkSchema } from './object-marks';
124
+
125
+ export type { EditorHelperType } from './object-marks';
126
+
127
+ /**
128
+ * Maps each canonical accessor name to its value type. This is the single
129
+ * source of truth for what every known `userData` key holds.
130
+ */
131
+ export interface UserDataSchema extends ObjectMarkSchema {
132
+ entityId: string;
133
+ forward: [number, number, number];
134
+ tags: string[];
135
+ attributes: Record<string, string | number | boolean>;
136
+ navRole: 'walkable' | 'obstacle';
137
+ pivot: [number, number, number];
138
+ splineCurve: THREE.Curve<THREE.Vector3>;
139
+ splineControlPoint: number;
140
+ _camera: THREE.Camera;
141
+ _particleSystem: ParticleSystem;
142
+ gaussianSplat: { src: string; numSplats: number };
143
+ _animMixer: THREE.AnimationMixer;
144
+ _animClips: Map<string, THREE.AnimationClip>;
145
+ _availableClips: string[];
146
+ _animationRuntime: AnimationRuntimeInspection;
147
+ __sharedGeometry: boolean;
148
+ __shadeOrig: THREE.Material | THREE.Material[];
149
+ __shadeUnlit: THREE.Material[];
150
+ __ingest: boolean;
151
+ __ingestNextId: number;
152
+ authoringRoot: boolean;
153
+ authoringInstance: string;
154
+ authoringLabel: string;
155
+ authoringComponent: string;
156
+ authoringDocument: {
157
+ readonly kind: 'project-tool';
158
+ readonly name: string;
159
+ readonly title: string;
160
+ };
161
+ /** Project-owned identity used to join authored hierarchy rows across adapter roots. */
162
+ authoringHierarchyId: string;
163
+ /** Project-owned parent identity when the authored parent can live on another surface. */
164
+ authoringHierarchyParentId: string;
165
+ /** Source-owned sibling ordinal used when hierarchy rows cross native roots. */
166
+ authoringHierarchyOrder: number;
167
+ editorIcon: boolean;
168
+ envObject: boolean;
169
+ constraints: readonly ConstraintMark[];
170
+ authoringSubject: Object3DAuthoringSubjectMark;
171
+ reflectionProbe: ReflectionProbeMark;
172
+ triggerVolume: TriggerVolumeMark;
173
+ vcDirIdx: number;
174
+ }
175
+
176
+ /** Any key in the typed registry. */
177
+ export type UserDataKey = keyof UserDataSchema;
178
+
179
+ /**
180
+ * Canonical name → literal `userData` string key. The accessor names match the
181
+ * raw string keys 1:1 (this is the documented set referenced by the AC), so the
182
+ * literal strings are owned here or by the included structural mark registry.
183
+ */
184
+ export const UserDataKeys = {
185
+ ...ObjectMarkKeys,
186
+ entityId: 'entityId',
187
+ forward: 'forward',
188
+ tags: 'tags',
189
+ attributes: 'attributes',
190
+ navRole: 'navRole',
191
+ pivot: 'pivot',
192
+ splineCurve: 'splineCurve',
193
+ splineControlPoint: 'splineControlPoint',
194
+ _camera: '_camera',
195
+ _particleSystem: '_particleSystem',
196
+ gaussianSplat: 'gaussianSplat',
197
+ _animMixer: '_animMixer',
198
+ _animClips: '_animClips',
199
+ _availableClips: '_availableClips',
200
+ _animationRuntime: '_animationRuntime',
201
+ __sharedGeometry: '__sharedGeometry',
202
+ __shadeOrig: '__shadeOrig',
203
+ __shadeUnlit: '__shadeUnlit',
204
+ __ingest: '__ingest',
205
+ __ingestNextId: '__ingestNextId',
206
+ authoringRoot: 'authoringRoot',
207
+ authoringInstance: 'authoringInstance',
208
+ authoringLabel: 'authoringLabel',
209
+ authoringComponent: 'authoringComponent',
210
+ authoringDocument: 'authoringDocument',
211
+ authoringHierarchyId: 'authoringHierarchyId',
212
+ authoringHierarchyParentId: 'authoringHierarchyParentId',
213
+ authoringHierarchyOrder: 'authoringHierarchyOrder',
214
+ editorIcon: 'editorIcon',
215
+ envObject: 'envObject',
216
+ constraints: 'constraints',
217
+ authoringSubject: 'authoringSubject',
218
+ reflectionProbe: 'reflectionProbe',
219
+ triggerVolume: 'triggerVolume',
220
+ vcDirIdx: 'vcDirIdx',
221
+ } as const satisfies Record<UserDataKey, string>;
222
+
223
+ /**
224
+ * Read a typed `userData` value. Returns `undefined` when the key is unset or
225
+ * when `obj` is nullish (so call sites can pass an optionally-resolved object).
226
+ */
227
+ export function getUserData<K extends UserDataKey>(
228
+ obj: THREE.Object3D | null | undefined,
229
+ key: K,
230
+ ): UserDataSchema[K] | undefined {
231
+ return obj ? (obj.userData[UserDataKeys[key]] as UserDataSchema[K] | undefined) : undefined;
232
+ }
233
+
234
+ /** Write a typed `userData` value. */
235
+ export function setUserData<K extends UserDataKey>(
236
+ obj: THREE.Object3D,
237
+ key: K,
238
+ value: UserDataSchema[K],
239
+ ): void {
240
+ obj.userData[UserDataKeys[key]] = value;
241
+ }
242
+
243
+ /** True when `key` is set (not `undefined`) on the object's `userData`. */
244
+ export function hasUserData(obj: THREE.Object3D | null | undefined, key: UserDataKey): boolean {
245
+ return obj ? obj.userData[UserDataKeys[key]] !== undefined : false;
246
+ }
247
+
248
+ /** Remove a `userData` key. */
249
+ export function deleteUserData(obj: THREE.Object3D, key: UserDataKey): void {
250
+ delete obj.userData[UserDataKeys[key]];
251
+ }
package/src/loader.ts ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Shared asset loading infrastructure.
3
+ *
4
+ * Provides a single LoadingManager (for Three.js loaders) and resolveUrl()
5
+ * (for raw fetch calls) so that all engine asset requests go through one
6
+ * configurable URL prefix. Call setAssetPrefix() once at runtime startup.
7
+ *
8
+ * Three.js loaders constructed with `loadingManager` automatically rewrite
9
+ * URLs via setURLModifier(). For raw fetch() calls, wrap the URL with
10
+ * resolveUrl() to apply the same prefix.
11
+ */
12
+
13
+ import * as THREE from 'three';
14
+ import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
15
+ import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
16
+ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
17
+ import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
18
+
19
+ let _prefix = '/';
20
+
21
+ function rewriteUrl(url: string): string {
22
+ if (
23
+ url.startsWith('/') ||
24
+ url.startsWith('http') ||
25
+ url.startsWith('data:') ||
26
+ url.startsWith('blob:')
27
+ ) {
28
+ return url;
29
+ }
30
+ return _prefix + url;
31
+ }
32
+
33
+ /** Shared Three.js LoadingManager — all engine loaders should use this. */
34
+ export const loadingManager = new THREE.LoadingManager();
35
+ loadingManager.setURLModifier(rewriteUrl);
36
+
37
+ /** Pre-configured TextureLoader using the shared manager. */
38
+ export const textureLoader = new THREE.TextureLoader(loadingManager);
39
+
40
+ /**
41
+ * Shared DRACOLoader for decoding DRACO-compressed GLTF/GLB meshes.
42
+ *
43
+ * The decoder wasm/js is vendored under
44
+ * packages/editor/template/public/jsm/libs/draco/gltf/ and served at the
45
+ * absolute runtime path '/jsm/libs/draco/gltf/' (DRACOLoader appends
46
+ * draco_wasm_wrapper.js / draco_decoder.wasm to this path). This is an
47
+ * absolute path, so it deliberately does NOT go through the asset prefix —
48
+ * the decoder is engine infrastructure, not a scene asset.
49
+ */
50
+ export const dracoLoader = new DRACOLoader(loadingManager);
51
+ dracoLoader.setDecoderPath('/jsm/libs/draco/gltf/');
52
+
53
+ /**
54
+ * Shared KTX2Loader for decoding KHR_texture_basisu (KTX2/Basis) textures.
55
+ *
56
+ * Vendored and served EXACTLY like the DRACO decoder above — the Basis
57
+ * transcoder js/wasm live in repo-root `public/jsm/libs/basis/` (the editor)
58
+ * and `packages/editor/template/public/jsm/libs/basis/` (a scaffolded game's
59
+ * own build), pinned in `vendor/upstream-assets.lock.json`, and KTX2Loader
60
+ * appends `basis_transcoder.js` / `.wasm` to this absolute path.
61
+ *
62
+ * Without this wiring, the Asset Budget's "Compress textures · KTX2" output
63
+ * is a GLB nothing in the engine can load — which is why the op and this
64
+ * loader landed together.
65
+ */
66
+ export const ktx2Loader = new KTX2Loader(loadingManager);
67
+ ktx2Loader.setTranscoderPath('/jsm/libs/basis/');
68
+
69
+ let ktx2SupportDetected = false;
70
+
71
+ /**
72
+ * Teach the shared KTX2Loader which compressed formats THIS GPU accepts.
73
+ *
74
+ * KTX2Loader refuses to transcode until it has seen a renderer, and the
75
+ * answer is a property of the page's GPU rather than of any one renderer —
76
+ * so the FIRST renderer to exist supplies it and every later one is a no-op.
77
+ * Called by the engine's own renderer setup (`setup/setup-renderer.ts`) and
78
+ * by the editor's viewport, which between them cover every surface that
79
+ * parses a GLB.
80
+ *
81
+ * This sits on the MOUNT path, so it must never throw for a renderer it
82
+ * cannot question. `detectSupport` reads `renderer.extensions.has/get` (or
83
+ * `renderer.hasFeature` on a WebGPU renderer) — surface a headless stand-in
84
+ * (the test harnesses' fake renderers, any non-WebGL host) does not have.
85
+ * Such a renderer is skipped WITHOUT latching, so the first renderer that
86
+ * can actually answer still configures the loader for the whole page.
87
+ */
88
+ export function detectKtx2Support(renderer: THREE.WebGLRenderer): void {
89
+ if (ktx2SupportDetected) return;
90
+ const probe = renderer as unknown as
91
+ | {
92
+ isWebGPURenderer?: boolean;
93
+ hasFeature?: unknown;
94
+ extensions?: { has?: unknown; get?: unknown };
95
+ }
96
+ | null
97
+ | undefined;
98
+ const canAnswer =
99
+ probe != null &&
100
+ (probe.isWebGPURenderer === true
101
+ ? typeof probe.hasFeature === 'function'
102
+ : typeof probe.extensions?.has === 'function' && typeof probe.extensions?.get === 'function');
103
+ if (!canAnswer) return;
104
+ ktx2Loader.detectSupport(renderer);
105
+ ktx2SupportDetected = true;
106
+ }
107
+
108
+ /** Pre-configured GLTFLoader using the shared manager, with DRACO decoding wired in. */
109
+ export const gltfLoader = new GLTFLoader(loadingManager);
110
+ gltfLoader.setDRACOLoader(dracoLoader);
111
+ gltfLoader.setKTX2Loader(ktx2Loader);
112
+ // EXT_meshopt_compression decoding (three's own bundled decoder — inline
113
+ // wasm, no fetch). Without this, a meshopt-compressed GLB — including the
114
+ // output of the editor's Asset Budget "Compress · meshopt" action (W4a M3) —
115
+ // throws at load time in every runtime and the editor viewport. Symmetric
116
+ // with the DRACO wiring above (CB2): the ONE shared loader owns codec setup.
117
+ gltfLoader.setMeshoptDecoder(MeshoptDecoder);
118
+
119
+ /**
120
+ * Set the URL prefix prepended to relative asset paths.
121
+ * Called once by createGameRuntime(). Defaults to '/'.
122
+ */
123
+ export function setAssetPrefix(prefix: string): void {
124
+ _prefix = prefix.endsWith('/') ? prefix : prefix ? `${prefix}/` : '/';
125
+ }
126
+
127
+ /**
128
+ * Resolve a relative asset URL using the current prefix.
129
+ * Use this for raw fetch() calls — Three.js loaders using
130
+ * `loadingManager` handle this automatically.
131
+ */
132
+ export function resolveUrl(url: string): string {
133
+ return rewriteUrl(url);
134
+ }
@@ -0,0 +1,92 @@
1
+ import * as THREE from 'three';
2
+
3
+ /**
4
+ * The neutral matcap sphere, DRAWN rather than shipped.
5
+ *
6
+ * A matcap is just a picture of a lit sphere sampled by view-space normal, so
7
+ * there is nothing to vendor: the same three numbers a shader would use
8
+ * (key, fill, rim) evaluated once per texel produce the image. Keeping it
9
+ * procedural means the diagnostic look has no binary asset, no license, no
10
+ * provenance row, and no way to go missing from a build.
11
+ *
12
+ * The look is deliberately CLAY: one warm key from the upper left, a cool
13
+ * fill from the lower right, a tight rim, and a broad soft highlight — the
14
+ * neutral sculpting material whose whole job is to let form read without
15
+ * colour, texture or authored lighting getting a vote.
16
+ */
17
+
18
+ const SIZE = 256;
19
+
20
+ /** sRGB transfer curve — the canvas holds display-referred bytes. */
21
+ function encode(value: number): number {
22
+ const c = Math.min(1, Math.max(0, value));
23
+ return c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055;
24
+ }
25
+
26
+ function normalize(x: number, y: number, z: number): [number, number, number] {
27
+ const length = Math.hypot(x, y, z) || 1;
28
+ return [x / length, y / length, z / length];
29
+ }
30
+
31
+ export function drawNeutralMatcap(size = SIZE): HTMLCanvasElement {
32
+ const canvas = document.createElement('canvas');
33
+ canvas.width = size;
34
+ canvas.height = size;
35
+ const context = canvas.getContext('2d');
36
+ if (!context) return canvas;
37
+ const image = context.createImageData(size, size);
38
+
39
+ const key = normalize(-0.45, 0.62, 0.64);
40
+ const fill = normalize(0.65, -0.35, 0.5);
41
+ // View direction is +Z for a matcap: the sphere is drawn facing the camera.
42
+ const half = normalize(key[0], key[1], key[2] + 1);
43
+ const base = [0.7, 0.69, 0.68];
44
+ const fillColor = [0.34, 0.4, 0.52];
45
+ const rimColor = [0.85, 0.88, 1];
46
+
47
+ for (let py = 0; py < size; py++) {
48
+ for (let px = 0; px < size; px++) {
49
+ const nx = (px + 0.5) / size / 0.5 - 1;
50
+ const ny = 1 - (py + 0.5) / size / 0.5;
51
+ const r2 = nx * nx + ny * ny;
52
+ // Outside the unit disc a matcap is never sampled by a facing surface,
53
+ // but bilinear filtering reaches one texel past the silhouette. Clamp
54
+ // to the rim normal so the edge stays the rim colour instead of
55
+ // bleeding whatever happened to be there.
56
+ const clamped = r2 > 1;
57
+ const scale = clamped ? 1 / Math.sqrt(r2) : 1;
58
+ const x = nx * scale;
59
+ const y = ny * scale;
60
+ const z = clamped ? 0 : Math.sqrt(Math.max(0, 1 - r2));
61
+
62
+ const diffuse = Math.max(0, x * key[0] + y * key[1] + z * key[2]);
63
+ const fillTerm = Math.max(0, x * fill[0] + y * fill[1] + z * fill[2]);
64
+ const specular = Math.max(0, x * half[0] + y * half[1] + z * half[2]) ** 42 * 0.5;
65
+ const rim = (1 - z) ** 3.2 * 0.4;
66
+
67
+ const offset = (py * size + px) * 4;
68
+ for (let channel = 0; channel < 3; channel++) {
69
+ const lit =
70
+ base[channel]! * (0.16 + 0.8 * diffuse) +
71
+ fillColor[channel]! * 0.3 * fillTerm +
72
+ rimColor[channel]! * rim +
73
+ specular;
74
+ image.data[offset + channel] = Math.round(encode(lit) * 255);
75
+ }
76
+ image.data[offset + 3] = 255;
77
+ }
78
+ }
79
+ context.putImageData(image, 0, 0);
80
+ return canvas;
81
+ }
82
+
83
+ /** The drawn sphere as a ready-to-sample texture. Callers own disposal. */
84
+ export function createNeutralMatcapTexture(size = SIZE): THREE.CanvasTexture {
85
+ const texture = new THREE.CanvasTexture(drawNeutralMatcap(size));
86
+ texture.colorSpace = THREE.SRGBColorSpace;
87
+ texture.minFilter = THREE.LinearFilter;
88
+ texture.magFilter = THREE.LinearFilter;
89
+ texture.generateMipmaps = false;
90
+ texture.needsUpdate = true;
91
+ return texture;
92
+ }
@@ -0,0 +1,64 @@
1
+ import type { SparkRenderer } from '@sparkjsdev/spark';
2
+ import type * as THREE from 'three';
3
+ import { hasUserData } from '../ecs/user-data';
4
+
5
+ const IDLE_POLL_MS = 16;
6
+ const MAX_IDLE_WAIT_MS = 3_000;
7
+ export const SPARK_DISCOVERY_INTERVAL_MS = 500;
8
+
9
+ /** Gate fallback discovery so ordinary non-splat scenes are never traversed every frame. */
10
+ export function shouldDiscoverGaussianSplat(lastCheckAt: number, now: number): boolean {
11
+ return now - lastCheckAt >= SPARK_DISCOVERY_INTERVAL_MS;
12
+ }
13
+
14
+ /** True when a native scene currently contains at least one Gaussian payload. */
15
+ export function sceneHasGaussianSplat(scene: THREE.Object3D): boolean {
16
+ let found = false;
17
+ scene.traverse((object) => {
18
+ if (!found && hasUserData(object, 'gaussianSplat')) found = true;
19
+ });
20
+ return found;
21
+ }
22
+
23
+ /**
24
+ * Stop and dispose Spark after its asynchronous depth-sort worker is idle.
25
+ *
26
+ * SparkRenderer.dispose() rejects any worker calls still in flight. Spark's
27
+ * automatic update path intentionally does not await those calls, so disposing
28
+ * synchronously during an editor stop otherwise produces an unhandled
29
+ * `Worker terminate` rejection. Disable future work immediately, then release
30
+ * the renderer once the current sort has naturally settled.
31
+ */
32
+ export function disposeSparkRendererWhenIdle(renderer: SparkRenderer): void {
33
+ renderer.autoUpdate = false;
34
+ renderer.sortDirty = false;
35
+
36
+ if (renderer.updateTimeoutId !== -1) {
37
+ clearTimeout(renderer.updateTimeoutId);
38
+ renderer.updateTimeoutId = -1;
39
+ }
40
+ if (renderer.sortTimeoutId !== -1) {
41
+ clearTimeout(renderer.sortTimeoutId);
42
+ renderer.sortTimeoutId = -1;
43
+ }
44
+
45
+ let remainingIdleWaitMs = MAX_IDLE_WAIT_MS;
46
+ const disposeWhenIdle = (): void => {
47
+ if (renderer.sorting) {
48
+ if (remainingIdleWaitMs > 0) {
49
+ remainingIdleWaitMs -= IDLE_POLL_MS;
50
+ setTimeout(disposeWhenIdle, IDLE_POLL_MS);
51
+ } else {
52
+ // A stuck upstream worker is safer to abandon than to turn a routine
53
+ // editor stop into an unhandled rejection. Page teardown will reclaim
54
+ // the worker; normal sorts complete in a few milliseconds.
55
+ // biome-ignore lint/suspicious/noConsole: This rare upstream worker leak must degrade loudly.
56
+ console.warn('SparkRenderer did not become idle; skipped worker termination.');
57
+ }
58
+ return;
59
+ }
60
+ renderer.dispose();
61
+ };
62
+
63
+ disposeWhenIdle();
64
+ }