@pascal-app/viewer 0.9.1 → 1.0.0-beta.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 (206) hide show
  1. package/README.md +20 -5
  2. package/dist/components/error-boundary.d.ts +6 -0
  3. package/dist/components/error-boundary.d.ts.map +1 -1
  4. package/dist/components/error-boundary.js +6 -0
  5. package/dist/components/renderers/node-renderer.d.ts +5 -1
  6. package/dist/components/renderers/node-renderer.d.ts.map +1 -1
  7. package/dist/components/renderers/node-renderer.js +5 -2
  8. package/dist/components/renderers/parametric-node-renderer.d.ts.map +1 -1
  9. package/dist/components/renderers/parametric-node-renderer.js +7 -4
  10. package/dist/components/viewer/bvh-ecctrl.d.ts +77 -0
  11. package/dist/components/viewer/bvh-ecctrl.d.ts.map +1 -0
  12. package/dist/components/viewer/bvh-ecctrl.js +552 -0
  13. package/dist/components/viewer/frame-limiter.d.ts.map +1 -1
  14. package/dist/components/viewer/frame-limiter.js +59 -4
  15. package/dist/components/viewer/glb-interactive.d.ts +41 -0
  16. package/dist/components/viewer/glb-interactive.d.ts.map +1 -0
  17. package/dist/components/viewer/glb-interactive.js +394 -0
  18. package/dist/components/viewer/glb-reference-nodes.d.ts +29 -0
  19. package/dist/components/viewer/glb-reference-nodes.d.ts.map +1 -0
  20. package/dist/components/viewer/glb-reference-nodes.js +80 -0
  21. package/dist/components/viewer/glb-replace-instances.d.ts +18 -0
  22. package/dist/components/viewer/glb-replace-instances.d.ts.map +1 -0
  23. package/dist/components/viewer/glb-replace-instances.js +61 -0
  24. package/dist/components/viewer/glb-scene.d.ts +54 -0
  25. package/dist/components/viewer/glb-scene.d.ts.map +1 -0
  26. package/dist/components/viewer/glb-scene.js +920 -0
  27. package/dist/components/viewer/glb-walkthrough-controller.d.ts +22 -0
  28. package/dist/components/viewer/glb-walkthrough-controller.d.ts.map +1 -0
  29. package/dist/components/viewer/glb-walkthrough-controller.js +445 -0
  30. package/dist/components/viewer/index.d.ts +19 -2
  31. package/dist/components/viewer/index.d.ts.map +1 -1
  32. package/dist/components/viewer/index.js +113 -12
  33. package/dist/components/viewer/lights.d.ts.map +1 -1
  34. package/dist/components/viewer/lights.js +36 -5
  35. package/dist/components/viewer/perf-monitor.d.ts.map +1 -1
  36. package/dist/components/viewer/perf-monitor.js +17 -2
  37. package/dist/components/viewer/post-processing.d.ts +7 -1
  38. package/dist/components/viewer/post-processing.d.ts.map +1 -1
  39. package/dist/components/viewer/post-processing.js +180 -24
  40. package/dist/components/viewer/registered-systems.d.ts.map +1 -1
  41. package/dist/components/viewer/registered-systems.js +6 -4
  42. package/dist/components/viewer/scene-bvh.d.ts +2 -1
  43. package/dist/components/viewer/scene-bvh.d.ts.map +1 -1
  44. package/dist/components/viewer/scene-bvh.js +3 -0
  45. package/dist/components/viewer/scene-bvh.test.d.ts +2 -0
  46. package/dist/components/viewer/scene-bvh.test.d.ts.map +1 -0
  47. package/dist/components/viewer/scene-bvh.test.js +13 -0
  48. package/dist/components/viewer/scene-environment.d.ts +2 -0
  49. package/dist/components/viewer/scene-environment.d.ts.map +1 -0
  50. package/dist/components/viewer/scene-environment.js +79 -0
  51. package/dist/components/viewer/selection-manager.d.ts.map +1 -1
  52. package/dist/components/viewer/selection-manager.js +23 -12
  53. package/dist/hooks/use-gltf-ktx2.d.ts.map +1 -1
  54. package/dist/hooks/use-gltf-ktx2.js +2 -24
  55. package/dist/hooks/use-node-events.d.ts +1 -1
  56. package/dist/hooks/use-node-events.d.ts.map +1 -1
  57. package/dist/hooks/use-node-events.js +22 -14
  58. package/dist/index.d.ts +24 -10
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +29 -13
  61. package/dist/lib/backdrop.d.ts +33 -0
  62. package/dist/lib/backdrop.d.ts.map +1 -0
  63. package/dist/lib/backdrop.js +92 -0
  64. package/dist/lib/box-uv.d.ts +13 -0
  65. package/dist/lib/box-uv.d.ts.map +1 -0
  66. package/dist/lib/box-uv.js +32 -0
  67. package/dist/lib/csg-utils.d.ts +3 -9
  68. package/dist/lib/csg-utils.d.ts.map +1 -1
  69. package/dist/lib/csg-utils.js +65 -5
  70. package/dist/lib/csg-utils.test.d.ts +2 -0
  71. package/dist/lib/csg-utils.test.d.ts.map +1 -0
  72. package/dist/lib/csg-utils.test.js +45 -0
  73. package/dist/lib/edge-style.d.ts +1 -0
  74. package/dist/lib/edge-style.d.ts.map +1 -1
  75. package/dist/lib/edge-style.js +26 -6
  76. package/dist/lib/hero-pose.d.ts +47 -0
  77. package/dist/lib/hero-pose.d.ts.map +1 -0
  78. package/dist/lib/hero-pose.js +227 -0
  79. package/dist/lib/ink-edges.d.ts +1 -1
  80. package/dist/lib/ink-edges.d.ts.map +1 -1
  81. package/dist/lib/ink-edges.js +13 -7
  82. package/dist/lib/ktx2-loader.d.ts +46 -0
  83. package/dist/lib/ktx2-loader.d.ts.map +1 -0
  84. package/dist/lib/ktx2-loader.js +142 -0
  85. package/dist/lib/layers.d.ts +10 -0
  86. package/dist/lib/layers.d.ts.map +1 -1
  87. package/dist/lib/layers.js +10 -0
  88. package/dist/lib/materials.d.ts +16 -1
  89. package/dist/lib/materials.d.ts.map +1 -1
  90. package/dist/lib/materials.js +158 -34
  91. package/dist/lib/materials.test.d.ts +2 -0
  92. package/dist/lib/materials.test.d.ts.map +1 -0
  93. package/dist/lib/materials.test.js +25 -0
  94. package/dist/lib/merged-outline-node.test.d.ts +2 -0
  95. package/dist/lib/merged-outline-node.test.d.ts.map +1 -0
  96. package/dist/lib/merged-outline-node.test.js +22 -0
  97. package/dist/lib/scene-themes.d.ts +1 -0
  98. package/dist/lib/scene-themes.d.ts.map +1 -1
  99. package/dist/lib/scene-themes.js +25 -16
  100. package/dist/lib/shadow-only.d.ts +4 -0
  101. package/dist/lib/shadow-only.d.ts.map +1 -0
  102. package/dist/lib/shadow-only.js +36 -0
  103. package/dist/lib/snapshot-pipeline.d.ts +42 -0
  104. package/dist/lib/snapshot-pipeline.d.ts.map +1 -0
  105. package/dist/lib/snapshot-pipeline.js +254 -0
  106. package/dist/lib/terrain-perimeter-fill.d.ts +7 -0
  107. package/dist/lib/terrain-perimeter-fill.d.ts.map +1 -0
  108. package/dist/lib/terrain-perimeter-fill.js +75 -0
  109. package/dist/lib/texture-node-guard.d.ts +13 -0
  110. package/dist/lib/texture-node-guard.d.ts.map +1 -0
  111. package/dist/lib/texture-node-guard.js +39 -0
  112. package/dist/lib/texture-reference.d.ts +31 -0
  113. package/dist/lib/texture-reference.d.ts.map +1 -0
  114. package/dist/lib/texture-reference.js +139 -0
  115. package/dist/lib/texture-reference.test.d.ts +2 -0
  116. package/dist/lib/texture-reference.test.d.ts.map +1 -0
  117. package/dist/lib/texture-reference.test.js +86 -0
  118. package/dist/lib/tsl-compat.d.ts +12 -0
  119. package/dist/lib/tsl-compat.d.ts.map +1 -0
  120. package/dist/lib/tsl-compat.js +13 -0
  121. package/dist/store/use-viewer.d.ts +73 -18
  122. package/dist/store/use-viewer.d.ts.map +1 -1
  123. package/dist/store/use-viewer.js +191 -4
  124. package/dist/store/use-viewer.test.d.ts +2 -0
  125. package/dist/store/use-viewer.test.d.ts.map +1 -0
  126. package/dist/store/use-viewer.test.js +61 -0
  127. package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
  128. package/dist/systems/ceiling/ceiling-system.js +12 -5
  129. package/dist/systems/door/door-animation.test.d.ts +2 -0
  130. package/dist/systems/door/door-animation.test.d.ts.map +1 -0
  131. package/dist/systems/door/door-animation.test.js +51 -0
  132. package/dist/systems/door/door-floor-alignment.test.d.ts +2 -0
  133. package/dist/systems/door/door-floor-alignment.test.d.ts.map +1 -0
  134. package/dist/systems/door/door-floor-alignment.test.js +50 -0
  135. package/dist/systems/door/door-system.d.ts +24 -0
  136. package/dist/systems/door/door-system.d.ts.map +1 -1
  137. package/dist/systems/door/door-system.js +494 -161
  138. package/dist/systems/fence/fence-system.d.ts +17 -0
  139. package/dist/systems/fence/fence-system.d.ts.map +1 -1
  140. package/dist/systems/fence/fence-system.js +221 -53
  141. package/dist/systems/floor-elevation/floor-elevation-system.d.ts.map +1 -1
  142. package/dist/systems/floor-elevation/floor-elevation-system.js +30 -7
  143. package/dist/systems/geometry/geometry-system.d.ts +7 -0
  144. package/dist/systems/geometry/geometry-system.d.ts.map +1 -1
  145. package/dist/systems/geometry/geometry-system.js +95 -26
  146. package/dist/systems/geometry/geometry-system.test.d.ts +2 -0
  147. package/dist/systems/geometry/geometry-system.test.d.ts.map +1 -0
  148. package/dist/systems/geometry/geometry-system.test.js +30 -0
  149. package/dist/systems/interactive/control-widget.d.ts +10 -0
  150. package/dist/systems/interactive/control-widget.d.ts.map +1 -0
  151. package/dist/systems/interactive/control-widget.js +35 -0
  152. package/dist/systems/interactive/interactive-system.d.ts.map +1 -1
  153. package/dist/systems/interactive/interactive-system.js +55 -56
  154. package/dist/systems/item/item-system.d.ts.map +1 -1
  155. package/dist/systems/item/item-system.js +20 -6
  156. package/dist/systems/level/level-system.d.ts.map +1 -1
  157. package/dist/systems/level/level-system.js +34 -9
  158. package/dist/systems/level/level-utils.d.ts +0 -3
  159. package/dist/systems/level/level-utils.d.ts.map +1 -1
  160. package/dist/systems/level/level-utils.js +7 -45
  161. package/dist/systems/roof/roof-materials.d.ts.map +1 -1
  162. package/dist/systems/roof/roof-materials.js +29 -8
  163. package/dist/systems/roof/roof-system.d.ts +21 -8
  164. package/dist/systems/roof/roof-system.d.ts.map +1 -1
  165. package/dist/systems/roof/roof-system.js +1079 -221
  166. package/dist/systems/slab/slab-system.d.ts +7 -4
  167. package/dist/systems/slab/slab-system.d.ts.map +1 -1
  168. package/dist/systems/slab/slab-system.js +31 -83
  169. package/dist/systems/slab/slab-system.test.js +54 -14
  170. package/dist/systems/stair/stair-system.js +7 -4
  171. package/dist/systems/wall/opening-cutout-geometry.d.ts +31 -0
  172. package/dist/systems/wall/opening-cutout-geometry.d.ts.map +1 -0
  173. package/dist/systems/wall/opening-cutout-geometry.js +167 -0
  174. package/dist/systems/wall/opening-cutout-geometry.test.d.ts +2 -0
  175. package/dist/systems/wall/opening-cutout-geometry.test.d.ts.map +1 -0
  176. package/dist/systems/wall/opening-cutout-geometry.test.js +172 -0
  177. package/dist/systems/wall/wall-cutout.d.ts.map +1 -1
  178. package/dist/systems/wall/wall-cutout.js +49 -17
  179. package/dist/systems/wall/wall-materials.d.ts +11 -7
  180. package/dist/systems/wall/wall-materials.d.ts.map +1 -1
  181. package/dist/systems/wall/wall-materials.js +244 -35
  182. package/dist/systems/wall/wall-opening-cutout.test.d.ts +2 -0
  183. package/dist/systems/wall/wall-opening-cutout.test.d.ts.map +1 -0
  184. package/dist/systems/wall/wall-opening-cutout.test.js +59 -0
  185. package/dist/systems/wall/wall-support-extension.test.d.ts +2 -0
  186. package/dist/systems/wall/wall-support-extension.test.d.ts.map +1 -0
  187. package/dist/systems/wall/wall-support-extension.test.js +116 -0
  188. package/dist/systems/wall/wall-system.d.ts +4 -8
  189. package/dist/systems/wall/wall-system.d.ts.map +1 -1
  190. package/dist/systems/wall/wall-system.js +397 -177
  191. package/dist/systems/wall/wall-terrain-bottom.test.d.ts +2 -0
  192. package/dist/systems/wall/wall-terrain-bottom.test.d.ts.map +1 -0
  193. package/dist/systems/wall/wall-terrain-bottom.test.js +34 -0
  194. package/dist/systems/window/window-animation-system.d.ts +12 -0
  195. package/dist/systems/window/window-animation-system.d.ts.map +1 -1
  196. package/dist/systems/window/window-animation-system.js +16 -5
  197. package/dist/systems/window/window-system.d.ts +7 -0
  198. package/dist/systems/window/window-system.d.ts.map +1 -1
  199. package/dist/systems/window/window-system.js +240 -14
  200. package/package.json +4 -4
  201. package/dist/components/viewer/ground-occluder.d.ts +0 -2
  202. package/dist/components/viewer/ground-occluder.d.ts.map +0 -1
  203. package/dist/components/viewer/ground-occluder.js +0 -67
  204. package/dist/components/viewer/walkthrough-controls.d.ts +0 -2
  205. package/dist/components/viewer/walkthrough-controls.d.ts.map +0 -1
  206. package/dist/components/viewer/walkthrough-controls.js +0 -131
@@ -1,8 +1,11 @@
1
- import { type SlabNode } from '@pascal-app/core';
1
+ import { type SlabNode, type SlabPolygonContext } from '@pascal-app/core';
2
2
  import * as THREE from 'three';
3
- export declare const SlabSystem: () => null;
4
3
  /**
5
- * Generates extruded slab geometry from polygon
4
+ * Generates slab geometry from polygon. `context` carries the slab's level
5
+ * neighbourhood (walls + sibling slabs) driving the per-edge render offsets —
6
+ * see `getRenderableSlabPolygon`. Branches on the explicit `recessed` intent:
7
+ * a recessed slab is an open shell (pool), everything else a solid occupying
8
+ * `[elevation − thickness, elevation]`.
6
9
  */
7
- export declare function generateSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry;
10
+ export declare function generateSlabGeometry(slabNode: SlabNode, context: SlabPolygonContext): THREE.BufferGeometry;
8
11
  //# sourceMappingURL=slab-system.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"slab-system.d.ts","sourceRoot":"","sources":["../../../src/systems/slab/slab-system.tsx"],"names":[],"mappings":"AAAA,OAAO,EAOL,KAAK,QAAQ,EAGd,MAAM,kBAAkB,CAAA;AAGzB,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAe9B,eAAO,MAAM,UAAU,YAkCtB,CAAA;AAkBD;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,QAAQ,GAAG,KAAK,CAAC,cAAc,CAG7E"}
1
+ {"version":3,"file":"slab-system.d.ts","sourceRoot":"","sources":["../../../src/systems/slab/slab-system.tsx"],"names":[],"mappings":"AAAA,OAAO,EAKL,KAAK,QAAQ,EACb,KAAK,kBAAkB,EACxB,MAAM,kBAAkB,CAAA;AACzB,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAQ9B;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,kBAAkB,GAC1B,KAAK,CAAC,cAAc,CAItB"}
@@ -1,68 +1,21 @@
1
- import { getEffectiveNode, getRenderableSlabPolygon, pointInPolygon2D, polygonsIntersect, sceneRegistry, useScene, } from '@pascal-app/core';
2
- import { useFrame } from '@react-three/fiber';
3
- import { useEffect } from 'react';
1
+ import { getRenderableSlabPolygon, pointInPolygon2D, polygonsIntersect, } from '@pascal-app/core';
4
2
  import * as THREE from 'three';
5
3
  import { subtractPolygonsFromPolygon } from '../../lib/polygon-union';
6
4
  import { mergeSurfaceHolePolygons } from '../surface-hole-geometry';
7
- function ensureUv2Attribute(geometry) {
8
- const uv = geometry.getAttribute('uv');
9
- if (!uv)
10
- return;
11
- geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2));
12
- }
13
5
  // ============================================================================
14
- // SLAB SYSTEM
6
+ // SLAB GEOMETRY GENERATORS
15
7
  // ============================================================================
16
- export const SlabSystem = () => {
17
- const dirtyNodes = useScene((state) => state.dirtyNodes);
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]);
28
- useFrame(() => {
29
- if (dirtyNodes.size === 0)
30
- return;
31
- const nodes = useScene.getState().nodes;
32
- // Process dirty slabs
33
- dirtyNodes.forEach((id) => {
34
- const node = nodes[id];
35
- if (!node || node.type !== 'slab')
36
- return;
37
- const mesh = sceneRegistry.nodes.get(id);
38
- if (mesh) {
39
- updateSlabGeometry(getEffectiveNode(node), mesh);
40
- clearDirty(id);
41
- }
42
- // If mesh not found, keep it dirty for next frame
43
- });
44
- }, 1);
45
- return null;
46
- };
47
8
  /**
48
- * Updates the geometry for a single slab
9
+ * Generates slab geometry from polygon. `context` carries the slab's level
10
+ * neighbourhood (walls + sibling slabs) driving the per-edge render offsets —
11
+ * see `getRenderableSlabPolygon`. Branches on the explicit `recessed` intent:
12
+ * a recessed slab is an open shell (pool), everything else a solid occupying
13
+ * `[elevation − thickness, elevation]`.
49
14
  */
50
- function updateSlabGeometry(node, mesh) {
51
- const newGeo = generateSlabGeometry(node);
52
- ensureUv2Attribute(newGeo);
53
- mesh.geometry.dispose();
54
- mesh.geometry = newGeo;
55
- // For negative elevation, shift the mesh down so the top face sits at Y=elevation
56
- // rather than at Y=0. Positive elevation stays at Y=0 (slab sits at floor level).
57
- const elevation = node.elevation ?? 0.05;
58
- mesh.position.y = elevation < 0 ? elevation : 0;
59
- }
60
- /**
61
- * Generates extruded slab geometry from polygon
62
- */
63
- export function generateSlabGeometry(slabNode) {
64
- const elevation = slabNode.elevation ?? 0.05;
65
- return elevation < 0 ? generatePoolGeometry(slabNode) : generatePositiveSlabGeometry(slabNode);
15
+ export function generateSlabGeometry(slabNode, context) {
16
+ return slabNode.recessed
17
+ ? generatePoolGeometry(slabNode, context)
18
+ : generateSolidSlabGeometry(slabNode, context);
66
19
  }
67
20
  // Earcut normalizes cap triangulation regardless of input winding, but the side
68
21
  // walls below assume a CCW contour (the unflipped quad's right-hand normal faces
@@ -104,7 +57,8 @@ function buildSlabRegions(contour, holes) {
104
57
  }));
105
58
  }
106
59
  /**
107
- * Standard slab: flat extrusion upward from Y=0 by elevation thickness.
60
+ * Solid slab occupying `[elevation − thickness, elevation]`: the top cap is
61
+ * the walking surface at `elevation`, the body grows downward by `thickness`.
108
62
  *
109
63
  * Built directly in 3D (Y-up) rather than via ExtrudeGeometry so the hole side
110
64
  * walls can be emitted double-sided. The slab material is forced to FrontSide
@@ -115,9 +69,11 @@ function buildSlabRegions(contour, holes) {
115
69
  * thickness visible from any angle: the two coincident triangles never z-fight
116
70
  * because exactly one faces the camera under FrontSide culling.
117
71
  */
118
- function generatePositiveSlabGeometry(slabNode) {
119
- const polygon = ensureCounterClockwisePolygon(getRenderableSlabPolygon(slabNode));
72
+ function generateSolidSlabGeometry(slabNode, context) {
73
+ const polygon = ensureCounterClockwisePolygon(getRenderableSlabPolygon(slabNode, context));
120
74
  const elevation = slabNode.elevation ?? 0.05;
75
+ const thickness = slabNode.thickness ?? 0.05;
76
+ const bottom = elevation - thickness;
121
77
  const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? []);
122
78
  if (polygon.length < 3)
123
79
  return new THREE.BufferGeometry();
@@ -131,14 +87,14 @@ function generatePositiveSlabGeometry(slabNode) {
131
87
  const addWall = (a, b, flipped) => {
132
88
  const base = positions.length / 3;
133
89
  const len = Math.max(Math.hypot(b.x - a.x, b.y - a.y), 0.001);
134
- positions.push(a.x, 0, a.y);
90
+ positions.push(a.x, bottom, a.y);
135
91
  uvs.push(0, 0);
136
- positions.push(b.x, 0, b.y);
92
+ positions.push(b.x, bottom, b.y);
137
93
  uvs.push(len, 0);
138
94
  positions.push(b.x, elevation, b.y);
139
- uvs.push(len, elevation);
95
+ uvs.push(len, thickness);
140
96
  positions.push(a.x, elevation, a.y);
141
- uvs.push(0, elevation);
97
+ uvs.push(0, thickness);
142
98
  // Standard winding on a CCW polygon gives inward-facing normals (see pool
143
99
  // path), so the unflipped quad faces outward; flipped is its back face.
144
100
  if (!flipped) {
@@ -165,7 +121,7 @@ function generatePositiveSlabGeometry(slabNode) {
165
121
  }
166
122
  const bottomBase = positions.length / 3;
167
123
  for (const p of capPoints) {
168
- positions.push(p.x, 0, p.y);
124
+ positions.push(p.x, bottom, p.y);
169
125
  uvs.push(p.x, -p.y);
170
126
  }
171
127
  const capTris = THREE.ShapeUtils.triangulateShape(contour2d, holes2d);
@@ -195,37 +151,29 @@ function generatePositiveSlabGeometry(slabNode) {
195
151
  return geometry;
196
152
  }
197
153
  /**
198
- * Pool / recessed slab: floor cap at Y=0 (local) + inner walls up to Y=|elevation|.
199
- * No top cap — the opening at ground level is handled by the ground occluder hole.
154
+ * Pool / recessed slab: floor cap at Y=0 (local) + inner walls up to the rim.
155
+ * No top cap — the opening at the rim is handled by the ground occluder hole.
200
156
  * mesh.position.y must be set to elevation so the floor sits at the correct world Y.
201
157
  *
202
158
  * Geometry is built directly in 3D (Y-up) to avoid rotation confusion:
203
159
  * - floor in XZ plane at Y=0, normals pointing +Y (visible when looking down into pool)
204
160
  * - walls from Y=0 to Y=depth, inward-facing normals (visible from inside pool)
205
161
  */
206
- function generatePoolGeometry(slabNode) {
207
- const polygon = ensureCounterClockwisePolygon(getRenderableSlabPolygon(slabNode));
208
- const depth = Math.abs(slabNode.elevation ?? 0.05);
162
+ function generatePoolGeometry(slabNode, context) {
163
+ const polygon = ensureCounterClockwisePolygon(getRenderableSlabPolygon(slabNode, context));
164
+ const floor = slabNode.elevation ?? 0.05;
165
+ const depth = Math.max(0, (slabNode.recessedRimElevation ?? 0) - floor);
209
166
  const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? []);
210
167
  if (polygon.length < 3)
211
168
  return new THREE.BufferGeometry();
212
169
  const positions = [];
213
170
  const uvs = [];
214
171
  const indices = [];
215
- const bounds = new THREE.Box2();
216
- for (const [x, z] of polygon) {
217
- bounds.expandByPoint(new THREE.Vector2(x, z));
218
- }
219
- for (const hole of holePolygons) {
220
- for (const [x, z] of hole) {
221
- bounds.expandByPoint(new THREE.Vector2(x, z));
222
- }
223
- }
224
- const floorWidth = Math.max(bounds.max.x - bounds.min.x, 0.001);
225
- const floorHeight = Math.max(bounds.max.y - bounds.min.y, 0.001);
226
172
  const pushFloorVertex = (x, y, z) => {
227
173
  positions.push(x, y, z);
228
- uvs.push((x - bounds.min.x) / floorWidth, (z - bounds.min.y) / floorHeight);
174
+ // Floor UVs in metres (shape-space x, -z), matching generateSolidSlabGeometry's
175
+ // cap mapping so a finish tiles at the same world scale on every surface.
176
+ uvs.push(x, -z);
229
177
  };
230
178
  const pushWallVertex = (x, y, z, u, v) => {
231
179
  positions.push(x, y, z);
@@ -3,6 +3,13 @@
3
3
  import { describe, expect, test } from 'bun:test';
4
4
  import { SlabNode } from '@pascal-app/core';
5
5
  import { generateSlabGeometry } from './slab-system';
6
+ const EMPTY_CONTEXT = { walls: [], siblingSlabs: [] };
7
+ const SQUARE = [
8
+ [0, 0],
9
+ [4, 0],
10
+ [4, 3],
11
+ [0, 3],
12
+ ];
6
13
  function hasVertexAt(geometry, x, z) {
7
14
  const positions = geometry.getAttribute('position');
8
15
  for (let index = 0; index < positions.count; index += 1) {
@@ -12,16 +19,19 @@ function hasVertexAt(geometry, x, z) {
12
19
  }
13
20
  return false;
14
21
  }
22
+ function uniqueSortedYs(geometry) {
23
+ const positions = geometry.getAttribute('position');
24
+ const ys = new Set();
25
+ for (let index = 0; index < positions.count; index += 1) {
26
+ ys.add(Math.round(positions.getY(index) * 1e4) / 1e4);
27
+ }
28
+ return [...ys].sort((a, b) => a - b);
29
+ }
15
30
  describe('generateSlabGeometry', () => {
16
31
  test('renders a boundary-overlapping hole as an open indentation', () => {
17
32
  const slab = SlabNode.parse({
18
33
  elevation: 0.05,
19
- polygon: [
20
- [0, 0],
21
- [4, 0],
22
- [4, 3],
23
- [0, 3],
24
- ],
34
+ polygon: SQUARE,
25
35
  holes: [
26
36
  [
27
37
  [1, -0.5],
@@ -31,7 +41,7 @@ describe('generateSlabGeometry', () => {
31
41
  ],
32
42
  ],
33
43
  });
34
- const geometry = generateSlabGeometry(slab);
44
+ const geometry = generateSlabGeometry(slab, EMPTY_CONTEXT);
35
45
  expect((geometry.index?.count ?? 0) / 3).toBeGreaterThan(0);
36
46
  expect(hasVertexAt(geometry, 1, 1)).toBe(true);
37
47
  expect(hasVertexAt(geometry, 3, 1)).toBe(true);
@@ -39,12 +49,8 @@ describe('generateSlabGeometry', () => {
39
49
  test('renders a boundary-overlapping hole as an open indentation on recessed slabs', () => {
40
50
  const slab = SlabNode.parse({
41
51
  elevation: -0.2,
42
- polygon: [
43
- [0, 0],
44
- [4, 0],
45
- [4, 3],
46
- [0, 3],
47
- ],
52
+ recessed: true,
53
+ polygon: SQUARE,
48
54
  holes: [
49
55
  [
50
56
  [1, -0.5],
@@ -54,9 +60,43 @@ describe('generateSlabGeometry', () => {
54
60
  ],
55
61
  ],
56
62
  });
57
- const geometry = generateSlabGeometry(slab);
63
+ const geometry = generateSlabGeometry(slab, EMPTY_CONTEXT);
58
64
  expect((geometry.index?.count ?? 0) / 3).toBeGreaterThan(0);
59
65
  expect(hasVertexAt(geometry, 1, 1)).toBe(true);
60
66
  expect(hasVertexAt(geometry, 3, 1)).toBe(true);
61
67
  });
68
+ test('solid slab occupies [elevation − thickness, elevation]', () => {
69
+ const slab = SlabNode.parse({ elevation: 0.3, thickness: 0.1, polygon: SQUARE });
70
+ const ys = uniqueSortedYs(generateSlabGeometry(slab, EMPTY_CONTEXT));
71
+ expect(ys).toEqual([0.2, 0.3]);
72
+ });
73
+ test('migrated legacy slab (thickness = elevation) reproduces the [0, elevation] extrusion', () => {
74
+ const slab = SlabNode.parse({ elevation: 0.05, thickness: 0.05, polygon: SQUARE });
75
+ const geometry = generateSlabGeometry(slab, EMPTY_CONTEXT);
76
+ // Old-style expectations: extrude-from-zero put the bottom cap at 0 and
77
+ // the top cap at `elevation`, with 8 cap verts + 4 side quads (16 verts)
78
+ // and 12 triangles for a plain quad slab.
79
+ expect(uniqueSortedYs(geometry)).toEqual([0, 0.05]);
80
+ expect(geometry.getAttribute('position').count).toBe(24);
81
+ expect((geometry.index?.count ?? 0) / 3).toBe(12);
82
+ for (const [x, z] of SQUARE) {
83
+ expect(hasVertexAt(geometry, x, z)).toBe(true);
84
+ }
85
+ });
86
+ test('recess is keyed by the flag, not the elevation sign', () => {
87
+ const belowPlaneSolid = SlabNode.parse({ elevation: -0.2, thickness: 0.05, polygon: SQUARE });
88
+ // Without `recessed`, a negative elevation is just a solid placed below
89
+ // the level plane: closed body at [-0.25, -0.2], world-space Y baked in.
90
+ expect(uniqueSortedYs(generateSlabGeometry(belowPlaneSolid, EMPTY_CONTEXT))).toEqual([
91
+ -0.25, -0.2,
92
+ ]);
93
+ // The recessed shell is authored at local Y=0 (floor) up to |elevation|
94
+ // (rim), with no top cap: 4 floor verts + 4 wall quads = 20 verts,
95
+ // 2 floor + 8 wall triangles.
96
+ const pool = SlabNode.parse({ elevation: -0.2, recessed: true, polygon: SQUARE });
97
+ const poolGeometry = generateSlabGeometry(pool, EMPTY_CONTEXT);
98
+ expect(uniqueSortedYs(poolGeometry)).toEqual([0, 0.2]);
99
+ expect(poolGeometry.getAttribute('position').count).toBe(20);
100
+ expect((poolGeometry.index?.count ?? 0) / 3).toBe(10);
101
+ });
62
102
  });
@@ -76,7 +76,7 @@ export const StairSystem = () => {
76
76
  // --- Pass 1b: Sync chained transforms to individual segment meshes (edit mode) ---
77
77
  for (const stairId of parentsNeedingSegmentSync) {
78
78
  const baseStairNode = nodes[stairId];
79
- if (!baseStairNode || baseStairNode.type !== 'stair')
79
+ if (baseStairNode?.type !== 'stair')
80
80
  continue;
81
81
  // Merge any in-flight drag override (e.g. parent-stair rotate handle)
82
82
  // so slab-elevation spatial queries match where the segments are
@@ -98,7 +98,7 @@ export const StairSystem = () => {
98
98
  if (stairsProcessed >= MAX_STAIRS_PER_FRAME)
99
99
  break;
100
100
  const node = nodes[id];
101
- if (!node || node.type !== 'stair') {
101
+ if (node?.type !== 'stair') {
102
102
  pendingStairUpdates.delete(id);
103
103
  continue;
104
104
  }
@@ -106,7 +106,7 @@ export const StairSystem = () => {
106
106
  if (group) {
107
107
  const mergedMesh = group.getObjectByName('merged-stair');
108
108
  if (mergedMesh?.visible !== false) {
109
- updateMergedStairGeometry(node, group, nodes);
109
+ updateMergedStairGeometry(getEffectiveNode(node), group, nodes);
110
110
  stairsProcessed++;
111
111
  }
112
112
  }
@@ -434,6 +434,9 @@ function createEmptyGeometry() {
434
434
  // unbound and the draw is rejected ("Vertex buffer slot 0 … was not set"),
435
435
  // poisoning the command encoder. The count-0 groups keep nothing drawn.
436
436
  geometry.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3));
437
+ geometry.setAttribute('normal', new THREE.Float32BufferAttribute(new Float32Array(9), 3));
438
+ geometry.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array(6), 2));
439
+ geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(new Float32Array(6), 2));
437
440
  geometry.addGroup(0, 0, STAIR_TREAD_MATERIAL_INDEX);
438
441
  geometry.addGroup(0, 0, STAIR_SIDE_MATERIAL_INDEX);
439
442
  return geometry;
@@ -745,7 +748,7 @@ function computeAbsoluteHeight(node) {
745
748
  if (!node.parentId)
746
749
  return 0;
747
750
  const parent = nodes[node.parentId];
748
- if (!parent || parent.type !== 'stair')
751
+ if (parent?.type !== 'stair')
749
752
  return 0;
750
753
  const stair = parent;
751
754
  const segments = (stair.children ?? [])
@@ -0,0 +1,31 @@
1
+ import type { DoorNode, WindowNode } from '@pascal-app/core';
2
+ import * as THREE from 'three';
3
+ export type OpeningCutoutNode = DoorNode | WindowNode;
4
+ export type OpeningCutoutRect = {
5
+ left: number;
6
+ right: number;
7
+ bottom: number;
8
+ top: number;
9
+ };
10
+ export declare function getOpeningCutoutProxyDepth(wallThickness: number): number;
11
+ /**
12
+ * Pure cutout profile for a shaped door / window opening. `rect` is in
13
+ * the caller's coordinate frame — the wall CSG pipeline passes wall-local
14
+ * coords, the roof-wall pipeline an origin-centered rect — so the same
15
+ * radii / arch math serves both hosts.
16
+ */
17
+ export declare function buildOpeningCutoutShape(opening: OpeningCutoutNode, rect: OpeningCutoutRect): THREE.Shape;
18
+ export declare function buildOpeningCutoutGeometry(opening: OpeningCutoutNode, rect: OpeningCutoutRect, depth: number, wallThickness: number): THREE.BufferGeometry;
19
+ /**
20
+ * Whether the cutout profile's bottom edge is a flat chord. Cuts whose
21
+ * bottom sits coplanar with the host wall base get extended slightly
22
+ * downward to keep CSG away from coplanar faces — but only a flat chord
23
+ * may extend; shifting a rounded bottom would distort the profile.
24
+ */
25
+ export declare function hasFlatOpeningCutoutBottom(opening: OpeningCutoutNode): boolean;
26
+ /**
27
+ * Extends floor-level flat cutouts below the host wall so CSG never has to
28
+ * subtract a face exactly coplanar with the wall base.
29
+ */
30
+ export declare function getOpeningCutoutBottomPadding(opening: OpeningCutoutNode, bottom: number): number;
31
+ //# sourceMappingURL=opening-cutout-geometry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"opening-cutout-geometry.d.ts","sourceRoot":"","sources":["../../../src/systems/wall/opening-cutout-geometry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAC5D,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAE9B,MAAM,MAAM,iBAAiB,GAAG,QAAQ,GAAG,UAAU,CAAA;AAErD,MAAM,MAAM,iBAAiB,GAAG;IAC9B,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,GAAG,EAAE,MAAM,CAAA;CACZ,CAAA;AAYD,wBAAgB,0BAA0B,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAExE;AASD;;;;;GAKG;AACH,wBAAgB,uBAAuB,CACrC,OAAO,EAAE,iBAAiB,EAC1B,IAAI,EAAE,iBAAiB,GACtB,KAAK,CAAC,KAAK,CAuCb;AAED,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,iBAAiB,EAC1B,IAAI,EAAE,iBAAiB,EACvB,KAAK,EAAE,MAAM,EACb,aAAa,EAAE,MAAM,GACpB,KAAK,CAAC,cAAc,CAqBtB;AAED;;;;;GAKG;AACH,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAW9E;AAED;;;GAGG;AACH,wBAAgB,6BAA6B,CAAC,OAAO,EAAE,iBAAiB,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAEhG"}
@@ -0,0 +1,167 @@
1
+ import * as THREE from 'three';
2
+ // The cutout proxy doubles as the invisible raycast hit target for an opening:
3
+ // centered on the wall and extending past both faces so it wins the scene
4
+ // raycast over the recessed door/window body for front AND back selection +
5
+ // paint. Wall CSG rebuilds opening cuts directly from node data, so this proxy
6
+ // only needs to clear the wall thickness plus a small proud margin. A snug depth
7
+ // keeps the hit target useful without blanketing the room floor in a top-down
8
+ // view (the bug a 1m-deep proxy caused in narrow hallways).
9
+ const OPENING_CUTOUT_PROXY_PROUD_MARGIN = 0.08;
10
+ const OPENING_CUTOUT_BOTTOM_PADDING = 0.02;
11
+ export function getOpeningCutoutProxyDepth(wallThickness) {
12
+ return Math.max(wallThickness, 0) + OPENING_CUTOUT_PROXY_PROUD_MARGIN;
13
+ }
14
+ /**
15
+ * Pure cutout profile for a shaped door / window opening. `rect` is in
16
+ * the caller's coordinate frame — the wall CSG pipeline passes wall-local
17
+ * coords, the roof-wall pipeline an origin-centered rect — so the same
18
+ * radii / arch math serves both hosts.
19
+ */
20
+ export function buildOpeningCutoutShape(opening, rect) {
21
+ const { left, right, bottom, top } = rect;
22
+ const width = Math.max(right - left, 1e-6);
23
+ const height = Math.max(top - bottom, 1e-6);
24
+ const shape = new THREE.Shape();
25
+ if (opening.openingShape === 'arch') {
26
+ const halfWidth = width / 2;
27
+ const centerX = (left + right) / 2;
28
+ const archHeight = Math.min(Math.max(opening.archHeight ?? width / 2, 0.01), height);
29
+ const springY = top - archHeight;
30
+ const segments = 32;
31
+ shape.moveTo(left, bottom);
32
+ shape.lineTo(right, bottom);
33
+ shape.lineTo(right, springY);
34
+ for (let index = 1; index <= segments; index += 1) {
35
+ const x = right + (left - right) * (index / segments);
36
+ const normalizedX = Math.min(Math.abs((x - centerX) / halfWidth), 1);
37
+ const y = springY + archHeight * Math.sqrt(Math.max(1 - normalizedX * normalizedX, 0));
38
+ shape.lineTo(x, y);
39
+ }
40
+ shape.lineTo(left, bottom);
41
+ shape.closePath();
42
+ return shape;
43
+ }
44
+ if (opening.openingShape === 'rounded') {
45
+ const radii = getRoundedOpeningRadii(opening, width, height);
46
+ applyRoundedOpeningShape(shape, left, right, bottom, top, radii);
47
+ return shape;
48
+ }
49
+ shape.moveTo(left, bottom);
50
+ shape.lineTo(right, bottom);
51
+ shape.lineTo(right, top);
52
+ shape.lineTo(left, top);
53
+ shape.closePath();
54
+ return shape;
55
+ }
56
+ export function buildOpeningCutoutGeometry(opening, rect, depth, wallThickness) {
57
+ const shape = buildOpeningCutoutShape(opening, rect);
58
+ const bevelSize = opening.openingShape === 'rounded'
59
+ ? Math.min(Math.max(opening.openingRevealRadius ?? 0.025, 0), Math.max(wallThickness * 0.45, 0.001), Math.max((opening.cornerRadius ?? 0.15) * 0.45, 0.001))
60
+ : 0;
61
+ const geometry = new THREE.ExtrudeGeometry(shape, {
62
+ depth,
63
+ bevelEnabled: bevelSize > 0,
64
+ bevelSegments: bevelSize > 0 ? 8 : 0,
65
+ bevelSize,
66
+ bevelThickness: bevelSize,
67
+ curveSegments: 24,
68
+ });
69
+ geometry.translate(0, 0, -depth / 2);
70
+ return geometry;
71
+ }
72
+ /**
73
+ * Whether the cutout profile's bottom edge is a flat chord. Cuts whose
74
+ * bottom sits coplanar with the host wall base get extended slightly
75
+ * downward to keep CSG away from coplanar faces — but only a flat chord
76
+ * may extend; shifting a rounded bottom would distort the profile.
77
+ */
78
+ export function hasFlatOpeningCutoutBottom(opening) {
79
+ if (opening.openingShape !== 'rounded' || opening.type !== 'window')
80
+ return true;
81
+ if (opening.openingRadiusMode === 'individual') {
82
+ const [, , bottomRight = 0, bottomLeft = 0] = opening.openingCornerRadii ?? [
83
+ 0.15, 0.15, 0.15, 0.15,
84
+ ];
85
+ return bottomRight <= 1e-6 && bottomLeft <= 1e-6;
86
+ }
87
+ return Math.max(opening.cornerRadius ?? 0.15, 0) <= 1e-6;
88
+ }
89
+ /**
90
+ * Extends floor-level flat cutouts below the host wall so CSG never has to
91
+ * subtract a face exactly coplanar with the wall base.
92
+ */
93
+ export function getOpeningCutoutBottomPadding(opening, bottom) {
94
+ return bottom < 0.005 && hasFlatOpeningCutoutBottom(opening) ? OPENING_CUTOUT_BOTTOM_PADDING : 0;
95
+ }
96
+ function getRoundedOpeningRadii(opening, width, height) {
97
+ if (opening.type !== 'window') {
98
+ if (opening.openingRadiusMode === 'individual') {
99
+ const [topLeft = 0, topRight = 0] = opening.openingTopRadii ?? [0.15, 0.15];
100
+ return normalizeCornerRadii({
101
+ topLeft: Math.max(topLeft, 0),
102
+ topRight: Math.max(topRight, 0),
103
+ bottomRight: 0,
104
+ bottomLeft: 0,
105
+ }, width, height);
106
+ }
107
+ const maxRadius = Math.min(width / 2, height);
108
+ const radius = Math.min(Math.max(opening.cornerRadius ?? 0.15, 0), maxRadius);
109
+ return { topLeft: radius, topRight: radius, bottomRight: 0, bottomLeft: 0 };
110
+ }
111
+ if (opening.openingRadiusMode === 'individual') {
112
+ const [topLeft = 0, topRight = 0, bottomRight = 0, bottomLeft = 0] = opening.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15];
113
+ return normalizeCornerRadii({
114
+ topLeft: Math.max(topLeft, 0),
115
+ topRight: Math.max(topRight, 0),
116
+ bottomRight: Math.max(bottomRight, 0),
117
+ bottomLeft: Math.max(bottomLeft, 0),
118
+ }, width, height);
119
+ }
120
+ const maxRadius = Math.min(width / 2, height / 2);
121
+ const radius = Math.min(Math.max(opening.cornerRadius ?? 0.15, 0), maxRadius);
122
+ return { topLeft: radius, topRight: radius, bottomRight: radius, bottomLeft: radius };
123
+ }
124
+ function normalizeCornerRadii(radii, width, height) {
125
+ const next = { ...radii };
126
+ const maxScale = Math.min(1, width / Math.max(next.topLeft + next.topRight, 1e-6), width / Math.max(next.bottomLeft + next.bottomRight, 1e-6), height / Math.max(next.topLeft + next.bottomLeft, 1e-6), height / Math.max(next.topRight + next.bottomRight, 1e-6));
127
+ if (maxScale < 1) {
128
+ next.topLeft *= maxScale;
129
+ next.topRight *= maxScale;
130
+ next.bottomRight *= maxScale;
131
+ next.bottomLeft *= maxScale;
132
+ }
133
+ return next;
134
+ }
135
+ function applyRoundedOpeningShape(shape, left, right, bottom, top, radii) {
136
+ const { topLeft, topRight, bottomRight, bottomLeft } = radii;
137
+ shape.moveTo(left + bottomLeft, bottom);
138
+ shape.lineTo(right - bottomRight, bottom);
139
+ if (bottomRight > 1e-6) {
140
+ shape.absarc(right - bottomRight, bottom + bottomRight, bottomRight, -Math.PI / 2, 0, false);
141
+ }
142
+ else {
143
+ shape.lineTo(right, bottom);
144
+ }
145
+ shape.lineTo(right, top - topRight);
146
+ if (topRight > 1e-6) {
147
+ shape.absarc(right - topRight, top - topRight, topRight, 0, Math.PI / 2, false);
148
+ }
149
+ else {
150
+ shape.lineTo(right, top);
151
+ }
152
+ shape.lineTo(left + topLeft, top);
153
+ if (topLeft > 1e-6) {
154
+ shape.absarc(left + topLeft, top - topLeft, topLeft, Math.PI / 2, Math.PI, false);
155
+ }
156
+ else {
157
+ shape.lineTo(left, top);
158
+ }
159
+ shape.lineTo(left, bottom + bottomLeft);
160
+ if (bottomLeft > 1e-6) {
161
+ shape.absarc(left + bottomLeft, bottom + bottomLeft, bottomLeft, Math.PI, Math.PI * 1.5, false);
162
+ }
163
+ else {
164
+ shape.lineTo(left, bottom);
165
+ }
166
+ shape.closePath();
167
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=opening-cutout-geometry.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"opening-cutout-geometry.test.d.ts","sourceRoot":"","sources":["../../../src/systems/wall/opening-cutout-geometry.test.ts"],"names":[],"mappings":""}