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,327 @@
1
+ /**
2
+ * HDR environment map light source detection.
3
+ *
4
+ * Analyzes equirectangular HDR pixel data to find dominant light sources
5
+ * (softboxes in studio HDRs, sun in outdoor HDRs). Returns direction,
6
+ * intensity, and color for up to 2 lights, used to create shadow-casting
7
+ * DirectionalLights in Studio mode.
8
+ *
9
+ * Algorithm: downsample to 128x64 luminance grid → threshold at 10x median
10
+ * → flood-fill cluster → convert centroids to 3D direction vectors.
11
+ * Runs ~5-10ms on CPU, no GPU readback needed.
12
+ */
13
+
14
+ import { logger } from "../utils/logger.js";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Types
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /** A detected dominant light source from HDR analysis. */
21
+ export interface DetectedLight {
22
+ /** Unit direction vector toward the light source (Y-up, before Z-up rotation). */
23
+ direction: [number, number, number];
24
+ /** Relative intensity (normalized, 0-1 range). */
25
+ intensity: number;
26
+ /** Linear RGB color of the light source (0-1 range). */
27
+ color: [number, number, number];
28
+ }
29
+
30
+ /** Result of light detection analysis. */
31
+ export interface LightDetectionResult {
32
+ /** Detected light sources (0-2 entries). */
33
+ lights: DetectedLight[];
34
+ /** Whether the result came from actual HDR analysis (true) or a fallback (false). */
35
+ wasAnalyzed: boolean;
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Constants
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /** Downsampled grid width for analysis. */
43
+ const GRID_W = 128;
44
+ /** Downsampled grid height for analysis. */
45
+ const GRID_H = 64;
46
+ /** Threshold multiplier: pixels brighter than median * this are "light sources". */
47
+ const THRESHOLD_MULTIPLIER = 10;
48
+ /** Maximum number of lights to return. */
49
+ const MAX_LIGHTS = 1;
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Half-float decoding
53
+ // ---------------------------------------------------------------------------
54
+
55
+ /**
56
+ * Decode a 16-bit half-float value to a JavaScript number.
57
+ * HDRLoader returns Uint16Array with HalfFloatType pixels.
58
+ */
59
+ function halfToFloat(h: number): number {
60
+ const sign = (h >> 15) & 0x1;
61
+ const exponent = (h >> 10) & 0x1f;
62
+ const mantissa = h & 0x3ff;
63
+
64
+ if (exponent === 0) {
65
+ // Subnormal or zero
66
+ return (sign ? -1 : 1) * Math.pow(2, -14) * (mantissa / 1024);
67
+ }
68
+ if (exponent === 31) {
69
+ // Infinity or NaN
70
+ return mantissa ? NaN : (sign ? -Infinity : Infinity);
71
+ }
72
+ return (sign ? -1 : 1) * Math.pow(2, exponent - 15) * (1 + mantissa / 1024);
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Core analysis
77
+ // ---------------------------------------------------------------------------
78
+
79
+ /**
80
+ * Detect dominant light sources from equirectangular HDR pixel data.
81
+ *
82
+ * @param data - Raw pixel data (Uint16Array for HalfFloat, or Float32Array)
83
+ * @param width - HDR image width in pixels
84
+ * @param height - HDR image height in pixels
85
+ * @returns Detection result with up to 2 lights
86
+ */
87
+ export function detectDominantLights(
88
+ data: Uint16Array | Float32Array,
89
+ width: number,
90
+ height: number,
91
+ ): LightDetectionResult {
92
+ const isHalf = data instanceof Uint16Array;
93
+ const channels = data.length / (width * height);
94
+ if (channels < 3) {
95
+ logger.warn("Light detection: unexpected channel count", channels);
96
+ return { lights: [], wasAnalyzed: false };
97
+ }
98
+
99
+ // 1. Downsample to GRID_W x GRID_H luminance grid with cos(latitude) weighting
100
+ const grid = new Float32Array(GRID_W * GRID_H);
101
+ const gridR = new Float32Array(GRID_W * GRID_H);
102
+ const gridG = new Float32Array(GRID_W * GRID_H);
103
+ const gridB = new Float32Array(GRID_W * GRID_H);
104
+ const gridCount = new Float32Array(GRID_W * GRID_H);
105
+
106
+ for (let sy = 0; sy < height; sy++) {
107
+ const gy = Math.min(Math.floor((sy / height) * GRID_H), GRID_H - 1);
108
+ // cos(latitude) weighting: equator has more area than poles
109
+ const phi = ((0.5 - sy / height)) * Math.PI;
110
+ const cosWeight = Math.cos(phi);
111
+
112
+ for (let sx = 0; sx < width; sx++) {
113
+ const gx = Math.min(Math.floor((sx / width) * GRID_W), GRID_W - 1);
114
+ const srcIdx = (sy * width + sx) * channels;
115
+
116
+ let r: number, g: number, b: number;
117
+ if (isHalf) {
118
+ r = halfToFloat(data[srcIdx]);
119
+ g = halfToFloat(data[srcIdx + 1]);
120
+ b = halfToFloat(data[srcIdx + 2]);
121
+ } else {
122
+ r = data[srcIdx];
123
+ g = data[srcIdx + 1];
124
+ b = data[srcIdx + 2];
125
+ }
126
+
127
+ // Clamp negative values (shouldn't happen in valid HDR but be safe)
128
+ r = Math.max(0, r);
129
+ g = Math.max(0, g);
130
+ b = Math.max(0, b);
131
+
132
+ const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
133
+ const gi = gy * GRID_W + gx;
134
+
135
+ grid[gi] += luminance * cosWeight;
136
+ gridR[gi] += r * cosWeight;
137
+ gridG[gi] += g * cosWeight;
138
+ gridB[gi] += b * cosWeight;
139
+ gridCount[gi] += cosWeight;
140
+ }
141
+ }
142
+
143
+ // Normalize by sample count
144
+ for (let i = 0; i < grid.length; i++) {
145
+ if (gridCount[i] > 0) {
146
+ grid[i] /= gridCount[i];
147
+ gridR[i] /= gridCount[i];
148
+ gridG[i] /= gridCount[i];
149
+ gridB[i] /= gridCount[i];
150
+ }
151
+ }
152
+
153
+ // 2. Compute median luminance and threshold
154
+ const sorted = Array.from(grid).filter(v => v > 0).sort((a, b) => a - b);
155
+ if (sorted.length === 0) {
156
+ return { lights: [], wasAnalyzed: true };
157
+ }
158
+ const median = sorted[Math.floor(sorted.length / 2)];
159
+ const threshold = median * THRESHOLD_MULTIPLIER;
160
+
161
+ // 3. Mark bright pixels
162
+ const bright = new Uint8Array(GRID_W * GRID_H);
163
+ for (let i = 0; i < grid.length; i++) {
164
+ bright[i] = grid[i] >= threshold ? 1 : 0;
165
+ }
166
+
167
+ // 4. Flood-fill clustering
168
+ const visited = new Uint8Array(GRID_W * GRID_H);
169
+ const clusters: Array<{
170
+ totalLum: number;
171
+ totalR: number;
172
+ totalG: number;
173
+ totalB: number;
174
+ weightedGx: number;
175
+ weightedGy: number;
176
+ count: number;
177
+ }> = [];
178
+
179
+ for (let startY = 0; startY < GRID_H; startY++) {
180
+ for (let startX = 0; startX < GRID_W; startX++) {
181
+ const startIdx = startY * GRID_W + startX;
182
+ if (!bright[startIdx] || visited[startIdx]) continue;
183
+
184
+ // BFS flood-fill
185
+ const cluster = {
186
+ totalLum: 0,
187
+ totalR: 0,
188
+ totalG: 0,
189
+ totalB: 0,
190
+ weightedGx: 0,
191
+ weightedGy: 0,
192
+ count: 0,
193
+ };
194
+
195
+ const queue: number[] = [startIdx];
196
+ visited[startIdx] = 1;
197
+
198
+ while (queue.length > 0) {
199
+ const idx = queue.pop()!;
200
+ const cy = Math.floor(idx / GRID_W);
201
+ const cx = idx % GRID_W;
202
+ const lum = grid[idx];
203
+
204
+ cluster.totalLum += lum;
205
+ cluster.totalR += gridR[idx] * lum;
206
+ cluster.totalG += gridG[idx] * lum;
207
+ cluster.totalB += gridB[idx] * lum;
208
+ cluster.weightedGx += cx * lum;
209
+ cluster.weightedGy += cy * lum;
210
+ cluster.count++;
211
+
212
+ // 4-connected neighbors (wrap horizontally for equirectangular)
213
+ const neighbors = [
214
+ [cx, cy - 1],
215
+ [cx, cy + 1],
216
+ [(cx - 1 + GRID_W) % GRID_W, cy],
217
+ [(cx + 1) % GRID_W, cy],
218
+ ];
219
+
220
+ for (const [nx, ny] of neighbors) {
221
+ if (ny < 0 || ny >= GRID_H) continue;
222
+ const ni = ny * GRID_W + nx;
223
+ if (!bright[ni] || visited[ni]) continue;
224
+ visited[ni] = 1;
225
+ queue.push(ni);
226
+ }
227
+ }
228
+
229
+ if (cluster.count > 0) {
230
+ clusters.push(cluster);
231
+ }
232
+ }
233
+ }
234
+
235
+ if (clusters.length === 0) {
236
+ return { lights: [], wasAnalyzed: true };
237
+ }
238
+
239
+ // 5. Sort by total luminance (brightest first) and take top N
240
+ clusters.sort((a, b) => b.totalLum - a.totalLum);
241
+ const topClusters = clusters.slice(0, MAX_LIGHTS);
242
+
243
+ // Find max total luminance for normalization
244
+ const maxLum = topClusters[0].totalLum;
245
+
246
+ // 6. Convert centroids to 3D direction vectors
247
+ const lights: DetectedLight[] = topClusters.map((c) => {
248
+ // Centroid in grid coordinates
249
+ const cx = c.weightedGx / c.totalLum;
250
+ const cy = c.weightedGy / c.totalLum;
251
+
252
+ // Convert to equirectangular UV
253
+ const u = cx / GRID_W;
254
+ const v = cy / GRID_H;
255
+
256
+ // Convert to spherical: theta = azimuth, phi = elevation
257
+ // Y-up convention (before Z-up rotation applied in viewer.ts)
258
+ const theta = (u - 0.5) * 2 * Math.PI;
259
+ const phi = (0.5 - v) * Math.PI;
260
+
261
+ const cosPhi = Math.cos(phi);
262
+ const direction: [number, number, number] = [
263
+ Math.cos(theta) * cosPhi,
264
+ Math.sin(phi),
265
+ Math.sin(theta) * cosPhi,
266
+ ];
267
+
268
+ // Normalize color
269
+ const colorTotal = c.totalR + c.totalG + c.totalB;
270
+ let color: [number, number, number];
271
+ if (colorTotal > 0) {
272
+ color = [
273
+ c.totalR / colorTotal * 3,
274
+ c.totalG / colorTotal * 3,
275
+ c.totalB / colorTotal * 3,
276
+ ];
277
+ // Clamp to 0-1
278
+ color = [
279
+ Math.min(1, color[0]),
280
+ Math.min(1, color[1]),
281
+ Math.min(1, color[2]),
282
+ ];
283
+ } else {
284
+ color = [1, 1, 1];
285
+ }
286
+
287
+ return {
288
+ direction,
289
+ intensity: c.totalLum / maxLum,
290
+ color,
291
+ };
292
+ });
293
+
294
+ logger.debug(
295
+ `Light detection: found ${lights.length} dominant light(s) from ${clusters.length} cluster(s)`,
296
+ );
297
+
298
+ return { lights, wasAnalyzed: true };
299
+ }
300
+
301
+ /**
302
+ * Return hardcoded fallback lights for procedural RoomEnvironment.
303
+ *
304
+ * The RoomEnvironment has no raw HDR data to analyze. A single top-front
305
+ * light direction approximates its primary illumination.
306
+ */
307
+ export function getDefaultLights(): LightDetectionResult {
308
+ // Top-front-right direction (Y-up, similar to RoomEnvironment's main emitter)
309
+ const dir = normalize([0.3, 0.8, 0.5]);
310
+ return {
311
+ lights: [
312
+ {
313
+ direction: dir,
314
+ intensity: 1.0,
315
+ color: [1, 1, 1],
316
+ },
317
+ ],
318
+ wasAnalyzed: false,
319
+ };
320
+ }
321
+
322
+ /** Normalize a 3-component vector in-place and return it. */
323
+ function normalize(v: [number, number, number]): [number, number, number] {
324
+ const len = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
325
+ if (len === 0) return [0, 1, 0];
326
+ return [v[0] / len, v[1] / len, v[2] / len];
327
+ }