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.
- package/Readme.md +12 -5
- package/dist/camera/camera.d.ts +14 -2
- package/dist/core/studio-manager.d.ts +91 -0
- package/dist/core/types.d.ts +260 -9
- package/dist/core/viewer-state.d.ts +28 -2
- package/dist/core/viewer.d.ts +200 -6
- package/dist/index.d.ts +7 -2
- package/dist/rendering/environment.d.ts +239 -0
- package/dist/rendering/light-detection.d.ts +44 -0
- package/dist/rendering/material-factory.d.ts +77 -2
- package/dist/rendering/material-presets.d.ts +32 -0
- package/dist/rendering/room-environment.d.ts +13 -0
- package/dist/rendering/studio-composer.d.ts +130 -0
- package/dist/rendering/studio-floor.d.ts +53 -0
- package/dist/rendering/texture-cache.d.ts +142 -0
- package/dist/rendering/triplanar.d.ts +37 -0
- package/dist/scene/animation.d.ts +1 -1
- package/dist/scene/clipping.d.ts +31 -0
- package/dist/scene/nestedgroup.d.ts +64 -27
- package/dist/scene/objectgroup.d.ts +47 -0
- package/dist/three-cad-viewer.css +339 -29
- package/dist/three-cad-viewer.esm.js +27567 -11874
- package/dist/three-cad-viewer.esm.js.map +1 -1
- package/dist/three-cad-viewer.esm.min.js +10 -4
- package/dist/three-cad-viewer.js +27486 -11787
- package/dist/three-cad-viewer.min.js +10 -4
- package/dist/ui/display.d.ts +147 -0
- package/dist/utils/decode-instances.d.ts +60 -0
- package/dist/utils/utils.d.ts +10 -0
- package/package.json +4 -2
- package/src/_version.ts +1 -1
- package/src/camera/camera.ts +27 -10
- package/src/core/studio-manager.ts +682 -0
- package/src/core/types.ts +328 -9
- package/src/core/viewer-state.ts +84 -4
- package/src/core/viewer.ts +453 -22
- package/src/index.ts +25 -1
- package/src/rendering/environment.ts +840 -0
- package/src/rendering/light-detection.ts +327 -0
- package/src/rendering/material-factory.ts +456 -2
- package/src/rendering/material-presets.ts +303 -0
- package/src/rendering/raycast.ts +2 -2
- package/src/rendering/room-environment.ts +192 -0
- package/src/rendering/studio-composer.ts +577 -0
- package/src/rendering/studio-floor.ts +108 -0
- package/src/rendering/texture-cache.ts +1020 -0
- package/src/rendering/triplanar.ts +329 -0
- package/src/scene/animation.ts +3 -2
- package/src/scene/clipping.ts +59 -0
- package/src/scene/nestedgroup.ts +399 -0
- package/src/scene/objectgroup.ts +186 -11
- package/src/scene/orientation.ts +12 -0
- package/src/scene/render-shape.ts +55 -21
- package/src/types/n8ao.d.ts +28 -0
- package/src/ui/display.ts +1032 -27
- package/src/ui/index.html +181 -44
- package/src/utils/decode-instances.ts +233 -0
- 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
|
+
}
|