@volter/editor-threejs 0.5.65 → 0.5.67

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.
Files changed (112) hide show
  1. package/NOTICE +2 -0
  2. package/contributions/animation-mixers.service.ts +20 -0
  3. package/contributions/animation-timeline.utility.tsx +44 -0
  4. package/contributions/three-integration.service.ts +13 -0
  5. package/dist-node/serving.mjs +405 -0
  6. package/package.json +113 -5
  7. package/serving/animation-live-module.ts +70 -0
  8. package/serving/animation-stamp.ts +88 -0
  9. package/serving/index.ts +14 -0
  10. package/serving/model-import-conversion.ts +344 -0
  11. package/src/adapter/ingest/scene-capture.ts +1 -23
  12. package/src/adapter/renderer-config.ts +3 -4
  13. package/src/adapter/three-contract.ts +72 -0
  14. package/src/animation/live-mixers.ts +55 -0
  15. package/src/ecs/object-marks.ts +1 -1
  16. package/src/ecs/user-data.ts +0 -16
  17. package/src/host-hierarchy-objects.ts +31 -0
  18. package/src/kit/animation/three-clips-subject.ts +190 -0
  19. package/src/kit/asset-compare.ts +294 -0
  20. package/src/kit/asset-preview-command.ts +265 -0
  21. package/src/kit/asset-preview-framing.ts +357 -0
  22. package/src/kit/asset-preview.ts +2802 -0
  23. package/src/kit/asset-workflow/model-inspection.ts +830 -0
  24. package/src/kit/authoring/component-instance-root.ts +171 -0
  25. package/src/kit/authoring/design-time-settle.ts +343 -0
  26. package/src/kit/authoring/live-object-transform.ts +62 -0
  27. package/src/kit/authoring/object3d-document-session-registry.ts +154 -0
  28. package/src/kit/authoring/object3d-document-session.ts +1965 -0
  29. package/src/kit/authoring/object3d-gesture-controller.ts +113 -0
  30. package/src/kit/authoring/quarks-particle-systems.ts +19 -0
  31. package/src/kit/authoring/shell-viewport-policy.ts +48 -0
  32. package/src/kit/authoring/source-object3d-authoring-adapter.ts +526 -0
  33. package/src/kit/authoring/three-projection-core.ts +226 -0
  34. package/src/kit/authoring/viewport-pick-context.ts +39 -0
  35. package/src/kit/authoring/viewport-raycast.ts +240 -0
  36. package/src/kit/authoring/world-hidden-viewport.ts +95 -0
  37. package/src/kit/camera-authoring.ts +175 -0
  38. package/src/kit/components/CameraInfo.tsx +56 -0
  39. package/src/kit/components/InspectorObjectPreview.tsx +57 -0
  40. package/src/kit/components/Object3DDocumentToolbar.tsx +549 -0
  41. package/src/kit/components/Object3DDocumentViewport.tsx +58 -0
  42. package/src/kit/components/StageHost.tsx +2547 -0
  43. package/src/kit/components/StageOverlays.tsx +21 -0
  44. package/src/kit/components/StatsOverlay.tsx +78 -0
  45. package/src/kit/components/ToolObject3DPreview.tsx +39 -0
  46. package/src/kit/components/ViewportFurniture.tsx +655 -0
  47. package/src/kit/components/ViewportOverlay.tsx +215 -0
  48. package/src/kit/components/ViewportShadingMenu.tsx +340 -0
  49. package/src/kit/components/ViewportViewMenu.tsx +155 -0
  50. package/src/kit/components/asset-viewers/EntityModelDocument.tsx +121 -0
  51. package/src/kit/components/asset-viewers/EnvironmentAssetDocument.tsx +440 -0
  52. package/src/kit/components/asset-viewers/LiveModuleDocument.tsx +395 -0
  53. package/src/kit/components/asset-viewers/LutAssetDocument.tsx +444 -0
  54. package/src/kit/components/asset-viewers/ModelAssetDocument.tsx +105 -0
  55. package/src/kit/components/asset-viewers/Object3DPreview.tsx +356 -0
  56. package/src/kit/components/asset-viewers/QuarksAssetDocument.tsx +527 -0
  57. package/src/kit/components/asset-viewers/ShaderAssetDocument.tsx +743 -0
  58. package/src/kit/components/asset-viewers/three-asset-viewers.tsx +132 -0
  59. package/src/kit/components/object3d-contribution-surfaces.tsx +33 -0
  60. package/src/kit/components/stage-keyboard.tsx +40 -0
  61. package/src/kit/components/stage-overlay-set.tsx +105 -0
  62. package/src/kit/components/stage-presence-markers.ts +482 -0
  63. package/src/kit/components/stage-transform-chrome.ts +30 -0
  64. package/src/kit/components/stage-transform-tools.tsx +73 -0
  65. package/src/kit/components/stage-view-name.ts +30 -0
  66. package/src/kit/components/standard-viewport-dressing.ts +1042 -0
  67. package/src/kit/components/world-root-binding.ts +64 -0
  68. package/src/kit/constraint-helper.ts +338 -0
  69. package/src/kit/editor-shell-store.ts +814 -0
  70. package/src/kit/editor-viewport.ts +6621 -0
  71. package/src/kit/entity-lod.ts +31 -0
  72. package/src/kit/entity-object.ts +92 -0
  73. package/src/kit/hierarchy-mark-reader.ts +74 -0
  74. package/src/kit/instanced-presentation.ts +164 -0
  75. package/src/kit/live-module-source.ts +230 -0
  76. package/src/kit/model-thumbnail.ts +539 -0
  77. package/src/kit/play-camera-flight.ts +300 -0
  78. package/src/kit/projection/three.ts +898 -0
  79. package/src/kit/reflection-probe-helper.ts +142 -0
  80. package/src/kit/scene-document-viewport.ts +51 -0
  81. package/src/kit/scene-framing.ts +315 -0
  82. package/src/kit/scene-view-fog.ts +89 -0
  83. package/src/kit/spatial-handle-visuals.ts +332 -0
  84. package/src/kit/stories/three-story-model.ts +66 -0
  85. package/src/kit/three-canvas-render.ts +44 -0
  86. package/src/kit/three-hierarchy-row-media.ts +26 -0
  87. package/src/kit/three-inspection-media.ts +73 -0
  88. package/src/kit/three-integration.ts +86 -0
  89. package/src/kit/three-state.ts +33 -0
  90. package/src/kit/three-viewport/bone-selection-highlight.ts +119 -0
  91. package/src/kit/three-viewport/camera-fit.ts +41 -0
  92. package/src/kit/three-viewport/interactive-renderer.ts +132 -0
  93. package/src/kit/three-viewport/selection-brackets.ts +355 -0
  94. package/src/kit/three-viewport/selection-outline.ts +333 -0
  95. package/src/kit/three-viewport/skeleton-helper.ts +61 -0
  96. package/src/kit/three-viewport/source-color.ts +197 -0
  97. package/src/kit/three-viewport/studio-environment.ts +96 -0
  98. package/src/kit/trigger-volume-helper.ts +116 -0
  99. package/src/kit/viewport-actions.ts +128 -0
  100. package/src/kit/viewport-authoring-policy.ts +154 -0
  101. package/src/kit/viewport-commands.ts +318 -0
  102. package/src/kit/viewport-hotkeys.ts +119 -0
  103. package/src/kit/viewport-shading-boundary.ts +12 -0
  104. package/src/kit/viewport-status-facet.ts +53 -0
  105. package/src/object3d-contributions.ts +494 -0
  106. package/src/render/viewport-shading.ts +6 -2
  107. package/src/viewport/content-bounds.ts +38 -4
  108. package/src/viewport/environment.ts +16 -0
  109. package/src/viewport-api.ts +92 -0
  110. package/src/viewport-door.ts +237 -0
  111. package/src/animation/animation-clock.ts +0 -479
  112. package/src/animation/runtime-inspection.ts +0 -45
@@ -0,0 +1,527 @@
1
+ import type { ToolObject3DDocumentAuthoringFactory } from '../../../object3d-contributions';
2
+ import { themeVars } from '@volter/editor-sdk/widgets';
3
+ import type {
4
+ AuthoringAdapter,
5
+ InspectorProvider,
6
+ PropertyDescriptor,
7
+ } from '@volter/editor-project/adapter';
8
+ import { resolveUrl } from '@volter/editor-threejs/loader';
9
+ import {
10
+ ColorGeneratorFromJSON,
11
+ type FunctionJSON,
12
+ GeneratorFromJSON,
13
+ ValueGeneratorFromJSON,
14
+ } from 'quarks.core';
15
+ import { useCallback, useEffect, useMemo, useState } from 'react';
16
+ import type * as THREE from 'three';
17
+ import { type ParticleSystem, QuarksLoader } from 'three.quarks';
18
+ import { quarksParticleSystems } from '../../authoring/quarks-particle-systems';
19
+ import { projectOutputPath } from '@volter/editor-sdk/kit/project-provenance';
20
+ import { Object3DDocumentViewport } from '../StageHost';
21
+ import { threeObject } from '../../../adapter/three-contract';
22
+
23
+ type JsonObject = Record<string, unknown>;
24
+
25
+ interface QuarksObjectNode extends JsonObject {
26
+ uuid?: string;
27
+ type?: string;
28
+ ps?: JsonObject;
29
+ children?: QuarksObjectNode[];
30
+ }
31
+
32
+ export interface NativeQuarksJsonSource {
33
+ readonly text: string;
34
+ readonly json: JsonObject;
35
+ readonly projectPath: string;
36
+ }
37
+
38
+ interface QuarksEmitterArtifact {
39
+ readonly system: ParticleSystem;
40
+ readonly json: QuarksObjectNode;
41
+ }
42
+
43
+ interface QuarksAssetArtifact {
44
+ readonly root: THREE.Object3D;
45
+ readonly originalText: string;
46
+ readonly json: JsonObject;
47
+ readonly emitters: ReadonlyMap<THREE.Object3D, QuarksEmitterArtifact>;
48
+ dirty: boolean;
49
+ }
50
+
51
+ const artifacts = new WeakMap<THREE.Object3D, QuarksAssetArtifact>();
52
+
53
+ function isObject(value: unknown): value is JsonObject {
54
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
55
+ }
56
+
57
+ function objectNode(value: unknown): QuarksObjectNode | null {
58
+ return isObject(value) ? (value as QuarksObjectNode) : null;
59
+ }
60
+
61
+ function walkObjectJson(node: QuarksObjectNode, visit: (node: QuarksObjectNode) => void): void {
62
+ visit(node);
63
+ for (const child of node.children ?? []) walkObjectJson(child, visit);
64
+ }
65
+
66
+ /** Recognize the library's own Object3D JSON structurally, independent of a
67
+ * filename convention. Quarks does not define a special file extension. */
68
+ export function isNativeQuarksJson(value: unknown): value is JsonObject {
69
+ if (!isObject(value)) return false;
70
+ const root = objectNode(value['object']);
71
+ if (!root) return false;
72
+ let emitter = false;
73
+ walkObjectJson(root, (node) => {
74
+ if (node.type === 'ParticleEmitter' && isObject(node.ps) && node.ps['version'] !== undefined) {
75
+ emitter = true;
76
+ }
77
+ });
78
+ return emitter;
79
+ }
80
+
81
+ function jsonNodesByUuid(json: JsonObject): Map<string, QuarksObjectNode> {
82
+ const nodes = new Map<string, QuarksObjectNode>();
83
+ const root = objectNode(json['object']);
84
+ if (!root) return nodes;
85
+ walkObjectJson(root, (node) => {
86
+ if (typeof node.uuid === 'string') nodes.set(node.uuid, node);
87
+ });
88
+ return nodes;
89
+ }
90
+
91
+ function buildQuarksArtifact(source: NativeQuarksJsonSource): QuarksAssetArtifact {
92
+ const root = new QuarksLoader().parse<THREE.Object3D>(source.json);
93
+ const jsonByUuid = jsonNodesByUuid(source.json);
94
+ const systems = quarksParticleSystems(root);
95
+ const systemByEmitter = new Map(
96
+ systems.map((system) => [system.emitter as THREE.Object3D, system]),
97
+ );
98
+ const emitters = new Map<THREE.Object3D, QuarksEmitterArtifact>();
99
+ root.traverse((object) => {
100
+ const system = systemByEmitter.get(object);
101
+ if (!system) return;
102
+ const json = jsonByUuid.get(object.uuid);
103
+ if (!json?.ps) {
104
+ throw new Error(
105
+ `Native Quarks emitter ${object.name || object.uuid} has no matching JSON node.`,
106
+ );
107
+ }
108
+ emitters.set(object, { system, json });
109
+ });
110
+ if (emitters.size === 0) throw new Error('The Quarks document contains no loadable emitter.');
111
+ const artifact: QuarksAssetArtifact = {
112
+ root,
113
+ originalText: source.text,
114
+ json: source.json,
115
+ emitters,
116
+ dirty: false,
117
+ };
118
+ artifacts.set(root, artifact);
119
+ return artifact;
120
+ }
121
+
122
+ function artifactFor(root: THREE.Object3D): QuarksAssetArtifact {
123
+ const artifact = artifacts.get(root);
124
+ if (!artifact) throw new Error('Object3D is not a native three.quarks JSON document.');
125
+ return artifact;
126
+ }
127
+
128
+ function disposeQuarksArtifact(artifact: QuarksAssetArtifact): void {
129
+ for (const emitter of artifact.emitters.values()) emitter.system.dispose();
130
+ artifacts.delete(artifact.root);
131
+ }
132
+
133
+ function serializeQuarksArtifact(root: THREE.Object3D): string {
134
+ const artifact = artifactFor(root);
135
+ // The initial history fingerprint must be the exact bytes that were opened;
136
+ // formatting is canonicalized only after a real native-field edit.
137
+ return artifact.dirty ? `${JSON.stringify(artifact.json, null, 2)}\n` : artifact.originalText;
138
+ }
139
+
140
+ const NATIVE_PARTICLE_PATHS = new Set([
141
+ 'particles.duration',
142
+ 'particles.looping',
143
+ 'particles.prewarm',
144
+ 'particles.worldSpace',
145
+ 'particles.startLife',
146
+ 'particles.startSpeed',
147
+ 'particles.startRotation',
148
+ 'particles.startSize',
149
+ 'particles.startColor',
150
+ 'particles.emissionOverTime',
151
+ 'particles.emissionOverDistance',
152
+ ]);
153
+
154
+ function particleProperties(): PropertyDescriptor[] {
155
+ return [
156
+ {
157
+ path: 'particles.duration',
158
+ label: 'Duration',
159
+ type: 'number',
160
+ group: 'Emission',
161
+ },
162
+ {
163
+ path: 'particles.looping',
164
+ label: 'Looping',
165
+ type: 'boolean',
166
+ group: 'Emission',
167
+ },
168
+ {
169
+ path: 'particles.prewarm',
170
+ label: 'Prewarm',
171
+ type: 'boolean',
172
+ group: 'Emission',
173
+ },
174
+ {
175
+ path: 'particles.emissionOverTime',
176
+ label: 'Rate over time',
177
+ type: 'json',
178
+ group: 'Emission',
179
+ },
180
+ {
181
+ path: 'particles.emissionOverDistance',
182
+ label: 'Rate over distance',
183
+ type: 'json',
184
+ group: 'Emission',
185
+ },
186
+ {
187
+ path: 'particles.startLife',
188
+ label: 'Lifetime',
189
+ type: 'json',
190
+ group: 'Start',
191
+ },
192
+ {
193
+ path: 'particles.startSpeed',
194
+ label: 'Speed',
195
+ type: 'json',
196
+ group: 'Start',
197
+ },
198
+ {
199
+ path: 'particles.startRotation',
200
+ label: 'Rotation',
201
+ type: 'json',
202
+ group: 'Start',
203
+ },
204
+ {
205
+ path: 'particles.startSize',
206
+ label: 'Size',
207
+ type: 'json',
208
+ group: 'Start',
209
+ },
210
+ {
211
+ path: 'particles.startColor',
212
+ label: 'Color',
213
+ type: 'json',
214
+ group: 'Start',
215
+ },
216
+ {
217
+ path: 'particles.worldSpace',
218
+ label: 'World space',
219
+ type: 'boolean',
220
+ group: 'Simulation',
221
+ },
222
+ {
223
+ path: 'particles.shape',
224
+ label: 'Shape',
225
+ type: 'string',
226
+ readonly: true,
227
+ group: 'Shape',
228
+ },
229
+ {
230
+ path: 'particles.behaviors',
231
+ label: 'Modules',
232
+ type: 'json',
233
+ readonly: true,
234
+ group: 'Behavior',
235
+ },
236
+ {
237
+ path: 'particles.renderMode',
238
+ label: 'Native render mode',
239
+ type: 'number',
240
+ readonly: true,
241
+ group: 'Renderer',
242
+ },
243
+ ];
244
+ }
245
+
246
+ function functionJson(value: unknown, label: string): FunctionJSON {
247
+ if (!isObject(value) || typeof value['type'] !== 'string') {
248
+ throw new Error(`${label} must be a native quarks function JSON object with a type.`);
249
+ }
250
+ return value as FunctionJSON;
251
+ }
252
+
253
+ function particleValue(emitter: QuarksEmitterArtifact, path: string): unknown {
254
+ const { system } = emitter;
255
+ switch (path) {
256
+ case 'particles.duration':
257
+ return system.duration;
258
+ case 'particles.looping':
259
+ return system.looping;
260
+ case 'particles.prewarm':
261
+ return system.prewarm;
262
+ case 'particles.worldSpace':
263
+ return system.worldSpace;
264
+ case 'particles.startLife':
265
+ return system.startLife.toJSON();
266
+ case 'particles.startSpeed':
267
+ return system.startSpeed.toJSON();
268
+ case 'particles.startRotation':
269
+ return system.startRotation.toJSON();
270
+ case 'particles.startSize':
271
+ return system.startSize.toJSON();
272
+ case 'particles.startColor':
273
+ return system.startColor.toJSON();
274
+ case 'particles.emissionOverTime':
275
+ return system.emissionOverTime.toJSON();
276
+ case 'particles.emissionOverDistance':
277
+ return system.emissionOverDistance.toJSON();
278
+ case 'particles.shape':
279
+ return system.emitterShape.type;
280
+ case 'particles.behaviors':
281
+ return system.behaviors.map((behavior) => behavior.type);
282
+ case 'particles.renderMode':
283
+ return system.renderMode;
284
+ default:
285
+ return undefined;
286
+ }
287
+ }
288
+
289
+ function nativeJsonKey(path: string): string {
290
+ return path.slice('particles.'.length);
291
+ }
292
+
293
+ function applyParticleValue(emitter: QuarksEmitterArtifact, path: string, value: unknown): void {
294
+ const { system } = emitter;
295
+ switch (path) {
296
+ case 'particles.duration': {
297
+ const duration = Number(value);
298
+ if (!Number.isFinite(duration) || duration <= 0) {
299
+ throw new Error('Particle duration must be a positive finite number.');
300
+ }
301
+ system.duration = duration;
302
+ break;
303
+ }
304
+ case 'particles.looping':
305
+ system.looping = Boolean(value);
306
+ break;
307
+ case 'particles.prewarm':
308
+ system.prewarm = Boolean(value);
309
+ break;
310
+ case 'particles.worldSpace':
311
+ system.worldSpace = Boolean(value);
312
+ break;
313
+ case 'particles.startLife':
314
+ system.startLife = ValueGeneratorFromJSON(functionJson(value, 'Lifetime'));
315
+ break;
316
+ case 'particles.startSpeed':
317
+ system.startSpeed = ValueGeneratorFromJSON(functionJson(value, 'Speed'));
318
+ break;
319
+ case 'particles.startRotation':
320
+ system.startRotation = GeneratorFromJSON(
321
+ functionJson(value, 'Rotation'),
322
+ ) as typeof system.startRotation;
323
+ break;
324
+ case 'particles.startSize':
325
+ system.startSize = GeneratorFromJSON(functionJson(value, 'Size')) as typeof system.startSize;
326
+ break;
327
+ case 'particles.startColor':
328
+ system.startColor = ColorGeneratorFromJSON(functionJson(value, 'Color'));
329
+ break;
330
+ case 'particles.emissionOverTime':
331
+ system.emissionOverTime = ValueGeneratorFromJSON(functionJson(value, 'Rate over time'));
332
+ break;
333
+ case 'particles.emissionOverDistance':
334
+ system.emissionOverDistance = ValueGeneratorFromJSON(
335
+ functionJson(value, 'Rate over distance'),
336
+ );
337
+ break;
338
+ default:
339
+ throw new Error(`Particle field ${path} is read-only.`);
340
+ }
341
+ emitter.json.ps![nativeJsonKey(path)] = value;
342
+ system.restart();
343
+ }
344
+
345
+ function restoreParticleValue(
346
+ emitter: QuarksEmitterArtifact,
347
+ path: string,
348
+ previousLive: unknown,
349
+ previousJson: unknown,
350
+ ): void {
351
+ applyParticleValue(emitter, path, previousLive);
352
+ emitter.json.ps![nativeJsonKey(path)] = previousJson;
353
+ }
354
+
355
+ function cloneJson(value: unknown): unknown {
356
+ return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
357
+ }
358
+
359
+ const createQuarksAuthoring: ToolObject3DDocumentAuthoringFactory = ({
360
+ root,
361
+ defaultAdapter,
362
+ commit,
363
+ }) => {
364
+ const artifact = artifactFor(root);
365
+ const listeners = new Set<() => void>();
366
+ const notify = () => {
367
+ for (const listener of listeners) listener();
368
+ };
369
+ const emitterFor = (id: string): QuarksEmitterArtifact | null => {
370
+ const object = threeObject(defaultAdapter.hierarchy, id);
371
+ return object ? (artifact.emitters.get(object) ?? null) : null;
372
+ };
373
+ const inspector: InspectorProvider = {
374
+ properties: (id) => [
375
+ ...(defaultAdapter.inspector?.properties(id) ?? []),
376
+ ...(emitterFor(id) ? particleProperties() : []),
377
+ ],
378
+ get: (id, path) => {
379
+ const emitter = emitterFor(id);
380
+ return emitter && path.startsWith('particles.')
381
+ ? particleValue(emitter, path)
382
+ : defaultAdapter.inspector?.get(id, path);
383
+ },
384
+ set: async (id, path, value) => {
385
+ const emitter = emitterFor(id);
386
+ if (!emitter || !NATIVE_PARTICLE_PATHS.has(path)) {
387
+ await defaultAdapter.inspector?.set(id, path, value);
388
+ return;
389
+ }
390
+ const previousLive = particleValue(emitter, path);
391
+ const key = nativeJsonKey(path);
392
+ const previousJson = cloneJson(emitter.json.ps?.[key]);
393
+ const wasDirty = artifact.dirty;
394
+ try {
395
+ applyParticleValue(emitter, path, value);
396
+ artifact.dirty = true;
397
+ notify();
398
+ await commit(`Edit ${emitter.system.emitter.name || 'Particle Emitter'}`);
399
+ } catch (cause) {
400
+ restoreParticleValue(emitter, path, previousLive, previousJson);
401
+ artifact.dirty = wasDirty;
402
+ notify();
403
+ throw cause;
404
+ }
405
+ },
406
+ };
407
+ const adapter: AuthoringAdapter = {
408
+ capabilities: { ...defaultAdapter.capabilities, inspectorFields: true, persist: true },
409
+ provenance: {
410
+ source: 'document',
411
+ label: 'three.quarks JSON',
412
+ detail:
413
+ 'The hierarchy and emitter fields are the native three.quarks Object3D JSON document.',
414
+ },
415
+ hierarchy: defaultAdapter.hierarchy,
416
+ ...(defaultAdapter.selection ? { selection: defaultAdapter.selection } : {}),
417
+ ...(defaultAdapter.transforms ? { transforms: defaultAdapter.transforms } : {}),
418
+ inspector,
419
+ subscribe: (listener) => {
420
+ listeners.add(listener);
421
+ const unsubscribeDefault = defaultAdapter.subscribe?.(listener) ?? (() => {});
422
+ return () => {
423
+ listeners.delete(listener);
424
+ unsubscribeDefault();
425
+ };
426
+ },
427
+ };
428
+ return { adapter, dispose: () => listeners.clear() };
429
+ };
430
+
431
+ export async function loadNativeQuarksJsonSource(
432
+ assetPath: string,
433
+ signal?: AbortSignal,
434
+ ): Promise<NativeQuarksJsonSource | null> {
435
+ const response = await fetch(resolveUrl(assetPath), {
436
+ cache: 'no-store',
437
+ ...(signal ? { signal } : {}),
438
+ });
439
+ if (!response.ok) throw new Error(`Could not load ${assetPath}: HTTP ${response.status}.`);
440
+ const text = await response.text();
441
+ let json: unknown;
442
+ try {
443
+ json = JSON.parse(text);
444
+ } catch {
445
+ return null;
446
+ }
447
+ return isNativeQuarksJson(json)
448
+ ? { text, json, projectPath: projectOutputPath(assetPath) }
449
+ : null;
450
+ }
451
+
452
+ export function QuarksAssetDocument({
453
+ documentId,
454
+ assetPath,
455
+ displayName,
456
+ active,
457
+ source,
458
+ }: {
459
+ readonly documentId: string;
460
+ readonly assetPath: string;
461
+ readonly displayName: string;
462
+ readonly active: boolean;
463
+ readonly source: NativeQuarksJsonSource;
464
+ }) {
465
+ const [artifact, setArtifact] = useState<QuarksAssetArtifact | null>(null);
466
+ const [error, setError] = useState<string | null>(null);
467
+
468
+ useEffect(() => {
469
+ if (!active) {
470
+ setArtifact(null);
471
+ return;
472
+ }
473
+ let built: QuarksAssetArtifact | null = null;
474
+ try {
475
+ built = buildQuarksArtifact(source);
476
+ setArtifact(built);
477
+ setError(null);
478
+ } catch (cause) {
479
+ setError(cause instanceof Error ? cause.message : String(cause));
480
+ }
481
+ return () => {
482
+ if (built) disposeQuarksArtifact(built);
483
+ };
484
+ }, [active, source]);
485
+
486
+ const build = useCallback(() => {
487
+ if (!artifact) throw new Error(`Particle asset ${assetPath} is not loaded.`);
488
+ return { root: artifact.root, dispose() {} };
489
+ }, [artifact, assetPath]);
490
+ const persistence = useMemo(
491
+ () => ({
492
+ label: `Edit ${displayName}`,
493
+ resources: [
494
+ {
495
+ path: source.projectPath,
496
+ contentType: 'application/json',
497
+ serialize: ({ root }: { root: THREE.Object3D }) => serializeQuarksArtifact(root),
498
+ },
499
+ ],
500
+ }),
501
+ [displayName, source.projectPath],
502
+ );
503
+
504
+ if (error)
505
+ return (
506
+ <div role="alert" style={{ padding: 20, color: themeVars.semantic.danger }}>
507
+ {error}
508
+ </div>
509
+ );
510
+ if (!artifact)
511
+ return (
512
+ <div style={{ padding: 20, color: themeVars.content.muted }}>Loading {displayName}…</div>
513
+ );
514
+ return (
515
+ <Object3DDocumentViewport
516
+ documentId={documentId}
517
+ sourcePath={assetPath}
518
+ displayName={displayName}
519
+ build={build}
520
+ active={active}
521
+ assetType="particles"
522
+ persistence={persistence}
523
+ authoring={createQuarksAuthoring}
524
+ dressing={{ grid: true }}
525
+ />
526
+ );
527
+ }