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,577 @@
1
+ /**
2
+ * StudioComposer -- postprocessing pipeline for Studio mode.
3
+ *
4
+ * Wraps the pmndrs EffectComposer to provide:
5
+ * - Scene rendering (RenderPass)
6
+ * - Screen-space ambient occlusion (N8AOPostPass)
7
+ * - Screen-space shadow mask (BasicShadowMap + KawaseBlurPass)
8
+ * - Tone mapping + sRGB output + antialiasing (ToneMappingEffect + SMAAEffect)
9
+ *
10
+ * Tone mapping is handled by the postprocessing ToneMappingEffect, which uses
11
+ * Three.js's own GLSL tone mapping functions (via #include <tonemapping_pars_fragment>).
12
+ * The renderer's toneMapping must be set to NoToneMapping (the postprocessing
13
+ * library's documented requirement). Exposure is controlled via
14
+ * renderer.toneMappingExposure, which the GLSL functions read automatically.
15
+ *
16
+ * Background protection: solid-color backgrounds are excluded from tone mapping.
17
+ * The RenderPass skips the background (ignoreBackground), the FBO is cleared to
18
+ * transparent, and the EffectPass alpha-blends its output onto a pre-cleared
19
+ * canvas that already has the correct background color.
20
+ *
21
+ * Shadow mask: BasicShadowMap produces sharp shadow boundaries at 4096×4096.
22
+ * A half-resolution ShadowMaterial override pass captures the mask, which is
23
+ * then blurred via KawaseBlurPass and composited by ShadowMaskEffect before
24
+ * tone mapping. The floor keeps its own ShadowMaterial reading the shadow map
25
+ * directly (sharp but clean).
26
+ *
27
+ * Only instantiated when Studio mode is active. Non-Studio rendering
28
+ * bypasses this entirely and uses direct `renderer.render()`.
29
+ */
30
+
31
+ import {
32
+ EffectComposer,
33
+ RenderPass,
34
+ EffectPass,
35
+ SMAAEffect,
36
+ SMAAPreset,
37
+ ToneMappingEffect,
38
+ ToneMappingMode,
39
+ KawaseBlurPass,
40
+ KernelSize,
41
+ Effect,
42
+ EffectAttribute,
43
+ BlendFunction,
44
+ } from "postprocessing";
45
+ import { N8AOPostPass } from "n8ao";
46
+ import * as THREE from "three";
47
+ import type { StudioToneMapping } from "../core/types.js";
48
+ import { logger } from "../utils/logger.js";
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Tone-mapping: maps viewer strings to postprocessing ToneMappingMode
52
+ // ---------------------------------------------------------------------------
53
+
54
+ const TONE_MAP_MODE: Record<StudioToneMapping, ToneMappingMode> = {
55
+ "neutral": ToneMappingMode.NEUTRAL,
56
+ "ACES": ToneMappingMode.ACES_FILMIC,
57
+ "none": ToneMappingMode.LINEAR,
58
+ };
59
+
60
+ // Scratch color to avoid per-frame allocation
61
+ const _savedClearColor = new THREE.Color();
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // ShadowMaskEffect — composites blurred shadow mask onto the scene
65
+ // ---------------------------------------------------------------------------
66
+
67
+ const _shadowMaskFragmentShader = /* glsl */ `
68
+ uniform sampler2D shadowMaskObjects;
69
+ uniform sampler2D shadowMaskFloor;
70
+ uniform float shadowIntensity;
71
+
72
+ void mainImage(const in vec4 inputColor, const in vec2 uv, const in float depth, out vec4 outputColor) {
73
+ float objects = texture2D(shadowMaskObjects, uv).a;
74
+ float floorShadow = texture2D(shadowMaskFloor, uv).a;
75
+ // Floor is hidden in the main render, so pixels at depth ≈ 1.0 are where
76
+ // the floor would be (background/clear depth). Pixels with depth < 1.0
77
+ // are objects — don't apply floor shadow there (prevents bleed-through).
78
+ float isFloorArea = step(0.9999, depth);
79
+ float shadowAmount = max(objects, floorShadow * isFloorArea);
80
+ float attenuation = 1.0 - shadowIntensity * shadowAmount;
81
+
82
+ if (inputColor.a < 0.001) {
83
+ // Background-protect mode: FBO is transparent, canvas has bg color.
84
+ // Output shadow as alpha — NormalBlending composites:
85
+ // (0,0,0)*a + bgColor*(1-a) = bgColor darkened by shadow.
86
+ outputColor = vec4(0.0, 0.0, 0.0, shadowIntensity * shadowAmount);
87
+ } else {
88
+ outputColor = vec4(inputColor.rgb * clamp(attenuation, 0.0, 1.0), inputColor.a);
89
+ }
90
+ }
91
+ `;
92
+
93
+ class ShadowMaskEffect extends Effect {
94
+ constructor() {
95
+ super("ShadowMaskEffect", _shadowMaskFragmentShader, {
96
+ blendFunction: BlendFunction.NORMAL,
97
+ attributes: EffectAttribute.DEPTH,
98
+ uniforms: new Map<string, THREE.Uniform>([
99
+ ["shadowMaskObjects", new THREE.Uniform(null)],
100
+ ["shadowMaskFloor", new THREE.Uniform(null)],
101
+ ["shadowIntensity", new THREE.Uniform(0.5)],
102
+ ]),
103
+ });
104
+ }
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // StudioComposer
109
+ // ---------------------------------------------------------------------------
110
+
111
+ class StudioComposer {
112
+ private _composer: EffectComposer;
113
+ private _renderPass: RenderPass;
114
+ // N8AOPostPass doesn't formally extend the postprocessing Pass class,
115
+ // so EffectComposer.addPass() needs an `any` cast.
116
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
117
+ private _n8aoPass: any;
118
+ private _toneMappingEffect: ToneMappingEffect;
119
+ private _effectPass: EffectPass;
120
+ private _renderer: THREE.WebGLRenderer;
121
+ private _scene: THREE.Scene;
122
+ private _camera: THREE.Camera;
123
+
124
+ /** Solid background color to protect from tone mapping, or null. */
125
+ private _bgProtectColor: THREE.Color | null = null;
126
+
127
+ // Shadow mask pipeline — two separate masks to avoid depth-discontinuity halos
128
+ private _shadowMaskRT: THREE.WebGLRenderTarget | null = null;
129
+ private _blurredObjectMaskRT: THREE.WebGLRenderTarget | null = null;
130
+ private _blurredFloorMaskRT: THREE.WebGLRenderTarget | null = null;
131
+ private _blurPass: KawaseBlurPass | null = null;
132
+ private _shadowMaskMaterial: THREE.ShadowMaterial | null = null;
133
+ private _shadowMaskEffect: ShadowMaskEffect;
134
+ private _shadowMaskEnabled: boolean = false;
135
+ private _width: number = 0;
136
+ private _height: number = 0;
137
+
138
+ // Scratch containers for shadow mask passes — hoisted to avoid per-frame GC
139
+ private _receiveShadowState: Map<THREE.Mesh, boolean> = new Map();
140
+ private _savedIntensities: Map<THREE.DirectionalLight, number> = new Map();
141
+ private _savedVisibility: Map<THREE.Object3D, boolean> = new Map();
142
+
143
+ /**
144
+ * Create the postprocessing pipeline.
145
+ *
146
+ * @param renderer - WebGL renderer
147
+ * @param scene - Scene to render
148
+ * @param camera - Active camera (perspective or orthographic)
149
+ * @param width - Canvas width in pixels
150
+ * @param height - Canvas height in pixels
151
+ */
152
+ constructor(
153
+ renderer: THREE.WebGLRenderer,
154
+ scene: THREE.Scene,
155
+ camera: THREE.Camera,
156
+ width: number,
157
+ height: number,
158
+ ) {
159
+ this._renderer = renderer;
160
+ this._scene = scene;
161
+ this._camera = camera;
162
+ this._width = width;
163
+ this._height = height;
164
+
165
+ // Postprocessing library requires renderer.toneMapping = NoToneMapping.
166
+ // Tone mapping is handled by ToneMappingEffect in the pipeline.
167
+ this._renderer.toneMapping = THREE.NoToneMapping;
168
+
169
+ // HDR pipeline with HalfFloat framebuffer.
170
+ // multisampling = 0: MSAA conflicts with depth-based AO passes;
171
+ // antialiasing is handled by SMAAEffect instead.
172
+ this._composer = new EffectComposer(renderer, {
173
+ frameBufferType: THREE.HalfFloatType,
174
+ multisampling: 0,
175
+ });
176
+
177
+ // --- Pass 1: scene render ---
178
+ this._renderPass = new RenderPass(scene, camera);
179
+ // Shadow maps are generated in the manual shadow mask pass (Phase 1),
180
+ // so the main RenderPass should not regenerate them.
181
+ this._renderPass.skipShadowMapUpdate = true;
182
+ this._composer.addPass(this._renderPass);
183
+
184
+ // --- Pass 2: N8AO ambient occlusion ---
185
+ this._n8aoPass = new N8AOPostPass(scene, camera, width, height);
186
+ this._n8aoPass.configuration.aoRadius = 2.0;
187
+ this._n8aoPass.configuration.distanceFalloff = 0.5;
188
+ this._n8aoPass.configuration.intensity = 1.5;
189
+ this._n8aoPass.configuration.halfRes = true;
190
+ this._n8aoPass.configuration.depthAwareUpsampling = true;
191
+ this._n8aoPass.configuration.gammaCorrection = false;
192
+ this._n8aoPass.setQualityMode("Medium");
193
+ this._n8aoPass.enabled = false; // off by default
194
+ this._composer.addPass(this._n8aoPass);
195
+
196
+ // --- Pass 3: shadow mask compositing + tone mapping + SMAA ---
197
+ this._toneMappingEffect = new ToneMappingEffect({
198
+ mode: ToneMappingMode.NEUTRAL,
199
+ });
200
+ const smaaEffect = new SMAAEffect({ preset: SMAAPreset.HIGH });
201
+ // ShadowMaskEffect is first so it runs in linear space before tone mapping
202
+ this._shadowMaskEffect = new ShadowMaskEffect();
203
+ this._effectPass = new EffectPass(
204
+ camera,
205
+ this._shadowMaskEffect,
206
+ this._toneMappingEffect,
207
+ smaaEffect,
208
+ );
209
+ this._composer.addPass(this._effectPass);
210
+
211
+ logger.debug("StudioComposer: pipeline created");
212
+ }
213
+
214
+ // -----------------------------------------------------------------------
215
+ // Background protection
216
+ // -----------------------------------------------------------------------
217
+
218
+ /**
219
+ * Enable or disable solid-background protection.
220
+ *
221
+ * When a solid color is set, the RenderPass skips the scene background
222
+ * (ignoreBackground=true), the FBO is cleared to transparent, and the
223
+ * canvas is pre-cleared with the correct color. The EffectPass then
224
+ * alpha-blends its output so background pixels (alpha=0) show the
225
+ * canvas clear color underneath.
226
+ *
227
+ * Pass null to disable protection (for gradient/environment backgrounds).
228
+ */
229
+ setBackgroundProtect(color: THREE.Color | null): void {
230
+ this._bgProtectColor = color;
231
+ if (color) {
232
+ this._renderPass.ignoreBackground = true;
233
+ // Force the ClearPass to clear the FBO with transparent black
234
+ this._renderPass.clearPass.overrideClearColor = new THREE.Color(0, 0, 0);
235
+ (this._renderPass.clearPass as any).overrideClearAlpha = 0;
236
+ // Alpha-blend the final output onto the pre-cleared canvas
237
+ this._effectPass.fullscreenMaterial.blending = THREE.NormalBlending;
238
+ this._effectPass.fullscreenMaterial.transparent = true;
239
+ } else {
240
+ this._renderPass.ignoreBackground = false;
241
+ (this._renderPass.clearPass as any).overrideClearColor = null;
242
+ (this._renderPass.clearPass as any).overrideClearAlpha = -1;
243
+ // Opaque overwrite (default postprocessing behavior)
244
+ this._effectPass.fullscreenMaterial.blending = THREE.NoBlending;
245
+ this._effectPass.fullscreenMaterial.transparent = false;
246
+ }
247
+ }
248
+
249
+ // -----------------------------------------------------------------------
250
+ // Ambient Occlusion
251
+ // -----------------------------------------------------------------------
252
+
253
+ setAOEnabled(flag: boolean): void {
254
+ this._n8aoPass.enabled = flag;
255
+ logger.debug(`StudioComposer: AO ${flag ? "enabled" : "disabled"}`);
256
+ }
257
+
258
+ setAOIntensity(value: number): void {
259
+ this._n8aoPass.configuration.intensity = value;
260
+ }
261
+
262
+ // -----------------------------------------------------------------------
263
+ // Tone Mapping
264
+ // -----------------------------------------------------------------------
265
+
266
+ /**
267
+ * Set the tone mapping algorithm and exposure.
268
+ *
269
+ * @param mode - One of "neutral", "ACES", "none"
270
+ * @param exposure - Exposure multiplier (0 to 2, default 1.0)
271
+ */
272
+ setToneMapping(mode: StudioToneMapping, exposure: number): void {
273
+ const mapped = TONE_MAP_MODE[mode];
274
+ if (mapped === undefined) {
275
+ logger.warn(`StudioComposer: unknown tone mapping mode "${mode}", falling back to Neutral`);
276
+ this._toneMappingEffect.mode = ToneMappingMode.NEUTRAL;
277
+ } else {
278
+ this._toneMappingEffect.mode = mapped;
279
+ }
280
+ this._renderer.toneMappingExposure = exposure;
281
+ }
282
+
283
+ // -----------------------------------------------------------------------
284
+ // Shadow Mask
285
+ // -----------------------------------------------------------------------
286
+
287
+ /**
288
+ * Enable or disable the screen-space shadow mask pipeline.
289
+ *
290
+ * When enabled, creates half-resolution render targets and a KawaseBlurPass.
291
+ * When disabled, disposes those resources and disables the mask effect.
292
+ */
293
+ setShadowMaskEnabled(enabled: boolean): void {
294
+ this._shadowMaskEnabled = enabled;
295
+
296
+ if (enabled) {
297
+ const halfW = Math.max(1, Math.floor(this._width / 2));
298
+ const halfH = Math.max(1, Math.floor(this._height / 2));
299
+
300
+ this._shadowMaskRT = new THREE.WebGLRenderTarget(halfW, halfH, {
301
+ type: THREE.UnsignedByteType,
302
+ depthBuffer: true,
303
+ });
304
+ this._blurredObjectMaskRT = new THREE.WebGLRenderTarget(halfW, halfH, {
305
+ type: THREE.UnsignedByteType,
306
+ depthBuffer: false,
307
+ });
308
+ this._blurredFloorMaskRT = new THREE.WebGLRenderTarget(halfW, halfH, {
309
+ type: THREE.UnsignedByteType,
310
+ depthBuffer: false,
311
+ });
312
+
313
+ this._blurPass = new KawaseBlurPass({ kernelSize: KernelSize.HUGE });
314
+ this._blurPass.setSize(halfW, halfH);
315
+
316
+ this._shadowMaskMaterial = new THREE.ShadowMaterial({
317
+ color: 0x000000,
318
+ opacity: 1.0,
319
+ });
320
+ // Force opaque rendering with no blending. ShadowMaterial defaults to
321
+ // transparent=true which uses alpha blending — a front lit surface
322
+ // (alpha=0) won't overwrite a back shadowed surface (alpha=1), causing
323
+ // the entire shadow footprint to bleed through to all objects.
324
+ // With NoBlending, the depth test alone determines which surface writes.
325
+ this._shadowMaskMaterial.transparent = false;
326
+ this._shadowMaskMaterial.blending = THREE.NoBlending;
327
+
328
+ logger.debug("StudioComposer: shadow mask enabled");
329
+ } else {
330
+ this._shadowMaskRT?.dispose();
331
+ this._shadowMaskRT = null;
332
+ this._blurredObjectMaskRT?.dispose();
333
+ this._blurredObjectMaskRT = null;
334
+ this._blurredFloorMaskRT?.dispose();
335
+ this._blurredFloorMaskRT = null;
336
+ this._blurPass?.dispose();
337
+ this._blurPass = null;
338
+ this._shadowMaskMaterial?.dispose();
339
+ this._shadowMaskMaterial = null;
340
+
341
+ this._shadowMaskEffect.uniforms.get("shadowMaskObjects")!.value = null;
342
+ this._shadowMaskEffect.uniforms.get("shadowMaskFloor")!.value = null;
343
+ this._shadowMaskEffect.uniforms.get("shadowIntensity")!.value = 0;
344
+ logger.debug("StudioComposer: shadow mask disabled");
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Set shadow blur softness. Uses a fixed HUGE kernel with continuous scale.
350
+ *
351
+ * @param softness - 0 (sharpest) to 1 (softest)
352
+ */
353
+ setShadowSoftness(softness: number): void {
354
+ if (!this._blurPass) return;
355
+ // Continuous scale on a fixed large kernel avoids discrete jumps.
356
+ // Range 0.05–1.0 gives sharp-to-very-soft; clamped so scale never hits 0
357
+ // (which would collapse all iterations to a single pixel).
358
+ this._blurPass.scale = 0.05 + softness * 0.95;
359
+ }
360
+
361
+ /**
362
+ * Set the intensity of the object shadow mask overlay.
363
+ *
364
+ * @param intensity - 0 (no shadow) to 1 (full shadow)
365
+ */
366
+ setShadowMaskIntensity(intensity: number): void {
367
+ this._shadowMaskEffect.uniforms.get("shadowIntensity")!.value = intensity;
368
+ }
369
+
370
+ // -----------------------------------------------------------------------
371
+ // Camera
372
+ // -----------------------------------------------------------------------
373
+
374
+ setCamera(camera: THREE.Camera): void {
375
+ this._camera = camera;
376
+ this._renderPass.mainCamera = camera;
377
+ this._n8aoPass.camera = camera;
378
+ this._effectPass.mainCamera = camera;
379
+ }
380
+
381
+ // -----------------------------------------------------------------------
382
+ // Resize
383
+ // -----------------------------------------------------------------------
384
+
385
+ setSize(width: number, height: number): void {
386
+ this._width = width;
387
+ this._height = height;
388
+ this._composer.setSize(width, height, false);
389
+ this._n8aoPass.setSize(width, height);
390
+
391
+ // Resize shadow mask RTs at half resolution
392
+ if (this._shadowMaskRT && this._blurredObjectMaskRT && this._blurredFloorMaskRT) {
393
+ const halfW = Math.max(1, Math.floor(width / 2));
394
+ const halfH = Math.max(1, Math.floor(height / 2));
395
+ this._shadowMaskRT.setSize(halfW, halfH);
396
+ this._blurredObjectMaskRT.setSize(halfW, halfH);
397
+ this._blurredFloorMaskRT.setSize(halfW, halfH);
398
+ this._blurPass?.setSize(halfW, halfH);
399
+ }
400
+ }
401
+
402
+ // -----------------------------------------------------------------------
403
+ // Render
404
+ // -----------------------------------------------------------------------
405
+
406
+ /**
407
+ * Render one frame through the postprocessing pipeline.
408
+ *
409
+ * When shadow mask is enabled, runs a 3-phase pipeline:
410
+ * 1. Render shadow mask (ShadowMaterial override) to half-res RT
411
+ * 2. Blur via KawaseBlurPass
412
+ * 3. Composite via ShadowMaskEffect in the main EffectPass
413
+ *
414
+ * When background protection is active, pre-clears the canvas with the
415
+ * solid background color before compositing the tone-mapped scene on top
416
+ * via alpha blending.
417
+ */
418
+ render(deltaTime?: number): void {
419
+ // Two-pass shadow mask: objects and floor are blurred separately to
420
+ // avoid depth-discontinuity halos at their boundary.
421
+ if (this._shadowMaskEnabled && this._shadowMaskRT && this._blurPass
422
+ && this._blurredObjectMaskRT && this._blurredFloorMaskRT) {
423
+ this._renderer.shadowMap.autoUpdate = false;
424
+ this._renderer.shadowMap.needsUpdate = true;
425
+
426
+ // Pass 1: object shadow mask (floor hidden, generates shadow map)
427
+ this._renderShadowMask("objects");
428
+ this._blurPass.render(this._renderer, this._shadowMaskRT, this._blurredObjectMaskRT);
429
+
430
+ // Pass 2: floor shadow mask (objects hidden, reuses shadow map)
431
+ this._renderer.shadowMap.needsUpdate = false;
432
+ this._renderShadowMask("floor");
433
+ this._blurPass.render(this._renderer, this._shadowMaskRT, this._blurredFloorMaskRT);
434
+
435
+ // Feed both blurred masks to the compositing effect
436
+ this._shadowMaskEffect.uniforms.get("shadowMaskObjects")!.value = this._blurredObjectMaskRT.texture;
437
+ this._shadowMaskEffect.uniforms.get("shadowMaskFloor")!.value = this._blurredFloorMaskRT.texture;
438
+
439
+ this._renderer.shadowMap.autoUpdate = true;
440
+ }
441
+
442
+ // Hide floor during main render — blurred shadow mask provides floor shadow
443
+ let floor: THREE.Object3D | undefined;
444
+ let floorWasVisible = false;
445
+ if (this._shadowMaskEnabled) {
446
+ floor = this._scene.getObjectByName("studioFloor") ?? undefined;
447
+ if (floor) {
448
+ floorWasVisible = floor.visible;
449
+ floor.visible = false;
450
+ }
451
+ }
452
+
453
+ // Phase 3: main composer pipeline
454
+ if (this._bgProtectColor) {
455
+ this._renderer.getClearColor(_savedClearColor);
456
+ const savedAlpha = this._renderer.getClearAlpha();
457
+
458
+ this._renderer.setRenderTarget(null);
459
+ this._renderer.setClearColor(this._bgProtectColor, 1.0);
460
+ this._renderer.clear(true, false, false);
461
+
462
+ this._renderer.setClearColor(_savedClearColor, savedAlpha);
463
+
464
+ this._composer.render(deltaTime);
465
+ } else {
466
+ this._composer.render(deltaTime);
467
+ }
468
+
469
+ // Restore floor visibility for next frame's mask pass
470
+ if (floor) floor.visible = floorWasVisible;
471
+ }
472
+
473
+ /**
474
+ * Render a shadow mask to the half-resolution render target.
475
+ *
476
+ * @param mode - "objects" renders only objects (floor hidden),
477
+ * "floor" renders only the floor (objects hidden)
478
+ * @internal
479
+ */
480
+ private _renderShadowMask(mode: "objects" | "floor"): void {
481
+ if (!this._shadowMaskRT || !this._shadowMaskMaterial) return;
482
+
483
+ // Save state
484
+ const savedOverrideMaterial = this._scene.overrideMaterial;
485
+ const savedBackground = this._scene.background;
486
+ const floor = this._scene.getObjectByName("studioFloor");
487
+ const floorWasVisible = floor ? floor.visible : false;
488
+
489
+ this._renderer.getClearColor(_savedClearColor);
490
+ const savedClearAlpha = this._renderer.getClearAlpha();
491
+
492
+ this._receiveShadowState.clear();
493
+ this._savedIntensities.clear();
494
+ this._savedVisibility.clear();
495
+
496
+ try {
497
+ if (mode === "objects") {
498
+ // Hide floor, show objects with receiveShadow=true
499
+ if (floor) floor.visible = false;
500
+ this._scene.traverse((obj) => {
501
+ if (obj instanceof THREE.Mesh && obj.castShadow) {
502
+ this._receiveShadowState.set(obj, obj.receiveShadow);
503
+ obj.receiveShadow = true;
504
+ }
505
+ });
506
+ } else {
507
+ // Show floor, hide all non-floor meshes
508
+ if (floor) floor.visible = true;
509
+ this._scene.traverse((obj) => {
510
+ if (obj instanceof THREE.Mesh && obj.castShadow) {
511
+ this._savedVisibility.set(obj, obj.visible);
512
+ obj.visible = false;
513
+ }
514
+ });
515
+ }
516
+
517
+ this._scene.overrideMaterial = this._shadowMaskMaterial;
518
+ this._scene.background = null;
519
+
520
+ // Temporarily set shadow intensity to 1.0 (mask captures full shadow)
521
+ this._scene.traverse((obj) => {
522
+ if (obj instanceof THREE.DirectionalLight && obj.castShadow) {
523
+ this._savedIntensities.set(obj, obj.shadow.intensity);
524
+ obj.shadow.intensity = 1.0;
525
+ }
526
+ });
527
+
528
+ this._renderer.setRenderTarget(this._shadowMaskRT);
529
+ this._renderer.setClearColor(0x000000, 0);
530
+ this._renderer.clear(true, true, false);
531
+ this._renderer.render(this._scene, this._camera);
532
+ } finally {
533
+ for (const [light, intensity] of this._savedIntensities) {
534
+ light.shadow.intensity = intensity;
535
+ }
536
+
537
+ if (mode === "objects") {
538
+ for (const [mesh, wasReceiving] of this._receiveShadowState) {
539
+ mesh.receiveShadow = wasReceiving;
540
+ }
541
+ } else {
542
+ for (const [obj, wasVisible] of this._savedVisibility) {
543
+ obj.visible = wasVisible;
544
+ }
545
+ }
546
+
547
+ this._scene.overrideMaterial = savedOverrideMaterial;
548
+ this._scene.background = savedBackground;
549
+ if (floor) floor.visible = floorWasVisible;
550
+
551
+ this._renderer.setRenderTarget(null);
552
+ this._renderer.setClearColor(_savedClearColor, savedClearAlpha);
553
+ }
554
+ }
555
+
556
+ // -----------------------------------------------------------------------
557
+ // Disposal
558
+ // -----------------------------------------------------------------------
559
+
560
+ dispose(): void {
561
+ this._shadowMaskRT?.dispose();
562
+ this._shadowMaskRT = null;
563
+ this._blurredObjectMaskRT?.dispose();
564
+ this._blurredObjectMaskRT = null;
565
+ this._blurredFloorMaskRT?.dispose();
566
+ this._blurredFloorMaskRT = null;
567
+ this._blurPass?.dispose();
568
+ this._blurPass = null;
569
+ this._shadowMaskMaterial?.dispose();
570
+ this._shadowMaskMaterial = null;
571
+
572
+ this._composer.dispose();
573
+ logger.debug("StudioComposer: disposed");
574
+ }
575
+ }
576
+
577
+ export { StudioComposer };
@@ -0,0 +1,108 @@
1
+ import * as THREE from "three";
2
+
3
+ /**
4
+ * Studio floor — shadow-catching ground plane for Studio mode.
5
+ *
6
+ * Uses ShadowMaterial which is fully transparent except where shadows
7
+ * are cast, providing a natural grounding effect without obscuring
8
+ * the background (like KeyShot's Ground material or Fusion 360's
9
+ * ground plane).
10
+ *
11
+ * The floor is added to the scene via its `group` property.
12
+ * Shadow plane visibility is toggled via `setShadowsEnabled()`.
13
+ */
14
+ class StudioFloor {
15
+ /** The Group to add to the scene. Contains the shadow plane. */
16
+ readonly group: THREE.Group;
17
+
18
+ /** Shadow-receiving plane (ShadowMaterial) */
19
+ private _shadowPlane: THREE.Mesh | null = null;
20
+
21
+ /** Whether shadows are currently enabled */
22
+ private _shadowsEnabled: boolean = false;
23
+
24
+ constructor() {
25
+ this.group = new THREE.Group();
26
+ this.group.name = "studioFloor";
27
+ this.group.visible = false;
28
+ }
29
+
30
+ /**
31
+ * Create or recreate the floor for the given scene bounds.
32
+ *
33
+ * Call this when the bounding box changes (new shapes loaded).
34
+ *
35
+ * @param zPosition - Z coordinate for the floor (typically bbox.min.z)
36
+ * @param sceneSize - Approximate scene size (max extent) for sizing the floor
37
+ */
38
+ configure(zPosition: number, sceneSize: number): void {
39
+ this._clearCurrent();
40
+ this._createShadowPlane(zPosition, sceneSize);
41
+ }
42
+
43
+ /**
44
+ * Toggle shadow plane visibility.
45
+ *
46
+ * @param enabled - Whether to show the shadow plane
47
+ */
48
+ setShadowsEnabled(enabled: boolean): void {
49
+ this._shadowsEnabled = enabled;
50
+ if (this._shadowPlane) this._shadowPlane.visible = enabled;
51
+ this.group.visible = this._shadowsEnabled;
52
+ }
53
+
54
+ /**
55
+ * Set the ground shadow opacity (how dark the shadow appears on the floor).
56
+ * This supplements `light.shadow.intensity` which controls shadow darkness
57
+ * on lit materials; the ground plane ShadowMaterial needs its own opacity.
58
+ *
59
+ * @param intensity - Shadow intensity 0-1
60
+ */
61
+ setShadowIntensity(intensity: number): void {
62
+ if (this._shadowPlane) {
63
+ (this._shadowPlane.material as THREE.ShadowMaterial).opacity = intensity * 1.0;
64
+ }
65
+ }
66
+
67
+ /** Dispose all GPU resources. */
68
+ dispose(): void {
69
+ this._clearCurrent();
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Internal
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /**
77
+ * Create a shadow-receiving plane at the floor position.
78
+ */
79
+ private _createShadowPlane(zPosition: number, sceneSize: number): void {
80
+ const floorSize = sceneSize * 4;
81
+
82
+ const geometry = new THREE.PlaneGeometry(floorSize, floorSize);
83
+ const material = new THREE.ShadowMaterial({ opacity: 0.5, depthWrite: false });
84
+
85
+ const plane = new THREE.Mesh(geometry, material);
86
+ plane.position.z = zPosition;
87
+ plane.receiveShadow = true;
88
+ plane.name = "studioShadowPlane";
89
+
90
+ // Start hidden; setShadowsEnabled() controls visibility
91
+ plane.visible = this._shadowsEnabled;
92
+
93
+ this._shadowPlane = plane;
94
+ this.group.add(plane);
95
+ }
96
+
97
+ /** Remove and dispose the current shadow plane. */
98
+ private _clearCurrent(): void {
99
+ if (this._shadowPlane) {
100
+ this.group.remove(this._shadowPlane);
101
+ this._shadowPlane.geometry.dispose();
102
+ (this._shadowPlane.material as THREE.Material).dispose();
103
+ this._shadowPlane = null;
104
+ }
105
+ }
106
+ }
107
+
108
+ export { StudioFloor };