three-cad-viewer 4.1.2 → 4.2.0

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 (58) hide show
  1. package/Readme.md +12 -5
  2. package/dist/camera/camera.d.ts +14 -2
  3. package/dist/core/studio-manager.d.ts +91 -0
  4. package/dist/core/types.d.ts +260 -9
  5. package/dist/core/viewer-state.d.ts +28 -2
  6. package/dist/core/viewer.d.ts +200 -6
  7. package/dist/index.d.ts +7 -2
  8. package/dist/rendering/environment.d.ts +239 -0
  9. package/dist/rendering/light-detection.d.ts +44 -0
  10. package/dist/rendering/material-factory.d.ts +77 -2
  11. package/dist/rendering/material-presets.d.ts +32 -0
  12. package/dist/rendering/room-environment.d.ts +13 -0
  13. package/dist/rendering/studio-composer.d.ts +130 -0
  14. package/dist/rendering/studio-floor.d.ts +53 -0
  15. package/dist/rendering/texture-cache.d.ts +142 -0
  16. package/dist/rendering/triplanar.d.ts +37 -0
  17. package/dist/scene/animation.d.ts +1 -1
  18. package/dist/scene/clipping.d.ts +31 -0
  19. package/dist/scene/nestedgroup.d.ts +64 -27
  20. package/dist/scene/objectgroup.d.ts +47 -0
  21. package/dist/three-cad-viewer.css +339 -29
  22. package/dist/three-cad-viewer.esm.js +27567 -11874
  23. package/dist/three-cad-viewer.esm.js.map +1 -1
  24. package/dist/three-cad-viewer.esm.min.js +10 -4
  25. package/dist/three-cad-viewer.js +27486 -11787
  26. package/dist/three-cad-viewer.min.js +10 -4
  27. package/dist/ui/display.d.ts +147 -0
  28. package/dist/utils/decode-instances.d.ts +60 -0
  29. package/dist/utils/utils.d.ts +10 -0
  30. package/package.json +4 -2
  31. package/src/_version.ts +1 -1
  32. package/src/camera/camera.ts +27 -10
  33. package/src/core/studio-manager.ts +682 -0
  34. package/src/core/types.ts +328 -9
  35. package/src/core/viewer-state.ts +84 -4
  36. package/src/core/viewer.ts +453 -22
  37. package/src/index.ts +25 -1
  38. package/src/rendering/environment.ts +840 -0
  39. package/src/rendering/light-detection.ts +327 -0
  40. package/src/rendering/material-factory.ts +456 -2
  41. package/src/rendering/material-presets.ts +303 -0
  42. package/src/rendering/raycast.ts +2 -2
  43. package/src/rendering/room-environment.ts +192 -0
  44. package/src/rendering/studio-composer.ts +577 -0
  45. package/src/rendering/studio-floor.ts +108 -0
  46. package/src/rendering/texture-cache.ts +1020 -0
  47. package/src/rendering/triplanar.ts +329 -0
  48. package/src/scene/animation.ts +3 -2
  49. package/src/scene/clipping.ts +59 -0
  50. package/src/scene/nestedgroup.ts +399 -0
  51. package/src/scene/objectgroup.ts +186 -11
  52. package/src/scene/orientation.ts +12 -0
  53. package/src/scene/render-shape.ts +55 -21
  54. package/src/types/n8ao.d.ts +28 -0
  55. package/src/ui/display.ts +1032 -27
  56. package/src/ui/index.html +181 -44
  57. package/src/utils/decode-instances.ts +233 -0
  58. package/src/utils/utils.ts +33 -20
@@ -0,0 +1,1020 @@
1
+ import * as THREE from "three";
2
+ import type { TextureEntry } from "../core/types.js";
3
+ import { gpuTracker } from "../utils/gpu-tracker.js";
4
+ import { logger } from "../utils/logger.js";
5
+
6
+ // =============================================================================
7
+ // Constants
8
+ // =============================================================================
9
+
10
+ /** Size of procedurally generated builtin textures (pixels) */
11
+ const BUILTIN_SIZE = 256;
12
+
13
+ /** Names of all supported builtin procedural textures */
14
+ const BUILTIN_NAMES = [
15
+ "brushed",
16
+ "knurled",
17
+ "sandblasted",
18
+ "hammered",
19
+ "checker",
20
+ "wood-dark",
21
+ "leather",
22
+ "fabric-weave",
23
+ ] as const;
24
+
25
+ type BuiltinName = (typeof BUILTIN_NAMES)[number];
26
+
27
+ /**
28
+ * Texture fields that carry sRGB color data.
29
+ *
30
+ * When a texture is used for one of these roles, its `colorSpace` must be set
31
+ * to `SRGBColorSpace` so Three.js applies the sRGB-to-linear decode on
32
+ * sampling. All other texture roles (normal, metallic-roughness, occlusion,
33
+ * thickness, transmission, roughness maps) remain `LinearSRGBColorSpace`.
34
+ */
35
+ const SRGB_TEXTURE_ROLES = new Set([
36
+ "baseColorTexture",
37
+ "emissiveTexture",
38
+ "sheenColorTexture",
39
+ "specularColorTexture",
40
+ ]);
41
+
42
+ /**
43
+ * Three.js MeshPhysicalMaterial map property names that carry sRGB color data.
44
+ *
45
+ * Used by threejs-materials integration where texture params use Three.js property
46
+ * names directly (e.g., "map", "emissiveMap") instead of MaterialAppearance
47
+ * role names (e.g., "baseColorTexture", "emissiveTexture").
48
+ */
49
+ const THREEJS_SRGB_MAPS = new Set([
50
+ "map",
51
+ "emissiveMap",
52
+ "sheenColorMap",
53
+ "specularColorMap",
54
+ ]);
55
+
56
+ // =============================================================================
57
+ // Builtin Procedural Texture Generators
58
+ // =============================================================================
59
+
60
+ /**
61
+ * Create a 2D canvas context of the given size.
62
+ */
63
+ function createCanvas(size: number): {
64
+ canvas: HTMLCanvasElement | OffscreenCanvas;
65
+ ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
66
+ } {
67
+ // Prefer OffscreenCanvas when available (Web Workers, modern browsers)
68
+ if (typeof OffscreenCanvas !== "undefined") {
69
+ const canvas = new OffscreenCanvas(size, size);
70
+ const ctx = canvas.getContext("2d")!;
71
+ return { canvas, ctx };
72
+ }
73
+ const canvas = document.createElement("canvas");
74
+ canvas.width = size;
75
+ canvas.height = size;
76
+ const ctx = canvas.getContext("2d")!;
77
+ return { canvas, ctx };
78
+ }
79
+
80
+ /**
81
+ * Simple pseudo-random number generator (mulberry32) for deterministic output.
82
+ * Ensures builtin textures look identical across sessions.
83
+ */
84
+ function mulberry32(seed: number): () => number {
85
+ return () => {
86
+ seed |= 0;
87
+ seed = (seed + 0x6d2b79f5) | 0;
88
+ let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
89
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
90
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Generate a brushed-metal normal map.
96
+ *
97
+ * Creates horizontal noise streaks simulating directional brushing.
98
+ * The streaks run along the X axis with slight vertical variation.
99
+ */
100
+ function generateBrushed(size: number): HTMLCanvasElement | OffscreenCanvas {
101
+ const { canvas, ctx } = createCanvas(size);
102
+ const imageData = ctx.createImageData(size, size);
103
+ const data = imageData.data;
104
+ const rng = mulberry32(42);
105
+
106
+ // Generate per-row intensity variation (horizontal streaks)
107
+ const rowIntensity = new Float32Array(size);
108
+ for (let y = 0; y < size; y++) {
109
+ rowIntensity[y] = 0.3 + rng() * 0.7;
110
+ }
111
+
112
+ for (let y = 0; y < size; y++) {
113
+ for (let x = 0; x < size; x++) {
114
+ const idx = (y * size + x) * 4;
115
+
116
+ // High-frequency horizontal noise for brush lines
117
+ const noise = (rng() - 0.5) * 0.15 * rowIntensity[y];
118
+
119
+ // Slight vertical gradient perturbation
120
+ const yNoise = (rng() - 0.5) * 0.05;
121
+
122
+ // Normal map encoding: (0.5, 0.5, 1.0) = flat
123
+ data[idx] = Math.round((0.5 + noise) * 255); // R: tangent X
124
+ data[idx + 1] = Math.round((0.5 + yNoise) * 255); // G: tangent Y
125
+ data[idx + 2] = 255; // B: tangent Z (flat)
126
+ data[idx + 3] = 255; // A: opaque
127
+ }
128
+ }
129
+
130
+ ctx.putImageData(imageData, 0, 0);
131
+ return canvas;
132
+ }
133
+
134
+ /**
135
+ * Generate a diamond knurl pattern normal map.
136
+ *
137
+ * Creates a repeating diamond grid pattern typical of knurled metal surfaces.
138
+ */
139
+ function generateKnurled(size: number): HTMLCanvasElement | OffscreenCanvas {
140
+ const { canvas, ctx } = createCanvas(size);
141
+ const imageData = ctx.createImageData(size, size);
142
+ const data = imageData.data;
143
+
144
+ const diamonds = 16; // Number of diamond repeats
145
+ const step = size / diamonds;
146
+
147
+ for (let y = 0; y < size; y++) {
148
+ for (let x = 0; x < size; x++) {
149
+ const idx = (y * size + x) * 4;
150
+
151
+ // Diamond pattern using modular arithmetic
152
+ const dx = ((x % step) / step) * 2 - 1; // -1 to 1 within cell
153
+ const dy = ((y % step) / step) * 2 - 1;
154
+
155
+ // Diamond distance (L1 norm)
156
+ const dist = Math.abs(dx) + Math.abs(dy);
157
+
158
+ // Gradient direction for normal
159
+ const sx = dx > 0 ? 1 : -1;
160
+ const sy = dy > 0 ? 1 : -1;
161
+
162
+ // Smooth pyramid shape
163
+ const strength = 0.3;
164
+ const nx = dist < 1 ? sx * strength * (1 - dist) : 0;
165
+ const ny = dist < 1 ? sy * strength * (1 - dist) : 0;
166
+
167
+ data[idx] = Math.round((0.5 + nx) * 255);
168
+ data[idx + 1] = Math.round((0.5 + ny) * 255);
169
+ data[idx + 2] = 255;
170
+ data[idx + 3] = 255;
171
+ }
172
+ }
173
+
174
+ ctx.putImageData(imageData, 0, 0);
175
+ return canvas;
176
+ }
177
+
178
+ /**
179
+ * Generate a sandblasted surface normal map.
180
+ *
181
+ * Creates fine random grain using layered noise at different frequencies,
182
+ * simulating a sandblasted or bead-blasted metal surface.
183
+ */
184
+ function generateSandblasted(size: number): HTMLCanvasElement | OffscreenCanvas {
185
+ const { canvas, ctx } = createCanvas(size);
186
+ const imageData = ctx.createImageData(size, size);
187
+ const data = imageData.data;
188
+ const rng = mulberry32(137);
189
+
190
+ // Generate a height field with multi-octave noise
191
+ const heights = new Float32Array(size * size);
192
+ for (let i = 0; i < heights.length; i++) {
193
+ heights[i] = rng() * 0.5 + rng() * 0.3 + rng() * 0.2;
194
+ }
195
+
196
+ // Derive normals from height field via finite differences
197
+ const strength = 0.2;
198
+ for (let y = 0; y < size; y++) {
199
+ for (let x = 0; x < size; x++) {
200
+ const idx = (y * size + x) * 4;
201
+
202
+ const xp = (x + 1) % size;
203
+ const xm = (x - 1 + size) % size;
204
+ const yp = (y + 1) % size;
205
+ const ym = (y - 1 + size) % size;
206
+
207
+ const dhdx = (heights[y * size + xp] - heights[y * size + xm]) * 0.5;
208
+ const dhdy = (heights[yp * size + x] - heights[ym * size + x]) * 0.5;
209
+
210
+ data[idx] = Math.round((0.5 - dhdx * strength) * 255);
211
+ data[idx + 1] = Math.round((0.5 - dhdy * strength) * 255);
212
+ data[idx + 2] = 255;
213
+ data[idx + 3] = 255;
214
+ }
215
+ }
216
+
217
+ ctx.putImageData(imageData, 0, 0);
218
+ return canvas;
219
+ }
220
+
221
+ /**
222
+ * Generate a hammered/peened surface normal map.
223
+ *
224
+ * Creates random crater bumps simulating a hand-hammered metal surface.
225
+ */
226
+ function generateHammered(size: number): HTMLCanvasElement | OffscreenCanvas {
227
+ const { canvas, ctx } = createCanvas(size);
228
+ const imageData = ctx.createImageData(size, size);
229
+ const data = imageData.data;
230
+ const rng = mulberry32(314);
231
+
232
+ // Generate a height field with random circular craters
233
+ const heights = new Float32Array(size * size);
234
+ const craterCount = 80;
235
+
236
+ for (let c = 0; c < craterCount; c++) {
237
+ const cx = rng() * size;
238
+ const cy = rng() * size;
239
+ const radius = 8 + rng() * 16;
240
+ const depth = 0.3 + rng() * 0.7;
241
+
242
+ // Stamp crater (with wrapping for tileable texture)
243
+ const r2 = radius * radius;
244
+ for (let dy = -radius; dy <= radius; dy++) {
245
+ for (let dx = -radius; dx <= radius; dx++) {
246
+ const d2 = dx * dx + dy * dy;
247
+ if (d2 < r2) {
248
+ const px = ((Math.round(cx + dx) % size) + size) % size;
249
+ const py = ((Math.round(cy + dy) % size) + size) % size;
250
+ // Smooth hemisphere shape
251
+ const t = 1 - d2 / r2;
252
+ heights[py * size + px] += depth * t * t;
253
+ }
254
+ }
255
+ }
256
+ }
257
+
258
+ // Derive normals from height field
259
+ const strength = 0.25;
260
+ for (let y = 0; y < size; y++) {
261
+ for (let x = 0; x < size; x++) {
262
+ const idx = (y * size + x) * 4;
263
+
264
+ const xp = (x + 1) % size;
265
+ const xm = (x - 1 + size) % size;
266
+ const yp = (y + 1) % size;
267
+ const ym = (y - 1 + size) % size;
268
+
269
+ const dhdx = (heights[y * size + xp] - heights[y * size + xm]) * 0.5;
270
+ const dhdy = (heights[yp * size + x] - heights[ym * size + x]) * 0.5;
271
+
272
+ data[idx] = Math.round(Math.max(0, Math.min(255, (0.5 - dhdx * strength) * 255)));
273
+ data[idx + 1] = Math.round(Math.max(0, Math.min(255, (0.5 - dhdy * strength) * 255)));
274
+ data[idx + 2] = 255;
275
+ data[idx + 3] = 255;
276
+ }
277
+ }
278
+
279
+ ctx.putImageData(imageData, 0, 0);
280
+ return canvas;
281
+ }
282
+
283
+ /**
284
+ * Generate a black/white checkerboard texture.
285
+ *
286
+ * Useful for UV debugging and as a base color texture for testing.
287
+ */
288
+ function generateChecker(size: number): HTMLCanvasElement | OffscreenCanvas {
289
+ const { canvas, ctx } = createCanvas(size);
290
+ const squares = 8; // 8x8 checkerboard
291
+ const step = size / squares;
292
+
293
+ ctx.fillStyle = "#ffffff";
294
+ ctx.fillRect(0, 0, size, size);
295
+
296
+ ctx.fillStyle = "#000000";
297
+ for (let row = 0; row < squares; row++) {
298
+ for (let col = 0; col < squares; col++) {
299
+ if ((row + col) % 2 === 0) {
300
+ ctx.fillRect(col * step, row * step, step, step);
301
+ }
302
+ }
303
+ }
304
+
305
+ return canvas;
306
+ }
307
+
308
+ /**
309
+ * Generate a dark wood grain base color texture.
310
+ *
311
+ * Creates concentric growth rings with noise perturbation,
312
+ * in warm walnut/mahogany tones. Intended as a baseColorTexture (sRGB).
313
+ */
314
+ function generateWoodDark(size: number): HTMLCanvasElement | OffscreenCanvas {
315
+ const { canvas, ctx } = createCanvas(size);
316
+ const imageData = ctx.createImageData(size, size);
317
+ const data = imageData.data;
318
+ const rng = mulberry32(73);
319
+
320
+ // Pre-generate a noise field for grain perturbation
321
+ const noise = new Float32Array(size * size);
322
+ for (let i = 0; i < noise.length; i++) {
323
+ noise[i] = rng();
324
+ }
325
+
326
+ // Smooth the noise (simple box blur, 2 passes)
327
+ const tmp = new Float32Array(size * size);
328
+ for (let pass = 0; pass < 2; pass++) {
329
+ const src = pass === 0 ? noise : tmp;
330
+ const dst = pass === 0 ? tmp : noise;
331
+ const k = 3;
332
+ for (let y = 0; y < size; y++) {
333
+ for (let x = 0; x < size; x++) {
334
+ let sum = 0;
335
+ let count = 0;
336
+ for (let dy = -k; dy <= k; dy++) {
337
+ for (let dx = -k; dx <= k; dx++) {
338
+ const px = (x + dx + size) % size;
339
+ const py = (y + dy + size) % size;
340
+ sum += src[py * size + px];
341
+ count++;
342
+ }
343
+ }
344
+ dst[y * size + x] = sum / count;
345
+ }
346
+ }
347
+ }
348
+
349
+ // Wood color palette (sRGB, will be decoded by Three.js)
350
+ // Dark grain lines: ~[80, 45, 22] Light wood body: ~[145, 90, 48]
351
+ const darkR = 80, darkG = 45, darkB = 22;
352
+ const lightR = 145, lightG = 90, lightB = 48;
353
+
354
+ // Ring center (offset from image center for asymmetry)
355
+ const cx = size * 0.45;
356
+ const cy = size * 0.52;
357
+
358
+ const ringScale = 0.08; // Controls ring spacing
359
+
360
+ for (let y = 0; y < size; y++) {
361
+ for (let x = 0; x < size; x++) {
362
+ const idx = (y * size + x) * 4;
363
+
364
+ // Distance from center with noise distortion
365
+ const n = noise[y * size + x];
366
+ const dx = x - cx + (n - 0.5) * 30;
367
+ const dy = y - cy + (n - 0.5) * 15;
368
+ const dist = Math.sqrt(dx * dx + dy * dy);
369
+
370
+ // Growth ring pattern (sinusoidal)
371
+ const ring = Math.sin(dist * ringScale * Math.PI * 2);
372
+ // Remap from [-1,1] to [0,1]
373
+ const t = ring * 0.5 + 0.5;
374
+ // sqrt biases toward light — dark lines stay thin, brown dominates
375
+ const ringFactor = Math.sqrt(t);
376
+
377
+ // Add fine-grain noise for fiber texture
378
+ const fineNoise = (rng() - 0.5) * 12;
379
+
380
+ // Interpolate between dark and light
381
+ const r = Math.round(darkR + (lightR - darkR) * ringFactor + fineNoise);
382
+ const g = Math.round(darkG + (lightG - darkG) * ringFactor + fineNoise * 0.6);
383
+ const b = Math.round(darkB + (lightB - darkB) * ringFactor + fineNoise * 0.3);
384
+
385
+ data[idx] = Math.max(0, Math.min(255, r));
386
+ data[idx + 1] = Math.max(0, Math.min(255, g));
387
+ data[idx + 2] = Math.max(0, Math.min(255, b));
388
+ data[idx + 3] = 255;
389
+ }
390
+ }
391
+
392
+ ctx.putImageData(imageData, 0, 0);
393
+ return canvas;
394
+ }
395
+
396
+ /**
397
+ * Generate a leather pebble-grain normal map.
398
+ *
399
+ * Creates irregular rounded bumps (Voronoi-like cells) typical of
400
+ * top-grain or pebbled leather. Each cell has a smooth dome shape
401
+ * with slight creases between cells.
402
+ */
403
+ function generateLeather(size: number): HTMLCanvasElement | OffscreenCanvas {
404
+ const { canvas, ctx } = createCanvas(size);
405
+ const imageData = ctx.createImageData(size, size);
406
+ const data = imageData.data;
407
+ const rng = mulberry32(217);
408
+
409
+ // Scatter seed points for Voronoi cells (pebbles)
410
+ const cellCount = 180;
411
+ const seeds: Array<{ x: number; y: number }> = [];
412
+ for (let i = 0; i < cellCount; i++) {
413
+ seeds.push({ x: rng() * size, y: rng() * size });
414
+ }
415
+
416
+ // Build a height field from Voronoi distance
417
+ const heights = new Float32Array(size * size);
418
+
419
+ for (let y = 0; y < size; y++) {
420
+ for (let x = 0; x < size; x++) {
421
+ // Find distance to nearest seed (with wrapping for tileability)
422
+ let minDist = Infinity;
423
+ for (const s of seeds) {
424
+ let dx = Math.abs(x - s.x);
425
+ let dy = Math.abs(y - s.y);
426
+ if (dx > size / 2) dx = size - dx;
427
+ if (dy > size / 2) dy = size - dy;
428
+ const d = Math.sqrt(dx * dx + dy * dy);
429
+ if (d < minDist) minDist = d;
430
+ }
431
+ // Invert: closer to seed = higher (dome center)
432
+ // Normalize roughly by expected average cell radius
433
+ const avgRadius = size / Math.sqrt(cellCount);
434
+ const t = Math.min(minDist / avgRadius, 1.0);
435
+ // Smooth dome falloff: 1 at center, 0 at edge
436
+ heights[y * size + x] = (1 - t * t) * 0.8 + rng() * 0.05;
437
+ }
438
+ }
439
+
440
+ // Derive normals from height field via finite differences
441
+ const strength = 0.35;
442
+ for (let y = 0; y < size; y++) {
443
+ for (let x = 0; x < size; x++) {
444
+ const idx = (y * size + x) * 4;
445
+
446
+ const xp = (x + 1) % size;
447
+ const xm = (x - 1 + size) % size;
448
+ const yp = (y + 1) % size;
449
+ const ym = (y - 1 + size) % size;
450
+
451
+ const dhdx = (heights[y * size + xp] - heights[y * size + xm]) * 0.5;
452
+ const dhdy = (heights[yp * size + x] - heights[ym * size + x]) * 0.5;
453
+
454
+ data[idx] = Math.round(Math.max(0, Math.min(255, (0.5 - dhdx * strength) * 255)));
455
+ data[idx + 1] = Math.round(Math.max(0, Math.min(255, (0.5 - dhdy * strength) * 255)));
456
+ data[idx + 2] = 255;
457
+ data[idx + 3] = 255;
458
+ }
459
+ }
460
+
461
+ ctx.putImageData(imageData, 0, 0);
462
+ return canvas;
463
+ }
464
+
465
+ /**
466
+ * Generate a fabric twill-weave normal map.
467
+ *
468
+ * Creates a repeating over-under weave pattern with slightly raised
469
+ * warp/weft threads and recessed gaps, typical of upholstery fabric.
470
+ */
471
+ function generateFabricWeave(size: number): HTMLCanvasElement | OffscreenCanvas {
472
+ const { canvas, ctx } = createCanvas(size);
473
+ const imageData = ctx.createImageData(size, size);
474
+ const data = imageData.data;
475
+ const rng = mulberry32(159);
476
+
477
+ // Thread parameters
478
+ const threadCount = 32; // threads per axis
479
+ const cellSize = size / threadCount;
480
+
481
+ // Build height field: each cell is either warp-over or weft-over
482
+ const heights = new Float32Array(size * size);
483
+
484
+ for (let y = 0; y < size; y++) {
485
+ for (let x = 0; x < size; x++) {
486
+ const cx = Math.floor(x / cellSize);
487
+ const cy = Math.floor(y / cellSize);
488
+
489
+ // Position within the cell [0, 1]
490
+ const lx = (x % cellSize) / cellSize;
491
+ const ly = (y % cellSize) / cellSize;
492
+
493
+ // Twill pattern: diagonal shift (2/1 twill)
494
+ const isWarpOver = ((cx + cy) % 3) < 2;
495
+
496
+ // Thread profile: rounded bump across the thread width
497
+ // Warp threads run vertically (bump shape across x)
498
+ // Weft threads run horizontally (bump shape across y)
499
+ const warpProfile = Math.sin(lx * Math.PI);
500
+ const weftProfile = Math.sin(ly * Math.PI);
501
+
502
+ // Gap between threads (edges of cells are lower)
503
+ const edgeFalloff = Math.min(
504
+ Math.sin(lx * Math.PI),
505
+ Math.sin(ly * Math.PI),
506
+ );
507
+
508
+ let h: number;
509
+ if (isWarpOver) {
510
+ // Warp on top: height from warp profile
511
+ h = 0.5 + warpProfile * 0.4 * edgeFalloff;
512
+ } else {
513
+ // Weft on top: height from weft profile
514
+ h = 0.5 + weftProfile * 0.4 * edgeFalloff;
515
+ }
516
+
517
+ // Add subtle noise for fabric irregularity
518
+ h += (rng() - 0.5) * 0.04;
519
+
520
+ heights[y * size + x] = h;
521
+ }
522
+ }
523
+
524
+ // Derive normals from height field
525
+ const strength = 0.3;
526
+ for (let y = 0; y < size; y++) {
527
+ for (let x = 0; x < size; x++) {
528
+ const idx = (y * size + x) * 4;
529
+
530
+ const xp = (x + 1) % size;
531
+ const xm = (x - 1 + size) % size;
532
+ const yp = (y + 1) % size;
533
+ const ym = (y - 1 + size) % size;
534
+
535
+ const dhdx = (heights[y * size + xp] - heights[y * size + xm]) * 0.5;
536
+ const dhdy = (heights[yp * size + x] - heights[ym * size + x]) * 0.5;
537
+
538
+ data[idx] = Math.round(Math.max(0, Math.min(255, (0.5 - dhdx * strength) * 255)));
539
+ data[idx + 1] = Math.round(Math.max(0, Math.min(255, (0.5 - dhdy * strength) * 255)));
540
+ data[idx + 2] = 255;
541
+ data[idx + 3] = 255;
542
+ }
543
+ }
544
+
545
+ ctx.putImageData(imageData, 0, 0);
546
+ return canvas;
547
+ }
548
+
549
+ /** Map of builtin texture names to their generator functions */
550
+ const BUILTIN_GENERATORS: Record<
551
+ BuiltinName,
552
+ (size: number) => HTMLCanvasElement | OffscreenCanvas
553
+ > = {
554
+ brushed: generateBrushed,
555
+ knurled: generateKnurled,
556
+ sandblasted: generateSandblasted,
557
+ hammered: generateHammered,
558
+ checker: generateChecker,
559
+ "wood-dark": generateWoodDark,
560
+ leather: generateLeather,
561
+ "fabric-weave": generateFabricWeave,
562
+ };
563
+
564
+ // =============================================================================
565
+ // TextureCache
566
+ // =============================================================================
567
+
568
+ /**
569
+ * Manages loading, caching, and lifecycle of all Studio mode textures.
570
+ *
571
+ * The TextureCache is the **sole owner** of all THREE.Texture objects used by
572
+ * Studio mode. Materials reference textures but never dispose them directly.
573
+ * Only TextureCache.dispose() / disposeFull() disposes GPU texture resources.
574
+ *
575
+ * Resolution order for texture reference strings:
576
+ * 1. `builtin:` prefix -- procedural texture from the persistent builtin cache
577
+ * 2. Key in the root-level `textures` table -- embedded data or URL
578
+ * 3. `data:` prefix -- treat as data URI, load directly
579
+ * 4. Otherwise -- treat as URL, resolve relative to HTML page
580
+ *
581
+ * Features:
582
+ * - Two-tier caching: user textures (disposed on clear) + builtin textures
583
+ * (persistent, only disposed on viewer teardown)
584
+ * - In-flight promise deduplication (no duplicate loads for the same key)
585
+ * - Correct colorSpace assignment per texture semantic role
586
+ * - GPU resource tracking via gpuTracker
587
+ */
588
+ class TextureCache {
589
+ /** User textures cache (disposed on clear/dispose, rebuilt per shape data) */
590
+ private _cache: Map<string, THREE.Texture> = new Map();
591
+
592
+ /** Built-in procedural textures (persistent, only disposed via disposeFull) */
593
+ private _builtinCache: Map<string, THREE.Texture> = new Map();
594
+
595
+ /** In-flight load promises keyed by cache key */
596
+ private _inflight: Map<string, Promise<THREE.Texture>> = new Map();
597
+
598
+ /** Root-level textures table from shape data */
599
+ private _texturesTable: Record<string, TextureEntry> = {};
600
+
601
+ /** THREE.TextureLoader instance (created lazily) */
602
+ private _textureLoader: THREE.TextureLoader | null = null;
603
+
604
+ /** Whether this cache has been fully disposed */
605
+ private _disposed = false;
606
+
607
+ // ---------------------------------------------------------------------------
608
+ // Public API
609
+ // ---------------------------------------------------------------------------
610
+
611
+ /**
612
+ * Set or update the root-level textures table.
613
+ *
614
+ * Called when new shape data is loaded. The table maps string keys to
615
+ * TextureEntry objects (embedded base64 data or URL references).
616
+ *
617
+ * @param table - The textures table from root Shapes node, or undefined to clear
618
+ */
619
+ setTexturesTable(table: Record<string, TextureEntry> | undefined): void {
620
+ this._texturesTable = table ?? {};
621
+ }
622
+
623
+ /**
624
+ * Resolve a texture reference string and return a cached or newly loaded
625
+ * THREE.Texture with the correct colorSpace set.
626
+ *
627
+ * @param ref - Texture reference string (builtin name, table key, data URI, or URL)
628
+ * @param textureRole - The texture role name (MaterialAppearance field name or proxy role)
629
+ * (e.g. "baseColorTexture", "normalTexture"). Used to determine colorSpace.
630
+ * @returns The resolved THREE.Texture, or null if the reference is invalid
631
+ * or loading fails
632
+ */
633
+ async get(
634
+ ref: string,
635
+ textureRole: string,
636
+ ): Promise<THREE.Texture | null> {
637
+ if (this._disposed) {
638
+ logger.warn("TextureCache.get() called after dispose");
639
+ return null;
640
+ }
641
+
642
+ if (!ref) {
643
+ return null;
644
+ }
645
+
646
+ // Determine the target color space based on the texture role
647
+ const colorSpace = SRGB_TEXTURE_ROLES.has(textureRole)
648
+ ? THREE.SRGBColorSpace
649
+ : THREE.LinearSRGBColorSpace;
650
+
651
+ // 1. builtin: prefix
652
+ if (ref.startsWith("builtin:")) {
653
+ return this._getBuiltin(ref, colorSpace);
654
+ }
655
+
656
+ // 2. Look up in textures table
657
+ const tableEntry = this._texturesTable[ref];
658
+ if (tableEntry) {
659
+ return this._getFromTable(ref, tableEntry, colorSpace);
660
+ }
661
+
662
+ // 3. data: prefix (data URI)
663
+ if (ref.startsWith("data:")) {
664
+ return this._getFromDataUri(ref, colorSpace);
665
+ }
666
+
667
+ // 4. URL (relative to HTML page)
668
+ return this._getFromUrl(ref, colorSpace);
669
+ }
670
+
671
+ /**
672
+ * Check whether a texture reference string would resolve to a builtin texture.
673
+ */
674
+ isBuiltin(ref: string): boolean {
675
+ return ref.startsWith("builtin:");
676
+ }
677
+
678
+ /**
679
+ * Dispose user textures (called on viewer.clear() when shape data is replaced).
680
+ *
681
+ * Disposes all textures in the user cache and clears in-flight promises.
682
+ * The builtin procedural texture cache is preserved.
683
+ */
684
+ dispose(): void {
685
+ // Dispose user textures
686
+ for (const [key, texture] of this._cache) {
687
+ gpuTracker.untrack("texture", texture);
688
+ texture.dispose();
689
+ logger.debug(`Disposed user texture: ${key}`);
690
+ }
691
+ this._cache.clear();
692
+
693
+ // Clear in-flight promises (they may resolve but won't be used)
694
+ this._inflight.clear();
695
+
696
+ // Clear the textures table (will be set again with new shape data)
697
+ this._texturesTable = {};
698
+ }
699
+
700
+ /**
701
+ * Dispose all textures including builtin procedural textures.
702
+ *
703
+ * Called on viewer.dispose() when the viewer is fully torn down.
704
+ * After this call, the TextureCache cannot be used again.
705
+ */
706
+ disposeFull(): void {
707
+ this._disposed = true;
708
+
709
+ // Dispose user textures first
710
+ this.dispose();
711
+
712
+ // Dispose builtin textures
713
+ for (const [key, texture] of this._builtinCache) {
714
+ gpuTracker.untrack("texture", texture);
715
+ texture.dispose();
716
+ logger.debug(`Disposed builtin texture: ${key}`);
717
+ }
718
+ this._builtinCache.clear();
719
+
720
+ // Null the texture loader reference
721
+ this._textureLoader = null;
722
+
723
+ logger.debug("TextureCache fully disposed");
724
+ }
725
+
726
+ // ---------------------------------------------------------------------------
727
+ // Private: Builtin procedural textures
728
+ // ---------------------------------------------------------------------------
729
+
730
+ /**
731
+ * Get or generate a builtin procedural texture.
732
+ *
733
+ * Builtin textures are cached in the persistent `_builtinCache` and survive
734
+ * `dispose()` calls. They are only freed via `disposeFull()`.
735
+ */
736
+ private _getBuiltin(
737
+ ref: string,
738
+ colorSpace: THREE.ColorSpace,
739
+ ): THREE.Texture | null {
740
+ // Extract name after "builtin:" prefix
741
+ const name = ref.slice(8) as BuiltinName;
742
+
743
+ if (!BUILTIN_GENERATORS[name]) {
744
+ logger.warn(`Unknown builtin texture: "${ref}". Available: ${BUILTIN_NAMES.join(", ")}`);
745
+ return null;
746
+ }
747
+
748
+ // Check builtin cache
749
+ const cached = this._builtinCache.get(ref);
750
+ if (cached) {
751
+ // TODO: If the same builtin is used in different roles (sRGB vs Linear),
752
+ // mutating colorSpace in-place would affect other materials. This is
753
+ // unlikely for builtins (almost always normal maps) but could be fixed
754
+ // with a composite cache key (ref + colorSpace) if needed.
755
+ cached.colorSpace = colorSpace;
756
+ return cached;
757
+ }
758
+
759
+ // Generate the procedural texture
760
+ const canvas = BUILTIN_GENERATORS[name](BUILTIN_SIZE);
761
+ const texture = new THREE.CanvasTexture(canvas as TexImageSource);
762
+ texture.wrapS = THREE.RepeatWrapping;
763
+ texture.wrapT = THREE.RepeatWrapping;
764
+ texture.colorSpace = colorSpace;
765
+ texture.needsUpdate = true;
766
+
767
+ // Cache in the persistent builtin cache
768
+ this._builtinCache.set(ref, texture);
769
+ gpuTracker.trackTexture(texture, `Builtin procedural texture: ${ref}`);
770
+ logger.debug(`Generated builtin texture: ${ref}`);
771
+
772
+ return texture;
773
+ }
774
+
775
+ // ---------------------------------------------------------------------------
776
+ // Private: Table entry loading
777
+ // ---------------------------------------------------------------------------
778
+
779
+ /**
780
+ * Load a texture from the root-level textures table entry.
781
+ *
782
+ * Handles both embedded (base64 data) and URL-referenced entries.
783
+ */
784
+ private async _getFromTable(
785
+ key: string,
786
+ entry: TextureEntry,
787
+ colorSpace: THREE.ColorSpace,
788
+ ): Promise<THREE.Texture | null> {
789
+ // Check user cache
790
+ // TODO: If the same texture table entry is used in different roles
791
+ // (sRGB vs Linear), mutating colorSpace in-place would affect other
792
+ // materials. Could be fixed with a composite cache key (key + colorSpace).
793
+ const cached = this._cache.get(key);
794
+ if (cached) {
795
+ cached.colorSpace = colorSpace;
796
+ return cached;
797
+ }
798
+
799
+ // Check in-flight
800
+ const inflight = this._inflight.get(key);
801
+ if (inflight) {
802
+ const texture = await inflight;
803
+ texture.colorSpace = colorSpace;
804
+ return texture;
805
+ }
806
+
807
+ // Resolve table entry to a loadable source
808
+ if (entry.data && entry.format) {
809
+ // Embedded: construct data URI from base64 data
810
+ const mimeType = this._formatToMime(entry.format);
811
+ const dataUri = `data:${mimeType};base64,${entry.data}`;
812
+ return this._loadAndCache(key, dataUri, colorSpace);
813
+ }
814
+
815
+ if (entry.url) {
816
+ // URL reference
817
+ return this._loadAndCache(key, entry.url, colorSpace);
818
+ }
819
+
820
+ // Invalid entry (neither data nor url)
821
+ logger.warn(`Texture table entry "${key}" has neither data nor url, skipping`);
822
+ return null;
823
+ }
824
+
825
+ // ---------------------------------------------------------------------------
826
+ // Private: Data URI loading
827
+ // ---------------------------------------------------------------------------
828
+
829
+ /**
830
+ * Load a texture from a data URI string.
831
+ */
832
+ private async _getFromDataUri(
833
+ dataUri: string,
834
+ colorSpace: THREE.ColorSpace,
835
+ ): Promise<THREE.Texture | null> {
836
+ // Use the data URI itself as the cache key
837
+ const cacheKey = dataUri;
838
+
839
+ const cached = this._cache.get(cacheKey);
840
+ if (cached) {
841
+ cached.colorSpace = colorSpace;
842
+ return cached;
843
+ }
844
+
845
+ const inflight = this._inflight.get(cacheKey);
846
+ if (inflight) {
847
+ const texture = await inflight;
848
+ texture.colorSpace = colorSpace;
849
+ return texture;
850
+ }
851
+
852
+ return this._loadAndCache(cacheKey, dataUri, colorSpace);
853
+ }
854
+
855
+ // ---------------------------------------------------------------------------
856
+ // Private: URL loading
857
+ // ---------------------------------------------------------------------------
858
+
859
+ /**
860
+ * Load a texture from a URL (resolved relative to the HTML page).
861
+ */
862
+ private async _getFromUrl(
863
+ url: string,
864
+ colorSpace: THREE.ColorSpace,
865
+ ): Promise<THREE.Texture | null> {
866
+ const cached = this._cache.get(url);
867
+ if (cached) {
868
+ cached.colorSpace = colorSpace;
869
+ return cached;
870
+ }
871
+
872
+ const inflight = this._inflight.get(url);
873
+ if (inflight) {
874
+ const texture = await inflight;
875
+ texture.colorSpace = colorSpace;
876
+ return texture;
877
+ }
878
+
879
+ return this._loadAndCache(url, url, colorSpace);
880
+ }
881
+
882
+ // ---------------------------------------------------------------------------
883
+ // Private: Core loading
884
+ // ---------------------------------------------------------------------------
885
+
886
+ /**
887
+ * Load a texture from a source (URL or data URI), cache it, and return it.
888
+ *
889
+ * Deduplicates in-flight loads for the same cache key.
890
+ *
891
+ * @param cacheKey - Key for the user cache
892
+ * @param source - URL or data URI to load from
893
+ * @param colorSpace - Color space to assign to the loaded texture
894
+ * @returns The loaded texture, or null on failure
895
+ */
896
+ private async _loadAndCache(
897
+ cacheKey: string,
898
+ source: string,
899
+ colorSpace: THREE.ColorSpace,
900
+ ): Promise<THREE.Texture | null> {
901
+ const promise = this._doLoad(source, colorSpace);
902
+ this._inflight.set(cacheKey, promise);
903
+
904
+ try {
905
+ const texture = await promise;
906
+
907
+ // Check if disposed while loading:
908
+ // - disposeFull() sets _disposed
909
+ // - dispose() clears _inflight (so our entry is gone)
910
+ if (this._disposed || !this._inflight.has(cacheKey)) {
911
+ texture.dispose();
912
+ return null;
913
+ }
914
+
915
+ // Cache and track
916
+ this._cache.set(cacheKey, texture);
917
+ const label = cacheKey.startsWith("data:")
918
+ ? `Texture (data URI, ${cacheKey.length} chars)`
919
+ : `Texture: ${cacheKey}`;
920
+ gpuTracker.trackTexture(texture, label);
921
+ logger.debug(`Loaded and cached texture: ${label}`);
922
+
923
+ return texture;
924
+ } catch (error) {
925
+ if (this._disposed) return null;
926
+
927
+ const displayKey = cacheKey.startsWith("data:")
928
+ ? `data URI (${cacheKey.length} chars)`
929
+ : cacheKey;
930
+ logger.warn(
931
+ `Failed to load texture "${displayKey}":`,
932
+ error instanceof Error ? error.message : error,
933
+ );
934
+ return null;
935
+ } finally {
936
+ this._inflight.delete(cacheKey);
937
+ }
938
+ }
939
+
940
+ /**
941
+ * Perform the actual texture load via THREE.TextureLoader.
942
+ *
943
+ * THREE.TextureLoader handles both URLs and data URIs.
944
+ */
945
+ private _doLoad(
946
+ source: string,
947
+ colorSpace: THREE.ColorSpace,
948
+ ): Promise<THREE.Texture> {
949
+ const loader = this._ensureTextureLoader();
950
+
951
+ return new Promise<THREE.Texture>((resolve, reject) => {
952
+ loader.load(
953
+ source,
954
+ (texture) => {
955
+ texture.colorSpace = colorSpace;
956
+ texture.wrapS = THREE.RepeatWrapping;
957
+ texture.wrapT = THREE.RepeatWrapping;
958
+ resolve(texture);
959
+ },
960
+ undefined, // onProgress (not used)
961
+ (error) => {
962
+ reject(
963
+ error instanceof Error
964
+ ? error
965
+ : new Error(`Texture load failed: ${source.substring(0, 100)}`),
966
+ );
967
+ },
968
+ );
969
+ });
970
+ }
971
+
972
+ // ---------------------------------------------------------------------------
973
+ // Private: Utilities
974
+ // ---------------------------------------------------------------------------
975
+
976
+ /**
977
+ * Get or create the THREE.TextureLoader (lazy initialization).
978
+ */
979
+ private _ensureTextureLoader(): THREE.TextureLoader {
980
+ if (!this._textureLoader) {
981
+ this._textureLoader = new THREE.TextureLoader();
982
+ logger.debug("Created TextureLoader");
983
+ }
984
+ return this._textureLoader;
985
+ }
986
+
987
+ /**
988
+ * Convert a format string (e.g. "png", "jpg", "webp") to a MIME type.
989
+ */
990
+ private _formatToMime(format: string): string {
991
+ switch (format.toLowerCase()) {
992
+ case "jpg":
993
+ case "jpeg":
994
+ return "image/jpeg";
995
+ case "png":
996
+ return "image/png";
997
+ case "webp":
998
+ return "image/webp";
999
+ default:
1000
+ // Default to PNG for unknown formats
1001
+ logger.warn(`Unknown texture format "${format}", defaulting to image/png`);
1002
+ return "image/png";
1003
+ }
1004
+ }
1005
+ }
1006
+
1007
+ /**
1008
+ * Get the correct color space for a Three.js material map property name.
1009
+ *
1010
+ * sRGB maps (color data): map, emissiveMap, sheenColorMap, specularColorMap
1011
+ * Linear maps (non-color data): everything else (normalMap, roughnessMap, etc.)
1012
+ *
1013
+ * @param mapName - Three.js material property name (e.g., "map", "normalMap")
1014
+ * @returns THREE.SRGBColorSpace or THREE.LinearSRGBColorSpace
1015
+ */
1016
+ function getColorSpaceForMap(mapName: string): THREE.ColorSpace {
1017
+ return THREEJS_SRGB_MAPS.has(mapName) ? THREE.SRGBColorSpace : THREE.LinearSRGBColorSpace;
1018
+ }
1019
+
1020
+ export { TextureCache, SRGB_TEXTURE_ROLES, BUILTIN_NAMES, getColorSpaceForMap };