@pascal-app/viewer 0.9.2 → 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 (77) hide show
  1. package/README.md +20 -5
  2. package/dist/components/viewer/frame-limiter.d.ts.map +1 -1
  3. package/dist/components/viewer/frame-limiter.js +25 -5
  4. package/dist/components/viewer/glb-scene.js +2 -2
  5. package/dist/components/viewer/glb-walkthrough-controller.d.ts +10 -0
  6. package/dist/components/viewer/glb-walkthrough-controller.d.ts.map +1 -1
  7. package/dist/components/viewer/glb-walkthrough-controller.js +96 -8
  8. package/dist/components/viewer/index.d.ts.map +1 -1
  9. package/dist/components/viewer/index.js +9 -0
  10. package/dist/components/viewer/post-processing.d.ts.map +1 -1
  11. package/dist/components/viewer/post-processing.js +1 -0
  12. package/dist/components/viewer/selection-manager.js +5 -2
  13. package/dist/hooks/use-gltf-ktx2.d.ts.map +1 -1
  14. package/dist/hooks/use-gltf-ktx2.js +2 -5
  15. package/dist/index.d.ts +8 -5
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +10 -8
  18. package/dist/lib/hero-pose.d.ts +47 -0
  19. package/dist/lib/hero-pose.d.ts.map +1 -0
  20. package/dist/lib/hero-pose.js +227 -0
  21. package/dist/lib/ktx2-loader.d.ts +3 -0
  22. package/dist/lib/ktx2-loader.d.ts.map +1 -1
  23. package/dist/lib/ktx2-loader.js +6 -0
  24. package/dist/lib/materials.d.ts +2 -0
  25. package/dist/lib/materials.d.ts.map +1 -1
  26. package/dist/lib/materials.js +56 -9
  27. package/dist/lib/materials.test.d.ts +2 -0
  28. package/dist/lib/materials.test.d.ts.map +1 -0
  29. package/dist/lib/materials.test.js +25 -0
  30. package/dist/lib/merged-outline-node.test.d.ts +2 -0
  31. package/dist/lib/merged-outline-node.test.d.ts.map +1 -0
  32. package/dist/lib/merged-outline-node.test.js +22 -0
  33. package/dist/lib/snapshot-pipeline.d.ts +42 -0
  34. package/dist/lib/snapshot-pipeline.d.ts.map +1 -0
  35. package/dist/lib/snapshot-pipeline.js +254 -0
  36. package/dist/lib/terrain-perimeter-fill.d.ts +7 -0
  37. package/dist/lib/terrain-perimeter-fill.d.ts.map +1 -0
  38. package/dist/lib/terrain-perimeter-fill.js +75 -0
  39. package/dist/lib/texture-node-guard.d.ts +13 -0
  40. package/dist/lib/texture-node-guard.d.ts.map +1 -0
  41. package/dist/lib/texture-node-guard.js +39 -0
  42. package/dist/lib/texture-reference.d.ts +31 -0
  43. package/dist/lib/texture-reference.d.ts.map +1 -0
  44. package/dist/lib/texture-reference.js +139 -0
  45. package/dist/lib/texture-reference.test.d.ts +2 -0
  46. package/dist/lib/texture-reference.test.d.ts.map +1 -0
  47. package/dist/lib/texture-reference.test.js +86 -0
  48. package/dist/store/use-viewer.d.ts +13 -0
  49. package/dist/store/use-viewer.d.ts.map +1 -1
  50. package/dist/store/use-viewer.js +16 -1
  51. package/dist/store/use-viewer.test.js +19 -0
  52. package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
  53. package/dist/systems/ceiling/ceiling-system.js +8 -4
  54. package/dist/systems/floor-elevation/floor-elevation-system.d.ts.map +1 -1
  55. package/dist/systems/floor-elevation/floor-elevation-system.js +30 -7
  56. package/dist/systems/level/level-system.d.ts.map +1 -1
  57. package/dist/systems/level/level-system.js +8 -7
  58. package/dist/systems/level/level-utils.d.ts.map +1 -1
  59. package/dist/systems/level/level-utils.js +7 -6
  60. package/dist/systems/slab/slab-system.d.ts +5 -4
  61. package/dist/systems/slab/slab-system.d.ts.map +1 -1
  62. package/dist/systems/slab/slab-system.js +24 -94
  63. package/dist/systems/slab/slab-system.test.js +51 -12
  64. package/dist/systems/stair/stair-system.js +1 -1
  65. package/dist/systems/wall/wall-cutout.d.ts.map +1 -1
  66. package/dist/systems/wall/wall-cutout.js +5 -2
  67. package/dist/systems/wall/wall-support-extension.test.js +69 -1
  68. package/dist/systems/wall/wall-system.d.ts +3 -7
  69. package/dist/systems/wall/wall-system.d.ts.map +1 -1
  70. package/dist/systems/wall/wall-system.js +87 -22
  71. package/dist/systems/wall/wall-terrain-bottom.test.d.ts +2 -0
  72. package/dist/systems/wall/wall-terrain-bottom.test.d.ts.map +1 -0
  73. package/dist/systems/wall/wall-terrain-bottom.test.js +34 -0
  74. package/package.json +2 -2
  75. package/dist/components/viewer/walkthrough-controls.d.ts +0 -3
  76. package/dist/components/viewer/walkthrough-controls.d.ts.map +0 -1
  77. package/dist/components/viewer/walkthrough-controls.js +0 -151
@@ -1,95 +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
- const contextByLevel = new Map();
33
- // Process dirty slabs
34
- dirtyNodes.forEach((id) => {
35
- const node = nodes[id];
36
- if (node?.type !== 'slab')
37
- return;
38
- const mesh = sceneRegistry.nodes.get(id);
39
- if (mesh) {
40
- const slab = node;
41
- const levelContext = contextByLevel.get(slab.parentId) ?? buildLevelSlabContext(slab.parentId, nodes);
42
- contextByLevel.set(slab.parentId, levelContext);
43
- updateSlabGeometry(getEffectiveNode(slab), excludeSlabFromContext(levelContext, slab.id), mesh);
44
- clearDirty(id);
45
- }
46
- // If mesh not found, keep it dirty for next frame
47
- });
48
- }, 1);
49
- return null;
50
- };
51
- function buildLevelSlabContext(levelId, nodes) {
52
- const walls = [];
53
- const siblingSlabs = [];
54
- for (const node of Object.values(nodes)) {
55
- if (node.parentId !== levelId)
56
- continue;
57
- if (node.type === 'wall')
58
- walls.push(node);
59
- else if (node.type === 'slab')
60
- siblingSlabs.push(node);
61
- }
62
- return { walls, siblingSlabs };
63
- }
64
- function excludeSlabFromContext(context, slabId) {
65
- return {
66
- walls: context.walls,
67
- siblingSlabs: context.siblingSlabs.filter((slab) => slab.id !== slabId),
68
- };
69
- }
70
- /**
71
- * Updates the geometry for a single slab
72
- */
73
- function updateSlabGeometry(node, context, mesh) {
74
- const newGeo = generateSlabGeometry(node, context);
75
- ensureUv2Attribute(newGeo);
76
- mesh.geometry.dispose();
77
- mesh.geometry = newGeo;
78
- // For negative elevation, shift the mesh down so the top face sits at Y=elevation
79
- // rather than at Y=0. Positive elevation stays at Y=0 (slab sits at floor level).
80
- const elevation = node.elevation ?? 0.05;
81
- mesh.position.y = elevation < 0 ? elevation : 0;
82
- }
83
8
  /**
84
- * Generates extruded slab geometry from polygon. `context` carries the
85
- * slab's level neighbourhood (walls + sibling slabs) driving the per-edge
86
- * render offsets — see `getRenderableSlabPolygon`.
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]`.
87
14
  */
88
15
  export function generateSlabGeometry(slabNode, context) {
89
- const elevation = slabNode.elevation ?? 0.05;
90
- return elevation < 0
16
+ return slabNode.recessed
91
17
  ? generatePoolGeometry(slabNode, context)
92
- : generatePositiveSlabGeometry(slabNode, context);
18
+ : generateSolidSlabGeometry(slabNode, context);
93
19
  }
94
20
  // Earcut normalizes cap triangulation regardless of input winding, but the side
95
21
  // walls below assume a CCW contour (the unflipped quad's right-hand normal faces
@@ -131,7 +57,8 @@ function buildSlabRegions(contour, holes) {
131
57
  }));
132
58
  }
133
59
  /**
134
- * 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`.
135
62
  *
136
63
  * Built directly in 3D (Y-up) rather than via ExtrudeGeometry so the hole side
137
64
  * walls can be emitted double-sided. The slab material is forced to FrontSide
@@ -142,9 +69,11 @@ function buildSlabRegions(contour, holes) {
142
69
  * thickness visible from any angle: the two coincident triangles never z-fight
143
70
  * because exactly one faces the camera under FrontSide culling.
144
71
  */
145
- function generatePositiveSlabGeometry(slabNode, context) {
72
+ function generateSolidSlabGeometry(slabNode, context) {
146
73
  const polygon = ensureCounterClockwisePolygon(getRenderableSlabPolygon(slabNode, context));
147
74
  const elevation = slabNode.elevation ?? 0.05;
75
+ const thickness = slabNode.thickness ?? 0.05;
76
+ const bottom = elevation - thickness;
148
77
  const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? []);
149
78
  if (polygon.length < 3)
150
79
  return new THREE.BufferGeometry();
@@ -158,14 +87,14 @@ function generatePositiveSlabGeometry(slabNode, context) {
158
87
  const addWall = (a, b, flipped) => {
159
88
  const base = positions.length / 3;
160
89
  const len = Math.max(Math.hypot(b.x - a.x, b.y - a.y), 0.001);
161
- positions.push(a.x, 0, a.y);
90
+ positions.push(a.x, bottom, a.y);
162
91
  uvs.push(0, 0);
163
- positions.push(b.x, 0, b.y);
92
+ positions.push(b.x, bottom, b.y);
164
93
  uvs.push(len, 0);
165
94
  positions.push(b.x, elevation, b.y);
166
- uvs.push(len, elevation);
95
+ uvs.push(len, thickness);
167
96
  positions.push(a.x, elevation, a.y);
168
- uvs.push(0, elevation);
97
+ uvs.push(0, thickness);
169
98
  // Standard winding on a CCW polygon gives inward-facing normals (see pool
170
99
  // path), so the unflipped quad faces outward; flipped is its back face.
171
100
  if (!flipped) {
@@ -192,7 +121,7 @@ function generatePositiveSlabGeometry(slabNode, context) {
192
121
  }
193
122
  const bottomBase = positions.length / 3;
194
123
  for (const p of capPoints) {
195
- positions.push(p.x, 0, p.y);
124
+ positions.push(p.x, bottom, p.y);
196
125
  uvs.push(p.x, -p.y);
197
126
  }
198
127
  const capTris = THREE.ShapeUtils.triangulateShape(contour2d, holes2d);
@@ -222,8 +151,8 @@ function generatePositiveSlabGeometry(slabNode, context) {
222
151
  return geometry;
223
152
  }
224
153
  /**
225
- * Pool / recessed slab: floor cap at Y=0 (local) + inner walls up to Y=|elevation|.
226
- * 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.
227
156
  * mesh.position.y must be set to elevation so the floor sits at the correct world Y.
228
157
  *
229
158
  * Geometry is built directly in 3D (Y-up) to avoid rotation confusion:
@@ -232,7 +161,8 @@ function generatePositiveSlabGeometry(slabNode, context) {
232
161
  */
233
162
  function generatePoolGeometry(slabNode, context) {
234
163
  const polygon = ensureCounterClockwisePolygon(getRenderableSlabPolygon(slabNode, context));
235
- const depth = Math.abs(slabNode.elevation ?? 0.05);
164
+ const floor = slabNode.elevation ?? 0.05;
165
+ const depth = Math.max(0, (slabNode.recessedRimElevation ?? 0) - floor);
236
166
  const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? []);
237
167
  if (polygon.length < 3)
238
168
  return new THREE.BufferGeometry();
@@ -241,7 +171,7 @@ function generatePoolGeometry(slabNode, context) {
241
171
  const indices = [];
242
172
  const pushFloorVertex = (x, y, z) => {
243
173
  positions.push(x, y, z);
244
- // Floor UVs in metres (shape-space x, -z), matching generatePositiveSlabGeometry's
174
+ // Floor UVs in metres (shape-space x, -z), matching generateSolidSlabGeometry's
245
175
  // cap mapping so a finish tiles at the same world scale on every surface.
246
176
  uvs.push(x, -z);
247
177
  };
@@ -4,6 +4,12 @@ import { describe, expect, test } from 'bun:test';
4
4
  import { SlabNode } from '@pascal-app/core';
5
5
  import { generateSlabGeometry } from './slab-system';
6
6
  const EMPTY_CONTEXT = { walls: [], siblingSlabs: [] };
7
+ const SQUARE = [
8
+ [0, 0],
9
+ [4, 0],
10
+ [4, 3],
11
+ [0, 3],
12
+ ];
7
13
  function hasVertexAt(geometry, x, z) {
8
14
  const positions = geometry.getAttribute('position');
9
15
  for (let index = 0; index < positions.count; index += 1) {
@@ -13,16 +19,19 @@ function hasVertexAt(geometry, x, z) {
13
19
  }
14
20
  return false;
15
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
+ }
16
30
  describe('generateSlabGeometry', () => {
17
31
  test('renders a boundary-overlapping hole as an open indentation', () => {
18
32
  const slab = SlabNode.parse({
19
33
  elevation: 0.05,
20
- polygon: [
21
- [0, 0],
22
- [4, 0],
23
- [4, 3],
24
- [0, 3],
25
- ],
34
+ polygon: SQUARE,
26
35
  holes: [
27
36
  [
28
37
  [1, -0.5],
@@ -40,12 +49,8 @@ describe('generateSlabGeometry', () => {
40
49
  test('renders a boundary-overlapping hole as an open indentation on recessed slabs', () => {
41
50
  const slab = SlabNode.parse({
42
51
  elevation: -0.2,
43
- polygon: [
44
- [0, 0],
45
- [4, 0],
46
- [4, 3],
47
- [0, 3],
48
- ],
52
+ recessed: true,
53
+ polygon: SQUARE,
49
54
  holes: [
50
55
  [
51
56
  [1, -0.5],
@@ -60,4 +65,38 @@ describe('generateSlabGeometry', () => {
60
65
  expect(hasVertexAt(geometry, 1, 1)).toBe(true);
61
66
  expect(hasVertexAt(geometry, 3, 1)).toBe(true);
62
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
+ });
63
102
  });
@@ -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
  }
@@ -1 +1 @@
1
- {"version":3,"file":"wall-cutout.d.ts","sourceRoot":"","sources":["../../../src/systems/wall/wall-cutout.tsx"],"names":[],"mappings":"AAqDA,eAAO,MAAM,UAAU,YAyKtB,CAAA"}
1
+ {"version":3,"file":"wall-cutout.d.ts","sourceRoot":"","sources":["../../../src/systems/wall/wall-cutout.tsx"],"names":[],"mappings":"AAyDA,eAAO,MAAM,UAAU,YAuLtB,CAAA"}
@@ -1,4 +1,4 @@
1
- import { emitter, getWallFaceBandConfig, sceneRegistry, useScene, } from '@pascal-app/core';
1
+ import { emitter, getWallFaceBandConfig, getWallPlaneTop, resolveLevelId, resolveWallEffectiveHeight, sceneRegistry, spatialGridManager, useScene, } from '@pascal-app/core';
2
2
  import { useFrame } from '@react-three/fiber';
3
3
  import { useEffect, useRef } from 'react';
4
4
  import { Vector3 } from 'three/webgpu';
@@ -101,7 +101,10 @@ export const WallCutout = () => {
101
101
  const hideWall = getWallHideState(wallNode, wallMesh, wallMode, u);
102
102
  const isDeleteHighlighted = deleteHoveredWallId === wallId;
103
103
  const isSelectionHighlighted = !isDeleteHighlighted && highlightedWallIds.has(wallId);
104
- const shouldSelectionHighlight = isSelectionHighlighted && !getWallFaceBandConfig(wallNode).enabled;
104
+ const levelId = resolveLevelId(wallNode, sceneState.nodes);
105
+ const support = spatialGridManager.getSlabSupportForWall(levelId, wallNode.start, wallNode.end, wallNode.curveOffset ?? 0, wallNode.thickness, wallNode.supportSlabId);
106
+ const effectiveWallHeight = resolveWallEffectiveHeight(wallNode, getWallPlaneTop(wallNode, levelId, sceneState.nodes), support.elevation);
107
+ const shouldSelectionHighlight = isSelectionHighlighted && !getWallFaceBandConfig(wallNode, effectiveWallHeight).enabled;
105
108
  const materials = getMaterialsForWall(wallNode, shading, textures, colorPreset, sceneTheme, sceneState.materials);
106
109
  if (wallMode === 'translucent') {
107
110
  ;
@@ -1,7 +1,7 @@
1
1
  // @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
2
2
  // depend on @types/bun so the import type is unresolved at compile time.
3
3
  import { describe, expect, test } from 'bun:test';
4
- import { calculateLevelMiters, WallNode } from '@pascal-app/core';
4
+ import { calculateLevelMiters, getWallPlaneTop, WallNode, } from '@pascal-app/core';
5
5
  import { generateExtrudedWall } from './wall-system';
6
6
  describe('wall support extension', () => {
7
7
  test('preserves the raised wall origin and top while filling down to the lower support', () => {
@@ -24,6 +24,74 @@ describe('wall support extension', () => {
24
24
  expect((geometry.boundingBox?.max.y ?? 0) - 0.4).toBeCloseTo(2.5);
25
25
  geometry.dispose();
26
26
  });
27
+ test('plane-bound wall tops out at the storey plane regardless of slab elevation', () => {
28
+ const wall = WallNode.parse({ start: [0, 0], end: [4, 0], thickness: 0.1 });
29
+ const flat = generateExtrudedWall(wall, [], calculateLevelMiters([wall]), 0, 0, undefined, 3);
30
+ flat.computeBoundingBox();
31
+ expect(flat.boundingBox?.max.y).toBeCloseTo(3);
32
+ expect(flat.boundingBox?.min.y).toBeCloseTo(0);
33
+ flat.dispose();
34
+ const raised = generateExtrudedWall(wall, [], calculateLevelMiters([wall]), 0.6, 0.6, undefined, 3);
35
+ raised.computeBoundingBox();
36
+ // Mesh sits at Y=0.6, so a 2.4 local top keeps the world top at the 3m
37
+ // plane — the raised slab shortens the wall instead of lifting its top.
38
+ expect(raised.boundingBox?.max.y).toBeCloseTo(2.4);
39
+ expect(raised.boundingBox?.min.y).toBeCloseTo(0);
40
+ raised.dispose();
41
+ });
42
+ test('plane-bound wall under a flush thick deck tops out at the deck underside', () => {
43
+ // level_1 carries a flush deck occupying [-0.3, 0] above the storey
44
+ // plane: the covering-clamped plane for level_0 is 2.5 − 0.3 = 2.2.
45
+ const wall = WallNode.parse({
46
+ start: [0.5, 2],
47
+ end: [3.5, 2],
48
+ thickness: 0.1,
49
+ parentId: 'level_0',
50
+ });
51
+ const base = { object: 'node', parentId: null, visible: true, metadata: {}, children: [] };
52
+ const nodes = {
53
+ level_0: {
54
+ ...base,
55
+ id: 'level_0',
56
+ type: 'level',
57
+ level: 0,
58
+ height: 2.5,
59
+ children: [wall.id],
60
+ },
61
+ level_1: {
62
+ ...base,
63
+ id: 'level_1',
64
+ type: 'level',
65
+ level: 1,
66
+ height: 2.5,
67
+ children: ['slab_deck'],
68
+ },
69
+ slab_deck: {
70
+ ...base,
71
+ id: 'slab_deck',
72
+ type: 'slab',
73
+ parentId: 'level_1',
74
+ polygon: [
75
+ [0, 0],
76
+ [4, 0],
77
+ [4, 4],
78
+ [0, 4],
79
+ ],
80
+ holes: [],
81
+ elevation: 0,
82
+ thickness: 0.3,
83
+ },
84
+ };
85
+ const planeTop = getWallPlaneTop(wall, 'level_0', nodes);
86
+ expect(planeTop).toBeCloseTo(2.2);
87
+ const geometry = generateExtrudedWall(wall, [], calculateLevelMiters([wall]), 0, 0, undefined, planeTop);
88
+ geometry.computeBoundingBox();
89
+ // Mesh sits at Y=0, so the world top lands at the deck underside instead
90
+ // of colliding with the slab solid above.
91
+ expect(geometry.boundingBox?.max.y).toBeCloseTo(2.2);
92
+ expect(geometry.boundingBox?.min.y).toBeCloseTo(0);
93
+ geometry.dispose();
94
+ });
27
95
  test('raises only the high-supported part of a mixed wall run', () => {
28
96
  const wall = WallNode.parse({ start: [0, 0], end: [4, 0], height: 2.5, thickness: 0.1 });
29
97
  const geometry = generateExtrudedWall(wall, [], calculateLevelMiters([wall]), 0.6, 0.05, [
@@ -1,11 +1,7 @@
1
1
  import { type AnyNode, type WallMiterData, type WallNode, type WallSlabSupportSegment } from '@pascal-app/core';
2
2
  import * as THREE from 'three';
3
3
  export declare const WallSystem: () => null;
4
- /**
5
- * Generates extruded wall geometry with mitering and cutouts
6
- *
7
- * Key insight from demo: polygon is built in WORLD coordinates first,
8
- * then we transform to wall-local for the 3D mesh.
9
- */
10
- export declare function generateExtrudedWall(wallNode: WallNode, childrenNodes: AnyNode[], miterData: WallMiterData, slabElevation?: number, baseElevation?: number, baseSegments?: readonly WallSlabSupportSegment[]): THREE.BufferGeometry;
4
+ type WallTerrainBottomSampler = (x: number, z: number) => number | null;
5
+ export declare function generateExtrudedWall(wallNode: WallNode, childrenNodes: AnyNode[], miterData: WallMiterData, slabElevation?: number, baseElevation?: number, baseSegments?: readonly WallSlabSupportSegment[], storeyHeight?: number, terrainBottomAt?: WallTerrainBottomSampler): THREE.BufferGeometry;
6
+ export {};
11
7
  //# sourceMappingURL=wall-system.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"wall-system.d.ts","sourceRoot":"","sources":["../../../src/systems/wall/wall-system.tsx"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,OAAO,EAwBZ,KAAK,aAAa,EAClB,KAAK,QAAQ,EACb,KAAK,sBAAsB,EAI5B,MAAM,kBAAkB,CAAA;AAEzB,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AA4c9B,eAAO,MAAM,UAAU,YAwJtB,CAAA;AA2LD;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,QAAQ,EAClB,aAAa,EAAE,OAAO,EAAE,EACxB,SAAS,EAAE,aAAa,EACxB,aAAa,SAAI,EACjB,aAAa,SAAgB,EAC7B,YAAY,GAAE,SAAS,sBAAsB,EAE5C,GACA,KAAK,CAAC,cAAc,CAsNtB"}
1
+ {"version":3,"file":"wall-system.d.ts","sourceRoot":"","sources":["../../../src/systems/wall/wall-system.tsx"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,OAAO,EA2BZ,KAAK,aAAa,EAClB,KAAK,QAAQ,EACb,KAAK,sBAAsB,EAI5B,MAAM,kBAAkB,CAAA;AAEzB,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAqd9B,eAAO,MAAM,UAAU,YAwJtB,CAAA;AAgND,KAAK,wBAAwB,GAAG,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAA;AAgEvE,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,QAAQ,EAClB,aAAa,EAAE,OAAO,EAAE,EACxB,SAAS,EAAE,aAAa,EACxB,aAAa,SAAI,EACjB,aAAa,SAAgB,EAC7B,YAAY,GAAE,SAAS,sBAAsB,EAE5C,EACD,YAAY,SAAuB,EACnC,eAAe,CAAC,EAAE,wBAAwB,GACzC,KAAK,CAAC,cAAc,CA0OtB"}
@@ -1,9 +1,11 @@
1
- import { calculateLevelMiters, DEFAULT_WALL_HEIGHT, getAdjacentWallIds, getEffectiveNode, getWallBandSlotId, getWallCurveFrameAt, getWallFaceBandConfig, getWallFaceBandForHeight, getWallMiterBoundaryPoints, getWallPlanFootprint, getWallSurfacePolygon, getWallThickness, isCurvedWall, pointToKey, resolveLevelId, sceneRegistry, spatialGridManager, useLiveNodeOverrides, useLiveTransforms, useScene, } from '@pascal-app/core';
1
+ import { calculateLevelMiters, DEFAULT_LEVEL_HEIGHT, getAdjacentWallIds, getEffectiveNode, getWallBandSlotId, getWallCurveFrameAt, getWallFaceBandConfig, getWallFaceBandForHeight, getWallMiterBoundaryPoints, getWallPlaneTop, getWallPlanFootprint, getWallSurfacePolygon, getWallThickness, isCurvedWall, pointToKey, resolveLevelId, resolveWallTop, sceneRegistry, spatialGridManager, terrainSupportLift, useLiveNodeOverrides, useLiveTransforms, useScene, } from '@pascal-app/core';
2
2
  import { useFrame } from '@react-three/fiber';
3
3
  import * as THREE from 'three';
4
+ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
4
5
  import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg';
5
6
  import { computeBoundsTree } from 'three-mesh-bvh';
6
7
  import { ensureRenderableGeometryAttributes, prepareBrushForCSG } from '../../lib/csg-utils';
8
+ import { buildTerrainPerimeterFillGeometry } from '../../lib/terrain-perimeter-fill';
7
9
  import { buildOpeningCutoutGeometry, getOpeningCutoutBottomPadding, } from './opening-cutout-geometry';
8
10
  // Reusable CSG evaluator for better performance
9
11
  const csgEvaluator = new Evaluator();
@@ -133,17 +135,17 @@ function distanceToWallBoundaryEdge(point, edge) {
133
135
  const closestZ = edge.start.y + edgeDz * t;
134
136
  return Math.hypot(point.x - closestX, point.y - closestZ);
135
137
  }
136
- function getWallFaceMaterialIndex(wall, face, y) {
138
+ function getWallFaceMaterialIndex(wall, face, y, effectiveWallHeight) {
137
139
  const semantic = face === 'front' ? wall.frontSide : wall.backSide;
138
140
  const fallback = face === 'front' ? 'interior' : 'exterior';
139
141
  const side = semantic === 'interior' || semantic === 'exterior' ? semantic : fallback;
140
- const bands = getWallFaceBandConfig(wall);
142
+ const bands = getWallFaceBandConfig(wall, effectiveWallHeight);
141
143
  if (!bands.enabled)
142
144
  return WALL_BAND_SLOT_MATERIAL_INDEX[side];
143
- const band = getWallFaceBandForHeight(wall, y);
145
+ const band = getWallFaceBandForHeight(wall, y, effectiveWallHeight);
144
146
  return WALL_BAND_SLOT_MATERIAL_INDEX[getWallBandSlotId(side, band)];
145
147
  }
146
- function assignWallMaterialGroups(geometry, wall, boundaryEdges) {
148
+ function assignWallMaterialGroups(geometry, wall, boundaryEdges, effectiveWallHeight) {
147
149
  const position = geometry.getAttribute('position');
148
150
  if (!position)
149
151
  return;
@@ -206,7 +208,7 @@ function assignWallMaterialGroups(geometry, wall, boundaryEdges) {
206
208
  triangleMaterials[triangleIndex] = 0;
207
209
  continue;
208
210
  }
209
- triangleMaterials[triangleIndex] = getWallFaceMaterialIndex(wall, nearestTag, centroid.y);
211
+ triangleMaterials[triangleIndex] = getWallFaceMaterialIndex(wall, nearestTag, centroid.y, effectiveWallHeight);
210
212
  }
211
213
  geometry.clearGroups();
212
214
  let currentMaterial = triangleMaterials[0] ?? 0;
@@ -309,8 +311,8 @@ function splitGeometryAtHorizontalPlanes(geometry, planes) {
309
311
  split.computeVertexNormals();
310
312
  return split;
311
313
  }
312
- function getWallBandSplitPlanes(wall) {
313
- const bands = getWallFaceBandConfig(wall);
314
+ function getWallBandSplitPlanes(wall, effectiveWallHeight) {
315
+ const bands = getWallFaceBandConfig(wall, effectiveWallHeight);
314
316
  if (!bands.enabled)
315
317
  return [];
316
318
  const planes = [bands.lowerTop];
@@ -318,7 +320,7 @@ function getWallBandSplitPlanes(wall) {
318
320
  planes.push(bands.middleTop);
319
321
  if (bands.count >= 4)
320
322
  planes.push(bands.upperTop);
321
- return planes.filter((plane) => plane > WALL_BAND_SPLIT_EPSILON && plane < (wall.height ?? 2.5) - WALL_BAND_SPLIT_EPSILON);
323
+ return planes.filter((plane) => plane > WALL_BAND_SPLIT_EPSILON && plane < effectiveWallHeight - WALL_BAND_SPLIT_EPSILON);
322
324
  }
323
325
  // ============================================================================
324
326
  // WALL SYSTEM
@@ -529,8 +531,14 @@ function updateWallGeometry(wallId, miterData) {
529
531
  if (!mesh)
530
532
  return;
531
533
  const levelId = resolveLevelId(node, nodes);
532
- const slabSupport = spatialGridManager.getSlabSupportForWall(levelId, node.start, node.end, node.curveOffset ?? 0, node.thickness);
534
+ // Covering-clamped plane: a flush/thick slab on the level above shortens
535
+ // the plane-bound walls below it (explicit-height walls ignore the value).
536
+ const planeTop = getWallPlaneTop(node, levelId, nodes);
537
+ const slabSupport = spatialGridManager.getSlabSupportForWall(levelId, node.start, node.end, node.curveOffset ?? 0, node.thickness, node.supportSlabId, undefined, node.supportOffset);
533
538
  const slabElevation = slabSupport.elevation;
539
+ const terrainBottomAt = node.fillToTerrain
540
+ ? (x, z) => terrainSupportLift(nodes, levelId, x, z)
541
+ : undefined;
534
542
  const childrenIds = node.children || [];
535
543
  // Merge live overrides into door / window children so cutouts track an
536
544
  // in-flight resize drag (door width arrow, window height arrow, etc.)
@@ -552,7 +560,7 @@ function updateWallGeometry(wallId, miterData) {
552
560
  return effective;
553
561
  return { ...effective, position: live.position };
554
562
  });
555
- const builtGeo = generateExtrudedWall(node, childrenNodes, miterData, slabElevation, slabSupport.baseElevation, slabSupport.baseSegments);
563
+ const builtGeo = generateExtrudedWall(node, childrenNodes, miterData, slabElevation, slabSupport.baseElevation, slabSupport.baseSegments, planeTop, terrainBottomAt);
556
564
  const wallAngle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0]);
557
565
  // World transform the render mesh will apply (position + Y-rotation below).
558
566
  // Reproduce it here so the UVs can be projected in WORLD space — see
@@ -564,7 +572,7 @@ function updateWallGeometry(wallId, miterData) {
564
572
  // Update collision mesh
565
573
  const collisionMesh = mesh.getObjectByName('collision-mesh');
566
574
  if (collisionMesh) {
567
- const collisionGeo = generateExtrudedWall(node, [], miterData, slabElevation, slabSupport.baseElevation, slabSupport.baseSegments);
575
+ const collisionGeo = generateExtrudedWall(node, [], miterData, slabElevation, slabSupport.baseElevation, slabSupport.baseSegments, planeTop, terrainBottomAt);
568
576
  collisionMesh.geometry.dispose();
569
577
  collisionMesh.geometry = collisionGeo;
570
578
  }
@@ -635,16 +643,70 @@ function applyWorldPlanarWallUVs(geometry, worldMatrix) {
635
643
  * Key insight from demo: polygon is built in WORLD coordinates first,
636
644
  * then we transform to wall-local for the 3D mesh.
637
645
  */
646
+ const WALL_TERRAIN_SAMPLE_STEP = 0.25;
647
+ function densifyClosedWallPerimeter(points) {
648
+ const dense = [];
649
+ for (let index = 0; index < points.length; index += 1) {
650
+ const start = points[index];
651
+ const end = points[(index + 1) % points.length];
652
+ const length = Math.hypot(end.x - start.x, end.y - start.y);
653
+ const segments = Math.max(1, Math.ceil(length / WALL_TERRAIN_SAMPLE_STEP));
654
+ for (let segment = 0; segment < segments; segment += 1) {
655
+ const t = segment / segments;
656
+ dense.push({
657
+ x: start.x + (end.x - start.x) * t,
658
+ y: start.y + (end.y - start.y) * t,
659
+ });
660
+ }
661
+ }
662
+ return dense;
663
+ }
664
+ function buildWallTerrainFillGeometry(perimeter, worldToLocal, wallBaseElevation, terrainBottomAt) {
665
+ const worldPoints = densifyClosedWallPerimeter(perimeter);
666
+ if (worldPoints.length < 3)
667
+ return null;
668
+ const localPoints = worldPoints.map(worldToLocal);
669
+ const bottomY = worldPoints.map((point) => {
670
+ const terrainElevation = terrainBottomAt(point.x, point.y);
671
+ return terrainElevation == null ? 0 : Math.min(0, terrainElevation - wallBaseElevation);
672
+ });
673
+ return buildTerrainPerimeterFillGeometry(localPoints, bottomY, 0);
674
+ }
675
+ function mergeWallTerrainFill(body, fill, wall, boundaryEdges, effectiveWallHeight) {
676
+ if (!fill)
677
+ return body;
678
+ const bodyGeometry = body.index ? body.toNonIndexed() : body;
679
+ if (bodyGeometry !== body)
680
+ body.dispose();
681
+ ensureRenderableGeometryAttributes(bodyGeometry);
682
+ ensureRenderableGeometryAttributes(fill);
683
+ const merged = mergeGeometries([bodyGeometry, fill], false);
684
+ if (!merged) {
685
+ fill.dispose();
686
+ return bodyGeometry;
687
+ }
688
+ bodyGeometry.dispose();
689
+ fill.dispose();
690
+ merged.computeVertexNormals();
691
+ assignWallMaterialGroups(merged, wall, boundaryEdges, effectiveWallHeight);
692
+ ensureRenderableGeometryAttributes(merged);
693
+ return merged;
694
+ }
638
695
  export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabElevation = 0, baseElevation = slabElevation, baseSegments = [
639
696
  { start: 0, end: 1, elevation: baseElevation },
640
- ]) {
697
+ ], storeyHeight = DEFAULT_LEVEL_HEIGHT, terrainBottomAt) {
641
698
  const wallStart = { x: wallNode.start[0], y: wallNode.start[1] };
642
699
  const wallEnd = { x: wallNode.end[0], y: wallNode.end[1] };
643
- const wallHeight = wallNode.height ?? DEFAULT_WALL_HEIGHT;
644
- const topElevation = slabElevation > 0 ? slabElevation + wallHeight : wallHeight;
700
+ const topElevation = resolveWallTop(wallNode, storeyHeight, slabElevation);
701
+ const effectiveWallHeight = topElevation - slabElevation;
645
702
  const effectiveBaseElevation = Math.min(baseElevation, slabElevation);
646
703
  const localBottom = effectiveBaseElevation - slabElevation;
647
704
  const height = topElevation - effectiveBaseElevation;
705
+ // A slab at or above the storey plane leaves a plane-bound wall with no
706
+ // body — bail before ExtrudeGeometry sees a non-positive depth.
707
+ if (height <= 1e-9) {
708
+ return new THREE.BufferGeometry();
709
+ }
648
710
  const thickness = getWallThickness(wallNode);
649
711
  // Wall direction and normal (exactly like demo)
650
712
  const v = { x: wallEnd.x - wallStart.x, y: wallEnd.y - wallStart.y };
@@ -675,6 +737,9 @@ export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabEle
675
737
  // Convert polygon to local coordinates
676
738
  const localPoints = polyPoints.map(worldToLocal);
677
739
  const boundaryEdges = buildTaggedWallBoundaryEdges(wallNode, localPoints, miterData);
740
+ const terrainFill = terrainBottomAt
741
+ ? buildWallTerrainFillGeometry(polyPoints, worldToLocal, slabElevation, terrainBottomAt)
742
+ : null;
678
743
  // Build THREE.js shape
679
744
  // Shape uses (x, y) where we map: shape.x = local.x, shape.y = -local.z
680
745
  // The negation is needed because after rotateX(-PI/2), shape.y becomes -geometry.z
@@ -694,7 +759,7 @@ export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabEle
694
759
  if (Math.abs(localBottom) > 1e-9)
695
760
  geometry.translate(0, localBottom, 0);
696
761
  geometry.computeVertexNormals();
697
- assignWallMaterialGroups(geometry, wallNode, boundaryEdges);
762
+ assignWallMaterialGroups(geometry, wallNode, boundaryEdges, effectiveWallHeight);
698
763
  ensureRenderableGeometryAttributes(geometry);
699
764
  // Start with the lowest required wall prism, then remove the volume below
700
765
  // each higher-supported run. This keeps the existing mitered footprint and
@@ -785,11 +850,11 @@ export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabEle
785
850
  ...collectCutoutBrushes(wallNode, childrenNodes, thickness),
786
851
  ];
787
852
  if (cutoutBrushes.length === 0) {
788
- const splitGeometry = splitGeometryAtHorizontalPlanes(geometry, getWallBandSplitPlanes(wallNode));
853
+ const splitGeometry = splitGeometryAtHorizontalPlanes(geometry, getWallBandSplitPlanes(wallNode, effectiveWallHeight));
789
854
  splitGeometry.computeVertexNormals();
790
- assignWallMaterialGroups(splitGeometry, wallNode, boundaryEdges);
855
+ assignWallMaterialGroups(splitGeometry, wallNode, boundaryEdges, effectiveWallHeight);
791
856
  ensureRenderableGeometryAttributes(splitGeometry);
792
- return splitGeometry;
857
+ return mergeWallTerrainFill(splitGeometry, terrainFill, wallNode, boundaryEdges, effectiveWallHeight);
793
858
  }
794
859
  // Create wall brush from geometry
795
860
  // Pre-compute BVH with new API to avoid deprecation warning
@@ -814,11 +879,11 @@ export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabEle
814
879
  csgGeometry(brush).dispose();
815
880
  }
816
881
  const resultGeometry = csgGeometry(resultBrush);
817
- const splitResultGeometry = splitGeometryAtHorizontalPlanes(resultGeometry, getWallBandSplitPlanes(wallNode));
882
+ const splitResultGeometry = splitGeometryAtHorizontalPlanes(resultGeometry, getWallBandSplitPlanes(wallNode, effectiveWallHeight));
818
883
  splitResultGeometry.computeVertexNormals();
819
- assignWallMaterialGroups(splitResultGeometry, wallNode, boundaryEdges);
884
+ assignWallMaterialGroups(splitResultGeometry, wallNode, boundaryEdges, effectiveWallHeight);
820
885
  ensureRenderableGeometryAttributes(splitResultGeometry);
821
- return splitResultGeometry;
886
+ return mergeWallTerrainFill(splitResultGeometry, terrainFill, wallNode, boundaryEdges, effectiveWallHeight);
822
887
  }
823
888
  /**
824
889
  * Collects opening and item cutout brushes for CSG subtraction. Door/window
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=wall-terrain-bottom.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wall-terrain-bottom.test.d.ts","sourceRoot":"","sources":["../../../src/systems/wall/wall-terrain-bottom.test.ts"],"names":[],"mappings":""}