@pascal-app/viewer 1.0.0-beta.4 → 1.0.0-beta.5

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 (103) hide show
  1. package/README.md +52 -0
  2. package/dist/components/viewer/frame-limiter.d.ts +11 -0
  3. package/dist/components/viewer/frame-limiter.d.ts.map +1 -1
  4. package/dist/components/viewer/frame-limiter.js +58 -18
  5. package/dist/components/viewer/index.d.ts +15 -0
  6. package/dist/components/viewer/index.d.ts.map +1 -1
  7. package/dist/components/viewer/index.js +34 -50
  8. package/dist/components/viewer/perf-monitor.js +2 -2
  9. package/dist/components/viewer/selection-manager.d.ts.map +1 -1
  10. package/dist/components/viewer/selection-manager.js +7 -2
  11. package/dist/components/viewer/unsupported-gpu-fallback.d.ts +2 -0
  12. package/dist/components/viewer/unsupported-gpu-fallback.d.ts.map +1 -0
  13. package/dist/components/viewer/unsupported-gpu-fallback.js +4 -0
  14. package/dist/index.d.ts +8 -5
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +7 -4
  17. package/dist/lib/dispose-object3d.d.ts +4 -0
  18. package/dist/lib/dispose-object3d.d.ts.map +1 -0
  19. package/dist/lib/dispose-object3d.js +27 -0
  20. package/dist/lib/geometry-groups.d.ts +17 -0
  21. package/dist/lib/geometry-groups.d.ts.map +1 -0
  22. package/dist/lib/geometry-groups.js +53 -0
  23. package/dist/lib/isolation.d.ts +8 -8
  24. package/dist/lib/isolation.d.ts.map +1 -1
  25. package/dist/lib/isolation.js +18 -30
  26. package/dist/lib/layers.d.ts +16 -0
  27. package/dist/lib/layers.d.ts.map +1 -1
  28. package/dist/lib/layers.js +18 -0
  29. package/dist/lib/renderer-capability.d.ts +56 -0
  30. package/dist/lib/renderer-capability.d.ts.map +1 -0
  31. package/dist/lib/renderer-capability.js +114 -0
  32. package/dist/lib/scene-visibility.d.ts +14 -0
  33. package/dist/lib/scene-visibility.d.ts.map +1 -0
  34. package/dist/lib/scene-visibility.js +48 -0
  35. package/dist/lib/shadow-only.d.ts +16 -0
  36. package/dist/lib/shadow-only.d.ts.map +1 -1
  37. package/dist/lib/shadow-only.js +8 -17
  38. package/dist/lib/snapshot-pipeline.d.ts +9 -0
  39. package/dist/lib/snapshot-pipeline.d.ts.map +1 -1
  40. package/dist/lib/snapshot-pipeline.js +33 -9
  41. package/dist/store/use-viewer.d.ts +5 -4
  42. package/dist/store/use-viewer.d.ts.map +1 -1
  43. package/dist/systems/floor-elevation/floor-elevation-system.d.ts.map +1 -1
  44. package/dist/systems/floor-elevation/floor-elevation-system.js +9 -0
  45. package/dist/systems/geometry/geometry-system.d.ts.map +1 -1
  46. package/dist/systems/geometry/geometry-system.js +2 -26
  47. package/dist/systems/level/level-system.d.ts.map +1 -1
  48. package/dist/systems/level/level-system.js +7 -1
  49. package/dist/systems/wall/level-miter-cache.d.ts +10 -0
  50. package/dist/systems/wall/level-miter-cache.d.ts.map +1 -0
  51. package/dist/systems/wall/level-miter-cache.js +40 -0
  52. package/dist/systems/wall/wall-cutout.d.ts +12 -0
  53. package/dist/systems/wall/wall-cutout.d.ts.map +1 -1
  54. package/dist/systems/wall/wall-cutout.js +41 -11
  55. package/dist/systems/wall/wall-system.d.ts +4 -0
  56. package/dist/systems/wall/wall-system.d.ts.map +1 -1
  57. package/dist/systems/wall/wall-system.js +31 -18
  58. package/package.json +4 -2
  59. package/dist/components/viewer/scene-bvh.test.d.ts +0 -2
  60. package/dist/components/viewer/scene-bvh.test.d.ts.map +0 -1
  61. package/dist/components/viewer/scene-bvh.test.js +0 -13
  62. package/dist/lib/csg-utils.test.d.ts +0 -2
  63. package/dist/lib/csg-utils.test.d.ts.map +0 -1
  64. package/dist/lib/csg-utils.test.js +0 -45
  65. package/dist/lib/materials.test.d.ts +0 -2
  66. package/dist/lib/materials.test.d.ts.map +0 -1
  67. package/dist/lib/materials.test.js +0 -25
  68. package/dist/lib/merged-outline-node.test.d.ts +0 -2
  69. package/dist/lib/merged-outline-node.test.d.ts.map +0 -1
  70. package/dist/lib/merged-outline-node.test.js +0 -22
  71. package/dist/lib/polygon-union.test.d.ts +0 -2
  72. package/dist/lib/polygon-union.test.d.ts.map +0 -1
  73. package/dist/lib/polygon-union.test.js +0 -105
  74. package/dist/lib/texture-reference.test.d.ts +0 -2
  75. package/dist/lib/texture-reference.test.d.ts.map +0 -1
  76. package/dist/lib/texture-reference.test.js +0 -86
  77. package/dist/store/use-viewer.test.d.ts +0 -2
  78. package/dist/store/use-viewer.test.d.ts.map +0 -1
  79. package/dist/store/use-viewer.test.js +0 -61
  80. package/dist/systems/door/door-animation.test.d.ts +0 -2
  81. package/dist/systems/door/door-animation.test.d.ts.map +0 -1
  82. package/dist/systems/door/door-animation.test.js +0 -51
  83. package/dist/systems/door/door-floor-alignment.test.d.ts +0 -2
  84. package/dist/systems/door/door-floor-alignment.test.d.ts.map +0 -1
  85. package/dist/systems/door/door-floor-alignment.test.js +0 -50
  86. package/dist/systems/geometry/geometry-system.test.d.ts +0 -2
  87. package/dist/systems/geometry/geometry-system.test.d.ts.map +0 -1
  88. package/dist/systems/geometry/geometry-system.test.js +0 -30
  89. package/dist/systems/slab/slab-system.test.d.ts +0 -2
  90. package/dist/systems/slab/slab-system.test.d.ts.map +0 -1
  91. package/dist/systems/slab/slab-system.test.js +0 -102
  92. package/dist/systems/wall/opening-cutout-geometry.test.d.ts +0 -2
  93. package/dist/systems/wall/opening-cutout-geometry.test.d.ts.map +0 -1
  94. package/dist/systems/wall/opening-cutout-geometry.test.js +0 -172
  95. package/dist/systems/wall/wall-opening-cutout.test.d.ts +0 -2
  96. package/dist/systems/wall/wall-opening-cutout.test.d.ts.map +0 -1
  97. package/dist/systems/wall/wall-opening-cutout.test.js +0 -59
  98. package/dist/systems/wall/wall-support-extension.test.d.ts +0 -2
  99. package/dist/systems/wall/wall-support-extension.test.d.ts.map +0 -1
  100. package/dist/systems/wall/wall-support-extension.test.js +0 -116
  101. package/dist/systems/wall/wall-terrain-bottom.test.d.ts +0 -2
  102. package/dist/systems/wall/wall-terrain-bottom.test.d.ts.map +0 -1
  103. package/dist/systems/wall/wall-terrain-bottom.test.js +0 -34
@@ -0,0 +1,53 @@
1
+ import * as THREE from 'three';
2
+ /**
3
+ * Rewrites a geometry's material groups so every material is drawn exactly once.
4
+ *
5
+ * A group is a contiguous slice of the index buffer, so a mesh whose triangles
6
+ * alternate between materials pays a draw call per *run*, not per material.
7
+ * Extruded walls hit this hard: `ExtrudeGeometry` emits the cap and side faces
8
+ * interleaved, so run-length grouping produces four groups for two materials —
9
+ * multiplied by a thousand walls, that is thousands of avoidable draw calls.
10
+ * Bucketing the triangles by material first makes each material one run.
11
+ *
12
+ * The geometry gains an index buffer if it had none. Triangle winding, vertex
13
+ * data and material assignment are untouched, so the rendered image is
14
+ * unchanged; only the order in which the GPU is asked to draw it differs.
15
+ */
16
+ export function setGroupsSortedByMaterial(geometry, triangleMaterials) {
17
+ geometry.clearGroups();
18
+ const position = geometry.getAttribute('position');
19
+ if (!position)
20
+ return;
21
+ const sourceIndex = geometry.getIndex();
22
+ const triangleCount = Math.min(triangleMaterials.length, sourceIndex ? Math.floor(sourceIndex.count / 3) : Math.floor(position.count / 3));
23
+ if (triangleCount === 0)
24
+ return;
25
+ const buckets = new Map();
26
+ for (let triangle = 0; triangle < triangleCount; triangle += 1) {
27
+ const material = triangleMaterials[triangle] ?? 0;
28
+ const bucket = buckets.get(material);
29
+ if (bucket)
30
+ bucket.push(triangle);
31
+ else
32
+ buckets.set(material, [triangle]);
33
+ }
34
+ const singleMaterial = buckets.size === 1 ? [...buckets.keys()][0] : undefined;
35
+ if (singleMaterial !== undefined) {
36
+ geometry.addGroup(0, triangleCount * 3, singleMaterial);
37
+ return;
38
+ }
39
+ const reordered = new Uint32Array(triangleCount * 3);
40
+ let cursor = 0;
41
+ for (const [material, triangles] of [...buckets].sort((left, right) => left[0] - right[0])) {
42
+ const groupStart = cursor;
43
+ for (const triangle of triangles) {
44
+ const base = triangle * 3;
45
+ reordered[cursor] = sourceIndex ? sourceIndex.getX(base) : base;
46
+ reordered[cursor + 1] = sourceIndex ? sourceIndex.getX(base + 1) : base + 1;
47
+ reordered[cursor + 2] = sourceIndex ? sourceIndex.getX(base + 2) : base + 2;
48
+ cursor += 3;
49
+ }
50
+ geometry.addGroup(groupStart, cursor - groupStart, material);
51
+ }
52
+ geometry.setIndex(new THREE.BufferAttribute(reordered, 1));
53
+ }
@@ -15,21 +15,21 @@ export declare function collectIsolationSubtree(ids: ReadonlyArray<string>): Set
15
15
  /**
16
16
  * Imperative visibility filter on the live `sceneRegistry`. Hides every
17
17
  * registered group (and its synthesized child meshes) outside the
18
- * isolated subtree by disabling the {@link SCENE_LAYER} bit on the
19
- * relevant `Object3D.layers` masks.
18
+ * isolated subtree by taking it off the scene layer.
20
19
  *
21
20
  * Why layers instead of `obj.visible = false`? Three.js's visibility
22
21
  * flag *cascades* — hiding a parent hides every descendant — so we
23
22
  * can't hide a host wall while keeping a door rendered inside it.
24
23
  * Layer masks are per-object and don't cascade: `WebGLRenderer
25
24
  * .projectObject` skips objects whose layer mask doesn't intersect the
26
- * camera's, but always recurses into their children. So we can disable
27
- * `SCENE_LAYER` on the wall and the door (hosted under it in the
28
- * scene graph) still renders, with its local position relative to the
29
- * wall preserved automatically by the matrix walk.
25
+ * camera's, but always recurses into their children. So we can hide
26
+ * the wall and the door (hosted under it in the scene graph) still
27
+ * renders, with its local position relative to the wall preserved
28
+ * automatically by the matrix walk.
30
29
  *
31
- * The original `layers.mask` is stashed under a private Symbol so
32
- * {@link clearIsolation} can restore the exact prior state.
30
+ * The mask itself belongs to `lib/scene-visibility.ts`, which reconciles
31
+ * isolation with solo's shadow-caster pass, so {@link clearIsolation}
32
+ * gives an object back only what isolation took.
33
33
  *
34
34
  * Pass `null` to clear isolation (equivalent to calling
35
35
  * {@link clearIsolation}).
@@ -1 +1 @@
1
- {"version":3,"file":"isolation.d.ts","sourceRoot":"","sources":["../../src/lib/isolation.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;AAEjD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAgBrC,8EAA8E;AAC9E,wBAAgB,iBAAiB,IAAI,OAAO,CAE3C;AAED;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,aAAa,CAAC,MAAM,CAAC,GAAG,GAAG,CAAC,QAAQ,CAAC,CAUjF;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,aAAa,CAAC,SAAS,CAAC,GAAG,IAAI,GAAG,IAAI,CAiBzE;AAcD,wBAAgB,cAAc,IAAI,IAAI,CAcrC"}
1
+ {"version":3,"file":"isolation.d.ts","sourceRoot":"","sources":["../../src/lib/isolation.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;AAEjD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AASrC,8EAA8E;AAC9E,wBAAgB,iBAAiB,IAAI,OAAO,CAE3C;AAED;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,aAAa,CAAC,MAAM,CAAC,GAAG,GAAG,CAAC,QAAQ,CAAC,CAUjF;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,aAAa,CAAC,SAAS,CAAC,GAAG,IAAI,GAAG,IAAI,CAiBzE;AAUD,wBAAgB,cAAc,IAAI,IAAI,CAUrC"}
@@ -1,12 +1,8 @@
1
1
  'use client';
2
2
  import { sceneRegistry } from '@pascal-app/core';
3
- import { SCENE_LAYER } from './layers';
4
- // Marker on each Object3D we modify during isolation so we can restore
5
- // the original `layers.mask` bitfield. Stored under a `Symbol` so it
6
- // can't collide with any kind's own userData fields.
7
- const ORIGINAL_LAYERS = Symbol('isolation:original-layers');
8
- // Whether a subtree is currently isolated (some objects have SCENE_LAYER
9
- // disabled). Read by consumers that must not act on the partial view — e.g.
3
+ import { hideFromScene, showInScene } from './scene-visibility';
4
+ // Whether a subtree is currently isolated (some objects are held off the
5
+ // scene layer). Read by consumers that must not act on the partial view — e.g.
10
6
  // the project-thumbnail autosave skips capturing while isolated so it never
11
7
  // snapshots a single focused item as the whole project's thumbnail.
12
8
  let isolationActive = false;
@@ -38,21 +34,21 @@ export function collectIsolationSubtree(ids) {
38
34
  /**
39
35
  * Imperative visibility filter on the live `sceneRegistry`. Hides every
40
36
  * registered group (and its synthesized child meshes) outside the
41
- * isolated subtree by disabling the {@link SCENE_LAYER} bit on the
42
- * relevant `Object3D.layers` masks.
37
+ * isolated subtree by taking it off the scene layer.
43
38
  *
44
39
  * Why layers instead of `obj.visible = false`? Three.js's visibility
45
40
  * flag *cascades* — hiding a parent hides every descendant — so we
46
41
  * can't hide a host wall while keeping a door rendered inside it.
47
42
  * Layer masks are per-object and don't cascade: `WebGLRenderer
48
43
  * .projectObject` skips objects whose layer mask doesn't intersect the
49
- * camera's, but always recurses into their children. So we can disable
50
- * `SCENE_LAYER` on the wall and the door (hosted under it in the
51
- * scene graph) still renders, with its local position relative to the
52
- * wall preserved automatically by the matrix walk.
44
+ * camera's, but always recurses into their children. So we can hide
45
+ * the wall and the door (hosted under it in the scene graph) still
46
+ * renders, with its local position relative to the wall preserved
47
+ * automatically by the matrix walk.
53
48
  *
54
- * The original `layers.mask` is stashed under a private Symbol so
55
- * {@link clearIsolation} can restore the exact prior state.
49
+ * The mask itself belongs to `lib/scene-visibility.ts`, which reconciles
50
+ * isolation with solo's shadow-caster pass, so {@link clearIsolation}
51
+ * gives an object back only what isolation took.
56
52
  *
57
53
  * Pass `null` to clear isolation (equivalent to calling
58
54
  * {@link clearIsolation}).
@@ -64,9 +60,9 @@ export function applyIsolation(ids) {
64
60
  }
65
61
  const keep = collectIsolationSubtree(ids);
66
62
  // Iterate registered roots. For each one outside the keep set,
67
- // disable `SCENE_LAYER` on it and on every descendant — *except*
68
- // descendants that are themselves in `keep` (a kept node nested under
69
- // a non-kept host: the isolated door under the hidden wall).
63
+ // hide it and every descendant — *except* descendants that are
64
+ // themselves in `keep` (a kept node nested under a non-kept host:
65
+ // the isolated door under the hidden wall).
70
66
  for (const [, obj] of sceneRegistry.nodes) {
71
67
  if (keep.has(obj))
72
68
  continue;
@@ -77,26 +73,18 @@ export function applyIsolation(ids) {
77
73
  function hideRecursive(obj, keep) {
78
74
  if (keep.has(obj))
79
75
  return;
80
- const carrier = obj;
81
- if (carrier[ORIGINAL_LAYERS] === undefined) {
82
- carrier[ORIGINAL_LAYERS] = obj.layers.mask;
83
- }
84
- obj.layers.disable(SCENE_LAYER);
76
+ hideFromScene(obj, 'isolated');
85
77
  for (const child of obj.children) {
86
78
  hideRecursive(child, keep);
87
79
  }
88
80
  }
89
81
  export function clearIsolation() {
90
82
  // We don't know which objects were touched without re-walking, so
91
- // walk every registered root + its descendants and restore any
92
- // stashed original-mask. `traverse` is cheap and idempotent here.
83
+ // walk every registered root + its descendants and drop the isolation
84
+ // reason wherever it was set. `traverse` is cheap and idempotent here.
93
85
  for (const [, obj] of sceneRegistry.nodes) {
94
86
  obj.traverse((child) => {
95
- const carrier = child;
96
- if (carrier[ORIGINAL_LAYERS] !== undefined) {
97
- child.layers.mask = carrier[ORIGINAL_LAYERS];
98
- delete carrier[ORIGINAL_LAYERS];
99
- }
87
+ showInScene(child, 'isolated');
100
88
  });
101
89
  }
102
90
  isolationActive = false;
@@ -1,3 +1,4 @@
1
+ import type { Layers } from 'three';
1
2
  /** Default Three.js layer for main scene geometry. */
2
3
  export declare const SCENE_LAYER = 0;
3
4
  /**
@@ -30,4 +31,19 @@ export declare const GRID_LAYER = 3;
30
31
  * cascade) via `applyShadowOnly` / `clearShadowOnly` in `lib/shadow-only.ts`.
31
32
  */
32
33
  export declare const SHADOW_ONLY_LAYER = 4;
34
+ /**
35
+ * Layer for source geometry that a collective batch already draws. No camera
36
+ * or pass enables it, so a batched object costs no draw call while its children
37
+ * and interaction proxies remain in the graph.
38
+ *
39
+ * Raycasters that query real surfaces must opt in via
40
+ * {@link setSurfaceRaycastLayers}, otherwise a sewn wall would stop answering
41
+ * measurement rays.
42
+ */
43
+ export declare const BATCHED_LAYER = 5;
44
+ /**
45
+ * Aims a raycaster at every real scene surface, whether a wall still draws
46
+ * itself or a level batch draws it for us.
47
+ */
48
+ export declare function setSurfaceRaycastLayers(layers: Layers): void;
33
49
  //# sourceMappingURL=layers.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"layers.d.ts","sourceRoot":"","sources":["../../src/lib/layers.ts"],"names":[],"mappings":"AAAA,sDAAsD;AACtD,eAAO,MAAM,WAAW,IAAI,CAAA;AAE5B;;;;;;;GAOG;AACH,eAAO,MAAM,aAAa,IAAI,CAAA;AAE9B,oEAAoE;AACpE,eAAO,MAAM,UAAU,IAAI,CAAA;AAE3B;;;;;;;GAOG;AACH,eAAO,MAAM,UAAU,IAAI,CAAA;AAE3B;;;;;;;;GAQG;AACH,eAAO,MAAM,iBAAiB,IAAI,CAAA"}
1
+ {"version":3,"file":"layers.d.ts","sourceRoot":"","sources":["../../src/lib/layers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,OAAO,CAAA;AAEnC,sDAAsD;AACtD,eAAO,MAAM,WAAW,IAAI,CAAA;AAE5B;;;;;;;GAOG;AACH,eAAO,MAAM,aAAa,IAAI,CAAA;AAE9B,oEAAoE;AACpE,eAAO,MAAM,UAAU,IAAI,CAAA;AAE3B;;;;;;;GAOG;AACH,eAAO,MAAM,UAAU,IAAI,CAAA;AAE3B;;;;;;;;GAQG;AACH,eAAO,MAAM,iBAAiB,IAAI,CAAA;AAElC;;;;;;;;GAQG;AACH,eAAO,MAAM,aAAa,IAAI,CAAA;AAE9B;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAG5D"}
@@ -30,3 +30,21 @@ export const GRID_LAYER = 3;
30
30
  * cascade) via `applyShadowOnly` / `clearShadowOnly` in `lib/shadow-only.ts`.
31
31
  */
32
32
  export const SHADOW_ONLY_LAYER = 4;
33
+ /**
34
+ * Layer for source geometry that a collective batch already draws. No camera
35
+ * or pass enables it, so a batched object costs no draw call while its children
36
+ * and interaction proxies remain in the graph.
37
+ *
38
+ * Raycasters that query real surfaces must opt in via
39
+ * {@link setSurfaceRaycastLayers}, otherwise a sewn wall would stop answering
40
+ * measurement rays.
41
+ */
42
+ export const BATCHED_LAYER = 5;
43
+ /**
44
+ * Aims a raycaster at every real scene surface, whether a wall still draws
45
+ * itself or a level batch draws it for us.
46
+ */
47
+ export function setSurfaceRaycastLayers(layers) {
48
+ layers.set(SCENE_LAYER);
49
+ layers.enable(BATCHED_LAYER);
50
+ }
@@ -0,0 +1,56 @@
1
+ export type RendererCapabilityCanvas = {
2
+ getContext(contextId: 'webgl2'): unknown;
3
+ };
4
+ /** Mirrors `GPUPowerPreference` without pulling WebGPU ambient types into the declaration build. */
5
+ export type RendererPowerPreference = 'high-performance' | 'low-power';
6
+ type RendererGpuAdapter = {
7
+ features?: Iterable<string>;
8
+ requestDevice(descriptor?: {
9
+ requiredFeatures?: string[];
10
+ }): Promise<unknown>;
11
+ };
12
+ type RendererGpu = {
13
+ requestAdapter(options?: Record<string, unknown>): Promise<RendererGpuAdapter | null>;
14
+ };
15
+ export type RendererCapability = {
16
+ backend: 'webgpu';
17
+ device: unknown;
18
+ status: 'supported';
19
+ } | {
20
+ backend: 'webgl';
21
+ status: 'supported';
22
+ } | {
23
+ error?: unknown;
24
+ status: 'unsupported';
25
+ };
26
+ export type RendererBackendParameters = {
27
+ device?: unknown;
28
+ forceWebGL?: boolean;
29
+ };
30
+ type InitializableRenderer = {
31
+ dispose?: () => void;
32
+ init: () => Promise<unknown>;
33
+ };
34
+ export type RendererInitializationResult<Renderer> = {
35
+ backend: 'webgpu' | 'webgl';
36
+ renderer: Renderer;
37
+ status: 'ready';
38
+ } | {
39
+ error?: unknown;
40
+ status: 'unsupported';
41
+ };
42
+ export declare function detectRendererCapability({ canvas, gpu, powerPreference, webgpuTimeoutMs, }?: {
43
+ canvas?: RendererCapabilityCanvas | null;
44
+ gpu?: RendererGpu | null;
45
+ powerPreference?: RendererPowerPreference;
46
+ webgpuTimeoutMs?: number;
47
+ }): Promise<RendererCapability>;
48
+ export declare function initializeGpuRenderer<Renderer extends InitializableRenderer>({ createRenderer, gpu, powerPreference, probeCanvas, webgpuTimeoutMs, }: {
49
+ createRenderer: (parameters: RendererBackendParameters) => Renderer;
50
+ gpu?: RendererGpu | null;
51
+ powerPreference?: RendererPowerPreference;
52
+ probeCanvas?: RendererCapabilityCanvas | null;
53
+ webgpuTimeoutMs?: number;
54
+ }): Promise<RendererInitializationResult<Renderer>>;
55
+ export {};
56
+ //# sourceMappingURL=renderer-capability.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"renderer-capability.d.ts","sourceRoot":"","sources":["../../src/lib/renderer-capability.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,wBAAwB,GAAG;IACrC,UAAU,CAAC,SAAS,EAAE,QAAQ,GAAG,OAAO,CAAA;CACzC,CAAA;AAED,oGAAoG;AACpG,MAAM,MAAM,uBAAuB,GAAG,kBAAkB,GAAG,WAAW,CAAA;AAEtE,KAAK,kBAAkB,GAAG;IACxB,QAAQ,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAA;IAC3B,aAAa,CAAC,UAAU,CAAC,EAAE;QAAE,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAA;KAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;CAC9E,CAAA;AAED,KAAK,WAAW,GAAG;IACjB,cAAc,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC,CAAA;CACtF,CAAA;AAED,MAAM,MAAM,kBAAkB,GAC1B;IAAE,OAAO,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,WAAW,CAAA;CAAE,GAC3D;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,WAAW,CAAA;CAAE,GACzC;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,aAAa,CAAA;CAAE,CAAA;AAE9C,MAAM,MAAM,yBAAyB,GAAG;IACtC,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,UAAU,CAAC,EAAE,OAAO,CAAA;CACrB,CAAA;AAED,KAAK,qBAAqB,GAAG;IAC3B,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;IACpB,IAAI,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAA;CAC7B,CAAA;AAED,MAAM,MAAM,4BAA4B,CAAC,QAAQ,IAC7C;IAAE,OAAO,EAAE,QAAQ,GAAG,OAAO,CAAC;IAAC,QAAQ,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,OAAO,CAAA;CAAE,GACpE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,aAAa,CAAA;CAAE,CAAA;AAiD9C,wBAAsB,wBAAwB,CAAC,EAC7C,MAAwB,EACxB,GAAkB,EAClB,eAAe,EACf,eAAkD,GACnD,GAAE;IACD,MAAM,CAAC,EAAE,wBAAwB,GAAG,IAAI,CAAA;IACxC,GAAG,CAAC,EAAE,WAAW,GAAG,IAAI,CAAA;IACxB,eAAe,CAAC,EAAE,uBAAuB,CAAA;IACzC,eAAe,CAAC,EAAE,MAAM,CAAA;CACpB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CA2BnC;AAED,wBAAsB,qBAAqB,CAAC,QAAQ,SAAS,qBAAqB,EAAE,EAClF,cAAc,EACd,GAAG,EACH,eAAe,EACf,WAA6B,EAC7B,eAAkD,GACnD,EAAE;IACD,cAAc,EAAE,CAAC,UAAU,EAAE,yBAAyB,KAAK,QAAQ,CAAA;IACnE,GAAG,CAAC,EAAE,WAAW,GAAG,IAAI,CAAA;IACxB,eAAe,CAAC,EAAE,uBAAuB,CAAA;IACzC,WAAW,CAAC,EAAE,wBAAwB,GAAG,IAAI,CAAA;IAC7C,eAAe,CAAC,EAAE,MAAM,CAAA;CACzB,GAAG,OAAO,CAAC,4BAA4B,CAAC,QAAQ,CAAC,CAAC,CAuClD"}
@@ -0,0 +1,114 @@
1
+ const WEBGPU_INITIALIZATION_TIMEOUT_MS = 4000;
2
+ function browserGpu() {
3
+ if (typeof navigator === 'undefined')
4
+ return null;
5
+ return navigator.gpu ?? null;
6
+ }
7
+ function browserCanvas() {
8
+ if (typeof document === 'undefined')
9
+ return null;
10
+ return document.createElement('canvas');
11
+ }
12
+ /**
13
+ * Because we hand the device to `WebGPURenderer` ourselves, three treats it as
14
+ * caller-owned and never destroys it, and `Renderer.dispose()` is a no-op when
15
+ * `init()` failed. Any device we request and then abandon has to be released
16
+ * here or it counts against the browser's concurrent-device limit for the rest
17
+ * of the page's life.
18
+ */
19
+ function releaseDevice(device) {
20
+ try {
21
+ ;
22
+ device?.destroy?.();
23
+ }
24
+ catch { }
25
+ }
26
+ function withTimeout(promise, timeoutMs, operation) {
27
+ let timeout;
28
+ const timeoutPromise = new Promise((_, reject) => {
29
+ timeout = setTimeout(() => {
30
+ reject(new Error(`${operation} timed out after ${timeoutMs}ms`));
31
+ }, timeoutMs);
32
+ });
33
+ return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timeout));
34
+ }
35
+ async function requestWebGpuDevice(gpu, powerPreference) {
36
+ const adapter = await gpu.requestAdapter({
37
+ featureLevel: 'compatibility',
38
+ ...(powerPreference ? { powerPreference } : {}),
39
+ });
40
+ if (!adapter)
41
+ return null;
42
+ const requiredFeatures = adapter.features ? Array.from(adapter.features) : undefined;
43
+ return adapter.requestDevice(requiredFeatures?.length ? { requiredFeatures } : undefined);
44
+ }
45
+ export async function detectRendererCapability({ canvas = browserCanvas(), gpu = browserGpu(), powerPreference, webgpuTimeoutMs = WEBGPU_INITIALIZATION_TIMEOUT_MS, } = {}) {
46
+ let capabilityError;
47
+ if (gpu) {
48
+ const pending = requestWebGpuDevice(gpu, powerPreference);
49
+ try {
50
+ const device = await withTimeout(pending, webgpuTimeoutMs, 'WebGPU adapter/device request');
51
+ if (device)
52
+ return { backend: 'webgpu', device, status: 'supported' };
53
+ }
54
+ catch (error) {
55
+ capabilityError = error;
56
+ // Losing the race means a device may still arrive after we have given up
57
+ // on it, so reclaim it. Attaching the handler only on this path keeps
58
+ // ownership of a device we did return unambiguous.
59
+ pending.then(releaseDevice).catch(() => { });
60
+ }
61
+ }
62
+ if (canvas) {
63
+ try {
64
+ const context = canvas.getContext('webgl2');
65
+ if (context)
66
+ return { backend: 'webgl', status: 'supported' };
67
+ }
68
+ catch (error) {
69
+ capabilityError ??= error;
70
+ }
71
+ }
72
+ return { error: capabilityError, status: 'unsupported' };
73
+ }
74
+ export async function initializeGpuRenderer({ createRenderer, gpu, powerPreference, probeCanvas = browserCanvas(), webgpuTimeoutMs = WEBGPU_INITIALIZATION_TIMEOUT_MS, }) {
75
+ const capability = await detectRendererCapability({
76
+ canvas: probeCanvas,
77
+ gpu,
78
+ powerPreference,
79
+ webgpuTimeoutMs,
80
+ });
81
+ if (capability.status === 'unsupported')
82
+ return capability;
83
+ let renderer;
84
+ try {
85
+ renderer = createRenderer(capability.backend === 'webgpu' ? { device: capability.device } : { forceWebGL: true });
86
+ const initPromise = renderer.init();
87
+ await (capability.backend === 'webgpu'
88
+ ? withTimeout(initPromise, webgpuTimeoutMs, 'WebGPU renderer initialization')
89
+ : initPromise);
90
+ return { backend: capability.backend, renderer, status: 'ready' };
91
+ }
92
+ catch (error) {
93
+ try {
94
+ renderer?.dispose?.();
95
+ }
96
+ catch { }
97
+ if (capability.backend !== 'webgpu')
98
+ return { error, status: 'unsupported' };
99
+ releaseDevice(capability.device);
100
+ renderer = undefined;
101
+ try {
102
+ renderer = createRenderer({ forceWebGL: true });
103
+ await renderer.init();
104
+ return { backend: 'webgl', renderer, status: 'ready' };
105
+ }
106
+ catch (fallbackError) {
107
+ try {
108
+ renderer?.dispose?.();
109
+ }
110
+ catch { }
111
+ return { error: fallbackError, status: 'unsupported' };
112
+ }
113
+ }
114
+ }
@@ -0,0 +1,14 @@
1
+ import type { Object3D } from 'three';
2
+ /**
3
+ * Why an object is currently held off the scene layer.
4
+ *
5
+ * - `isolated` — outside the focused subtree of the viewer's isolation filter.
6
+ * - `shadow-only` — solo mode: out of the color passes, still casting shadows.
7
+ * - `batched` — a level's merged wall mesh draws this wall now.
8
+ */
9
+ export type HiddenReason = 'isolated' | 'shadow-only' | 'batched';
10
+ /** Holds `obj` off the scene layer for `reason`. Idempotent per reason. */
11
+ export declare function hideFromScene(obj: Object3D, reason: HiddenReason): void;
12
+ /** Drops `reason`, restoring the mask `obj` had before the first one arrived. */
13
+ export declare function showInScene(obj: Object3D, reason: HiddenReason): void;
14
+ //# sourceMappingURL=scene-visibility.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scene-visibility.d.ts","sourceRoot":"","sources":["../../src/lib/scene-visibility.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAGrC;;;;;;GAMG;AACH,MAAM,MAAM,YAAY,GAAG,UAAU,GAAG,aAAa,GAAG,SAAS,CAAA;AAmBjE,2EAA2E;AAC3E,wBAAgB,aAAa,CAAC,GAAG,EAAE,QAAQ,EAAE,MAAM,EAAE,YAAY,GAAG,IAAI,CAMvE;AAED,iFAAiF;AACjF,wBAAgB,WAAW,CAAC,GAAG,EAAE,QAAQ,EAAE,MAAM,EAAE,YAAY,GAAG,IAAI,CAarE"}
@@ -0,0 +1,48 @@
1
+ import { BATCHED_LAYER, SCENE_LAYER, SHADOW_ONLY_LAYER } from './layers';
2
+ /**
3
+ * Single owner of `Object3D.layers` for every feature that hides an object.
4
+ *
5
+ * Isolation, solo's shadow-caster pass and wall batching all hide by clearing
6
+ * {@link SCENE_LAYER}, and they overlap freely — a wall can be sewn into a
7
+ * batch, then soloed, then isolated. While each stashed and restored the mask privately, the
8
+ * second to finish wrote back a mask the first had since changed. Recording
9
+ * *reasons* rather than masks makes the order irrelevant: the mask is
10
+ * recomputed from the one snapshot taken when the first reason arrived, and
11
+ * handed back only when the last one leaves.
12
+ */
13
+ const HOLD = Symbol('pascal:scene-visibility:hold');
14
+ /** Holds `obj` off the scene layer for `reason`. Idempotent per reason. */
15
+ export function hideFromScene(obj, reason) {
16
+ const holder = obj;
17
+ const hold = holder[HOLD] ?? { original: obj.layers.mask, reasons: new Set() };
18
+ holder[HOLD] = hold;
19
+ hold.reasons.add(reason);
20
+ applyHold(obj, hold);
21
+ }
22
+ /** Drops `reason`, restoring the mask `obj` had before the first one arrived. */
23
+ export function showInScene(obj, reason) {
24
+ const holder = obj;
25
+ const hold = holder[HOLD];
26
+ if (!hold)
27
+ return;
28
+ hold.reasons.delete(reason);
29
+ if (hold.reasons.size > 0) {
30
+ applyHold(obj, hold);
31
+ return;
32
+ }
33
+ obj.layers.mask = hold.original;
34
+ delete holder[HOLD];
35
+ }
36
+ function applyHold(obj, hold) {
37
+ obj.layers.mask = hold.original;
38
+ obj.layers.disable(SCENE_LAYER);
39
+ // A batched wall is both drawn and shadowed by the merged mesh, so it stays
40
+ // out of the shadow pass too — enabling the shadow-only bit would submit its
41
+ // triangles a second time, on top of the copy the batch already casts.
42
+ if (hold.reasons.has('batched')) {
43
+ obj.layers.enable(BATCHED_LAYER);
44
+ return;
45
+ }
46
+ if (hold.reasons.has('shadow-only'))
47
+ obj.layers.enable(SHADOW_ONLY_LAYER);
48
+ }
@@ -1,4 +1,20 @@
1
1
  import type { Object3D } from 'three';
2
+ /**
3
+ * Shadow-caster-only hiding: removes an object (and its descendants) from the
4
+ * color passes while keeping it in the shadow map, so a hidden roof or level
5
+ * still shadows the interior and sun enters through windows correctly.
6
+ *
7
+ * Uses layer masks instead of `visible = false` for two reasons: `visible`
8
+ * cascades (and, critically, prunes the object from the shadow pass too),
9
+ * while layers are tested per-object against the rendering camera — the main
10
+ * camera never enables the shadow-only layer, but every shadow-casting
11
+ * light's shadow camera does (see `lights.tsx`).
12
+ *
13
+ * The mask itself belongs to `lib/scene-visibility.ts`, which reconciles this
14
+ * with the isolation filter. Both calls are idempotent and cheap to reapply —
15
+ * solo re-runs `applyShadowOnly` every frame so meshes rebuilt while hidden
16
+ * get re-hidden.
17
+ */
2
18
  export declare function applyShadowOnly(root: Object3D): void;
3
19
  export declare function clearShadowOnly(root: Object3D): void;
4
20
  //# sourceMappingURL=shadow-only.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"shadow-only.d.ts","sourceRoot":"","sources":["../../src/lib/shadow-only.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAuBrC,wBAAgB,eAAe,CAAC,IAAI,EAAE,QAAQ,GAAG,IAAI,CASpD;AAED,wBAAgB,eAAe,CAAC,IAAI,EAAE,QAAQ,GAAG,IAAI,CAOpD"}
1
+ {"version":3,"file":"shadow-only.d.ts","sourceRoot":"","sources":["../../src/lib/shadow-only.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAGrC;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,QAAQ,GAAG,IAAI,CAIpD;AAED,wBAAgB,eAAe,CAAC,IAAI,EAAE,QAAQ,GAAG,IAAI,CAIpD"}
@@ -1,4 +1,4 @@
1
- import { SCENE_LAYER, SHADOW_ONLY_LAYER } from './layers';
1
+ import { hideFromScene, showInScene } from './scene-visibility';
2
2
  /**
3
3
  * Shadow-caster-only hiding: removes an object (and its descendants) from the
4
4
  * color passes while keeping it in the shadow map, so a hidden roof or level
@@ -7,30 +7,21 @@ import { SCENE_LAYER, SHADOW_ONLY_LAYER } from './layers';
7
7
  * Uses layer masks instead of `visible = false` for two reasons: `visible`
8
8
  * cascades (and, critically, prunes the object from the shadow pass too),
9
9
  * while layers are tested per-object against the rendering camera — the main
10
- * camera never enables {@link SHADOW_ONLY_LAYER}, but every shadow-casting
10
+ * camera never enables the shadow-only layer, but every shadow-casting
11
11
  * light's shadow camera does (see `lights.tsx`).
12
12
  *
13
- * The original `layers.mask` is stashed under a private Symbol so
14
- * {@link clearShadowOnly} restores the exact prior state. Both calls are
15
- * idempotent and cheap to reapply.
13
+ * The mask itself belongs to `lib/scene-visibility.ts`, which reconciles this
14
+ * with the isolation filter. Both calls are idempotent and cheap to reapply —
15
+ * solo re-runs `applyShadowOnly` every frame so meshes rebuilt while hidden
16
+ * get re-hidden.
16
17
  */
17
- const ORIGINAL_LAYERS = Symbol('pascal:shadow-only:original-layers');
18
18
  export function applyShadowOnly(root) {
19
19
  root.traverse((obj) => {
20
- const carrier = obj;
21
- if (carrier[ORIGINAL_LAYERS] === undefined) {
22
- carrier[ORIGINAL_LAYERS] = obj.layers.mask;
23
- }
24
- obj.layers.disable(SCENE_LAYER);
25
- obj.layers.enable(SHADOW_ONLY_LAYER);
20
+ hideFromScene(obj, 'shadow-only');
26
21
  });
27
22
  }
28
23
  export function clearShadowOnly(root) {
29
24
  root.traverse((obj) => {
30
- const carrier = obj;
31
- if (carrier[ORIGINAL_LAYERS] === undefined)
32
- return;
33
- obj.layers.mask = carrier[ORIGINAL_LAYERS];
34
- delete carrier[ORIGINAL_LAYERS];
25
+ showInScene(obj, 'shadow-only');
35
26
  });
36
27
  }
@@ -3,6 +3,15 @@ import { type WebGPURenderer } from 'three/webgpu';
3
3
  import { type EdgeMode } from './edge-style';
4
4
  export declare const THUMBNAIL_WIDTH = 1920;
5
5
  export declare const THUMBNAIL_HEIGHT = 1080;
6
+ /**
7
+ * Captures are re-renderable artifacts, not user originals, so they encode as
8
+ * webp: a 1920×1080 hero shot lands roughly an order of magnitude under PNG,
9
+ * which is what listings and the catalog actually ship over the wire. Alpha
10
+ * survives, so transparent item/preset captures keep working.
11
+ */
12
+ export declare const SNAPSHOT_MIME = "image/webp";
13
+ export declare const SNAPSHOT_QUALITY = 0.9;
14
+ export declare const SNAPSHOT_MAX_EDGE = 2048;
6
15
  export type SnapshotCaptureMode = 'standard' | 'viewport' | 'area';
7
16
  export type SnapshotCropRegion = {
8
17
  x: number;
@@ -1 +1 @@
1
- {"version":3,"file":"snapshot-pipeline.d.ts","sourceRoot":"","sources":["../../src/lib/snapshot-pipeline.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,MAAM,EAAkB,KAAK,KAAK,EAAoB,MAAM,OAAO,CAAA;AAqBjF,OAAO,EAAgC,KAAK,cAAc,EAAE,MAAM,cAAc,CAAA;AAGhF,OAAO,EAAE,KAAK,QAAQ,EAAqC,MAAM,cAAc,CAAA;AAK/E,eAAO,MAAM,eAAe,OAAO,CAAA;AACnC,eAAO,MAAM,gBAAgB,OAAO,CAAA;AAEpC,MAAM,MAAM,mBAAmB,GAAG,UAAU,GAAG,UAAU,GAAG,MAAM,CAAA;AAElE,MAAM,MAAM,kBAAkB,GAAG;IAC/B,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,MAAM,CAAA;IACT,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;CACf,CAAA;AAED,MAAM,MAAM,YAAY,GAAG;IACzB,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,MAAM,CAAA;CACV,CAAA;AAED,MAAM,MAAM,qBAAqB,GAAG;IAClC,IAAI,EAAE,IAAI,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;CACb,CAAA;AAED,MAAM,MAAM,gBAAgB,GAAG;IAC7B,gBAAgB,EAAE,CAAC,EACjB,KAAK,EACL,WAAW,EACX,KAAK,EACL,KAAK,EACL,MAAM,GACP,EAAE;QACD,KAAK,EAAE,MAAM,CAAA;QACb,WAAW,EAAE,OAAO,CAAA;QACpB,KAAK,EAAE,OAAO,CAAA;QACd,KAAK,EAAE,QAAQ,CAAA;QACf,MAAM,EAAE,MAAM,CAAA;KACf,KAAK,IAAI,CAAA;IACV,OAAO,EAAE,CAAC,EACR,WAAW,EACX,UAAU,EACV,YAAY,GACb,EAAE;QACD,WAAW,CAAC,EAAE,mBAAmB,CAAA;QACjC,UAAU,CAAC,EAAE,kBAAkB,CAAA;QAC/B,YAAY,CAAC,EAAE,YAAY,CAAA;KAC5B,KAAK,OAAO,CAAC,qBAAqB,CAAC,CAAA;IACpC,OAAO,EAAE,MAAM,IAAI,CAAA;CACpB,CAAA;AAED,wBAAsB,sBAAsB,CAAC,EAC3C,QAAQ,EACR,KAAK,EACL,MAAM,GACP,EAAE;IACD,QAAQ,EAAE,cAAc,CAAA;IACxB,KAAK,EAAE,KAAK,CAAA;IACZ,MAAM,EAAE,MAAM,CAAA;CACf,GAAG,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAoSnC"}
1
+ {"version":3,"file":"snapshot-pipeline.d.ts","sourceRoot":"","sources":["../../src/lib/snapshot-pipeline.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,MAAM,EAAkB,KAAK,KAAK,EAAoB,MAAM,OAAO,CAAA;AAqBjF,OAAO,EAAgC,KAAK,cAAc,EAAE,MAAM,cAAc,CAAA;AAGhF,OAAO,EAAE,KAAK,QAAQ,EAAqC,MAAM,cAAc,CAAA;AAK/E,eAAO,MAAM,eAAe,OAAO,CAAA;AACnC,eAAO,MAAM,gBAAgB,OAAO,CAAA;AAEpC;;;;;GAKG;AACH,eAAO,MAAM,aAAa,eAAe,CAAA;AACzC,eAAO,MAAM,gBAAgB,MAAM,CAAA;AAEnC,eAAO,MAAM,iBAAiB,OAAO,CAAA;AAUrC,MAAM,MAAM,mBAAmB,GAAG,UAAU,GAAG,UAAU,GAAG,MAAM,CAAA;AAElE,MAAM,MAAM,kBAAkB,GAAG;IAC/B,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,MAAM,CAAA;IACT,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;CACf,CAAA;AAED,MAAM,MAAM,YAAY,GAAG;IACzB,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,MAAM,CAAA;CACV,CAAA;AAED,MAAM,MAAM,qBAAqB,GAAG;IAClC,IAAI,EAAE,IAAI,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;CACb,CAAA;AAED,MAAM,MAAM,gBAAgB,GAAG;IAC7B,gBAAgB,EAAE,CAAC,EACjB,KAAK,EACL,WAAW,EACX,KAAK,EACL,KAAK,EACL,MAAM,GACP,EAAE;QACD,KAAK,EAAE,MAAM,CAAA;QACb,WAAW,EAAE,OAAO,CAAA;QACpB,KAAK,EAAE,OAAO,CAAA;QACd,KAAK,EAAE,QAAQ,CAAA;QACf,MAAM,EAAE,MAAM,CAAA;KACf,KAAK,IAAI,CAAA;IACV,OAAO,EAAE,CAAC,EACR,WAAW,EACX,UAAU,EACV,YAAY,GACb,EAAE;QACD,WAAW,CAAC,EAAE,mBAAmB,CAAA;QACjC,UAAU,CAAC,EAAE,kBAAkB,CAAA;QAC/B,YAAY,CAAC,EAAE,YAAY,CAAA;KAC5B,KAAK,OAAO,CAAC,qBAAqB,CAAC,CAAA;IACpC,OAAO,EAAE,MAAM,IAAI,CAAA;CACpB,CAAA;AAED,wBAAsB,sBAAsB,CAAC,EAC3C,QAAQ,EACR,KAAK,EACL,MAAM,GACP,EAAE;IACD,QAAQ,EAAE,cAAc,CAAA;IACxB,KAAK,EAAE,KAAK,CAAA;IACZ,MAAM,EAAE,MAAM,CAAA;CACf,GAAG,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAwSnC"}
@@ -12,6 +12,23 @@ import { getSceneTheme } from './scene-themes';
12
12
  import { packNormalToRGB, unpackRGBToNormal } from './tsl-compat';
13
13
  export const THUMBNAIL_WIDTH = 1920;
14
14
  export const THUMBNAIL_HEIGHT = 1080;
15
+ /**
16
+ * Captures are re-renderable artifacts, not user originals, so they encode as
17
+ * webp: a 1920×1080 hero shot lands roughly an order of magnitude under PNG,
18
+ * which is what listings and the catalog actually ship over the wire. Alpha
19
+ * survives, so transparent item/preset captures keep working.
20
+ */
21
+ export const SNAPSHOT_MIME = 'image/webp';
22
+ export const SNAPSHOT_QUALITY = 0.9;
23
+ // Retina canvases make viewport/area captures multi-MB; 2048 keeps them near the 1920 presets.
24
+ export const SNAPSHOT_MAX_EDGE = 2048;
25
+ function clampSnapshotSize(width, height) {
26
+ const maxEdge = Math.max(width, height);
27
+ if (maxEdge <= SNAPSHOT_MAX_EDGE)
28
+ return { w: width, h: height };
29
+ const scale = SNAPSHOT_MAX_EDGE / maxEdge;
30
+ return { w: Math.round(width * scale), h: Math.round(height * scale) };
31
+ }
15
32
  export async function createSnapshotPipeline({ renderer, scene, camera, }) {
16
33
  try {
17
34
  if (renderer.init)
@@ -200,20 +217,27 @@ export async function createSnapshotPipeline({ renderer, scene, camera, }) {
200
217
  let outH;
201
218
  let blob;
202
219
  if (captureMode === 'viewport') {
203
- outW = captureWidth;
204
- outH = captureHeight;
220
+ ;
221
+ ({ w: outW, h: outH } = clampSnapshotSize(captureWidth, captureHeight));
205
222
  const offscreen = new OffscreenCanvas(outW, outH);
206
- offscreen.getContext('2d').drawImage(srcCanvas, 0, 0);
207
- blob = await offscreen.convertToBlob({ type: 'image/png' });
223
+ const ctx = offscreen.getContext('2d');
224
+ if (outW !== captureWidth || outH !== captureHeight)
225
+ ctx.imageSmoothingQuality = 'high';
226
+ ctx.drawImage(srcCanvas, 0, 0, captureWidth, captureHeight, 0, 0, outW, outH);
227
+ blob = await offscreen.convertToBlob({ type: SNAPSHOT_MIME, quality: SNAPSHOT_QUALITY });
208
228
  }
209
229
  else if (captureMode === 'area' && cropRegion) {
210
230
  const sx = Math.round(cropRegion.x * captureWidth);
211
231
  const sy = Math.round(cropRegion.y * captureHeight);
212
- outW = Math.round(cropRegion.width * captureWidth);
213
- outH = Math.round(cropRegion.height * captureHeight);
232
+ const sourceW = Math.round(cropRegion.width * captureWidth);
233
+ const sourceH = Math.round(cropRegion.height * captureHeight);
234
+ ({ w: outW, h: outH } = clampSnapshotSize(sourceW, sourceH));
214
235
  const offscreen = new OffscreenCanvas(outW, outH);
215
- offscreen.getContext('2d').drawImage(srcCanvas, sx, sy, outW, outH, 0, 0, outW, outH);
216
- blob = await offscreen.convertToBlob({ type: 'image/png' });
236
+ const ctx = offscreen.getContext('2d');
237
+ if (outW !== sourceW || outH !== sourceH)
238
+ ctx.imageSmoothingQuality = 'high';
239
+ ctx.drawImage(srcCanvas, sx, sy, sourceW, sourceH, 0, 0, outW, outH);
240
+ blob = await offscreen.convertToBlob({ type: SNAPSHOT_MIME, quality: SNAPSHOT_QUALITY });
217
241
  }
218
242
  else {
219
243
  // Standard: center-crop to the requested aspect (default 1920×1080)
@@ -237,7 +261,7 @@ export async function createSnapshotPipeline({ renderer, scene, camera, }) {
237
261
  offscreen
238
262
  .getContext('2d')
239
263
  .drawImage(srcCanvas, sx, sy, sWidth, sHeight, 0, 0, outW, outH);
240
- blob = await offscreen.convertToBlob({ type: 'image/png' });
264
+ blob = await offscreen.convertToBlob({ type: SNAPSHOT_MIME, quality: SNAPSHOT_QUALITY });
241
265
  }
242
266
  return { blob, outW, outH };
243
267
  },