@pascal-app/viewer 0.8.0 → 0.9.1

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 (194) hide show
  1. package/dist/components/renderers/node-renderer.d.ts +1 -1
  2. package/dist/components/renderers/node-renderer.d.ts.map +1 -1
  3. package/dist/components/renderers/node-renderer.js +38 -22
  4. package/dist/components/renderers/parametric-node-renderer.d.ts +5 -0
  5. package/dist/components/renderers/parametric-node-renderer.d.ts.map +1 -0
  6. package/dist/components/renderers/parametric-node-renderer.js +34 -0
  7. package/dist/components/renderers/scene-renderer.d.ts +1 -1
  8. package/dist/components/renderers/scene-renderer.d.ts.map +1 -1
  9. package/dist/components/viewer/ground-occluder.d.ts +1 -1
  10. package/dist/components/viewer/ground-occluder.d.ts.map +1 -1
  11. package/dist/components/viewer/ground-occluder.js +12 -24
  12. package/dist/components/viewer/index.d.ts +37 -1
  13. package/dist/components/viewer/index.d.ts.map +1 -1
  14. package/dist/components/viewer/index.js +186 -63
  15. package/dist/components/viewer/lights.d.ts +1 -1
  16. package/dist/components/viewer/lights.d.ts.map +1 -1
  17. package/dist/components/viewer/lights.js +170 -43
  18. package/dist/components/viewer/perf-monitor.d.ts +1 -1
  19. package/dist/components/viewer/perf-monitor.d.ts.map +1 -1
  20. package/dist/components/viewer/perf-monitor.js +99 -11
  21. package/dist/components/viewer/post-processing.d.ts.map +1 -1
  22. package/dist/components/viewer/post-processing.js +231 -54
  23. package/dist/components/viewer/registered-systems.d.ts +12 -0
  24. package/dist/components/viewer/registered-systems.d.ts.map +1 -0
  25. package/dist/components/viewer/registered-systems.js +47 -0
  26. package/dist/components/viewer/scene-bvh.d.ts +17 -0
  27. package/dist/components/viewer/scene-bvh.d.ts.map +1 -0
  28. package/dist/components/viewer/scene-bvh.js +94 -0
  29. package/dist/components/viewer/selection-manager.d.ts +1 -1
  30. package/dist/components/viewer/selection-manager.d.ts.map +1 -1
  31. package/dist/components/viewer/selection-manager.js +20 -5
  32. package/dist/components/viewer/viewer-camera.d.ts +1 -1
  33. package/dist/components/viewer/viewer-camera.d.ts.map +1 -1
  34. package/dist/components/viewer/walkthrough-controls.d.ts +1 -1
  35. package/dist/components/viewer/walkthrough-controls.d.ts.map +1 -1
  36. package/dist/hooks/use-gltf-ktx2.d.ts.map +1 -1
  37. package/dist/hooks/use-node-events.d.ts +5 -73
  38. package/dist/hooks/use-node-events.d.ts.map +1 -1
  39. package/dist/hooks/use-node-events.js +19 -7
  40. package/dist/index.d.ts +36 -4
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +67 -3
  43. package/dist/lib/csg-utils.d.ts +16 -0
  44. package/dist/lib/csg-utils.d.ts.map +1 -0
  45. package/dist/lib/csg-utils.js +33 -0
  46. package/dist/lib/drawable-geometry.d.ts +21 -0
  47. package/dist/lib/drawable-geometry.d.ts.map +1 -0
  48. package/dist/lib/drawable-geometry.js +22 -0
  49. package/dist/lib/edge-style.d.ts +3 -0
  50. package/dist/lib/edge-style.d.ts.map +1 -0
  51. package/dist/lib/edge-style.js +11 -0
  52. package/dist/lib/gpu-perf.d.ts +4 -0
  53. package/dist/lib/gpu-perf.d.ts.map +1 -0
  54. package/dist/lib/gpu-perf.js +23 -0
  55. package/dist/lib/ink-edges.d.ts +9 -0
  56. package/dist/lib/ink-edges.d.ts.map +1 -0
  57. package/dist/lib/ink-edges.js +43 -0
  58. package/dist/lib/isolation.d.ts +39 -0
  59. package/dist/lib/isolation.d.ts.map +1 -0
  60. package/dist/lib/isolation.js +103 -0
  61. package/dist/lib/layers.d.ts +18 -0
  62. package/dist/lib/layers.d.ts.map +1 -1
  63. package/dist/lib/layers.js +18 -0
  64. package/dist/lib/materials.d.ts +25 -15
  65. package/dist/lib/materials.d.ts.map +1 -1
  66. package/dist/lib/materials.js +277 -62
  67. package/dist/lib/merged-outline-node.d.ts.map +1 -1
  68. package/dist/lib/merged-outline-node.js +7 -0
  69. package/dist/lib/polygon-union.d.ts +4 -0
  70. package/dist/lib/polygon-union.d.ts.map +1 -0
  71. package/dist/lib/polygon-union.js +289 -0
  72. package/dist/lib/polygon-union.test.d.ts +2 -0
  73. package/dist/lib/polygon-union.test.d.ts.map +1 -0
  74. package/dist/lib/polygon-union.test.js +105 -0
  75. package/dist/lib/scene-themes.d.ts +29 -0
  76. package/dist/lib/scene-themes.d.ts.map +1 -0
  77. package/dist/lib/scene-themes.js +193 -0
  78. package/dist/store/use-viewer.d.ts +42 -4
  79. package/dist/store/use-viewer.d.ts.map +1 -1
  80. package/dist/store/use-viewer.js +26 -3
  81. package/dist/systems/ceiling/ceiling-system.d.ts +6 -2
  82. package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
  83. package/dist/systems/ceiling/ceiling-system.js +62 -12
  84. package/dist/systems/door/door-system.d.ts.map +1 -1
  85. package/dist/systems/door/door-system.js +373 -22
  86. package/dist/systems/elevator/elevator-interaction-system.d.ts +2 -0
  87. package/dist/systems/elevator/elevator-interaction-system.d.ts.map +1 -0
  88. package/dist/systems/elevator/elevator-interaction-system.js +62 -0
  89. package/dist/systems/fence/fence-system.d.ts +3 -0
  90. package/dist/systems/fence/fence-system.d.ts.map +1 -1
  91. package/dist/systems/fence/fence-system.js +9 -5
  92. package/dist/systems/floor-elevation/floor-elevation-system.d.ts +20 -0
  93. package/dist/systems/floor-elevation/floor-elevation-system.d.ts.map +1 -0
  94. package/dist/systems/floor-elevation/floor-elevation-system.js +78 -0
  95. package/dist/systems/geometry/geometry-system.d.ts +34 -0
  96. package/dist/systems/geometry/geometry-system.d.ts.map +1 -0
  97. package/dist/systems/geometry/geometry-system.js +279 -0
  98. package/dist/systems/interactive/interactive-system.d.ts +1 -1
  99. package/dist/systems/interactive/interactive-system.d.ts.map +1 -1
  100. package/dist/systems/item/item-system.d.ts +7 -0
  101. package/dist/systems/item/item-system.d.ts.map +1 -1
  102. package/dist/systems/item/item-system.js +8 -11
  103. package/dist/systems/item-light/item-light-system.d.ts +1 -1
  104. package/dist/systems/item-light/item-light-system.d.ts.map +1 -1
  105. package/dist/systems/item-light/item-light-system.js +18 -5
  106. package/dist/systems/roof/roof-materials.d.ts +3 -2
  107. package/dist/systems/roof/roof-materials.d.ts.map +1 -1
  108. package/dist/systems/roof/roof-materials.js +38 -14
  109. package/dist/systems/roof/roof-system.d.ts +27 -0
  110. package/dist/systems/roof/roof-system.d.ts.map +1 -1
  111. package/dist/systems/roof/roof-system.js +396 -59
  112. package/dist/systems/slab/slab-system.d.ts.map +1 -1
  113. package/dist/systems/slab/slab-system.js +167 -49
  114. package/dist/systems/slab/slab-system.test.d.ts +2 -0
  115. package/dist/systems/slab/slab-system.test.d.ts.map +1 -0
  116. package/dist/systems/slab/slab-system.test.js +62 -0
  117. package/dist/systems/stair/stair-materials.d.ts +4 -3
  118. package/dist/systems/stair/stair-materials.d.ts.map +1 -1
  119. package/dist/systems/stair/stair-materials.js +30 -15
  120. package/dist/systems/stair/stair-system.d.ts.map +1 -1
  121. package/dist/systems/stair/stair-system.js +50 -61
  122. package/dist/systems/surface-hole-geometry.d.ts +3 -0
  123. package/dist/systems/surface-hole-geometry.d.ts.map +1 -0
  124. package/dist/systems/surface-hole-geometry.js +4 -0
  125. package/dist/systems/wall/wall-cutout.d.ts.map +1 -1
  126. package/dist/systems/wall/wall-cutout.js +18 -2
  127. package/dist/systems/wall/wall-materials.d.ts +4 -3
  128. package/dist/systems/wall/wall-materials.d.ts.map +1 -1
  129. package/dist/systems/wall/wall-materials.js +52 -28
  130. package/dist/systems/wall/wall-system.d.ts.map +1 -1
  131. package/dist/systems/wall/wall-system.js +171 -26
  132. package/dist/systems/window/window-system.d.ts.map +1 -1
  133. package/dist/systems/window/window-system.js +71 -7
  134. package/package.json +3 -4
  135. package/dist/components/renderers/building/building-renderer.d.ts +0 -5
  136. package/dist/components/renderers/building/building-renderer.d.ts.map +0 -1
  137. package/dist/components/renderers/building/building-renderer.js +0 -11
  138. package/dist/components/renderers/ceiling/ceiling-renderer.d.ts +0 -5
  139. package/dist/components/renderers/ceiling/ceiling-renderer.d.ts.map +0 -1
  140. package/dist/components/renderers/ceiling/ceiling-renderer.js +0 -58
  141. package/dist/components/renderers/column/column-renderer.d.ts +0 -5
  142. package/dist/components/renderers/column/column-renderer.d.ts.map +0 -1
  143. package/dist/components/renderers/column/column-renderer.js +0 -597
  144. package/dist/components/renderers/door/door-renderer.d.ts +0 -5
  145. package/dist/components/renderers/door/door-renderer.d.ts.map +0 -1
  146. package/dist/components/renderers/door/door-renderer.js +0 -16
  147. package/dist/components/renderers/fence/fence-renderer.d.ts +0 -5
  148. package/dist/components/renderers/fence/fence-renderer.d.ts.map +0 -1
  149. package/dist/components/renderers/fence/fence-renderer.js +0 -15
  150. package/dist/components/renderers/guide/guide-renderer.d.ts +0 -5
  151. package/dist/components/renderers/guide/guide-renderer.d.ts.map +0 -1
  152. package/dist/components/renderers/guide/guide-renderer.js +0 -38
  153. package/dist/components/renderers/item/item-renderer.d.ts +0 -5
  154. package/dist/components/renderers/item/item-renderer.d.ts.map +0 -1
  155. package/dist/components/renderers/item/item-renderer.js +0 -172
  156. package/dist/components/renderers/level/level-renderer.d.ts +0 -5
  157. package/dist/components/renderers/level/level-renderer.d.ts.map +0 -1
  158. package/dist/components/renderers/level/level-renderer.js +0 -11
  159. package/dist/components/renderers/roof/roof-materials.d.ts +0 -4
  160. package/dist/components/renderers/roof/roof-materials.d.ts.map +0 -1
  161. package/dist/components/renderers/roof/roof-materials.js +0 -16
  162. package/dist/components/renderers/roof/roof-renderer.d.ts +0 -5
  163. package/dist/components/renderers/roof/roof-renderer.d.ts.map +0 -1
  164. package/dist/components/renderers/roof/roof-renderer.js +0 -32
  165. package/dist/components/renderers/roof-segment/roof-segment-renderer.d.ts +0 -5
  166. package/dist/components/renderers/roof-segment/roof-segment-renderer.d.ts.map +0 -1
  167. package/dist/components/renderers/roof-segment/roof-segment-renderer.js +0 -40
  168. package/dist/components/renderers/scan/scan-renderer.d.ts +0 -5
  169. package/dist/components/renderers/scan/scan-renderer.d.ts.map +0 -1
  170. package/dist/components/renderers/scan/scan-renderer.js +0 -54
  171. package/dist/components/renderers/site/site-renderer.d.ts +0 -5
  172. package/dist/components/renderers/site/site-renderer.d.ts.map +0 -1
  173. package/dist/components/renderers/site/site-renderer.js +0 -106
  174. package/dist/components/renderers/slab/slab-renderer.d.ts +0 -5
  175. package/dist/components/renderers/slab/slab-renderer.d.ts.map +0 -1
  176. package/dist/components/renderers/slab/slab-renderer.js +0 -58
  177. package/dist/components/renderers/spawn/spawn-renderer.d.ts +0 -5
  178. package/dist/components/renderers/spawn/spawn-renderer.d.ts.map +0 -1
  179. package/dist/components/renderers/spawn/spawn-renderer.js +0 -32
  180. package/dist/components/renderers/stair/stair-renderer.d.ts +0 -5
  181. package/dist/components/renderers/stair/stair-renderer.d.ts.map +0 -1
  182. package/dist/components/renderers/stair/stair-renderer.js +0 -603
  183. package/dist/components/renderers/stair-segment/stair-segment-renderer.d.ts +0 -5
  184. package/dist/components/renderers/stair-segment/stair-segment-renderer.d.ts.map +0 -1
  185. package/dist/components/renderers/stair-segment/stair-segment-renderer.js +0 -51
  186. package/dist/components/renderers/wall/wall-renderer.d.ts +0 -5
  187. package/dist/components/renderers/wall/wall-renderer.d.ts.map +0 -1
  188. package/dist/components/renderers/wall/wall-renderer.js +0 -16
  189. package/dist/components/renderers/window/window-renderer.d.ts +0 -5
  190. package/dist/components/renderers/window/window-renderer.d.ts.map +0 -1
  191. package/dist/components/renderers/window/window-renderer.js +0 -21
  192. package/dist/components/renderers/zone/zone-renderer.d.ts +0 -5
  193. package/dist/components/renderers/zone/zone-renderer.d.ts.map +0 -1
  194. package/dist/components/renderers/zone/zone-renderer.js +0 -181
@@ -1,6 +1,9 @@
1
- import { getRenderableSlabPolygon, sceneRegistry, useScene, } from '@pascal-app/core';
1
+ import { getEffectiveNode, getRenderableSlabPolygon, pointInPolygon2D, polygonsIntersect, sceneRegistry, useScene, } from '@pascal-app/core';
2
2
  import { useFrame } from '@react-three/fiber';
3
+ import { useEffect } from 'react';
3
4
  import * as THREE from 'three';
5
+ import { subtractPolygonsFromPolygon } from '../../lib/polygon-union';
6
+ import { mergeSurfaceHolePolygons } from '../surface-hole-geometry';
4
7
  function ensureUv2Attribute(geometry) {
5
8
  const uv = geometry.getAttribute('uv');
6
9
  if (!uv)
@@ -13,6 +16,15 @@ function ensureUv2Attribute(geometry) {
13
16
  export const SlabSystem = () => {
14
17
  const dirtyNodes = useScene((state) => state.dirtyNodes);
15
18
  const clearDirty = useScene((state) => state.clearDirty);
19
+ const markDirty = useScene((state) => state.markDirty);
20
+ useEffect(() => {
21
+ const nodes = useScene.getState().nodes;
22
+ for (const node of Object.values(nodes)) {
23
+ if (node.type === 'slab') {
24
+ markDirty(node.id);
25
+ }
26
+ }
27
+ }, [markDirty]);
16
28
  useFrame(() => {
17
29
  if (dirtyNodes.size === 0)
18
30
  return;
@@ -24,7 +36,7 @@ export const SlabSystem = () => {
24
36
  return;
25
37
  const mesh = sceneRegistry.nodes.get(id);
26
38
  if (mesh) {
27
- updateSlabGeometry(node, mesh);
39
+ updateSlabGeometry(getEffectiveNode(node), mesh);
28
40
  clearDirty(id);
29
41
  }
30
42
  // If mesh not found, keep it dirty for next frame
@@ -52,31 +64,133 @@ export function generateSlabGeometry(slabNode) {
52
64
  const elevation = slabNode.elevation ?? 0.05;
53
65
  return elevation < 0 ? generatePoolGeometry(slabNode) : generatePositiveSlabGeometry(slabNode);
54
66
  }
67
+ // Earcut normalizes cap triangulation regardless of input winding, but the side
68
+ // walls below assume a CCW contour (the unflipped quad's right-hand normal faces
69
+ // outward only for CCW). outsetPolygon and the slab tool preserve the drawn
70
+ // winding, so a CW-drawn slab gets inward-facing walls that FrontSide culls and
71
+ // the slab reads as see-through from the front. Normalize to CCW first.
72
+ function ensureCounterClockwisePolygon(polygon) {
73
+ let area2 = 0;
74
+ for (let i = 0; i < polygon.length; i++) {
75
+ const j = (i + 1) % polygon.length;
76
+ area2 += polygon[i][0] * polygon[j][1] - polygon[j][0] * polygon[i][1];
77
+ }
78
+ return area2 < 0 ? [...polygon].reverse() : polygon;
79
+ }
80
+ function isStrictInteriorHole(contour, hole) {
81
+ return (hole.every((point) => pointInPolygon2D(point, contour, { includeBoundary: false })) &&
82
+ !polygonsIntersect(contour, hole));
83
+ }
84
+ function affectsContour(contour, hole) {
85
+ return (polygonsIntersect(contour, hole) ||
86
+ hole.some((point) => pointInPolygon2D(point, contour, { includeBoundary: false })) ||
87
+ contour.some((point) => pointInPolygon2D(point, hole, { includeBoundary: false })));
88
+ }
89
+ function buildSlabRegions(contour, holes) {
90
+ const containedHoles = [];
91
+ const edgeCutouts = [];
92
+ for (const hole of holes) {
93
+ if (hole.length < 3)
94
+ continue;
95
+ if (isStrictInteriorHole(contour, hole))
96
+ containedHoles.push(hole);
97
+ else if (affectsContour(contour, hole))
98
+ edgeCutouts.push(hole);
99
+ }
100
+ const contours = edgeCutouts.length > 0 ? subtractPolygonsFromPolygon(contour, edgeCutouts) : [contour];
101
+ return contours.map((regionContour) => ({
102
+ contour: regionContour,
103
+ holes: containedHoles.filter((hole) => isStrictInteriorHole(regionContour, hole)),
104
+ }));
105
+ }
55
106
  /**
56
107
  * Standard slab: flat extrusion upward from Y=0 by elevation thickness.
108
+ *
109
+ * Built directly in 3D (Y-up) rather than via ExtrudeGeometry so the hole side
110
+ * walls can be emitted double-sided. The slab material is forced to FrontSide
111
+ * (DoubleSide on the floor-role NodeMaterial poisons the MRT scene pass — see
112
+ * nodes/slab/geometry.ts), and ExtrudeGeometry's hole walls are single-sided,
113
+ * so their interior faces get back-face culled and you see straight through the
114
+ * cut. Emitting each hole-wall quad twice with opposite winding makes the inner
115
+ * thickness visible from any angle: the two coincident triangles never z-fight
116
+ * because exactly one faces the camera under FrontSide culling.
57
117
  */
58
118
  function generatePositiveSlabGeometry(slabNode) {
59
- const polygon = getRenderableSlabPolygon(slabNode);
119
+ const polygon = ensureCounterClockwisePolygon(getRenderableSlabPolygon(slabNode));
60
120
  const elevation = slabNode.elevation ?? 0.05;
121
+ const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? []);
61
122
  if (polygon.length < 3)
62
123
  return new THREE.BufferGeometry();
63
- const shape = new THREE.Shape();
64
- shape.moveTo(polygon[0][0], -polygon[0][1]);
65
- for (let i = 1; i < polygon.length; i++)
66
- shape.lineTo(polygon[i][0], -polygon[i][1]);
67
- shape.closePath();
68
- for (const holePolygon of slabNode.holes ?? []) {
69
- if (holePolygon.length < 3)
70
- continue;
71
- const holePath = new THREE.Path();
72
- holePath.moveTo(holePolygon[0][0], -holePolygon[0][1]);
73
- for (let i = 1; i < holePolygon.length; i++)
74
- holePath.lineTo(holePolygon[i][0], -holePolygon[i][1]);
75
- holePath.closePath();
76
- shape.holes.push(holePath);
124
+ const positions = [];
125
+ const uvs = [];
126
+ const indices = [];
127
+ // --- Side walls ---
128
+ // Each segment gets its own 4 verts so computeVertexNormals doesn't average
129
+ // across faces. Outer walls are single-sided with outward normals; hole walls
130
+ // emit a second flipped quad (own verts) so they read as double-sided.
131
+ const addWall = (a, b, flipped) => {
132
+ const base = positions.length / 3;
133
+ const len = Math.max(Math.hypot(b.x - a.x, b.y - a.y), 0.001);
134
+ positions.push(a.x, 0, a.y);
135
+ uvs.push(0, 0);
136
+ positions.push(b.x, 0, b.y);
137
+ uvs.push(len, 0);
138
+ positions.push(b.x, elevation, b.y);
139
+ uvs.push(len, elevation);
140
+ positions.push(a.x, elevation, a.y);
141
+ uvs.push(0, elevation);
142
+ // Standard winding on a CCW polygon gives inward-facing normals (see pool
143
+ // path), so the unflipped quad faces outward; flipped is its back face.
144
+ if (!flipped) {
145
+ indices.push(base, base + 2, base + 1, base, base + 3, base + 2);
146
+ }
147
+ else {
148
+ indices.push(base, base + 1, base + 2, base, base + 2, base + 3);
149
+ }
150
+ };
151
+ for (const region of buildSlabRegions(polygon, holePolygons)) {
152
+ const contour2d = ensureCounterClockwisePolygon(region.contour).map(([x, z]) => new THREE.Vector2(x, z));
153
+ const holes2d = region.holes
154
+ .filter((h) => h.length >= 3)
155
+ .map((h) => h.map(([x, z]) => new THREE.Vector2(x, z)));
156
+ // --- Top & bottom caps ---
157
+ // capPoints order (contour then holes) matches triangulateShape's index space.
158
+ // UVs reproduce ExtrudeGeometry's WorldUVGenerator mapping (shape-space x,-z)
159
+ // so textured slabs keep the same floor projection.
160
+ const capPoints = [...contour2d, ...holes2d.flat()];
161
+ const topBase = positions.length / 3;
162
+ for (const p of capPoints) {
163
+ positions.push(p.x, elevation, p.y);
164
+ uvs.push(p.x, -p.y);
165
+ }
166
+ const bottomBase = positions.length / 3;
167
+ for (const p of capPoints) {
168
+ positions.push(p.x, 0, p.y);
169
+ uvs.push(p.x, -p.y);
170
+ }
171
+ const capTris = THREE.ShapeUtils.triangulateShape(contour2d, holes2d);
172
+ for (const tri of capTris) {
173
+ const [a, b, c] = [tri[0], tri[1], tri[2]];
174
+ // Reversed winding → +Y normal on top; standard winding → -Y on bottom.
175
+ indices.push(topBase + a, topBase + c, topBase + b);
176
+ indices.push(bottomBase + a, bottomBase + b, bottomBase + c);
177
+ }
178
+ for (let i = 0; i < contour2d.length; i++) {
179
+ addWall(contour2d[i], contour2d[(i + 1) % contour2d.length], false);
180
+ }
181
+ for (const hole of holes2d) {
182
+ for (let i = 0; i < hole.length; i++) {
183
+ const a = hole[i];
184
+ const b = hole[(i + 1) % hole.length];
185
+ addWall(a, b, false);
186
+ addWall(a, b, true);
187
+ }
188
+ }
77
189
  }
78
- const geometry = new THREE.ExtrudeGeometry(shape, { depth: elevation, bevelEnabled: false });
79
- geometry.rotateX(-Math.PI / 2);
190
+ const geometry = new THREE.BufferGeometry();
191
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
192
+ geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
193
+ geometry.setIndex(indices);
80
194
  geometry.computeVertexNormals();
81
195
  return geometry;
82
196
  }
@@ -90,19 +204,19 @@ function generatePositiveSlabGeometry(slabNode) {
90
204
  * - walls from Y=0 to Y=depth, inward-facing normals (visible from inside pool)
91
205
  */
92
206
  function generatePoolGeometry(slabNode) {
93
- const polygon = getRenderableSlabPolygon(slabNode);
207
+ const polygon = ensureCounterClockwisePolygon(getRenderableSlabPolygon(slabNode));
94
208
  const depth = Math.abs(slabNode.elevation ?? 0.05);
209
+ const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? []);
95
210
  if (polygon.length < 3)
96
211
  return new THREE.BufferGeometry();
97
212
  const positions = [];
98
213
  const uvs = [];
99
214
  const indices = [];
100
- const n = polygon.length;
101
215
  const bounds = new THREE.Box2();
102
216
  for (const [x, z] of polygon) {
103
217
  bounds.expandByPoint(new THREE.Vector2(x, z));
104
218
  }
105
- for (const hole of slabNode.holes ?? []) {
219
+ for (const hole of holePolygons) {
106
220
  for (const [x, z] of hole) {
107
221
  bounds.expandByPoint(new THREE.Vector2(x, z));
108
222
  }
@@ -117,34 +231,38 @@ function generatePoolGeometry(slabNode) {
117
231
  positions.push(x, y, z);
118
232
  uvs.push(u, v);
119
233
  };
120
- // --- Floor at Y=0 ---
121
- for (const [x, z] of polygon)
122
- pushFloorVertex(x, 0, z);
123
- const pts2d = polygon.map(([x, z]) => new THREE.Vector2(x, z));
124
- const holesPts2d = (slabNode.holes ?? []).map((h) => h.map(([x, z]) => new THREE.Vector2(x, z)));
125
- for (const hole of slabNode.holes ?? []) {
126
- for (const [x, z] of hole)
234
+ for (const region of buildSlabRegions(polygon, holePolygons)) {
235
+ const contour = ensureCounterClockwisePolygon(region.contour);
236
+ const floorBase = positions.length / 3;
237
+ // --- Floor at Y=0 ---
238
+ for (const [x, z] of contour)
127
239
  pushFloorVertex(x, 0, z);
128
- }
129
- const floorTris = THREE.ShapeUtils.triangulateShape(pts2d, holesPts2d);
130
- for (const tri of floorTris) {
131
- // Reversed winding → normals point +Y (upward) in XZ plane
132
- indices.push(tri[0], tri[2], tri[1]);
133
- }
134
- // --- Inner walls (no top cap at Y=depth) ---
135
- // Standard winding on a CCW polygon in XZ gives inward-facing normals.
136
- for (let i = 0; i < n; i++) {
137
- const j = (i + 1) % n;
138
- const [x0, z0] = polygon[i];
139
- const [x1, z1] = polygon[j];
140
- const vBase = positions.length / 3;
141
- const segmentLength = Math.max(Math.hypot(x1 - x0, z1 - z0), 0.001);
142
- pushWallVertex(x0, 0, z0, 0, 0); // v0 — floor level
143
- pushWallVertex(x1, 0, z1, segmentLength, 0); // v1 — floor level
144
- pushWallVertex(x1, depth, z1, segmentLength, depth); // v2 — ground level
145
- pushWallVertex(x0, depth, z0, 0, depth); // v3 — ground level
146
- indices.push(vBase, vBase + 1, vBase + 2);
147
- indices.push(vBase, vBase + 2, vBase + 3);
240
+ const pts2d = contour.map(([x, z]) => new THREE.Vector2(x, z));
241
+ const holesPts2d = region.holes.map((h) => h.map(([x, z]) => new THREE.Vector2(x, z)));
242
+ for (const hole of region.holes) {
243
+ for (const [x, z] of hole)
244
+ pushFloorVertex(x, 0, z);
245
+ }
246
+ const floorTris = THREE.ShapeUtils.triangulateShape(pts2d, holesPts2d);
247
+ for (const tri of floorTris) {
248
+ // Reversed winding → normals point +Y (upward) in XZ plane
249
+ indices.push(floorBase + tri[0], floorBase + tri[2], floorBase + tri[1]);
250
+ }
251
+ // --- Inner walls (no top cap at Y=depth) ---
252
+ // Standard winding on a CCW polygon in XZ gives inward-facing normals.
253
+ for (let i = 0; i < contour.length; i++) {
254
+ const j = (i + 1) % contour.length;
255
+ const [x0, z0] = contour[i];
256
+ const [x1, z1] = contour[j];
257
+ const vBase = positions.length / 3;
258
+ const segmentLength = Math.max(Math.hypot(x1 - x0, z1 - z0), 0.001);
259
+ pushWallVertex(x0, 0, z0, 0, 0); // v0 — floor level
260
+ pushWallVertex(x1, 0, z1, segmentLength, 0); // v1 — floor level
261
+ pushWallVertex(x1, depth, z1, segmentLength, depth); // v2 — ground level
262
+ pushWallVertex(x0, depth, z0, 0, depth); // v3 — ground level
263
+ indices.push(vBase, vBase + 1, vBase + 2);
264
+ indices.push(vBase, vBase + 2, vBase + 3);
265
+ }
148
266
  }
149
267
  const geo = new THREE.BufferGeometry();
150
268
  geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=slab-system.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"slab-system.test.d.ts","sourceRoot":"","sources":["../../../src/systems/slab/slab-system.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,62 @@
1
+ // @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
2
+ // depend on @types/bun so the import type is unresolved at compile time.
3
+ import { describe, expect, test } from 'bun:test';
4
+ import { SlabNode } from '@pascal-app/core';
5
+ import { generateSlabGeometry } from './slab-system';
6
+ function hasVertexAt(geometry, x, z) {
7
+ const positions = geometry.getAttribute('position');
8
+ for (let index = 0; index < positions.count; index += 1) {
9
+ if (Math.abs(positions.getX(index) - x) < 1e-6 && Math.abs(positions.getZ(index) - z) < 1e-6) {
10
+ return true;
11
+ }
12
+ }
13
+ return false;
14
+ }
15
+ describe('generateSlabGeometry', () => {
16
+ test('renders a boundary-overlapping hole as an open indentation', () => {
17
+ const slab = SlabNode.parse({
18
+ elevation: 0.05,
19
+ polygon: [
20
+ [0, 0],
21
+ [4, 0],
22
+ [4, 3],
23
+ [0, 3],
24
+ ],
25
+ holes: [
26
+ [
27
+ [1, -0.5],
28
+ [3, -0.5],
29
+ [3, 1],
30
+ [1, 1],
31
+ ],
32
+ ],
33
+ });
34
+ const geometry = generateSlabGeometry(slab);
35
+ expect((geometry.index?.count ?? 0) / 3).toBeGreaterThan(0);
36
+ expect(hasVertexAt(geometry, 1, 1)).toBe(true);
37
+ expect(hasVertexAt(geometry, 3, 1)).toBe(true);
38
+ });
39
+ test('renders a boundary-overlapping hole as an open indentation on recessed slabs', () => {
40
+ const slab = SlabNode.parse({
41
+ elevation: -0.2,
42
+ polygon: [
43
+ [0, 0],
44
+ [4, 0],
45
+ [4, 3],
46
+ [0, 3],
47
+ ],
48
+ holes: [
49
+ [
50
+ [1, -0.5],
51
+ [3, -0.5],
52
+ [3, 1],
53
+ [1, 1],
54
+ ],
55
+ ],
56
+ });
57
+ const geometry = generateSlabGeometry(slab);
58
+ expect((geometry.index?.count ?? 0) / 3).toBeGreaterThan(0);
59
+ expect(hasVertexAt(geometry, 1, 1)).toBe(true);
60
+ expect(hasVertexAt(geometry, 3, 1)).toBe(true);
61
+ });
62
+ });
@@ -1,7 +1,8 @@
1
1
  import { type StairNode, type StairSegmentNode } from '@pascal-app/core';
2
2
  import type * as THREE from 'three';
3
+ import { type ColorPreset, type RenderShading } from '../../lib/materials';
3
4
  export type StairBodyMaterials = [THREE.Material, THREE.Material];
4
- export declare function getStairBodyMaterials(stair: StairNode): StairBodyMaterials;
5
- export declare function getStairRailingMaterial(stair: StairNode): THREE.Material;
6
- export declare function getStraightStairSegmentBodyMaterials(segment: StairSegmentNode, parentNode?: StairNode): StairBodyMaterials;
5
+ export declare function getStairBodyMaterials(stair: StairNode, shading?: RenderShading, textures?: boolean, colorPreset?: ColorPreset): StairBodyMaterials;
6
+ export declare function getStairRailingMaterial(stair: StairNode, shading?: RenderShading, textures?: boolean, colorPreset?: ColorPreset): THREE.Material;
7
+ export declare function getStraightStairSegmentBodyMaterials(segment: StairSegmentNode, parentNode?: StairNode, shading?: RenderShading, textures?: boolean, colorPreset?: ColorPreset): StairBodyMaterials;
7
8
  //# sourceMappingURL=stair-materials.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"stair-materials.d.ts","sourceRoot":"","sources":["../../../src/systems/stair/stair-materials.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,SAAS,EACd,KAAK,gBAAgB,EACtB,MAAM,kBAAkB,CAAA;AACzB,OAAO,KAAK,KAAK,KAAK,MAAM,OAAO,CAAA;AAOnC,MAAM,MAAM,kBAAkB,GAAG,CAAC,KAAK,CAAC,QAAQ,EAAE,KAAK,CAAC,QAAQ,CAAC,CAAA;AA6BjE,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,SAAS,GAAG,kBAAkB,CAkB1E;AAED,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,SAAS,GAAG,KAAK,CAAC,QAAQ,CASxE;AAED,wBAAgB,oCAAoC,CAClD,OAAO,EAAE,gBAAgB,EACzB,UAAU,CAAC,EAAE,SAAS,GACrB,kBAAkB,CAWpB"}
1
+ {"version":3,"file":"stair-materials.d.ts","sourceRoot":"","sources":["../../../src/systems/stair/stair-materials.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,SAAS,EACd,KAAK,gBAAgB,EACtB,MAAM,kBAAkB,CAAA;AACzB,OAAO,KAAK,KAAK,KAAK,MAAM,OAAO,CAAA;AACnC,OAAO,EACL,KAAK,WAAW,EAKhB,KAAK,aAAa,EACnB,MAAM,qBAAqB,CAAA;AAE5B,MAAM,MAAM,kBAAkB,GAAG,CAAC,KAAK,CAAC,QAAQ,EAAE,KAAK,CAAC,QAAQ,CAAC,CAAA;AAoCjE,wBAAgB,qBAAqB,CACnC,KAAK,EAAE,SAAS,EAChB,OAAO,GAAE,aAA0B,EACnC,QAAQ,UAAO,EACf,WAAW,GAAE,WAAoB,GAChC,kBAAkB,CAqBpB;AAED,wBAAgB,uBAAuB,CACrC,KAAK,EAAE,SAAS,EAChB,OAAO,GAAE,aAA0B,EACnC,QAAQ,UAAO,EACf,WAAW,GAAE,WAAoB,GAChC,KAAK,CAAC,QAAQ,CAoBhB;AAED,wBAAgB,oCAAoC,CAClD,OAAO,EAAE,gBAAgB,EACzB,UAAU,CAAC,EAAE,SAAS,EACtB,OAAO,GAAE,aAA0B,EACnC,QAAQ,UAAO,EACf,WAAW,GAAE,WAAoB,GAChC,kBAAkB,CAsBpB"}
@@ -1,5 +1,5 @@
1
1
  import { getEffectiveStairSurfaceMaterial, } from '@pascal-app/core';
2
- import { createMaterial, createMaterialFromPresetRef, DEFAULT_STAIR_MATERIAL, } from '../../lib/materials';
2
+ import { createMaterial, createMaterialFromPresetRef, createSurfaceRoleMaterial, DEFAULT_STAIR_MATERIAL, } from '../../lib/materials';
3
3
  const stairBodyMaterialCache = new Map();
4
4
  const stairRailingMaterialCache = new Map();
5
5
  function getSurfaceMaterialSignature(spec) {
@@ -8,19 +8,25 @@ function getSurfaceMaterialSignature(spec) {
8
8
  materialPreset: spec.materialPreset ?? null,
9
9
  });
10
10
  }
11
- function createResolvedMaterial(material, materialPreset) {
11
+ function createResolvedMaterial(material, materialPreset, shading, textures, colorPreset) {
12
+ if (!textures) {
13
+ return createSurfaceRoleMaterial('joinery', colorPreset);
14
+ }
12
15
  if (materialPreset) {
13
- return createMaterialFromPresetRef(materialPreset) ?? DEFAULT_STAIR_MATERIAL;
16
+ return createMaterialFromPresetRef(materialPreset, shading) ?? DEFAULT_STAIR_MATERIAL(shading);
14
17
  }
15
18
  if (material) {
16
- return createMaterial(material);
19
+ return createMaterial(material, shading);
17
20
  }
18
- return DEFAULT_STAIR_MATERIAL;
21
+ return DEFAULT_STAIR_MATERIAL(shading);
19
22
  }
20
- export function getStairBodyMaterials(stair) {
23
+ export function getStairBodyMaterials(stair, shading = 'rendered', textures = true, colorPreset = 'clay') {
21
24
  const tread = getEffectiveStairSurfaceMaterial(stair, 'tread');
22
25
  const side = getEffectiveStairSurfaceMaterial(stair, 'side');
23
26
  const cacheKey = JSON.stringify({
27
+ shading,
28
+ textures,
29
+ colorPreset,
24
30
  tread: getSurfaceMaterialSignature(tread),
25
31
  side: getSurfaceMaterialSignature(side),
26
32
  });
@@ -28,29 +34,38 @@ export function getStairBodyMaterials(stair) {
28
34
  if (cached)
29
35
  return cached;
30
36
  const materials = [
31
- createResolvedMaterial(tread.material, tread.materialPreset),
32
- createResolvedMaterial(side.material, side.materialPreset),
37
+ createResolvedMaterial(tread.material, tread.materialPreset, shading, textures, colorPreset),
38
+ createResolvedMaterial(side.material, side.materialPreset, shading, textures, colorPreset),
33
39
  ];
34
40
  stairBodyMaterialCache.set(cacheKey, materials);
35
41
  return materials;
36
42
  }
37
- export function getStairRailingMaterial(stair) {
43
+ export function getStairRailingMaterial(stair, shading = 'rendered', textures = true, colorPreset = 'clay') {
38
44
  const railing = getEffectiveStairSurfaceMaterial(stair, 'railing');
39
- const cacheKey = getSurfaceMaterialSignature(railing);
45
+ const cacheKey = JSON.stringify({
46
+ shading,
47
+ textures,
48
+ colorPreset,
49
+ railing: getSurfaceMaterialSignature(railing),
50
+ });
40
51
  const cached = stairRailingMaterialCache.get(cacheKey);
41
52
  if (cached)
42
53
  return cached;
43
- const material = createResolvedMaterial(railing.material, railing.materialPreset);
54
+ const material = createResolvedMaterial(railing.material, railing.materialPreset, shading, textures, colorPreset);
44
55
  stairRailingMaterialCache.set(cacheKey, material);
45
56
  return material;
46
57
  }
47
- export function getStraightStairSegmentBodyMaterials(segment, parentNode) {
58
+ export function getStraightStairSegmentBodyMaterials(segment, parentNode, shading = 'rendered', textures = true, colorPreset = 'clay') {
48
59
  if (segment.material !== undefined || typeof segment.materialPreset === 'string') {
49
- const override = createResolvedMaterial(segment.material, segment.materialPreset);
60
+ const override = createResolvedMaterial(segment.material, segment.materialPreset, shading, textures, colorPreset);
50
61
  return [override, override];
51
62
  }
52
63
  if (parentNode) {
53
- return getStairBodyMaterials(parentNode);
64
+ return getStairBodyMaterials(parentNode, shading, textures, colorPreset);
65
+ }
66
+ if (!textures) {
67
+ const material = createSurfaceRoleMaterial('joinery', colorPreset);
68
+ return [material, material];
54
69
  }
55
- return [DEFAULT_STAIR_MATERIAL, DEFAULT_STAIR_MATERIAL];
70
+ return [DEFAULT_STAIR_MATERIAL(shading), DEFAULT_STAIR_MATERIAL(shading)];
56
71
  }
@@ -1 +1 @@
1
- {"version":3,"file":"stair-system.d.ts","sourceRoot":"","sources":["../../../src/systems/stair/stair-system.tsx"],"names":[],"mappings":"AA4BA,eAAO,MAAM,WAAW,YAgHvB,CAAA"}
1
+ {"version":3,"file":"stair-system.d.ts","sourceRoot":"","sources":["../../../src/systems/stair/stair-system.tsx"],"names":[],"mappings":"AA0BA,eAAO,MAAM,WAAW,YAuGvB,CAAA"}
@@ -1,6 +1,5 @@
1
- import { resolveLevelId, sceneRegistry, spatialGridManager, syncAutoStairOpenings, useScene, } from '@pascal-app/core';
1
+ import { getEffectiveNode, getFloorStackedPosition, sceneRegistry, useScene, } from '@pascal-app/core';
2
2
  import { useFrame } from '@react-three/fiber';
3
- import { useEffect, useRef } from 'react';
4
3
  import * as THREE from 'three';
5
4
  import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
6
5
  const pendingStairUpdates = new Set();
@@ -17,26 +16,6 @@ export const StairSystem = () => {
17
16
  const dirtyNodes = useScene((state) => state.dirtyNodes);
18
17
  const clearDirty = useScene((state) => state.clearDirty);
19
18
  const rootNodeIds = useScene((state) => state.rootNodeIds);
20
- const syncingAutoOpeningsRef = useRef(false);
21
- useEffect(() => {
22
- const applyUpdates = (updates) => {
23
- if (updates.length === 0)
24
- return;
25
- syncingAutoOpeningsRef.current = true;
26
- useScene.getState().updateNodes(updates);
27
- queueMicrotask(() => {
28
- syncingAutoOpeningsRef.current = false;
29
- });
30
- };
31
- applyUpdates(syncAutoStairOpenings(useScene.getState().nodes));
32
- return useScene.subscribe((state, prevState) => {
33
- if (syncingAutoOpeningsRef.current)
34
- return;
35
- if (state.nodes === prevState.nodes)
36
- return;
37
- applyUpdates(syncAutoStairOpenings(state.nodes));
38
- });
39
- }, []);
40
19
  useFrame(() => {
41
20
  if (rootNodeIds.length === 0) {
42
21
  pendingStairUpdates.clear();
@@ -58,8 +37,13 @@ export const StairSystem = () => {
58
37
  if (mesh) {
59
38
  const isVisible = mesh.parent?.visible !== false;
60
39
  if (isVisible && segmentsProcessed < MAX_SEGMENTS_PER_FRAME) {
61
- // Geometry will be updated; chained position is applied in the parent sync pass below
62
- updateStairSegmentGeometry(node, mesh);
40
+ // Geometry will be updated; chained position is applied in the parent sync pass below.
41
+ // Merge live overrides so width / length / height drags update the
42
+ // mesh in real time — the resize arrows publish to
43
+ // `useLiveNodeOverrides` and only commit to scene on pointer-up, so
44
+ // without this the geometry would rebuild from the pre-drag values.
45
+ const effectiveSegment = getEffectiveNode(node);
46
+ updateStairSegmentGeometry(effectiveSegment, mesh);
63
47
  if (node.parentId)
64
48
  parentsNeedingSegmentSync.add(node.parentId);
65
49
  segmentsProcessed++;
@@ -68,11 +52,9 @@ export const StairSystem = () => {
68
52
  return; // Over budget — keep dirty, process next frame
69
53
  }
70
54
  else if (mesh.geometry.type === 'BoxGeometry') {
71
- // Replace BoxGeometry placeholder with empty geometry
55
+ // Replace BoxGeometry placeholder with a non-drawing degenerate one.
72
56
  mesh.geometry.dispose();
73
- const placeholder = new THREE.BufferGeometry();
74
- placeholder.setAttribute('position', new THREE.Float32BufferAttribute([], 3));
75
- mesh.geometry = placeholder;
57
+ mesh.geometry = createEmptyGeometry();
76
58
  }
77
59
  clearDirty(id);
78
60
  }
@@ -93,9 +75,17 @@ export const StairSystem = () => {
93
75
  });
94
76
  // --- Pass 1b: Sync chained transforms to individual segment meshes (edit mode) ---
95
77
  for (const stairId of parentsNeedingSegmentSync) {
96
- const stairNode = nodes[stairId];
97
- if (!stairNode || stairNode.type !== 'stair')
78
+ const baseStairNode = nodes[stairId];
79
+ if (!baseStairNode || baseStairNode.type !== 'stair')
98
80
  continue;
81
+ // Merge any in-flight drag override (e.g. parent-stair rotate handle)
82
+ // so slab-elevation spatial queries match where the segments are
83
+ // actually being rendered. Without this, dragging the rotate gizmo
84
+ // looks up slabs at the pre-drag world XZ — if rotation carries a
85
+ // segment off the original slab footprint, the floor-stack
86
+ // resolver would otherwise read the pre-drag footprint and drop
87
+ // the flight or landing below the floor mid-drag.
88
+ const stairNode = getEffectiveNode(baseStairNode);
99
89
  const group = sceneRegistry.nodes.get(stairId);
100
90
  if (group) {
101
91
  syncStairGroupElevation(stairNode, group, nodes);
@@ -217,9 +207,14 @@ function updateStairSegmentGeometry(node, mesh) {
217
207
  * not by the node's stored position field.
218
208
  */
219
209
  function syncSegmentMeshTransforms(stairNode, nodes) {
210
+ // Merge live overrides into each segment so the chain math reflects the
211
+ // in-flight drag (a width / length change shifts every downstream segment's
212
+ // anchor). Without this, dragging a width handle would resize the dragged
213
+ // segment's mesh but leave subsequent segments at their pre-drag positions.
220
214
  const segments = (stairNode.children ?? [])
221
215
  .map((childId) => nodes[childId])
222
- .filter((n) => n?.type === 'stair-segment');
216
+ .filter((n) => n?.type === 'stair-segment')
217
+ .map((n) => getEffectiveNode(n));
223
218
  if (segments.length === 0)
224
219
  return;
225
220
  const transforms = computeSegmentTransforms(segments);
@@ -234,35 +229,20 @@ function syncSegmentMeshTransforms(stairNode, nodes) {
234
229
  }
235
230
  }
236
231
  function syncStairGroupElevation(stairNode, group, nodes) {
237
- const levelId = resolveLevelId(stairNode, nodes);
238
- const slabElevation = getStairSlabElevation(levelId, stairNode, nodes);
239
- group.position.y = stairNode.position[1] + slabElevation;
240
- }
241
- function getStairSlabElevation(levelId, stairNode, nodes) {
242
- const segments = (stairNode.children ?? [])
243
- .map((childId) => nodes[childId])
244
- .filter((n) => n?.type === 'stair-segment');
245
- if (segments.length === 0)
246
- return 0;
247
- const transforms = computeSegmentTransforms(segments);
248
- let maxElevation = Number.NEGATIVE_INFINITY;
249
- for (let i = 0; i < segments.length; i++) {
250
- const segment = segments[i];
251
- const transform = transforms[i];
252
- const [centerOffsetX, centerOffsetZ] = rotateXZ(0, segment.length / 2, transform.rotation);
253
- const centerInGroupX = transform.position[0] + centerOffsetX;
254
- const centerInGroupZ = transform.position[2] + centerOffsetZ;
255
- const [centerOffsetWorldX, centerOffsetWorldZ] = rotateXZ(centerInGroupX, centerInGroupZ, stairNode.rotation);
256
- const slabElevation = spatialGridManager.getSlabElevationForItem(levelId, [
257
- stairNode.position[0] + centerOffsetWorldX,
258
- stairNode.position[1] + transform.position[1],
259
- stairNode.position[2] + centerOffsetWorldZ,
260
- ], [segment.width, Math.max(segment.height, segment.thickness, 0.01), segment.length], [0, stairNode.rotation + transform.rotation, 0]);
261
- if (slabElevation > maxElevation) {
262
- maxElevation = slabElevation;
232
+ const effectiveNodes = { ...nodes, [stairNode.id]: stairNode };
233
+ for (const childId of stairNode.children ?? []) {
234
+ const segment = nodes[childId];
235
+ if (segment?.type === 'stair-segment') {
236
+ effectiveNodes[segment.id] = getEffectiveNode(segment);
263
237
  }
264
238
  }
265
- return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation;
239
+ const visualPosition = getFloorStackedPosition({
240
+ node: stairNode,
241
+ nodes: effectiveNodes,
242
+ position: stairNode.position,
243
+ rotation: stairNode.rotation,
244
+ });
245
+ group.position.y = visualPosition[1];
266
246
  }
267
247
  // ============================================================================
268
248
  // MERGED STAIR GEOMETRY
@@ -281,9 +261,14 @@ function updateMergedStairGeometry(stairNode, group, nodes) {
281
261
  return;
282
262
  }
283
263
  const children = stairNode.children ?? [];
264
+ // Merge live overrides — same reason as `syncSegmentMeshTransforms`: a
265
+ // width / length / height drag publishes the new value to
266
+ // `useLiveNodeOverrides`, so the merged geometry has to read through that
267
+ // overlay or the merged mesh stays at pre-drag values until pointer-up.
284
268
  const segments = children
285
269
  .map((childId) => nodes[childId])
286
- .filter((n) => n?.type === 'stair-segment');
270
+ .filter((n) => n?.type === 'stair-segment')
271
+ .map((n) => getEffectiveNode(n));
287
272
  if (segments.length === 0) {
288
273
  replaceMeshGeometry(mergedMesh, createEmptyGeometry());
289
274
  return;
@@ -444,7 +429,11 @@ function rotateXZ(x, z, angle) {
444
429
  }
445
430
  function createEmptyGeometry() {
446
431
  const geometry = new THREE.BufferGeometry();
447
- geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3));
432
+ // Three zero-vertices (one degenerate, invisible triangle), not an empty
433
+ // attribute: an empty position (count 0) leaves WebGPU vertex buffer slot 0
434
+ // unbound and the draw is rejected ("Vertex buffer slot 0 … was not set"),
435
+ // poisoning the command encoder. The count-0 groups keep nothing drawn.
436
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3));
448
437
  geometry.addGroup(0, 0, STAIR_TREAD_MATERIAL_INDEX);
449
438
  geometry.addGroup(0, 0, STAIR_SIDE_MATERIAL_INDEX);
450
439
  return geometry;
@@ -479,7 +468,7 @@ function generateStairRailingGeometry(stairNode, segments, transforms) {
479
468
  for (let index = 1; index < segmentRailPaths.length; index++) {
480
469
  const previousPath = segmentRailPaths[index - 1];
481
470
  const currentPath = segmentRailPaths[index];
482
- if (!(previousPath && currentPath && currentPath.connectFromPrevious))
471
+ if (!(previousPath && currentPath?.connectFromPrevious))
483
472
  continue;
484
473
  if (previousPath.segment.segmentType === 'landing')
485
474
  continue;
@@ -0,0 +1,3 @@
1
+ import { type Point2D } from '../lib/polygon-union';
2
+ export declare function mergeSurfaceHolePolygons(holes: Point2D[][]): Point2D[][];
3
+ //# sourceMappingURL=surface-hole-geometry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"surface-hole-geometry.d.ts","sourceRoot":"","sources":["../../src/systems/surface-hole-geometry.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,OAAO,EAAiB,MAAM,sBAAsB,CAAA;AAElE,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,OAAO,EAAE,EAAE,GAAG,OAAO,EAAE,EAAE,CAExE"}