@pascal-app/viewer 0.9.1 → 0.9.2
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.
- package/dist/components/error-boundary.d.ts +6 -0
- package/dist/components/error-boundary.d.ts.map +1 -1
- package/dist/components/error-boundary.js +6 -0
- package/dist/components/renderers/node-renderer.d.ts +5 -1
- package/dist/components/renderers/node-renderer.d.ts.map +1 -1
- package/dist/components/renderers/node-renderer.js +5 -2
- package/dist/components/renderers/parametric-node-renderer.d.ts.map +1 -1
- package/dist/components/renderers/parametric-node-renderer.js +7 -4
- package/dist/components/viewer/bvh-ecctrl.d.ts +77 -0
- package/dist/components/viewer/bvh-ecctrl.d.ts.map +1 -0
- package/dist/components/viewer/bvh-ecctrl.js +552 -0
- package/dist/components/viewer/frame-limiter.d.ts.map +1 -1
- package/dist/components/viewer/frame-limiter.js +39 -4
- package/dist/components/viewer/glb-interactive.d.ts +41 -0
- package/dist/components/viewer/glb-interactive.d.ts.map +1 -0
- package/dist/components/viewer/glb-interactive.js +394 -0
- package/dist/components/viewer/glb-reference-nodes.d.ts +29 -0
- package/dist/components/viewer/glb-reference-nodes.d.ts.map +1 -0
- package/dist/components/viewer/glb-reference-nodes.js +80 -0
- package/dist/components/viewer/glb-replace-instances.d.ts +18 -0
- package/dist/components/viewer/glb-replace-instances.d.ts.map +1 -0
- package/dist/components/viewer/glb-replace-instances.js +61 -0
- package/dist/components/viewer/glb-scene.d.ts +54 -0
- package/dist/components/viewer/glb-scene.d.ts.map +1 -0
- package/dist/components/viewer/glb-scene.js +920 -0
- package/dist/components/viewer/glb-walkthrough-controller.d.ts +12 -0
- package/dist/components/viewer/glb-walkthrough-controller.d.ts.map +1 -0
- package/dist/components/viewer/glb-walkthrough-controller.js +357 -0
- package/dist/components/viewer/index.d.ts +19 -2
- package/dist/components/viewer/index.d.ts.map +1 -1
- package/dist/components/viewer/index.js +104 -12
- package/dist/components/viewer/lights.d.ts.map +1 -1
- package/dist/components/viewer/lights.js +36 -5
- package/dist/components/viewer/perf-monitor.d.ts.map +1 -1
- package/dist/components/viewer/perf-monitor.js +17 -2
- package/dist/components/viewer/post-processing.d.ts +7 -1
- package/dist/components/viewer/post-processing.d.ts.map +1 -1
- package/dist/components/viewer/post-processing.js +179 -24
- package/dist/components/viewer/registered-systems.d.ts.map +1 -1
- package/dist/components/viewer/registered-systems.js +6 -4
- package/dist/components/viewer/scene-bvh.d.ts +2 -1
- package/dist/components/viewer/scene-bvh.d.ts.map +1 -1
- package/dist/components/viewer/scene-bvh.js +3 -0
- package/dist/components/viewer/scene-bvh.test.d.ts +2 -0
- package/dist/components/viewer/scene-bvh.test.d.ts.map +1 -0
- package/dist/components/viewer/scene-bvh.test.js +13 -0
- package/dist/components/viewer/scene-environment.d.ts +2 -0
- package/dist/components/viewer/scene-environment.d.ts.map +1 -0
- package/dist/components/viewer/scene-environment.js +79 -0
- package/dist/components/viewer/selection-manager.d.ts.map +1 -1
- package/dist/components/viewer/selection-manager.js +18 -10
- package/dist/components/viewer/walkthrough-controls.d.ts +1 -0
- package/dist/components/viewer/walkthrough-controls.d.ts.map +1 -1
- package/dist/components/viewer/walkthrough-controls.js +20 -0
- package/dist/hooks/use-gltf-ktx2.d.ts.map +1 -1
- package/dist/hooks/use-gltf-ktx2.js +3 -22
- package/dist/hooks/use-node-events.d.ts +1 -1
- package/dist/hooks/use-node-events.d.ts.map +1 -1
- package/dist/hooks/use-node-events.js +22 -14
- package/dist/index.d.ts +19 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +22 -8
- package/dist/lib/backdrop.d.ts +33 -0
- package/dist/lib/backdrop.d.ts.map +1 -0
- package/dist/lib/backdrop.js +92 -0
- package/dist/lib/box-uv.d.ts +13 -0
- package/dist/lib/box-uv.d.ts.map +1 -0
- package/dist/lib/box-uv.js +32 -0
- package/dist/lib/csg-utils.d.ts +3 -9
- package/dist/lib/csg-utils.d.ts.map +1 -1
- package/dist/lib/csg-utils.js +65 -5
- package/dist/lib/csg-utils.test.d.ts +2 -0
- package/dist/lib/csg-utils.test.d.ts.map +1 -0
- package/dist/lib/csg-utils.test.js +45 -0
- package/dist/lib/edge-style.d.ts +1 -0
- package/dist/lib/edge-style.d.ts.map +1 -1
- package/dist/lib/edge-style.js +26 -6
- package/dist/lib/ink-edges.d.ts +1 -1
- package/dist/lib/ink-edges.d.ts.map +1 -1
- package/dist/lib/ink-edges.js +13 -7
- package/dist/lib/ktx2-loader.d.ts +43 -0
- package/dist/lib/ktx2-loader.d.ts.map +1 -0
- package/dist/lib/ktx2-loader.js +136 -0
- package/dist/lib/layers.d.ts +10 -0
- package/dist/lib/layers.d.ts.map +1 -1
- package/dist/lib/layers.js +10 -0
- package/dist/lib/materials.d.ts +14 -1
- package/dist/lib/materials.d.ts.map +1 -1
- package/dist/lib/materials.js +103 -26
- package/dist/lib/scene-themes.d.ts +1 -0
- package/dist/lib/scene-themes.d.ts.map +1 -1
- package/dist/lib/scene-themes.js +25 -16
- package/dist/lib/shadow-only.d.ts +4 -0
- package/dist/lib/shadow-only.d.ts.map +1 -0
- package/dist/lib/shadow-only.js +36 -0
- package/dist/lib/tsl-compat.d.ts +12 -0
- package/dist/lib/tsl-compat.d.ts.map +1 -0
- package/dist/lib/tsl-compat.js +13 -0
- package/dist/store/use-viewer.d.ts +60 -18
- package/dist/store/use-viewer.d.ts.map +1 -1
- package/dist/store/use-viewer.js +175 -3
- package/dist/store/use-viewer.test.d.ts +2 -0
- package/dist/store/use-viewer.test.d.ts.map +1 -0
- package/dist/store/use-viewer.test.js +42 -0
- package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
- package/dist/systems/ceiling/ceiling-system.js +4 -1
- package/dist/systems/door/door-animation.test.d.ts +2 -0
- package/dist/systems/door/door-animation.test.d.ts.map +1 -0
- package/dist/systems/door/door-animation.test.js +51 -0
- package/dist/systems/door/door-floor-alignment.test.d.ts +2 -0
- package/dist/systems/door/door-floor-alignment.test.d.ts.map +1 -0
- package/dist/systems/door/door-floor-alignment.test.js +50 -0
- package/dist/systems/door/door-system.d.ts +24 -0
- package/dist/systems/door/door-system.d.ts.map +1 -1
- package/dist/systems/door/door-system.js +494 -161
- package/dist/systems/fence/fence-system.d.ts +17 -0
- package/dist/systems/fence/fence-system.d.ts.map +1 -1
- package/dist/systems/fence/fence-system.js +221 -53
- package/dist/systems/geometry/geometry-system.d.ts +7 -0
- package/dist/systems/geometry/geometry-system.d.ts.map +1 -1
- package/dist/systems/geometry/geometry-system.js +95 -26
- package/dist/systems/geometry/geometry-system.test.d.ts +2 -0
- package/dist/systems/geometry/geometry-system.test.d.ts.map +1 -0
- package/dist/systems/geometry/geometry-system.test.js +30 -0
- package/dist/systems/interactive/control-widget.d.ts +10 -0
- package/dist/systems/interactive/control-widget.d.ts.map +1 -0
- package/dist/systems/interactive/control-widget.js +35 -0
- package/dist/systems/interactive/interactive-system.d.ts.map +1 -1
- package/dist/systems/interactive/interactive-system.js +55 -56
- package/dist/systems/item/item-system.d.ts.map +1 -1
- package/dist/systems/item/item-system.js +20 -6
- package/dist/systems/level/level-system.d.ts.map +1 -1
- package/dist/systems/level/level-system.js +28 -4
- package/dist/systems/level/level-utils.d.ts +0 -3
- package/dist/systems/level/level-utils.d.ts.map +1 -1
- package/dist/systems/level/level-utils.js +2 -41
- package/dist/systems/roof/roof-materials.d.ts.map +1 -1
- package/dist/systems/roof/roof-materials.js +29 -8
- package/dist/systems/roof/roof-system.d.ts +21 -8
- package/dist/systems/roof/roof-system.d.ts.map +1 -1
- package/dist/systems/roof/roof-system.js +1079 -221
- package/dist/systems/slab/slab-system.d.ts +5 -3
- package/dist/systems/slab/slab-system.d.ts.map +1 -1
- package/dist/systems/slab/slab-system.js +41 -23
- package/dist/systems/slab/slab-system.test.js +3 -2
- package/dist/systems/stair/stair-system.js +6 -3
- package/dist/systems/wall/opening-cutout-geometry.d.ts +31 -0
- package/dist/systems/wall/opening-cutout-geometry.d.ts.map +1 -0
- package/dist/systems/wall/opening-cutout-geometry.js +167 -0
- package/dist/systems/wall/opening-cutout-geometry.test.d.ts +2 -0
- package/dist/systems/wall/opening-cutout-geometry.test.d.ts.map +1 -0
- package/dist/systems/wall/opening-cutout-geometry.test.js +172 -0
- package/dist/systems/wall/wall-cutout.d.ts.map +1 -1
- package/dist/systems/wall/wall-cutout.js +46 -17
- package/dist/systems/wall/wall-materials.d.ts +11 -7
- package/dist/systems/wall/wall-materials.d.ts.map +1 -1
- package/dist/systems/wall/wall-materials.js +244 -35
- package/dist/systems/wall/wall-opening-cutout.test.d.ts +2 -0
- package/dist/systems/wall/wall-opening-cutout.test.d.ts.map +1 -0
- package/dist/systems/wall/wall-opening-cutout.test.js +59 -0
- package/dist/systems/wall/wall-support-extension.test.d.ts +2 -0
- package/dist/systems/wall/wall-support-extension.test.d.ts.map +1 -0
- package/dist/systems/wall/wall-support-extension.test.js +48 -0
- package/dist/systems/wall/wall-system.d.ts +2 -2
- package/dist/systems/wall/wall-system.d.ts.map +1 -1
- package/dist/systems/wall/wall-system.js +329 -174
- package/dist/systems/window/window-animation-system.d.ts +12 -0
- package/dist/systems/window/window-animation-system.d.ts.map +1 -1
- package/dist/systems/window/window-animation-system.js +16 -5
- package/dist/systems/window/window-system.d.ts +7 -0
- package/dist/systems/window/window-system.d.ts.map +1 -1
- package/dist/systems/window/window-system.js +240 -14
- package/package.json +4 -4
- package/dist/components/viewer/ground-occluder.d.ts +0 -2
- package/dist/components/viewer/ground-occluder.d.ts.map +0 -1
- package/dist/components/viewer/ground-occluder.js +0 -67
|
@@ -1,8 +1,10 @@
|
|
|
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
3
|
export declare const SlabSystem: () => null;
|
|
4
4
|
/**
|
|
5
|
-
* Generates extruded slab geometry from polygon
|
|
5
|
+
* Generates extruded slab geometry from polygon. `context` carries the
|
|
6
|
+
* slab's level neighbourhood (walls + sibling slabs) driving the per-edge
|
|
7
|
+
* render offsets — see `getRenderableSlabPolygon`.
|
|
6
8
|
*/
|
|
7
|
-
export declare function generateSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry;
|
|
9
|
+
export declare function generateSlabGeometry(slabNode: SlabNode, context: SlabPolygonContext): THREE.BufferGeometry;
|
|
8
10
|
//# 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,
|
|
1
|
+
{"version":3,"file":"slab-system.d.ts","sourceRoot":"","sources":["../../../src/systems/slab/slab-system.tsx"],"names":[],"mappings":"AAAA,OAAO,EAQL,KAAK,QAAQ,EACb,KAAK,kBAAkB,EAIxB,MAAM,kBAAkB,CAAA;AAGzB,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAe9B,eAAO,MAAM,UAAU,YA2CtB,CAAA;AAuCD;;;;GAIG;AACH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,kBAAkB,GAC1B,KAAK,CAAC,cAAc,CAKtB"}
|
|
@@ -29,14 +29,18 @@ export const SlabSystem = () => {
|
|
|
29
29
|
if (dirtyNodes.size === 0)
|
|
30
30
|
return;
|
|
31
31
|
const nodes = useScene.getState().nodes;
|
|
32
|
+
const contextByLevel = new Map();
|
|
32
33
|
// Process dirty slabs
|
|
33
34
|
dirtyNodes.forEach((id) => {
|
|
34
35
|
const node = nodes[id];
|
|
35
|
-
if (
|
|
36
|
+
if (node?.type !== 'slab')
|
|
36
37
|
return;
|
|
37
38
|
const mesh = sceneRegistry.nodes.get(id);
|
|
38
39
|
if (mesh) {
|
|
39
|
-
|
|
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);
|
|
40
44
|
clearDirty(id);
|
|
41
45
|
}
|
|
42
46
|
// If mesh not found, keep it dirty for next frame
|
|
@@ -44,11 +48,30 @@ export const SlabSystem = () => {
|
|
|
44
48
|
}, 1);
|
|
45
49
|
return null;
|
|
46
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
|
+
}
|
|
47
70
|
/**
|
|
48
71
|
* Updates the geometry for a single slab
|
|
49
72
|
*/
|
|
50
|
-
function updateSlabGeometry(node, mesh) {
|
|
51
|
-
const newGeo = generateSlabGeometry(node);
|
|
73
|
+
function updateSlabGeometry(node, context, mesh) {
|
|
74
|
+
const newGeo = generateSlabGeometry(node, context);
|
|
52
75
|
ensureUv2Attribute(newGeo);
|
|
53
76
|
mesh.geometry.dispose();
|
|
54
77
|
mesh.geometry = newGeo;
|
|
@@ -58,11 +81,15 @@ function updateSlabGeometry(node, mesh) {
|
|
|
58
81
|
mesh.position.y = elevation < 0 ? elevation : 0;
|
|
59
82
|
}
|
|
60
83
|
/**
|
|
61
|
-
* Generates extruded slab geometry from polygon
|
|
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`.
|
|
62
87
|
*/
|
|
63
|
-
export function generateSlabGeometry(slabNode) {
|
|
88
|
+
export function generateSlabGeometry(slabNode, context) {
|
|
64
89
|
const elevation = slabNode.elevation ?? 0.05;
|
|
65
|
-
return elevation < 0
|
|
90
|
+
return elevation < 0
|
|
91
|
+
? generatePoolGeometry(slabNode, context)
|
|
92
|
+
: generatePositiveSlabGeometry(slabNode, context);
|
|
66
93
|
}
|
|
67
94
|
// Earcut normalizes cap triangulation regardless of input winding, but the side
|
|
68
95
|
// walls below assume a CCW contour (the unflipped quad's right-hand normal faces
|
|
@@ -115,8 +142,8 @@ function buildSlabRegions(contour, holes) {
|
|
|
115
142
|
* thickness visible from any angle: the two coincident triangles never z-fight
|
|
116
143
|
* because exactly one faces the camera under FrontSide culling.
|
|
117
144
|
*/
|
|
118
|
-
function generatePositiveSlabGeometry(slabNode) {
|
|
119
|
-
const polygon = ensureCounterClockwisePolygon(getRenderableSlabPolygon(slabNode));
|
|
145
|
+
function generatePositiveSlabGeometry(slabNode, context) {
|
|
146
|
+
const polygon = ensureCounterClockwisePolygon(getRenderableSlabPolygon(slabNode, context));
|
|
120
147
|
const elevation = slabNode.elevation ?? 0.05;
|
|
121
148
|
const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? []);
|
|
122
149
|
if (polygon.length < 3)
|
|
@@ -203,8 +230,8 @@ function generatePositiveSlabGeometry(slabNode) {
|
|
|
203
230
|
* - floor in XZ plane at Y=0, normals pointing +Y (visible when looking down into pool)
|
|
204
231
|
* - walls from Y=0 to Y=depth, inward-facing normals (visible from inside pool)
|
|
205
232
|
*/
|
|
206
|
-
function generatePoolGeometry(slabNode) {
|
|
207
|
-
const polygon = ensureCounterClockwisePolygon(getRenderableSlabPolygon(slabNode));
|
|
233
|
+
function generatePoolGeometry(slabNode, context) {
|
|
234
|
+
const polygon = ensureCounterClockwisePolygon(getRenderableSlabPolygon(slabNode, context));
|
|
208
235
|
const depth = Math.abs(slabNode.elevation ?? 0.05);
|
|
209
236
|
const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? []);
|
|
210
237
|
if (polygon.length < 3)
|
|
@@ -212,20 +239,11 @@ function generatePoolGeometry(slabNode) {
|
|
|
212
239
|
const positions = [];
|
|
213
240
|
const uvs = [];
|
|
214
241
|
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
242
|
const pushFloorVertex = (x, y, z) => {
|
|
227
243
|
positions.push(x, y, z);
|
|
228
|
-
|
|
244
|
+
// Floor UVs in metres (shape-space x, -z), matching generatePositiveSlabGeometry's
|
|
245
|
+
// cap mapping so a finish tiles at the same world scale on every surface.
|
|
246
|
+
uvs.push(x, -z);
|
|
229
247
|
};
|
|
230
248
|
const pushWallVertex = (x, y, z, u, v) => {
|
|
231
249
|
positions.push(x, y, z);
|
|
@@ -3,6 +3,7 @@
|
|
|
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: [] };
|
|
6
7
|
function hasVertexAt(geometry, x, z) {
|
|
7
8
|
const positions = geometry.getAttribute('position');
|
|
8
9
|
for (let index = 0; index < positions.count; index += 1) {
|
|
@@ -31,7 +32,7 @@ describe('generateSlabGeometry', () => {
|
|
|
31
32
|
],
|
|
32
33
|
],
|
|
33
34
|
});
|
|
34
|
-
const geometry = generateSlabGeometry(slab);
|
|
35
|
+
const geometry = generateSlabGeometry(slab, EMPTY_CONTEXT);
|
|
35
36
|
expect((geometry.index?.count ?? 0) / 3).toBeGreaterThan(0);
|
|
36
37
|
expect(hasVertexAt(geometry, 1, 1)).toBe(true);
|
|
37
38
|
expect(hasVertexAt(geometry, 3, 1)).toBe(true);
|
|
@@ -54,7 +55,7 @@ describe('generateSlabGeometry', () => {
|
|
|
54
55
|
],
|
|
55
56
|
],
|
|
56
57
|
});
|
|
57
|
-
const geometry = generateSlabGeometry(slab);
|
|
58
|
+
const geometry = generateSlabGeometry(slab, EMPTY_CONTEXT);
|
|
58
59
|
expect((geometry.index?.count ?? 0) / 3).toBeGreaterThan(0);
|
|
59
60
|
expect(hasVertexAt(geometry, 1, 1)).toBe(true);
|
|
60
61
|
expect(hasVertexAt(geometry, 3, 1)).toBe(true);
|
|
@@ -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 (
|
|
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 (
|
|
101
|
+
if (node?.type !== 'stair') {
|
|
102
102
|
pendingStairUpdates.delete(id);
|
|
103
103
|
continue;
|
|
104
104
|
}
|
|
@@ -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 (
|
|
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 @@
|
|
|
1
|
+
{"version":3,"file":"opening-cutout-geometry.test.d.ts","sourceRoot":"","sources":["../../../src/systems/wall/opening-cutout-geometry.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,172 @@
|
|
|
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 { DoorNode, WindowNode } from '@pascal-app/core';
|
|
5
|
+
import { buildOpeningCutoutGeometry, buildOpeningCutoutShape, getOpeningCutoutBottomPadding, hasFlatOpeningCutoutBottom, } from './opening-cutout-geometry';
|
|
6
|
+
function containsPoint(points, x, y) {
|
|
7
|
+
return points.some((point) => Math.abs(point.x - x) < 1e-6 && Math.abs(point.y - y) < 1e-6);
|
|
8
|
+
}
|
|
9
|
+
function getBounds(points) {
|
|
10
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
11
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
12
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
13
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
14
|
+
for (const point of points) {
|
|
15
|
+
minX = Math.min(minX, point.x);
|
|
16
|
+
maxX = Math.max(maxX, point.x);
|
|
17
|
+
minY = Math.min(minY, point.y);
|
|
18
|
+
maxY = Math.max(maxY, point.y);
|
|
19
|
+
}
|
|
20
|
+
return { minX, maxX, minY, maxY };
|
|
21
|
+
}
|
|
22
|
+
describe('buildOpeningCutoutShape', () => {
|
|
23
|
+
test('rectangle profile passes the rect through unchanged', () => {
|
|
24
|
+
const door = DoorNode.parse({});
|
|
25
|
+
const rect = { left: 1.2, right: 2.1, bottom: 0, top: 2.1 };
|
|
26
|
+
const points = buildOpeningCutoutShape(door, rect).getPoints();
|
|
27
|
+
expect(containsPoint(points, rect.left, rect.bottom)).toBe(true);
|
|
28
|
+
expect(containsPoint(points, rect.right, rect.bottom)).toBe(true);
|
|
29
|
+
expect(containsPoint(points, rect.right, rect.top)).toBe(true);
|
|
30
|
+
expect(containsPoint(points, rect.left, rect.top)).toBe(true);
|
|
31
|
+
const bounds = getBounds(points);
|
|
32
|
+
expect(bounds.minX).toBeCloseTo(rect.left, 9);
|
|
33
|
+
expect(bounds.maxX).toBeCloseTo(rect.right, 9);
|
|
34
|
+
expect(bounds.minY).toBeCloseTo(rect.bottom, 9);
|
|
35
|
+
expect(bounds.maxY).toBeCloseTo(rect.top, 9);
|
|
36
|
+
});
|
|
37
|
+
test('door rounded profile rounds only the top corners', () => {
|
|
38
|
+
const door = DoorNode.parse({ openingShape: 'rounded', cornerRadius: 0.2 });
|
|
39
|
+
const rect = { left: -0.45, right: 0.45, bottom: 0, top: 2.1 };
|
|
40
|
+
const points = buildOpeningCutoutShape(door, rect).getPoints();
|
|
41
|
+
expect(containsPoint(points, rect.left, rect.bottom)).toBe(true);
|
|
42
|
+
expect(containsPoint(points, rect.right, rect.bottom)).toBe(true);
|
|
43
|
+
expect(containsPoint(points, rect.left, rect.top)).toBe(false);
|
|
44
|
+
expect(containsPoint(points, rect.right, rect.top)).toBe(false);
|
|
45
|
+
expect(containsPoint(points, rect.left, rect.top - 0.2)).toBe(true);
|
|
46
|
+
expect(containsPoint(points, rect.left + 0.2, rect.top)).toBe(true);
|
|
47
|
+
expect(containsPoint(points, rect.right, rect.top - 0.2)).toBe(true);
|
|
48
|
+
expect(containsPoint(points, rect.right - 0.2, rect.top)).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
test('window rounded profile rounds all four corners', () => {
|
|
51
|
+
const window = WindowNode.parse({ openingShape: 'rounded', cornerRadius: 0.2 });
|
|
52
|
+
const rect = { left: -0.75, right: 0.75, bottom: 0.9, top: 2.4 };
|
|
53
|
+
const points = buildOpeningCutoutShape(window, rect).getPoints();
|
|
54
|
+
expect(containsPoint(points, rect.left, rect.bottom)).toBe(false);
|
|
55
|
+
expect(containsPoint(points, rect.right, rect.bottom)).toBe(false);
|
|
56
|
+
expect(containsPoint(points, rect.left, rect.top)).toBe(false);
|
|
57
|
+
expect(containsPoint(points, rect.right, rect.top)).toBe(false);
|
|
58
|
+
expect(containsPoint(points, rect.left + 0.2, rect.bottom)).toBe(true);
|
|
59
|
+
expect(containsPoint(points, rect.left, rect.bottom + 0.2)).toBe(true);
|
|
60
|
+
expect(containsPoint(points, rect.right - 0.2, rect.top)).toBe(true);
|
|
61
|
+
expect(containsPoint(points, rect.right, rect.top - 0.2)).toBe(true);
|
|
62
|
+
const bounds = getBounds(points);
|
|
63
|
+
expect(bounds.minX).toBeCloseTo(rect.left, 9);
|
|
64
|
+
expect(bounds.maxX).toBeCloseTo(rect.right, 9);
|
|
65
|
+
expect(bounds.minY).toBeCloseTo(rect.bottom, 9);
|
|
66
|
+
expect(bounds.maxY).toBeCloseTo(rect.top, 9);
|
|
67
|
+
});
|
|
68
|
+
test('shared corner radius is clamped to the opening half-extent', () => {
|
|
69
|
+
const window = WindowNode.parse({ openingShape: 'rounded', cornerRadius: 10 });
|
|
70
|
+
const rect = { left: -0.75, right: 0.75, bottom: 0.9, top: 2.4 };
|
|
71
|
+
const points = buildOpeningCutoutShape(window, rect).getPoints();
|
|
72
|
+
// 1.5 × 1.5 opening → radius clamps to 0.75; arcs meet at edge midpoints.
|
|
73
|
+
expect(containsPoint(points, rect.left, rect.bottom + 0.75)).toBe(true);
|
|
74
|
+
expect(containsPoint(points, rect.right, rect.top - 0.75)).toBe(true);
|
|
75
|
+
const bounds = getBounds(points);
|
|
76
|
+
expect(bounds.minX).toBeCloseTo(rect.left, 9);
|
|
77
|
+
expect(bounds.maxX).toBeCloseTo(rect.right, 9);
|
|
78
|
+
});
|
|
79
|
+
test('individual radii normalize when their sum exceeds the opening width', () => {
|
|
80
|
+
const window = WindowNode.parse({
|
|
81
|
+
openingShape: 'rounded',
|
|
82
|
+
openingRadiusMode: 'individual',
|
|
83
|
+
openingCornerRadii: [4, 4, 0, 0],
|
|
84
|
+
});
|
|
85
|
+
const rect = { left: -0.5, right: 0.5, bottom: 0, top: 2 };
|
|
86
|
+
const points = buildOpeningCutoutShape(window, rect).getPoints();
|
|
87
|
+
// Width 1 with top radii summing to 8 → scaled down to 0.5 each.
|
|
88
|
+
expect(containsPoint(points, rect.left, rect.top - 0.5)).toBe(true);
|
|
89
|
+
expect(containsPoint(points, rect.left + 0.5, rect.top)).toBe(true);
|
|
90
|
+
expect(containsPoint(points, rect.left, rect.bottom)).toBe(true);
|
|
91
|
+
expect(containsPoint(points, rect.right, rect.bottom)).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
test('arch profile springs at top - archHeight and peaks at the rect top', () => {
|
|
94
|
+
const door = DoorNode.parse({ openingShape: 'arch', archHeight: 0.45 });
|
|
95
|
+
const rect = { left: -0.45, right: 0.45, bottom: 0, top: 2.1 };
|
|
96
|
+
const points = buildOpeningCutoutShape(door, rect).getPoints();
|
|
97
|
+
expect(containsPoint(points, rect.left, rect.bottom)).toBe(true);
|
|
98
|
+
expect(containsPoint(points, rect.right, rect.bottom)).toBe(true);
|
|
99
|
+
expect(containsPoint(points, rect.right, rect.top - 0.45)).toBe(true);
|
|
100
|
+
expect(containsPoint(points, 0, rect.top)).toBe(true);
|
|
101
|
+
expect(containsPoint(points, rect.left, rect.top)).toBe(false);
|
|
102
|
+
expect(containsPoint(points, rect.right, rect.top)).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
test('profiles are origin-agnostic — offset rects yield translated points', () => {
|
|
105
|
+
const window = WindowNode.parse({ openingShape: 'rounded', cornerRadius: 0.2 });
|
|
106
|
+
const centered = buildOpeningCutoutShape(window, {
|
|
107
|
+
left: -0.75,
|
|
108
|
+
right: 0.75,
|
|
109
|
+
bottom: -0.75,
|
|
110
|
+
top: 0.75,
|
|
111
|
+
}).getPoints();
|
|
112
|
+
const offset = buildOpeningCutoutShape(window, {
|
|
113
|
+
left: 2.25,
|
|
114
|
+
right: 3.75,
|
|
115
|
+
bottom: 0.9,
|
|
116
|
+
top: 2.4,
|
|
117
|
+
}).getPoints();
|
|
118
|
+
for (const point of centered) {
|
|
119
|
+
expect(containsPoint(offset, point.x + 3, point.y + 1.65)).toBe(true);
|
|
120
|
+
}
|
|
121
|
+
for (const point of offset) {
|
|
122
|
+
expect(containsPoint(centered, point.x - 3, point.y - 1.65)).toBe(true);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
describe('buildOpeningCutoutGeometry', () => {
|
|
127
|
+
test('extrudes the rect through the depth, centered on the mid-plane', () => {
|
|
128
|
+
const door = DoorNode.parse({});
|
|
129
|
+
const geometry = buildOpeningCutoutGeometry(door, { left: -0.45, right: 0.45, bottom: -1.05, top: 1.05 }, 0.24, 0.1);
|
|
130
|
+
geometry.computeBoundingBox();
|
|
131
|
+
// Float32 position buffer → ~1e-7 relative precision.
|
|
132
|
+
const box = geometry.boundingBox;
|
|
133
|
+
expect(box.min.x).toBeCloseTo(-0.45, 6);
|
|
134
|
+
expect(box.max.x).toBeCloseTo(0.45, 6);
|
|
135
|
+
expect(box.min.y).toBeCloseTo(-1.05, 6);
|
|
136
|
+
expect(box.max.y).toBeCloseTo(1.05, 6);
|
|
137
|
+
expect(box.min.z).toBeCloseTo(-0.12, 6);
|
|
138
|
+
expect(box.max.z).toBeCloseTo(0.12, 6);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
describe('hasFlatOpeningCutoutBottom', () => {
|
|
142
|
+
test('flat for rectangles, arches, and door rounded (top-only radii)', () => {
|
|
143
|
+
expect(hasFlatOpeningCutoutBottom(DoorNode.parse({}))).toBe(true);
|
|
144
|
+
expect(hasFlatOpeningCutoutBottom(WindowNode.parse({ openingShape: 'arch' }))).toBe(true);
|
|
145
|
+
expect(hasFlatOpeningCutoutBottom(DoorNode.parse({ openingShape: 'rounded' }))).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
test('rounded windows depend on their bottom radii', () => {
|
|
148
|
+
expect(hasFlatOpeningCutoutBottom(WindowNode.parse({ openingShape: 'rounded' }))).toBe(false);
|
|
149
|
+
expect(hasFlatOpeningCutoutBottom(WindowNode.parse({ openingShape: 'rounded', cornerRadius: 0 }))).toBe(true);
|
|
150
|
+
expect(hasFlatOpeningCutoutBottom(WindowNode.parse({
|
|
151
|
+
openingShape: 'rounded',
|
|
152
|
+
openingRadiusMode: 'individual',
|
|
153
|
+
openingCornerRadii: [0.2, 0.2, 0, 0],
|
|
154
|
+
}))).toBe(true);
|
|
155
|
+
expect(hasFlatOpeningCutoutBottom(WindowNode.parse({
|
|
156
|
+
openingShape: 'rounded',
|
|
157
|
+
openingRadiusMode: 'individual',
|
|
158
|
+
openingCornerRadii: [0.2, 0.2, 0.2, 0.2],
|
|
159
|
+
}))).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
describe('getOpeningCutoutBottomPadding', () => {
|
|
163
|
+
test('pads floor-level openings with a flat bottom', () => {
|
|
164
|
+
expect(getOpeningCutoutBottomPadding(DoorNode.parse({}), 0)).toBe(0.02);
|
|
165
|
+
expect(getOpeningCutoutBottomPadding(DoorNode.parse({ openingShape: 'rounded' }), 0)).toBe(0.02);
|
|
166
|
+
expect(getOpeningCutoutBottomPadding(WindowNode.parse({ openingShape: 'arch' }), 0)).toBe(0.02);
|
|
167
|
+
});
|
|
168
|
+
test('does not pad openings above the floor or rounded window bottoms', () => {
|
|
169
|
+
expect(getOpeningCutoutBottomPadding(DoorNode.parse({}), 0.9)).toBe(0);
|
|
170
|
+
expect(getOpeningCutoutBottomPadding(WindowNode.parse({ openingShape: 'rounded' }), 0)).toBe(0);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"wall-cutout.d.ts","sourceRoot":"","sources":["../../../src/systems/wall/wall-cutout.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"wall-cutout.d.ts","sourceRoot":"","sources":["../../../src/systems/wall/wall-cutout.tsx"],"names":[],"mappings":"AAqDA,eAAO,MAAM,UAAU,YAyKtB,CAAA"}
|