@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,242 @@
1
+ /**
2
+ * Canonical GLTF / texture load + cache layer (A2). (Formerly also hosted the
3
+ * `.animgraph.json` loader — removed by E5; see
4
+ * packages/threejs-runtime/src/animation/xstate-animation-binding.ts.)
5
+ *
6
+ * ONE place that owns the URL-keyed caches and the clone-safety contract, shared
7
+ * by BOTH the engine runtime (scene-loader, create-runtime) AND the editor
8
+ * (entity-factory, scene-sync). Previously this logic was duplicated across six
9
+ * sites with three separate GLTF caches and four GLTFLoader instances, so fixes
10
+ * (DRACO wiring, the `__sharedGeometry` clone-safety tag, skybox handling) did
11
+ * not propagate. Everything funnels through the shared loaders in `../loader`
12
+ * (`gltfLoader` is DRACO-wired — CB2 — and `textureLoader` shares the engine
13
+ * LoadingManager / asset-prefix rewrite).
14
+ *
15
+ * Cache lifetime / P1.8: these caches are intentionally module-level and persist
16
+ * ACROSS hot-reloads / scene switches (they are NOT cleared on every scene load).
17
+ * This is deliberate: (1) it makes repeated Play→Stop→Play cheap, and (2) it
18
+ * underpins the `__sharedGeometry` contract from P0.2 — GLTF clones share the
19
+ * cached source geometry, so clearing mid-session would orphan live clones. The
20
+ * caches are keyed by URL, so loading the SAME asset N times is bounded (one
21
+ * entry per distinct URL, not per load). They are released only on full runtime
22
+ * teardown via {@link clearAssetCaches}.
23
+ */
24
+
25
+ import type { SplatMesh } from '@sparkjsdev/spark';
26
+ import * as THREE from 'three';
27
+ import * as SkeletonUtils from 'three/addons/utils/SkeletonUtils.js';
28
+ import { AssetParseError } from './asset-parse-error';
29
+ import { setUserData } from './ecs/user-data';
30
+ import { gltfLoader, resolveUrl, textureLoader } from './loader';
31
+
32
+ const textureCache = new Map<string, THREE.Texture>();
33
+ const gltfCache = new Map<
34
+ string,
35
+ Promise<{ scene: THREE.Group; animations: THREE.AnimationClip[] }>
36
+ >();
37
+ const splatBytesCache = new Map<string, Promise<Uint8Array>>();
38
+
39
+ /** Load (and cache) a texture via the shared, prefix-aware TextureLoader. */
40
+ export function loadTexture(url: string): THREE.Texture {
41
+ const cached = textureCache.get(url);
42
+ if (cached) return cached;
43
+ const tex = textureLoader.load(url);
44
+ textureCache.set(url, tex);
45
+ return tex;
46
+ }
47
+
48
+ /**
49
+ * Load a GLTF/GLB and return a fresh, disposal-safe clone (+ the source's
50
+ * animation clips). DRACO decoding is handled by the shared `gltfLoader`.
51
+ *
52
+ * Clone-safety contract (P0.2 — load-bearing, tested):
53
+ * `SkeletonUtils.clone` shares geometry AND materials with the cached source.
54
+ * We give each clone its OWN materials (so scene cleanup can dispose them
55
+ * without corrupting the cache) and tag every mesh `userData.__sharedGeometry`
56
+ * so cleanup (engine create-runtime fullCleanup + editor scene-sync
57
+ * disposeObject3D) skips disposing the shared geometry buffers.
58
+ */
59
+ export function loadGLTF(
60
+ url: string,
61
+ ): Promise<{ scene: THREE.Group; animations: THREE.AnimationClip[] }> {
62
+ let cached = gltfCache.get(url);
63
+ if (!cached) {
64
+ cached = new Promise((resolve, reject) => {
65
+ gltfLoader.load(
66
+ url,
67
+ (gltf) => resolve({ scene: gltf.scene, animations: gltf.animations }),
68
+ undefined,
69
+ (err) => reject(err),
70
+ );
71
+ });
72
+ gltfCache.set(url, cached);
73
+ }
74
+ return cached.then((entry) => {
75
+ const scene = SkeletonUtils.clone(entry.scene) as THREE.Group;
76
+ scene.traverse((child) => {
77
+ if (child instanceof THREE.Mesh) {
78
+ child.material = Array.isArray(child.material)
79
+ ? child.material.map((m) => m.clone())
80
+ : child.material.clone();
81
+ setUserData(child, '__sharedGeometry', true);
82
+ }
83
+ });
84
+ return { scene, animations: entry.animations };
85
+ });
86
+ }
87
+
88
+ /**
89
+ * Load one native Gaussian-splat asset as Spark's own Object3D.
90
+ *
91
+ * The URL cache stores immutable source bytes, not a live SplatMesh: every scene
92
+ * entity needs its own transformable/disposable Object3D, while repeated uses of
93
+ * the same SPZ must not download the multi-megabyte source more than once.
94
+ * Spark stays a lazy chunk so games without splats do not pay its bundle cost.
95
+ */
96
+ export async function loadSplat(url: string, providedBytes?: Uint8Array): Promise<SplatMesh> {
97
+ let bytes = splatBytesCache.get(url);
98
+ if (!bytes && providedBytes) {
99
+ bytes = Promise.resolve(providedBytes);
100
+ splatBytesCache.set(url, bytes);
101
+ }
102
+ if (!bytes) {
103
+ bytes = fetch(resolveUrl(url)).then(async (response) => {
104
+ if (!response.ok) {
105
+ throw new Error(
106
+ `Failed to load Gaussian splat: ${url} (${response.status} ${response.statusText})`,
107
+ );
108
+ }
109
+ return new Uint8Array(await response.arrayBuffer());
110
+ });
111
+ splatBytesCache.set(url, bytes);
112
+ }
113
+
114
+ try {
115
+ const [{ SplatMesh }, fileBytes] = await Promise.all([import('@sparkjsdev/spark'), bytes]);
116
+ const mesh = new SplatMesh({ fileBytes, fileName: url, raycastable: true });
117
+ await mesh.initialized;
118
+ setUserData(mesh, 'gaussianSplat', { src: url, numSplats: mesh.numSplats });
119
+ const bounds = mesh.getBoundingBox();
120
+ if (!bounds.isEmpty()) {
121
+ // Three's generic Box3.setFromObject only understands geometry-bearing
122
+ // Object3Ds. Spark intentionally renders without THREE.BufferGeometry,
123
+ // so give editor/runtime framing a hidden, non-pickable bounds proxy.
124
+ // It is infrastructure, never authored hierarchy or rendered content.
125
+ // Include a framing margin. This proxy is consumed by generic editor
126
+ // focus/selection bounds, while inspectors continue to report Spark's
127
+ // exact Gaussian bounds directly.
128
+ const size = bounds.getSize(new THREE.Vector3()).multiplyScalar(1.5);
129
+ const center = bounds.getCenter(new THREE.Vector3());
130
+ const proxy = new THREE.Mesh(new THREE.BoxGeometry(size.x, size.y, size.z));
131
+ proxy.name = '__vgai_splat_bounds';
132
+ proxy.position.copy(center);
133
+ proxy.visible = false;
134
+ proxy.raycast = () => {};
135
+ setUserData(proxy, 'engineInternal', true);
136
+ mesh.add(proxy);
137
+ }
138
+ return mesh;
139
+ } catch (error) {
140
+ // A failed fetch must be retryable after the source is repaired or the
141
+ // network recovers; successful source bytes remain cached across instances.
142
+ splatBytesCache.delete(url);
143
+ throw error;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Resolve a single named node inside an already-loaded glTF scene graph (F4,
149
+ * `mesh.node`). Pure and headlessly testable — no fetch, no cache lookups;
150
+ * callers pass the `scene` they already got from {@link loadGLTF}.
151
+ *
152
+ * The returned node is the SAME object (and shares the SAME geometry) as
153
+ * found in `scene` — nothing is cloned or copied out. `scene` is already a
154
+ * fresh `SkeletonUtils.clone()` per {@link loadGLTF}'s clone-safety contract,
155
+ * so mutating/detaching the returned node is safe.
156
+ *
157
+ * Atomic-subtree guard: a `SkinnedMesh` cannot be lifted out of its armature
158
+ * (its skeleton/bind matrices reference sibling bone nodes elsewhere in the
159
+ * hierarchy), so resolving a node that IS a SkinnedMesh, or that CONTAINS one,
160
+ * throws a loud `AssetParseError` instead of silently producing broken skinning.
161
+ */
162
+ export function resolveGltfNode(
163
+ scene: THREE.Object3D,
164
+ nodeName: string,
165
+ src: string,
166
+ ): THREE.Object3D {
167
+ const node = scene.getObjectByName(nodeName);
168
+ if (!node) {
169
+ throw new AssetParseError(
170
+ [
171
+ {
172
+ code: 'custom',
173
+ path: ['mesh', 'node'],
174
+ message: `glTF "${src}" has no node named "${nodeName}" (getObjectByName found nothing)`,
175
+ },
176
+ ],
177
+ src,
178
+ );
179
+ }
180
+
181
+ const isSkinned = (o: THREE.Object3D) => (o as THREE.SkinnedMesh).isSkinnedMesh;
182
+ let containsSkinned = isSkinned(node);
183
+ if (!containsSkinned) {
184
+ node.traverse((child) => {
185
+ if (isSkinned(child)) containsSkinned = true;
186
+ });
187
+ }
188
+ if (containsSkinned) {
189
+ throw new AssetParseError(
190
+ [
191
+ {
192
+ code: 'custom',
193
+ path: ['mesh', 'node'],
194
+ message:
195
+ `glTF "${src}" node "${nodeName}" is (or contains) a SkinnedMesh — a skinned mesh ` +
196
+ 'cannot be lifted out of its armature (atomic-subtree rule). Reference the whole ' +
197
+ 'file instead of a `node`, or bake/export a non-skinned sub-asset.',
198
+ },
199
+ ],
200
+ src,
201
+ );
202
+ }
203
+
204
+ return node;
205
+ }
206
+
207
+ /**
208
+ * Clear the module-level GLTF / texture caches. Call on full runtime teardown
209
+ * (create-runtime fullCleanup / hot-reload) to release cached GPU resources
210
+ * and avoid leaking across editor Play sessions. See the lifetime note at the
211
+ * top of this file for why these are NOT cleared per scene load.
212
+ *
213
+ * NOTE: the IBL/skybox env-map cache lives in scene-loader (it needs a
214
+ * WebGLRenderer for PMREM); scene-loader's `clearAssetCaches` wraps this and
215
+ * also clears that env cache.
216
+ */
217
+ export function clearAssetCaches(): void {
218
+ textureCache.clear();
219
+ gltfCache.clear();
220
+ splatBytesCache.clear();
221
+ }
222
+
223
+ /**
224
+ * Drop the cached entry for ONE asset URL after its bytes changed on disk
225
+ * (editor asset-optimization write-back — W4a). Existing clones keep their
226
+ * shared geometry alive (the cache entry only owns the lookup, not the
227
+ * buffers), and the next `loadGLTF`/`loadTexture` for this URL re-fetches the
228
+ * rewritten file instead of serving stale bytes.
229
+ */
230
+ export function invalidateCachedAsset(url: string): void {
231
+ textureCache.delete(url);
232
+ gltfCache.delete(url);
233
+ }
234
+
235
+ /**
236
+ * Test-only: current sizes of the module-level caches. Used by the P1.8 headless
237
+ * test to assert caches stay bounded (one entry per distinct asset URL) across
238
+ * repeated loads of the same scene, rather than growing per load.
239
+ */
240
+ export function assetCacheSizes(): { textures: number; gltf: number; splat: number } {
241
+ return { textures: textureCache.size, gltf: gltfCache.size, splat: splatBytesCache.size };
242
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The GENERIC fetched-asset parse error.
3
+ *
4
+ * Every authored asset format the engine validates at fetch time throws this
5
+ * when Zod rejects the payload, naming the offending file (T4.6). It lives
6
+ * here — beside `loader.ts`/`assets.ts` — rather than inside a format module so
7
+ * every caller (currently the input-map loader in `input/input-manager.ts`)
8
+ * does not have to reach into an unrelated subsystem for it.
9
+ *
10
+ * The name says ASSET, not scene: an `.inputmap.json` failure is not a scene
11
+ * failure, and the class covers every structured fetched asset.
12
+ */
13
+
14
+ import type { ZodIssue } from 'zod';
15
+
16
+ export class AssetParseError extends Error {
17
+ readonly issues: ZodIssue[];
18
+ /** The offending file's path/URL, when the caller knows it (T4.6). */
19
+ readonly filePath?: string | undefined;
20
+
21
+ constructor(issues: ZodIssue[], filePath?: string) {
22
+ const msg = issues.map((i) => ` ${i.path.join('.')}: ${i.message}`).join('\n');
23
+ const header = filePath ? `Asset validation failed (${filePath}):` : 'Asset validation failed:';
24
+ super(`${header}\n${msg}`);
25
+ this.name = 'AssetParseError';
26
+ this.issues = issues;
27
+ this.filePath = filePath;
28
+ }
29
+ }
@@ -0,0 +1,36 @@
1
+ /** Shared Three capture mechanics; this entry must not import host or contribution code. */
2
+ import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
3
+
4
+ /** Three renders ordinary offscreen targets in linear space without tone
5
+ * mapping. Both scene and document captures resolve their half-float scene
6
+ * target through this pass before reading display pixels.
7
+ *
8
+ * One pass lives for the page, shared by every capture. Its uniforms/defines
9
+ * follow the supplied renderer on each render; reusing the pass avoids
10
+ * compiling another full-screen program on every screenshot or thumbnail.
11
+ */
12
+ let outputPass: OutputPass | null = null;
13
+ const unassociateAlphaUniform = { value: false };
14
+
15
+ export function viewportCaptureOutputPass(unassociateAlpha = false): OutputPass {
16
+ if (!outputPass) {
17
+ outputPass = new OutputPass();
18
+ Object.assign(outputPass.uniforms, { unassociateAlpha: unassociateAlphaUniform });
19
+ // Render targets contain associated linear RGB; PNG/ImageData require
20
+ // straight display RGB. Resolve alpha before Three's own display transform.
21
+ outputPass.material.fragmentShader = outputPass.material.fragmentShader
22
+ .replace(
23
+ 'uniform sampler2D tDiffuse;',
24
+ 'uniform sampler2D tDiffuse;\nuniform bool unassociateAlpha;',
25
+ )
26
+ .replace(
27
+ 'gl_FragColor = texture2D( tDiffuse, vUv );',
28
+ `gl_FragColor = texture2D( tDiffuse, vUv );
29
+ if (unassociateAlpha && gl_FragColor.a != 0.0) {
30
+ gl_FragColor.rgb /= gl_FragColor.a;
31
+ }`,
32
+ );
33
+ }
34
+ unassociateAlphaUniform.value = unassociateAlpha;
35
+ return outputPass;
36
+ }
@@ -0,0 +1,146 @@
1
+ /** Capture an already isolated scene. The caller owns the scene and camera;
2
+ * this function owns its renderer lease and targets for one synchronous draw. */
3
+ import * as THREE from 'three';
4
+ import { acquireInspectorPreviewRenderer } from '../viewport/preview-renderer';
5
+ import { viewportCaptureOutputPass } from './output-pass';
6
+
7
+ export interface SceneCaptureOptions {
8
+ width: number;
9
+ height: number;
10
+ transparent?: boolean;
11
+ toneMapping?: THREE.ToneMapping;
12
+ exposure?: number;
13
+ }
14
+
15
+ export interface LinearCaptureFrame {
16
+ readonly pixels: Uint16Array;
17
+ readonly width: number;
18
+ readonly height: number;
19
+ }
20
+
21
+ function capture(
22
+ scene: THREE.Scene,
23
+ camera: THREE.Camera,
24
+ options: SceneCaptureOptions,
25
+ linear: true,
26
+ ): LinearCaptureFrame;
27
+ function capture(
28
+ scene: THREE.Scene,
29
+ camera: THREE.Camera,
30
+ options: SceneCaptureOptions,
31
+ linear: false,
32
+ ): string;
33
+ function capture(
34
+ scene: THREE.Scene,
35
+ camera: THREE.Camera,
36
+ options: SceneCaptureOptions,
37
+ linear: boolean,
38
+ ): string | LinearCaptureFrame {
39
+ if (
40
+ !Number.isFinite(options.width) ||
41
+ !Number.isFinite(options.height) ||
42
+ options.width < 1 ||
43
+ options.height < 1
44
+ )
45
+ throw new Error('Scene capture requires finite positive dimensions');
46
+ const width = Math.min(2048, Math.round(options.width));
47
+ const height = Math.min(2048, Math.round(options.height));
48
+ const scale = linear ? 1 : 2;
49
+ const renderWidth = width * scale;
50
+ const renderHeight = height * scale;
51
+ const lease = acquireInspectorPreviewRenderer();
52
+ const renderer = lease.renderer;
53
+ const previous = {
54
+ target: renderer.getRenderTarget(),
55
+ toneMapping: renderer.toneMapping,
56
+ exposure: renderer.toneMappingExposure,
57
+ colorSpace: renderer.outputColorSpace,
58
+ shadows: renderer.shadowMap.enabled,
59
+ shadowType: renderer.shadowMap.type,
60
+ clearColor: renderer.getClearColor(new THREE.Color()),
61
+ clearAlpha: renderer.getClearAlpha(),
62
+ background: scene.background,
63
+ };
64
+ let hdr: THREE.WebGLRenderTarget | undefined;
65
+ let display: THREE.WebGLRenderTarget | undefined;
66
+ let completed = false;
67
+ try {
68
+ hdr = new THREE.WebGLRenderTarget(renderWidth, renderHeight, { type: THREE.HalfFloatType });
69
+ renderer.shadowMap.enabled = true;
70
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
71
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
72
+ renderer.toneMapping = options.toneMapping ?? THREE.NoToneMapping;
73
+ renderer.toneMappingExposure = options.exposure ?? 1;
74
+ renderer.setClearColor(0, options.transparent ? 0 : 1);
75
+ if (options.transparent) scene.background = null;
76
+ renderer.setRenderTarget(hdr);
77
+ renderer.clear();
78
+ renderer.render(scene, camera);
79
+ if (linear) {
80
+ const pixels = new Uint16Array(renderWidth * renderHeight * 4);
81
+ renderer.readRenderTargetPixels(hdr, 0, 0, renderWidth, renderHeight, pixels);
82
+ completed = true;
83
+ return { pixels, width, height };
84
+ }
85
+ display = new THREE.WebGLRenderTarget(renderWidth, renderHeight);
86
+ viewportCaptureOutputPass(options.transparent === true).render(
87
+ renderer,
88
+ display,
89
+ hdr,
90
+ 0,
91
+ false,
92
+ );
93
+ const pixels = new Uint8Array(renderWidth * renderHeight * 4);
94
+ renderer.readRenderTargetPixels(display, 0, 0, renderWidth, renderHeight, pixels);
95
+ const full = document.createElement('canvas');
96
+ full.width = renderWidth;
97
+ full.height = renderHeight;
98
+ const context = full.getContext('2d');
99
+ if (!context) throw new Error('Scene capture could not create an image canvas');
100
+ const image = context.createImageData(renderWidth, renderHeight);
101
+ const stride = renderWidth * 4;
102
+ for (let y = 0; y < renderHeight; y++)
103
+ image.data.set(
104
+ pixels.subarray((renderHeight - 1 - y) * stride, (renderHeight - y) * stride),
105
+ y * stride,
106
+ );
107
+ context.putImageData(image, 0, 0);
108
+ const output = document.createElement('canvas');
109
+ output.width = width;
110
+ output.height = height;
111
+ const outputContext = output.getContext('2d');
112
+ if (!outputContext) throw new Error('Scene capture could not create an output canvas');
113
+ outputContext.drawImage(full, 0, 0, width, height);
114
+ const imageUrl = output.toDataURL('image/png');
115
+ completed = true;
116
+ return imageUrl;
117
+ } finally {
118
+ scene.background = previous.background;
119
+ renderer.setRenderTarget(previous.target);
120
+ renderer.toneMapping = previous.toneMapping;
121
+ renderer.toneMappingExposure = previous.exposure;
122
+ renderer.outputColorSpace = previous.colorSpace;
123
+ renderer.shadowMap.enabled = previous.shadows;
124
+ renderer.shadowMap.type = previous.shadowType;
125
+ renderer.setClearColor(previous.clearColor, previous.clearAlpha);
126
+ display?.dispose();
127
+ hdr?.dispose();
128
+ lease.release({ discard: !completed });
129
+ }
130
+ }
131
+
132
+ export function captureSceneLinear(
133
+ scene: THREE.Scene,
134
+ camera: THREE.Camera,
135
+ options: SceneCaptureOptions,
136
+ ): LinearCaptureFrame {
137
+ return capture(scene, camera, options, true);
138
+ }
139
+
140
+ export function captureSceneImage(
141
+ scene: THREE.Scene,
142
+ camera: THREE.Camera,
143
+ options: SceneCaptureOptions,
144
+ ): string {
145
+ return capture(scene, camera, options, false);
146
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Structural object marks shared by runtime producers and Three editor tools.
3
+ * Keep this contract native and dependency-free except for Three's types: a
4
+ * bounds/layer reader must not load physics, animation or editor contracts.
5
+ * The full user-data registry extends this schema and reuses these same keys.
6
+ */
7
+ import type { Object3D } from 'three';
8
+
9
+ /** The kinds of editor helper objects tagged via `editorHelperType`. */
10
+ export type EditorHelperType =
11
+ | 'lights'
12
+ | 'cameras'
13
+ | 'audio'
14
+ | 'colliders'
15
+ // Joint anchor/axis/limit gizmos (W2a) — distinct from 'colliders' so the
16
+ // viewport's per-type visibility filter can toggle them independently.
17
+ | 'joints'
18
+ // LOD distance rings (W2b) — one ring per `mesh.lod` level, radius = the
19
+ // level's camera-distance threshold in world units.
20
+ | 'lod'
21
+ | 'splines'
22
+ // 'particles' tags the LIVE three.quarks emitter Object3D itself (so the
23
+ // editor's helper machinery skips disposing it); 'particle-shape' tags the
24
+ // editor's emitter-shape WIREFRAME (W1b) — they must stay distinct: sweeping
25
+ // "particles"-tagged children would detach the running emitter, and quarks
26
+ // self-disposes any system whose emitter leaves the scene.
27
+ | 'particles'
28
+ | 'particle-shape'
29
+ | 'pivot'
30
+ | 'navmesh'
31
+ | 'constraints'
32
+ | 'reflection-probes'
33
+ | 'trigger-volumes'
34
+ | 'skeletons'
35
+ // A package's own helper kind, shown through the editor's viewport door
36
+ // (`host.viewport.setHelper`): the host lists the kinds it toggles by name,
37
+ // any other follows the master Helpers toggle.
38
+ | (string & {});
39
+
40
+ export interface ObjectMarkSchema {
41
+ editorHelperType: EditorHelperType;
42
+ skeletonVisible: boolean;
43
+ skeletonEnabled: boolean;
44
+ engineInternal: boolean;
45
+ editorHelper: boolean;
46
+ vgaiComponentRoot: string;
47
+ vgaiBuiltInternal: boolean;
48
+ }
49
+
50
+ export const ObjectMarkKeys = {
51
+ editorHelperType: 'editorHelperType',
52
+ skeletonVisible: 'skeletonVisible',
53
+ skeletonEnabled: 'skeletonEnabled',
54
+ engineInternal: 'engineInternal',
55
+ editorHelper: 'editorHelper',
56
+ vgaiComponentRoot: 'vgaiComponentRoot',
57
+ vgaiBuiltInternal: 'vgaiBuiltInternal',
58
+ } as const satisfies Record<keyof ObjectMarkSchema, string>;
59
+
60
+ export function getObjectMark<K extends keyof ObjectMarkSchema>(
61
+ object: Object3D | null | undefined,
62
+ key: K,
63
+ ): ObjectMarkSchema[K] | undefined {
64
+ return object
65
+ ? (object.userData[ObjectMarkKeys[key]] as ObjectMarkSchema[K] | undefined)
66
+ : undefined;
67
+ }
68
+
69
+ export function setObjectMark<K extends keyof ObjectMarkSchema>(
70
+ object: Object3D,
71
+ key: K,
72
+ value: ObjectMarkSchema[K],
73
+ ): void {
74
+ object.userData[ObjectMarkKeys[key]] = value;
75
+ }