@volter/editor-threejs 0.5.66 → 0.5.68

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 (99) hide show
  1. package/contributions/animation-timeline.utility.tsx +44 -0
  2. package/contributions/three-integration.service.ts +13 -0
  3. package/package.json +96 -6
  4. package/src/adapter/renderer-config.ts +3 -4
  5. package/src/adapter/three-contract.ts +72 -0
  6. package/src/ecs/object-marks.ts +1 -1
  7. package/src/ecs/user-data.ts +0 -9
  8. package/src/host-hierarchy-objects.ts +31 -0
  9. package/src/kit/animation/three-clips-subject.ts +190 -0
  10. package/src/kit/asset-compare.ts +294 -0
  11. package/src/kit/asset-preview-command.ts +265 -0
  12. package/src/kit/asset-preview-framing.ts +357 -0
  13. package/src/kit/asset-preview.ts +2802 -0
  14. package/src/kit/asset-workflow/model-inspection.ts +830 -0
  15. package/src/kit/authoring/component-instance-root.ts +171 -0
  16. package/src/kit/authoring/design-time-settle.ts +343 -0
  17. package/src/kit/authoring/live-object-transform.ts +62 -0
  18. package/src/kit/authoring/object3d-document-session-registry.ts +154 -0
  19. package/src/kit/authoring/object3d-document-session.ts +1965 -0
  20. package/src/kit/authoring/object3d-gesture-controller.ts +113 -0
  21. package/src/kit/authoring/quarks-particle-systems.ts +19 -0
  22. package/src/kit/authoring/shell-viewport-policy.ts +48 -0
  23. package/src/kit/authoring/source-object3d-authoring-adapter.ts +526 -0
  24. package/src/kit/authoring/three-projection-core.ts +226 -0
  25. package/src/kit/authoring/viewport-pick-context.ts +39 -0
  26. package/src/kit/authoring/viewport-raycast.ts +240 -0
  27. package/src/kit/authoring/world-hidden-viewport.ts +95 -0
  28. package/src/kit/camera-authoring.ts +175 -0
  29. package/src/kit/components/CameraInfo.tsx +56 -0
  30. package/src/kit/components/InspectorObjectPreview.tsx +57 -0
  31. package/src/kit/components/Object3DDocumentToolbar.tsx +549 -0
  32. package/src/kit/components/Object3DDocumentViewport.tsx +58 -0
  33. package/src/kit/components/StageHost.tsx +2547 -0
  34. package/src/kit/components/StageOverlays.tsx +21 -0
  35. package/src/kit/components/StatsOverlay.tsx +78 -0
  36. package/src/kit/components/ToolObject3DPreview.tsx +39 -0
  37. package/src/kit/components/ViewportFurniture.tsx +655 -0
  38. package/src/kit/components/ViewportOverlay.tsx +215 -0
  39. package/src/kit/components/ViewportShadingMenu.tsx +340 -0
  40. package/src/kit/components/ViewportViewMenu.tsx +155 -0
  41. package/src/kit/components/asset-viewers/EntityModelDocument.tsx +121 -0
  42. package/src/kit/components/asset-viewers/EnvironmentAssetDocument.tsx +440 -0
  43. package/src/kit/components/asset-viewers/LiveModuleDocument.tsx +395 -0
  44. package/src/kit/components/asset-viewers/LutAssetDocument.tsx +444 -0
  45. package/src/kit/components/asset-viewers/ModelAssetDocument.tsx +105 -0
  46. package/src/kit/components/asset-viewers/Object3DPreview.tsx +356 -0
  47. package/src/kit/components/asset-viewers/QuarksAssetDocument.tsx +527 -0
  48. package/src/kit/components/asset-viewers/ShaderAssetDocument.tsx +743 -0
  49. package/src/kit/components/asset-viewers/three-asset-viewers.tsx +132 -0
  50. package/src/kit/components/object3d-contribution-surfaces.tsx +33 -0
  51. package/src/kit/components/stage-keyboard.tsx +40 -0
  52. package/src/kit/components/stage-overlay-set.tsx +105 -0
  53. package/src/kit/components/stage-presence-markers.ts +482 -0
  54. package/src/kit/components/stage-transform-chrome.ts +30 -0
  55. package/src/kit/components/stage-transform-tools.tsx +73 -0
  56. package/src/kit/components/stage-view-name.ts +30 -0
  57. package/src/kit/components/standard-viewport-dressing.ts +1042 -0
  58. package/src/kit/components/world-root-binding.ts +64 -0
  59. package/src/kit/constraint-helper.ts +338 -0
  60. package/src/kit/editor-shell-store.ts +814 -0
  61. package/src/kit/editor-viewport.ts +6621 -0
  62. package/src/kit/entity-lod.ts +31 -0
  63. package/src/kit/entity-object.ts +92 -0
  64. package/src/kit/hierarchy-mark-reader.ts +74 -0
  65. package/src/kit/instanced-presentation.ts +164 -0
  66. package/src/kit/live-module-source.ts +230 -0
  67. package/src/kit/model-thumbnail.ts +539 -0
  68. package/src/kit/play-camera-flight.ts +300 -0
  69. package/src/kit/projection/three.ts +898 -0
  70. package/src/kit/reflection-probe-helper.ts +142 -0
  71. package/src/kit/scene-document-viewport.ts +51 -0
  72. package/src/kit/scene-framing.ts +315 -0
  73. package/src/kit/scene-view-fog.ts +89 -0
  74. package/src/kit/spatial-handle-visuals.ts +332 -0
  75. package/src/kit/stories/three-story-model.ts +66 -0
  76. package/src/kit/three-canvas-render.ts +44 -0
  77. package/src/kit/three-hierarchy-row-media.ts +26 -0
  78. package/src/kit/three-inspection-media.ts +73 -0
  79. package/src/kit/three-integration.ts +86 -0
  80. package/src/kit/three-state.ts +33 -0
  81. package/src/kit/three-viewport/bone-selection-highlight.ts +119 -0
  82. package/src/kit/three-viewport/camera-fit.ts +41 -0
  83. package/src/kit/three-viewport/interactive-renderer.ts +132 -0
  84. package/src/kit/three-viewport/selection-brackets.ts +355 -0
  85. package/src/kit/three-viewport/selection-outline.ts +333 -0
  86. package/src/kit/three-viewport/skeleton-helper.ts +61 -0
  87. package/src/kit/three-viewport/source-color.ts +197 -0
  88. package/src/kit/three-viewport/studio-environment.ts +96 -0
  89. package/src/kit/trigger-volume-helper.ts +116 -0
  90. package/src/kit/viewport-actions.ts +128 -0
  91. package/src/kit/viewport-authoring-policy.ts +154 -0
  92. package/src/kit/viewport-commands.ts +318 -0
  93. package/src/kit/viewport-hotkeys.ts +119 -0
  94. package/src/kit/viewport-shading-boundary.ts +12 -0
  95. package/src/kit/viewport-status-facet.ts +53 -0
  96. package/src/object3d-contributions.ts +494 -0
  97. package/src/render/viewport-shading.ts +6 -2
  98. package/src/viewport-api.ts +92 -0
  99. package/src/viewport-door.ts +237 -0
@@ -0,0 +1,743 @@
1
+ import { faCode, faSliders, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
2
+ import { Checkbox, ColorInput, NumberInput, Select, themeVars } from '@volter/editor-sdk/widgets';
3
+ import { useEffect, useMemo, useRef, useState } from 'react';
4
+ import * as THREE from 'three';
5
+ import { readProjectTextFile } from '@volter/editor-sdk/kit/editor-api';
6
+ import type { InspectionSection } from '@volter/editor-sdk/kit/inspection-model';
7
+ import { CONTRIBUTED_SECTION_ORDER, PROPERTIES_SECTION_ORDER } from '@volter/editor-sdk/kit/inspection-model';
8
+ import { AssetEditorShell } from '@volter/editor-sdk/kit/components/AssetEditorShell';
9
+ import { subscribeProjectAsset } from '@volter/editor-sdk/kit/components/asset-editor-persistence';
10
+ import {
11
+ FALLBACK_FRAGMENT_SHADER,
12
+ FALLBACK_VERTEX_SHADER,
13
+ parseShaderDiagnostics,
14
+ previewShaderSource,
15
+ reflectShaderUniforms,
16
+ type ShaderDiagnostic,
17
+ type ShaderStage,
18
+ type ShaderUniformDescriptor,
19
+ shaderCompanionPath,
20
+ shaderStageFromPath,
21
+ } from '@volter/editor-sdk/kit/components/asset-viewers/shader-source';
22
+
23
+ type PreviewGeometry = 'sphere' | 'box' | 'plane';
24
+ type UniformPreviewValue = number | boolean | string | number[];
25
+
26
+ interface ShaderFileState {
27
+ readonly path: string | null;
28
+ readonly source: string;
29
+ readonly available: boolean;
30
+ }
31
+
32
+ type ShaderFiles = Record<ShaderStage, ShaderFileState>;
33
+
34
+ function fallbackFile(stage: ShaderStage, path: string | null = null): ShaderFileState {
35
+ const source = stage === 'vertex' ? FALLBACK_VERTEX_SHADER : FALLBACK_FRAGMENT_SHADER;
36
+ return { path, source, available: false };
37
+ }
38
+
39
+ function basename(path: string | null): string {
40
+ return path?.split('/').pop() ?? 'Preview fallback';
41
+ }
42
+
43
+ function isColorUniform(uniform: ShaderUniformDescriptor): boolean {
44
+ return uniform.type === 'vec3' && /(colou?r|tint|albedo|emissive)/i.test(uniform.name);
45
+ }
46
+
47
+ function automaticUniform(
48
+ uniform: ShaderUniformDescriptor,
49
+ ): 'time' | 'resolution' | 'mouse' | null {
50
+ if (/^(u|i)?time$/i.test(uniform.name) && /^(float|double)$/.test(uniform.type)) return 'time';
51
+ if (/^(u|i)?resolution$/i.test(uniform.name) && /^([diu]?vec)[234]$/.test(uniform.type))
52
+ return 'resolution';
53
+ if (/^(u|i)?mouse$/i.test(uniform.name) && /^([diu]?vec)[234]$/.test(uniform.type))
54
+ return 'mouse';
55
+ return null;
56
+ }
57
+
58
+ function defaultUniformValue(uniform: ShaderUniformDescriptor): UniformPreviewValue {
59
+ if (isColorUniform(uniform)) {
60
+ if (/horizon/i.test(uniform.name)) return '#badbe9';
61
+ if (/zenith/i.test(uniform.name)) return '#297aba';
62
+ if (/sun/i.test(uniform.name)) return '#fff0c2';
63
+ return '#6f8cff';
64
+ }
65
+ if (uniform.type === 'bool') return false;
66
+ if (/^(float|double|int|uint)$/.test(uniform.type)) return 0;
67
+ const vector = uniform.type.match(/^(?:[biu]?vec)([234])$/);
68
+ if (vector) return Array.from({ length: Number.parseInt(vector[1]!, 10) }, () => 0);
69
+ return 0;
70
+ }
71
+
72
+ function createCheckerTexture(): THREE.DataTexture {
73
+ const bytes = new Uint8Array([
74
+ 38, 43, 58, 255, 176, 190, 255, 255, 176, 190, 255, 255, 38, 43, 58, 255,
75
+ ]);
76
+ const texture = new THREE.DataTexture(bytes, 2, 2, THREE.RGBAFormat);
77
+ texture.colorSpace = THREE.SRGBColorSpace;
78
+ texture.magFilter = THREE.NearestFilter;
79
+ texture.needsUpdate = true;
80
+ return texture;
81
+ }
82
+
83
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: this is a bounded dispatcher from native GLSL uniform types to their native Three.js preview values.
84
+ function createThreeUniformValue(
85
+ uniform: ShaderUniformDescriptor,
86
+ value: UniformPreviewValue,
87
+ ownedTextures: THREE.Texture[],
88
+ ): unknown {
89
+ if (uniform.arraySize) {
90
+ const { arraySize, ...element } = uniform;
91
+ return Array.from({ length: arraySize }, () =>
92
+ createThreeUniformValue(element, value, ownedTextures),
93
+ );
94
+ }
95
+ if (/^sampler2D/.test(uniform.type)) {
96
+ const texture = createCheckerTexture();
97
+ ownedTextures.push(texture);
98
+ return texture;
99
+ }
100
+ if (/^sampler/.test(uniform.type)) return null;
101
+ if (uniform.type === 'mat3') return new THREE.Matrix3();
102
+ if (uniform.type === 'mat4') return new THREE.Matrix4();
103
+ if (isColorUniform(uniform))
104
+ return new THREE.Color(typeof value === 'string' ? value : '#6f8cff');
105
+ const vector = uniform.type.match(/^(?:[biu]?vec)([234])$/);
106
+ if (vector) {
107
+ const values = Array.isArray(value) ? value : [];
108
+ const count = Number.parseInt(vector[1]!, 10);
109
+ if (count === 2) return new THREE.Vector2(values[0] ?? 0, values[1] ?? 0);
110
+ if (count === 3) return new THREE.Vector3(values[0] ?? 0, values[1] ?? 0, values[2] ?? 0);
111
+ return new THREE.Vector4(values[0] ?? 0, values[1] ?? 0, values[2] ?? 0, values[3] ?? 0);
112
+ }
113
+ return value;
114
+ }
115
+
116
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one bounded native-uniform dispatcher keeps automatic inputs and mutable Three.js value types visible in one place.
117
+ function updateThreeUniformValue(
118
+ target: { value: unknown },
119
+ descriptor: ShaderUniformDescriptor,
120
+ previewValue: UniformPreviewValue,
121
+ elapsed: number,
122
+ size: { width: number; height: number },
123
+ ): void {
124
+ const automatic = automaticUniform(descriptor);
125
+ if (automatic === 'time') {
126
+ target.value = elapsed;
127
+ return;
128
+ }
129
+ if (target.value instanceof THREE.Color && typeof previewValue === 'string') {
130
+ target.value.set(previewValue);
131
+ return;
132
+ }
133
+ const values = Array.isArray(previewValue) ? previewValue : [];
134
+ if (target.value instanceof THREE.Vector2) {
135
+ target.value.set(
136
+ automatic === 'resolution' ? size.width : (values[0] ?? 0),
137
+ automatic === 'resolution' ? size.height : (values[1] ?? 0),
138
+ );
139
+ return;
140
+ }
141
+ if (target.value instanceof THREE.Vector3) {
142
+ target.value.set(
143
+ automatic === 'resolution' ? size.width : (values[0] ?? 0),
144
+ automatic === 'resolution' ? size.height : (values[1] ?? 0),
145
+ automatic === 'resolution' ? 1 : (values[2] ?? 0),
146
+ );
147
+ return;
148
+ }
149
+ if (target.value instanceof THREE.Vector4) {
150
+ target.value.set(
151
+ automatic === 'resolution' ? size.width : (values[0] ?? 0),
152
+ automatic === 'resolution' ? size.height : (values[1] ?? 0),
153
+ automatic === 'resolution' ? 1 : (values[2] ?? 0),
154
+ values[3] ?? 0,
155
+ );
156
+ return;
157
+ }
158
+ if (automatic !== 'mouse' && !descriptor.arraySize && !/^sampler|^mat/.test(descriptor.type)) {
159
+ target.value = previewValue;
160
+ }
161
+ }
162
+
163
+ function geometryFor(kind: PreviewGeometry): THREE.BufferGeometry {
164
+ if (kind === 'box') return new THREE.BoxGeometry(1.35, 1.35, 1.35, 48, 48, 48);
165
+ if (kind === 'plane') return new THREE.PlaneGeometry(2.2, 2.2, 96, 96);
166
+ return new THREE.SphereGeometry(0.95, 96, 64);
167
+ }
168
+
169
+ function ShaderPreview({
170
+ vertexSource,
171
+ fragmentSource,
172
+ descriptors,
173
+ valuesRef,
174
+ geometry,
175
+ onDiagnostics,
176
+ }: {
177
+ vertexSource: string;
178
+ fragmentSource: string;
179
+ descriptors: readonly ShaderUniformDescriptor[];
180
+ valuesRef: React.RefObject<Record<string, UniformPreviewValue>>;
181
+ geometry: PreviewGeometry;
182
+ onDiagnostics: (diagnostics: ShaderDiagnostic[]) => void;
183
+ }) {
184
+ const hostRef = useRef<HTMLDivElement>(null);
185
+
186
+ useEffect(() => {
187
+ const host = hostRef.current;
188
+ if (!host) return;
189
+ let disposed = false;
190
+ let frame = 0;
191
+ const canvas = document.createElement('canvas');
192
+ canvas.setAttribute('aria-label', 'Live shader preview');
193
+ canvas.style.width = '100%';
194
+ canvas.style.height = '100%';
195
+ canvas.style.display = 'block';
196
+ host.replaceChildren(canvas);
197
+
198
+ let renderer: THREE.WebGLRenderer;
199
+ try {
200
+ renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
201
+ } catch (cause) {
202
+ onDiagnostics([
203
+ {
204
+ stage: 'program',
205
+ severity: 'error',
206
+ message: cause instanceof Error ? cause.message : String(cause),
207
+ },
208
+ ]);
209
+ return;
210
+ }
211
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
212
+ renderer.setClearColor(0x11141d, 1);
213
+ renderer.debug.checkShaderErrors = true;
214
+
215
+ const scene = new THREE.Scene();
216
+ const camera = new THREE.PerspectiveCamera(38, 1, 0.01, 100);
217
+ camera.position.set(0, 0, 3.4);
218
+ const ownedTextures: THREE.Texture[] = [];
219
+ const uniforms: Record<string, THREE.IUniform> = {};
220
+ for (const descriptor of descriptors) {
221
+ uniforms[descriptor.name] = {
222
+ value: createThreeUniformValue(
223
+ descriptor,
224
+ valuesRef.current[descriptor.name] ?? defaultUniformValue(descriptor),
225
+ ownedTextures,
226
+ ),
227
+ };
228
+ }
229
+ const material = new THREE.ShaderMaterial({
230
+ vertexShader: previewShaderSource(vertexSource),
231
+ fragmentShader: previewShaderSource(fragmentSource),
232
+ uniforms,
233
+ side: THREE.DoubleSide,
234
+ toneMapped: false,
235
+ });
236
+ const meshGeometry = geometryFor(geometry);
237
+ const mesh = new THREE.Mesh(meshGeometry, material);
238
+ scene.add(mesh);
239
+
240
+ const diagnostics: ShaderDiagnostic[] = [];
241
+ renderer.debug.onShaderError = (gl, program, vertexShader, fragmentShader) => {
242
+ // Three's callback and the DOM context can resolve WebGL opaque handles
243
+ // through separate declaration universes. They are the same handles at
244
+ // runtime; normalize them at this boundary instead of widening either API.
245
+ const compatibleProgram = program as Parameters<typeof gl.getProgramInfoLog>[0];
246
+ const compatibleVertexShader = vertexShader as Parameters<typeof gl.getShaderInfoLog>[0];
247
+ const compatibleFragmentShader = fragmentShader as Parameters<typeof gl.getShaderInfoLog>[0];
248
+ diagnostics.push(
249
+ ...parseShaderDiagnostics(gl.getProgramInfoLog(compatibleProgram) ?? '', 'program'),
250
+ ...parseShaderDiagnostics(gl.getShaderInfoLog(compatibleVertexShader) ?? '', 'vertex'),
251
+ ...parseShaderDiagnostics(gl.getShaderInfoLog(compatibleFragmentShader) ?? '', 'fragment'),
252
+ );
253
+ };
254
+
255
+ const size = { width: 1, height: 1 };
256
+ const resize = () => {
257
+ const bounds = host.getBoundingClientRect();
258
+ size.width = Math.max(1, Math.floor(bounds.width));
259
+ size.height = Math.max(1, Math.floor(bounds.height));
260
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
261
+ renderer.setSize(size.width, size.height, false);
262
+ camera.aspect = size.width / size.height;
263
+ camera.position.z = 3.4 / Math.min(1, camera.aspect);
264
+ camera.updateProjectionMatrix();
265
+ };
266
+ resize();
267
+ const observer = new ResizeObserver(resize);
268
+ observer.observe(host);
269
+
270
+ const clock = new THREE.Clock();
271
+ try {
272
+ renderer.compile(scene, camera);
273
+ renderer.render(scene, camera);
274
+ onDiagnostics(
275
+ diagnostics.filter(
276
+ (diagnostic, index, all) =>
277
+ all.findIndex(
278
+ (candidate) =>
279
+ candidate.stage === diagnostic.stage &&
280
+ candidate.line === diagnostic.line &&
281
+ candidate.message === diagnostic.message,
282
+ ) === index,
283
+ ),
284
+ );
285
+ } catch (cause) {
286
+ onDiagnostics([
287
+ {
288
+ stage: 'program',
289
+ severity: 'error',
290
+ message: cause instanceof Error ? cause.message : String(cause),
291
+ },
292
+ ]);
293
+ }
294
+
295
+ const render = () => {
296
+ if (disposed) return;
297
+ const elapsed = clock.getElapsedTime();
298
+ for (const descriptor of descriptors) {
299
+ const uniform = uniforms[descriptor.name];
300
+ if (!uniform) continue;
301
+ updateThreeUniformValue(
302
+ uniform,
303
+ descriptor,
304
+ valuesRef.current[descriptor.name] ?? defaultUniformValue(descriptor),
305
+ elapsed,
306
+ size,
307
+ );
308
+ }
309
+ if (geometry !== 'plane') {
310
+ mesh.rotation.y = elapsed * 0.18;
311
+ mesh.rotation.x = Math.sin(elapsed * 0.22) * 0.08;
312
+ }
313
+ renderer.render(scene, camera);
314
+ frame = requestAnimationFrame(render);
315
+ };
316
+ frame = requestAnimationFrame(render);
317
+
318
+ return () => {
319
+ disposed = true;
320
+ cancelAnimationFrame(frame);
321
+ observer.disconnect();
322
+ renderer.debug.onShaderError = null;
323
+ meshGeometry.dispose();
324
+ material.dispose();
325
+ for (const texture of ownedTextures) texture.dispose();
326
+ renderer.dispose();
327
+ canvas.remove();
328
+ };
329
+ }, [descriptors, fragmentSource, geometry, onDiagnostics, valuesRef, vertexSource]);
330
+
331
+ return (
332
+ <div
333
+ ref={hostRef}
334
+ data-testid="shader-preview"
335
+ style={{ width: '100%', height: '100%', minHeight: 240, background: '#11141d' }}
336
+ />
337
+ );
338
+ }
339
+
340
+ function UniformControls({
341
+ descriptors,
342
+ values,
343
+ onChange,
344
+ }: {
345
+ descriptors: readonly ShaderUniformDescriptor[];
346
+ values: Record<string, UniformPreviewValue>;
347
+ onChange: (name: string, value: UniformPreviewValue) => void;
348
+ }) {
349
+ if (descriptors.length === 0) {
350
+ return <div style={{ color: themeVars.content.muted }}>No uniform declarations found.</div>;
351
+ }
352
+ return (
353
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 12, padding: 4 }}>
354
+ {descriptors.map((uniform) => (
355
+ <UniformControlRow
356
+ key={uniform.name}
357
+ uniform={uniform}
358
+ value={values[uniform.name] ?? defaultUniformValue(uniform)}
359
+ onChange={(value) => onChange(uniform.name, value)}
360
+ />
361
+ ))}
362
+ <div style={{ color: themeVars.content.muted, fontSize: 10, lineHeight: 1.4 }}>
363
+ Preview values are session-only. Author runtime defaults beside the ShaderMaterial in
364
+ TS/TSX.
365
+ </div>
366
+ </div>
367
+ );
368
+ }
369
+
370
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: this is the bounded visual-control projection of native GLSL uniform types.
371
+ function UniformControlRow({
372
+ uniform,
373
+ value,
374
+ onChange,
375
+ }: {
376
+ uniform: ShaderUniformDescriptor;
377
+ value: UniformPreviewValue;
378
+ onChange: (value: UniformPreviewValue) => void;
379
+ }) {
380
+ const automatic = automaticUniform(uniform);
381
+ const vector = uniform.type.match(/^(?:[biu]?vec)([234])$/);
382
+ let control: React.ReactNode;
383
+ if (automatic) {
384
+ control = (
385
+ <div style={{ color: themeVars.content.muted, fontSize: 10 }}>
386
+ Driven by preview {automatic}
387
+ </div>
388
+ );
389
+ } else if (isColorUniform(uniform)) {
390
+ control = (
391
+ <ColorInput value={typeof value === 'string' ? value : '#6f8cff'} onChange={onChange} />
392
+ );
393
+ } else if (uniform.type === 'bool') {
394
+ control = (
395
+ <label style={{ display: 'flex', alignItems: 'center', gap: 7, fontSize: 11 }}>
396
+ <Checkbox checked={Boolean(value)} onChange={(event) => onChange(event.target.checked)} />
397
+ {value ? 'True' : 'False'}
398
+ </label>
399
+ );
400
+ } else if (/^(float|double|int|uint)$/.test(uniform.type)) {
401
+ control = (
402
+ <NumberInput
403
+ value={typeof value === 'number' ? value : 0}
404
+ onChange={onChange}
405
+ step={uniform.type === 'int' || uniform.type === 'uint' ? 1 : 0.01}
406
+ precision={uniform.type === 'int' || uniform.type === 'uint' ? 0 : 3}
407
+ />
408
+ );
409
+ } else if (vector && Array.isArray(value)) {
410
+ control = (
411
+ <div
412
+ style={{
413
+ display: 'grid',
414
+ gridTemplateColumns: `repeat(${vector[1]}, minmax(0, 1fr))`,
415
+ gap: 4,
416
+ }}
417
+ >
418
+ {value.map((component, index) => (
419
+ <NumberInput
420
+ key={`${uniform.name}:${index}`}
421
+ value={component}
422
+ label={'xyzw'[index]!}
423
+ onChange={(next) => {
424
+ const copy = [...value];
425
+ copy[index] = next;
426
+ onChange(copy);
427
+ }}
428
+ step={0.01}
429
+ />
430
+ ))}
431
+ </div>
432
+ );
433
+ } else {
434
+ control = (
435
+ <div style={{ color: themeVars.content.muted, fontSize: 10 }}>
436
+ Reflected for runtime binding; no scalar preview control.
437
+ </div>
438
+ );
439
+ }
440
+ return (
441
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
442
+ <div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
443
+ <span style={{ color: themeVars.content.primary, fontSize: 11 }}>{uniform.name}</span>
444
+ <span style={{ color: themeVars.content.muted, fontSize: 10 }}>{uniform.type}</span>
445
+ </div>
446
+ {control}
447
+ </div>
448
+ );
449
+ }
450
+
451
+ function DiagnosticList({
452
+ diagnostics,
453
+ files,
454
+ }: {
455
+ diagnostics: readonly ShaderDiagnostic[];
456
+ files: ShaderFiles;
457
+ }) {
458
+ if (diagnostics.length === 0) {
459
+ return (
460
+ <div style={{ color: themeVars.semantic.success, fontSize: 11, padding: 4 }}>
461
+ Shader program compiled successfully.
462
+ </div>
463
+ );
464
+ }
465
+ return (
466
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6, padding: 4 }}>
467
+ {diagnostics.map((diagnostic, index) => {
468
+ const file = diagnostic.stage === 'program' ? null : files[diagnostic.stage];
469
+ return (
470
+ <div
471
+ key={`${diagnostic.stage}:${diagnostic.line ?? 0}:${diagnostic.message}:${index}`}
472
+ style={{
473
+ color:
474
+ diagnostic.severity === 'warning'
475
+ ? themeVars.semantic.warning
476
+ : themeVars.semantic.danger,
477
+ fontSize: 10,
478
+ lineHeight: 1.45,
479
+ }}
480
+ >
481
+ {file?.available ? basename(file.path) : diagnostic.stage}
482
+ {diagnostic.line
483
+ ? `:${diagnostic.line}${diagnostic.column ? `:${diagnostic.column}` : ''}`
484
+ : ''}
485
+ {' · '}
486
+ {diagnostic.message}
487
+ </div>
488
+ );
489
+ })}
490
+ </div>
491
+ );
492
+ }
493
+
494
+ async function loadShaderFiles(assetPath: string): Promise<ShaderFiles> {
495
+ const source = await readProjectTextFile(assetPath);
496
+ if (source === null) throw new Error(`Could not read ${assetPath}.`);
497
+ const primaryStage = shaderStageFromPath(assetPath, source);
498
+ const companionPath = shaderCompanionPath(assetPath);
499
+ const companionSource = companionPath ? await readProjectTextFile(companionPath) : null;
500
+ const files: ShaderFiles = {
501
+ vertex: fallbackFile('vertex'),
502
+ fragment: fallbackFile('fragment'),
503
+ };
504
+ files[primaryStage] = { path: assetPath, source, available: true };
505
+ const companionStage: ShaderStage = primaryStage === 'vertex' ? 'fragment' : 'vertex';
506
+ files[companionStage] =
507
+ companionSource === null
508
+ ? fallbackFile(companionStage, companionPath)
509
+ : {
510
+ path: companionPath,
511
+ source: companionSource,
512
+ available: true,
513
+ };
514
+ return files;
515
+ }
516
+
517
+ export function ShaderAssetDocument({
518
+ documentId,
519
+ assetPath,
520
+ active = true,
521
+ }: {
522
+ documentId: string;
523
+ assetPath: string;
524
+ active?: boolean;
525
+ }) {
526
+ const [files, setFiles] = useState<ShaderFiles | null>(null);
527
+ const [fileRevision, setFileRevision] = useState(0);
528
+ const [geometry, setGeometry] = useState<PreviewGeometry>('sphere');
529
+ const [diagnostics, setDiagnostics] = useState<ShaderDiagnostic[]>([]);
530
+ const [uniformValues, setUniformValues] = useState<Record<string, UniformPreviewValue>>({});
531
+ const [loadError, setLoadError] = useState<string | null>(null);
532
+
533
+ useEffect(() => {
534
+ if (!active) return;
535
+ const reload = () => setFileRevision((revision) => revision + 1);
536
+ const companionPath = shaderCompanionPath(assetPath);
537
+ const unsubscribers = [
538
+ subscribeProjectAsset(assetPath, reload),
539
+ ...(companionPath ? [subscribeProjectAsset(companionPath, reload)] : []),
540
+ ];
541
+ return () => {
542
+ for (const unsubscribe of unsubscribers) unsubscribe();
543
+ };
544
+ }, [active, assetPath]);
545
+
546
+ useEffect(() => {
547
+ if (!active) return;
548
+ let cancelled = false;
549
+ setFiles(null);
550
+ setLoadError(null);
551
+ void loadShaderFiles(assetPath).then(
552
+ (loaded) => {
553
+ if (cancelled) return;
554
+ setFiles(loaded);
555
+ },
556
+ (cause) => {
557
+ if (!cancelled) setLoadError(cause instanceof Error ? cause.message : String(cause));
558
+ },
559
+ );
560
+ return () => {
561
+ cancelled = true;
562
+ };
563
+ }, [active, assetPath, fileRevision]);
564
+
565
+ const descriptors = useMemo(
566
+ () => (files ? reflectShaderUniforms(files.vertex.source, files.fragment.source) : []),
567
+ [files],
568
+ );
569
+ useEffect(() => {
570
+ setUniformValues((current) =>
571
+ Object.fromEntries(
572
+ descriptors.map((descriptor) => [
573
+ descriptor.name,
574
+ current[descriptor.name] ?? defaultUniformValue(descriptor),
575
+ ]),
576
+ ),
577
+ );
578
+ }, [descriptors]);
579
+ const uniformValuesRef = useRef(uniformValues);
580
+ uniformValuesRef.current = uniformValues;
581
+
582
+ const sections = useMemo<InspectionSection[]>(
583
+ () => [
584
+ {
585
+ id: 'shader-pair',
586
+ title: 'Shader Pair',
587
+ icon: faCode,
588
+ order: PROPERTIES_SECTION_ORDER,
589
+ body: {
590
+ kind: 'custom',
591
+ render: () => (
592
+ <div
593
+ style={{ display: 'flex', flexDirection: 'column', gap: 5, padding: 4, fontSize: 10 }}
594
+ >
595
+ <span>
596
+ Vertex · {files ? basename(files.vertex.path) : 'Loading…'}
597
+ {files && !files.vertex.available ? ' (preview fallback)' : ''}
598
+ </span>
599
+ <span>
600
+ Fragment · {files ? basename(files.fragment.path) : 'Loading…'}
601
+ {files && !files.fragment.available ? ' (preview fallback)' : ''}
602
+ </span>
603
+ <span style={{ color: themeVars.content.muted }}>
604
+ Ordinary sibling GLSL files; runtime composition stays in TS/TSX.
605
+ </span>
606
+ </div>
607
+ ),
608
+ },
609
+ },
610
+ {
611
+ id: 'shader-uniforms',
612
+ title: 'Uniforms',
613
+ icon: faSliders,
614
+ order: CONTRIBUTED_SECTION_ORDER,
615
+ description: `${descriptors.length} reflected`,
616
+ body: {
617
+ kind: 'custom',
618
+ data: Object.fromEntries(
619
+ descriptors.map((descriptor) => [descriptor.name, uniformValues[descriptor.name]]),
620
+ ),
621
+ render: () => (
622
+ <UniformControls
623
+ descriptors={descriptors}
624
+ values={uniformValues}
625
+ onChange={(name, value) =>
626
+ setUniformValues((current) => ({ ...current, [name]: value }))
627
+ }
628
+ />
629
+ ),
630
+ },
631
+ },
632
+ {
633
+ id: 'shader-diagnostics',
634
+ title: 'Diagnostics',
635
+ icon: diagnostics.length > 0 ? faTriangleExclamation : faCode,
636
+ order: CONTRIBUTED_SECTION_ORDER + 100,
637
+ description:
638
+ diagnostics.length === 0
639
+ ? 'Compiled'
640
+ : `${diagnostics.length} issue${diagnostics.length === 1 ? '' : 's'}`,
641
+ body: {
642
+ kind: 'custom',
643
+ data: { diagnostics },
644
+ render: () => files && <DiagnosticList diagnostics={diagnostics} files={files} />,
645
+ },
646
+ },
647
+ ],
648
+ [descriptors, diagnostics, files, uniformValues],
649
+ );
650
+
651
+ if (loadError) {
652
+ return (
653
+ <AssetEditorShell
654
+ documentId={documentId}
655
+ active={active}
656
+ type="shader"
657
+ title={basename(assetPath)}
658
+ status="Shader unavailable"
659
+ >
660
+ <div role="alert" style={{ padding: 20, color: themeVars.semantic.danger }}>
661
+ {loadError}
662
+ </div>
663
+ </AssetEditorShell>
664
+ );
665
+ }
666
+ if (!files) {
667
+ return (
668
+ <AssetEditorShell
669
+ documentId={documentId}
670
+ active={active}
671
+ type="shader"
672
+ title={basename(assetPath)}
673
+ status="Loading GLSL"
674
+ >
675
+ <div style={{ padding: 20, color: themeVars.content.muted }}>Loading shader files…</div>
676
+ </AssetEditorShell>
677
+ );
678
+ }
679
+
680
+ return (
681
+ <AssetEditorShell
682
+ documentId={documentId}
683
+ active={active}
684
+ type="shader"
685
+ title={basename(assetPath)}
686
+ sections={sections}
687
+ status={
688
+ diagnostics.length === 0
689
+ ? `GLSL · compiles · ${descriptors.length} uniforms`
690
+ : `GLSL · ${diagnostics.length} compile issues`
691
+ }
692
+ fill
693
+ >
694
+ <div
695
+ style={{
696
+ width: '100%',
697
+ height: '100%',
698
+ minHeight: 0,
699
+ position: 'relative',
700
+ background: themeVars.surface.panel,
701
+ }}
702
+ >
703
+ <ShaderPreview
704
+ vertexSource={files.vertex.source}
705
+ fragmentSource={files.fragment.source}
706
+ descriptors={descriptors}
707
+ valuesRef={uniformValuesRef}
708
+ geometry={geometry}
709
+ onDiagnostics={setDiagnostics}
710
+ />
711
+ <div style={{ position: 'absolute', left: 10, top: 10 }}>
712
+ <Select
713
+ aria-label="Preview geometry"
714
+ value={geometry}
715
+ onChange={(event) => setGeometry(event.target.value as PreviewGeometry)}
716
+ >
717
+ <option value="sphere">Sphere</option>
718
+ <option value="box">Box</option>
719
+ <option value="plane">Plane</option>
720
+ </Select>
721
+ </div>
722
+ {diagnostics.length > 0 && (
723
+ <div
724
+ style={{
725
+ position: 'absolute',
726
+ left: 12,
727
+ right: 12,
728
+ bottom: 12,
729
+ maxHeight: 120,
730
+ overflow: 'auto',
731
+ padding: 8,
732
+ border: `1px solid ${themeVars.semantic.danger}`,
733
+ borderRadius: 6,
734
+ background: themeVars.surface.overlay,
735
+ }}
736
+ >
737
+ <DiagnosticList diagnostics={diagnostics} files={files} />
738
+ </div>
739
+ )}
740
+ </div>
741
+ </AssetEditorShell>
742
+ );
743
+ }