@pascal-app/viewer 0.8.0 → 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 +6 -2
- package/dist/components/renderers/node-renderer.d.ts.map +1 -1
- package/dist/components/renderers/node-renderer.js +41 -22
- package/dist/components/renderers/parametric-node-renderer.d.ts +5 -0
- package/dist/components/renderers/parametric-node-renderer.d.ts.map +1 -0
- package/dist/components/renderers/parametric-node-renderer.js +37 -0
- package/dist/components/renderers/scene-renderer.d.ts +1 -1
- package/dist/components/renderers/scene-renderer.d.ts.map +1 -1
- 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 +56 -3
- package/dist/components/viewer/index.d.ts.map +1 -1
- package/dist/components/viewer/index.js +280 -65
- package/dist/components/viewer/lights.d.ts +1 -1
- package/dist/components/viewer/lights.d.ts.map +1 -1
- package/dist/components/viewer/lights.js +201 -43
- package/dist/components/viewer/perf-monitor.d.ts +1 -1
- package/dist/components/viewer/perf-monitor.d.ts.map +1 -1
- package/dist/components/viewer/perf-monitor.js +115 -12
- 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 +395 -63
- package/dist/components/viewer/registered-systems.d.ts +12 -0
- package/dist/components/viewer/registered-systems.d.ts.map +1 -0
- package/dist/components/viewer/registered-systems.js +49 -0
- package/dist/components/viewer/scene-bvh.d.ts +18 -0
- package/dist/components/viewer/scene-bvh.d.ts.map +1 -0
- package/dist/components/viewer/scene-bvh.js +97 -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 +1 -1
- package/dist/components/viewer/selection-manager.d.ts.map +1 -1
- package/dist/components/viewer/selection-manager.js +37 -14
- package/dist/components/viewer/viewer-camera.d.ts +1 -1
- package/dist/components/viewer/viewer-camera.d.ts.map +1 -1
- package/dist/components/viewer/walkthrough-controls.d.ts +2 -1
- 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 +6 -74
- package/dist/hooks/use-node-events.d.ts.map +1 -1
- package/dist/hooks/use-node-events.js +28 -8
- package/dist/index.d.ts +48 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +82 -4
- 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 +10 -0
- package/dist/lib/csg-utils.d.ts.map +1 -0
- package/dist/lib/csg-utils.js +93 -0
- 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/drawable-geometry.d.ts +21 -0
- package/dist/lib/drawable-geometry.d.ts.map +1 -0
- package/dist/lib/drawable-geometry.js +22 -0
- package/dist/lib/edge-style.d.ts +4 -0
- package/dist/lib/edge-style.d.ts.map +1 -0
- package/dist/lib/edge-style.js +31 -0
- package/dist/lib/gpu-perf.d.ts +4 -0
- package/dist/lib/gpu-perf.d.ts.map +1 -0
- package/dist/lib/gpu-perf.js +23 -0
- package/dist/lib/ink-edges.d.ts +9 -0
- package/dist/lib/ink-edges.d.ts.map +1 -0
- package/dist/lib/ink-edges.js +49 -0
- package/dist/lib/isolation.d.ts +39 -0
- package/dist/lib/isolation.d.ts.map +1 -0
- package/dist/lib/isolation.js +103 -0
- 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 +28 -0
- package/dist/lib/layers.d.ts.map +1 -1
- package/dist/lib/layers.js +28 -0
- package/dist/lib/materials.d.ts +38 -15
- package/dist/lib/materials.d.ts.map +1 -1
- package/dist/lib/materials.js +364 -72
- package/dist/lib/merged-outline-node.d.ts.map +1 -1
- package/dist/lib/merged-outline-node.js +7 -0
- package/dist/lib/polygon-union.d.ts +4 -0
- package/dist/lib/polygon-union.d.ts.map +1 -0
- package/dist/lib/polygon-union.js +289 -0
- package/dist/lib/polygon-union.test.d.ts +2 -0
- package/dist/lib/polygon-union.test.d.ts.map +1 -0
- package/dist/lib/polygon-union.test.js +105 -0
- package/dist/lib/scene-themes.d.ts +30 -0
- package/dist/lib/scene-themes.d.ts.map +1 -0
- package/dist/lib/scene-themes.js +202 -0
- 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 +92 -12
- package/dist/store/use-viewer.d.ts.map +1 -1
- package/dist/store/use-viewer.js +201 -6
- 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 +6 -2
- package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
- package/dist/systems/ceiling/ceiling-system.js +66 -13
- 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 +864 -180
- package/dist/systems/elevator/elevator-interaction-system.d.ts +2 -0
- package/dist/systems/elevator/elevator-interaction-system.d.ts.map +1 -0
- package/dist/systems/elevator/elevator-interaction-system.js +62 -0
- package/dist/systems/fence/fence-system.d.ts +20 -0
- package/dist/systems/fence/fence-system.d.ts.map +1 -1
- package/dist/systems/fence/fence-system.js +229 -57
- package/dist/systems/floor-elevation/floor-elevation-system.d.ts +20 -0
- package/dist/systems/floor-elevation/floor-elevation-system.d.ts.map +1 -0
- package/dist/systems/floor-elevation/floor-elevation-system.js +78 -0
- package/dist/systems/geometry/geometry-system.d.ts +41 -0
- package/dist/systems/geometry/geometry-system.d.ts.map +1 -0
- package/dist/systems/geometry/geometry-system.js +348 -0
- 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 +1 -1
- 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 +7 -0
- package/dist/systems/item/item-system.d.ts.map +1 -1
- package/dist/systems/item/item-system.js +28 -17
- package/dist/systems/item-light/item-light-system.d.ts +1 -1
- package/dist/systems/item-light/item-light-system.d.ts.map +1 -1
- package/dist/systems/item-light/item-light-system.js +18 -5
- 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 +3 -2
- package/dist/systems/roof/roof-materials.d.ts.map +1 -1
- package/dist/systems/roof/roof-materials.js +59 -14
- package/dist/systems/roof/roof-system.d.ts +46 -6
- package/dist/systems/roof/roof-system.d.ts.map +1 -1
- package/dist/systems/roof/roof-system.js +1365 -170
- 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 +204 -68
- package/dist/systems/slab/slab-system.test.d.ts +2 -0
- package/dist/systems/slab/slab-system.test.d.ts.map +1 -0
- package/dist/systems/slab/slab-system.test.js +63 -0
- package/dist/systems/stair/stair-materials.d.ts +4 -3
- package/dist/systems/stair/stair-materials.d.ts.map +1 -1
- package/dist/systems/stair/stair-materials.js +30 -15
- package/dist/systems/stair/stair-system.d.ts.map +1 -1
- package/dist/systems/stair/stair-system.js +55 -63
- package/dist/systems/surface-hole-geometry.d.ts +3 -0
- package/dist/systems/surface-hole-geometry.d.ts.map +1 -0
- package/dist/systems/surface-hole-geometry.js +4 -0
- 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 +62 -17
- package/dist/systems/wall/wall-materials.d.ts +12 -7
- package/dist/systems/wall/wall-materials.d.ts.map +1 -1
- package/dist/systems/wall/wall-materials.js +279 -46
- 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 +493 -193
- 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 +308 -18
- package/package.json +5 -6
- package/dist/components/renderers/building/building-renderer.d.ts +0 -5
- package/dist/components/renderers/building/building-renderer.d.ts.map +0 -1
- package/dist/components/renderers/building/building-renderer.js +0 -11
- package/dist/components/renderers/ceiling/ceiling-renderer.d.ts +0 -5
- package/dist/components/renderers/ceiling/ceiling-renderer.d.ts.map +0 -1
- package/dist/components/renderers/ceiling/ceiling-renderer.js +0 -58
- package/dist/components/renderers/column/column-renderer.d.ts +0 -5
- package/dist/components/renderers/column/column-renderer.d.ts.map +0 -1
- package/dist/components/renderers/column/column-renderer.js +0 -597
- package/dist/components/renderers/door/door-renderer.d.ts +0 -5
- package/dist/components/renderers/door/door-renderer.d.ts.map +0 -1
- package/dist/components/renderers/door/door-renderer.js +0 -16
- package/dist/components/renderers/fence/fence-renderer.d.ts +0 -5
- package/dist/components/renderers/fence/fence-renderer.d.ts.map +0 -1
- package/dist/components/renderers/fence/fence-renderer.js +0 -15
- package/dist/components/renderers/guide/guide-renderer.d.ts +0 -5
- package/dist/components/renderers/guide/guide-renderer.d.ts.map +0 -1
- package/dist/components/renderers/guide/guide-renderer.js +0 -38
- package/dist/components/renderers/item/item-renderer.d.ts +0 -5
- package/dist/components/renderers/item/item-renderer.d.ts.map +0 -1
- package/dist/components/renderers/item/item-renderer.js +0 -172
- package/dist/components/renderers/level/level-renderer.d.ts +0 -5
- package/dist/components/renderers/level/level-renderer.d.ts.map +0 -1
- package/dist/components/renderers/level/level-renderer.js +0 -11
- package/dist/components/renderers/roof/roof-materials.d.ts +0 -4
- package/dist/components/renderers/roof/roof-materials.d.ts.map +0 -1
- package/dist/components/renderers/roof/roof-materials.js +0 -16
- package/dist/components/renderers/roof/roof-renderer.d.ts +0 -5
- package/dist/components/renderers/roof/roof-renderer.d.ts.map +0 -1
- package/dist/components/renderers/roof/roof-renderer.js +0 -32
- package/dist/components/renderers/roof-segment/roof-segment-renderer.d.ts +0 -5
- package/dist/components/renderers/roof-segment/roof-segment-renderer.d.ts.map +0 -1
- package/dist/components/renderers/roof-segment/roof-segment-renderer.js +0 -40
- package/dist/components/renderers/scan/scan-renderer.d.ts +0 -5
- package/dist/components/renderers/scan/scan-renderer.d.ts.map +0 -1
- package/dist/components/renderers/scan/scan-renderer.js +0 -54
- package/dist/components/renderers/site/site-renderer.d.ts +0 -5
- package/dist/components/renderers/site/site-renderer.d.ts.map +0 -1
- package/dist/components/renderers/site/site-renderer.js +0 -106
- package/dist/components/renderers/slab/slab-renderer.d.ts +0 -5
- package/dist/components/renderers/slab/slab-renderer.d.ts.map +0 -1
- package/dist/components/renderers/slab/slab-renderer.js +0 -58
- package/dist/components/renderers/spawn/spawn-renderer.d.ts +0 -5
- package/dist/components/renderers/spawn/spawn-renderer.d.ts.map +0 -1
- package/dist/components/renderers/spawn/spawn-renderer.js +0 -32
- package/dist/components/renderers/stair/stair-renderer.d.ts +0 -5
- package/dist/components/renderers/stair/stair-renderer.d.ts.map +0 -1
- package/dist/components/renderers/stair/stair-renderer.js +0 -603
- package/dist/components/renderers/stair-segment/stair-segment-renderer.d.ts +0 -5
- package/dist/components/renderers/stair-segment/stair-segment-renderer.d.ts.map +0 -1
- package/dist/components/renderers/stair-segment/stair-segment-renderer.js +0 -51
- package/dist/components/renderers/wall/wall-renderer.d.ts +0 -5
- package/dist/components/renderers/wall/wall-renderer.d.ts.map +0 -1
- package/dist/components/renderers/wall/wall-renderer.js +0 -16
- package/dist/components/renderers/window/window-renderer.d.ts +0 -5
- package/dist/components/renderers/window/window-renderer.d.ts.map +0 -1
- package/dist/components/renderers/window/window-renderer.js +0 -21
- package/dist/components/renderers/zone/zone-renderer.d.ts +0 -5
- package/dist/components/renderers/zone/zone-renderer.d.ts.map +0 -1
- package/dist/components/renderers/zone/zone-renderer.js +0 -181
- 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 -79
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getEffectiveNode, getFloorStackedPosition, sceneRegistry, useScene, } from '@pascal-app/core';
|
|
2
2
|
import { useFrame } from '@react-three/fiber';
|
|
3
|
-
import { useEffect, useRef } from 'react';
|
|
4
3
|
import * as THREE from 'three';
|
|
5
4
|
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
|
|
6
5
|
const pendingStairUpdates = new Set();
|
|
@@ -17,26 +16,6 @@ export const StairSystem = () => {
|
|
|
17
16
|
const dirtyNodes = useScene((state) => state.dirtyNodes);
|
|
18
17
|
const clearDirty = useScene((state) => state.clearDirty);
|
|
19
18
|
const rootNodeIds = useScene((state) => state.rootNodeIds);
|
|
20
|
-
const syncingAutoOpeningsRef = useRef(false);
|
|
21
|
-
useEffect(() => {
|
|
22
|
-
const applyUpdates = (updates) => {
|
|
23
|
-
if (updates.length === 0)
|
|
24
|
-
return;
|
|
25
|
-
syncingAutoOpeningsRef.current = true;
|
|
26
|
-
useScene.getState().updateNodes(updates);
|
|
27
|
-
queueMicrotask(() => {
|
|
28
|
-
syncingAutoOpeningsRef.current = false;
|
|
29
|
-
});
|
|
30
|
-
};
|
|
31
|
-
applyUpdates(syncAutoStairOpenings(useScene.getState().nodes));
|
|
32
|
-
return useScene.subscribe((state, prevState) => {
|
|
33
|
-
if (syncingAutoOpeningsRef.current)
|
|
34
|
-
return;
|
|
35
|
-
if (state.nodes === prevState.nodes)
|
|
36
|
-
return;
|
|
37
|
-
applyUpdates(syncAutoStairOpenings(state.nodes));
|
|
38
|
-
});
|
|
39
|
-
}, []);
|
|
40
19
|
useFrame(() => {
|
|
41
20
|
if (rootNodeIds.length === 0) {
|
|
42
21
|
pendingStairUpdates.clear();
|
|
@@ -58,8 +37,13 @@ export const StairSystem = () => {
|
|
|
58
37
|
if (mesh) {
|
|
59
38
|
const isVisible = mesh.parent?.visible !== false;
|
|
60
39
|
if (isVisible && segmentsProcessed < MAX_SEGMENTS_PER_FRAME) {
|
|
61
|
-
// Geometry will be updated; chained position is applied in the parent sync pass below
|
|
62
|
-
|
|
40
|
+
// Geometry will be updated; chained position is applied in the parent sync pass below.
|
|
41
|
+
// Merge live overrides so width / length / height drags update the
|
|
42
|
+
// mesh in real time — the resize arrows publish to
|
|
43
|
+
// `useLiveNodeOverrides` and only commit to scene on pointer-up, so
|
|
44
|
+
// without this the geometry would rebuild from the pre-drag values.
|
|
45
|
+
const effectiveSegment = getEffectiveNode(node);
|
|
46
|
+
updateStairSegmentGeometry(effectiveSegment, mesh);
|
|
63
47
|
if (node.parentId)
|
|
64
48
|
parentsNeedingSegmentSync.add(node.parentId);
|
|
65
49
|
segmentsProcessed++;
|
|
@@ -68,11 +52,9 @@ export const StairSystem = () => {
|
|
|
68
52
|
return; // Over budget — keep dirty, process next frame
|
|
69
53
|
}
|
|
70
54
|
else if (mesh.geometry.type === 'BoxGeometry') {
|
|
71
|
-
// Replace BoxGeometry placeholder with
|
|
55
|
+
// Replace BoxGeometry placeholder with a non-drawing degenerate one.
|
|
72
56
|
mesh.geometry.dispose();
|
|
73
|
-
|
|
74
|
-
placeholder.setAttribute('position', new THREE.Float32BufferAttribute([], 3));
|
|
75
|
-
mesh.geometry = placeholder;
|
|
57
|
+
mesh.geometry = createEmptyGeometry();
|
|
76
58
|
}
|
|
77
59
|
clearDirty(id);
|
|
78
60
|
}
|
|
@@ -93,9 +75,17 @@ export const StairSystem = () => {
|
|
|
93
75
|
});
|
|
94
76
|
// --- Pass 1b: Sync chained transforms to individual segment meshes (edit mode) ---
|
|
95
77
|
for (const stairId of parentsNeedingSegmentSync) {
|
|
96
|
-
const
|
|
97
|
-
if (
|
|
78
|
+
const baseStairNode = nodes[stairId];
|
|
79
|
+
if (baseStairNode?.type !== 'stair')
|
|
98
80
|
continue;
|
|
81
|
+
// Merge any in-flight drag override (e.g. parent-stair rotate handle)
|
|
82
|
+
// so slab-elevation spatial queries match where the segments are
|
|
83
|
+
// actually being rendered. Without this, dragging the rotate gizmo
|
|
84
|
+
// looks up slabs at the pre-drag world XZ — if rotation carries a
|
|
85
|
+
// segment off the original slab footprint, the floor-stack
|
|
86
|
+
// resolver would otherwise read the pre-drag footprint and drop
|
|
87
|
+
// the flight or landing below the floor mid-drag.
|
|
88
|
+
const stairNode = getEffectiveNode(baseStairNode);
|
|
99
89
|
const group = sceneRegistry.nodes.get(stairId);
|
|
100
90
|
if (group) {
|
|
101
91
|
syncStairGroupElevation(stairNode, group, nodes);
|
|
@@ -108,7 +98,7 @@ export const StairSystem = () => {
|
|
|
108
98
|
if (stairsProcessed >= MAX_STAIRS_PER_FRAME)
|
|
109
99
|
break;
|
|
110
100
|
const node = nodes[id];
|
|
111
|
-
if (
|
|
101
|
+
if (node?.type !== 'stair') {
|
|
112
102
|
pendingStairUpdates.delete(id);
|
|
113
103
|
continue;
|
|
114
104
|
}
|
|
@@ -217,9 +207,14 @@ function updateStairSegmentGeometry(node, mesh) {
|
|
|
217
207
|
* not by the node's stored position field.
|
|
218
208
|
*/
|
|
219
209
|
function syncSegmentMeshTransforms(stairNode, nodes) {
|
|
210
|
+
// Merge live overrides into each segment so the chain math reflects the
|
|
211
|
+
// in-flight drag (a width / length change shifts every downstream segment's
|
|
212
|
+
// anchor). Without this, dragging a width handle would resize the dragged
|
|
213
|
+
// segment's mesh but leave subsequent segments at their pre-drag positions.
|
|
220
214
|
const segments = (stairNode.children ?? [])
|
|
221
215
|
.map((childId) => nodes[childId])
|
|
222
|
-
.filter((n) => n?.type === 'stair-segment')
|
|
216
|
+
.filter((n) => n?.type === 'stair-segment')
|
|
217
|
+
.map((n) => getEffectiveNode(n));
|
|
223
218
|
if (segments.length === 0)
|
|
224
219
|
return;
|
|
225
220
|
const transforms = computeSegmentTransforms(segments);
|
|
@@ -234,35 +229,20 @@ function syncSegmentMeshTransforms(stairNode, nodes) {
|
|
|
234
229
|
}
|
|
235
230
|
}
|
|
236
231
|
function syncStairGroupElevation(stairNode, group, nodes) {
|
|
237
|
-
const
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const segments = (stairNode.children ?? [])
|
|
243
|
-
.map((childId) => nodes[childId])
|
|
244
|
-
.filter((n) => n?.type === 'stair-segment');
|
|
245
|
-
if (segments.length === 0)
|
|
246
|
-
return 0;
|
|
247
|
-
const transforms = computeSegmentTransforms(segments);
|
|
248
|
-
let maxElevation = Number.NEGATIVE_INFINITY;
|
|
249
|
-
for (let i = 0; i < segments.length; i++) {
|
|
250
|
-
const segment = segments[i];
|
|
251
|
-
const transform = transforms[i];
|
|
252
|
-
const [centerOffsetX, centerOffsetZ] = rotateXZ(0, segment.length / 2, transform.rotation);
|
|
253
|
-
const centerInGroupX = transform.position[0] + centerOffsetX;
|
|
254
|
-
const centerInGroupZ = transform.position[2] + centerOffsetZ;
|
|
255
|
-
const [centerOffsetWorldX, centerOffsetWorldZ] = rotateXZ(centerInGroupX, centerInGroupZ, stairNode.rotation);
|
|
256
|
-
const slabElevation = spatialGridManager.getSlabElevationForItem(levelId, [
|
|
257
|
-
stairNode.position[0] + centerOffsetWorldX,
|
|
258
|
-
stairNode.position[1] + transform.position[1],
|
|
259
|
-
stairNode.position[2] + centerOffsetWorldZ,
|
|
260
|
-
], [segment.width, Math.max(segment.height, segment.thickness, 0.01), segment.length], [0, stairNode.rotation + transform.rotation, 0]);
|
|
261
|
-
if (slabElevation > maxElevation) {
|
|
262
|
-
maxElevation = slabElevation;
|
|
232
|
+
const effectiveNodes = { ...nodes, [stairNode.id]: stairNode };
|
|
233
|
+
for (const childId of stairNode.children ?? []) {
|
|
234
|
+
const segment = nodes[childId];
|
|
235
|
+
if (segment?.type === 'stair-segment') {
|
|
236
|
+
effectiveNodes[segment.id] = getEffectiveNode(segment);
|
|
263
237
|
}
|
|
264
238
|
}
|
|
265
|
-
|
|
239
|
+
const visualPosition = getFloorStackedPosition({
|
|
240
|
+
node: stairNode,
|
|
241
|
+
nodes: effectiveNodes,
|
|
242
|
+
position: stairNode.position,
|
|
243
|
+
rotation: stairNode.rotation,
|
|
244
|
+
});
|
|
245
|
+
group.position.y = visualPosition[1];
|
|
266
246
|
}
|
|
267
247
|
// ============================================================================
|
|
268
248
|
// MERGED STAIR GEOMETRY
|
|
@@ -281,9 +261,14 @@ function updateMergedStairGeometry(stairNode, group, nodes) {
|
|
|
281
261
|
return;
|
|
282
262
|
}
|
|
283
263
|
const children = stairNode.children ?? [];
|
|
264
|
+
// Merge live overrides — same reason as `syncSegmentMeshTransforms`: a
|
|
265
|
+
// width / length / height drag publishes the new value to
|
|
266
|
+
// `useLiveNodeOverrides`, so the merged geometry has to read through that
|
|
267
|
+
// overlay or the merged mesh stays at pre-drag values until pointer-up.
|
|
284
268
|
const segments = children
|
|
285
269
|
.map((childId) => nodes[childId])
|
|
286
|
-
.filter((n) => n?.type === 'stair-segment')
|
|
270
|
+
.filter((n) => n?.type === 'stair-segment')
|
|
271
|
+
.map((n) => getEffectiveNode(n));
|
|
287
272
|
if (segments.length === 0) {
|
|
288
273
|
replaceMeshGeometry(mergedMesh, createEmptyGeometry());
|
|
289
274
|
return;
|
|
@@ -444,7 +429,14 @@ function rotateXZ(x, z, angle) {
|
|
|
444
429
|
}
|
|
445
430
|
function createEmptyGeometry() {
|
|
446
431
|
const geometry = new THREE.BufferGeometry();
|
|
447
|
-
|
|
432
|
+
// Three zero-vertices (one degenerate, invisible triangle), not an empty
|
|
433
|
+
// attribute: an empty position (count 0) leaves WebGPU vertex buffer slot 0
|
|
434
|
+
// unbound and the draw is rejected ("Vertex buffer slot 0 … was not set"),
|
|
435
|
+
// poisoning the command encoder. The count-0 groups keep nothing drawn.
|
|
436
|
+
geometry.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3));
|
|
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));
|
|
448
440
|
geometry.addGroup(0, 0, STAIR_TREAD_MATERIAL_INDEX);
|
|
449
441
|
geometry.addGroup(0, 0, STAIR_SIDE_MATERIAL_INDEX);
|
|
450
442
|
return geometry;
|
|
@@ -479,7 +471,7 @@ function generateStairRailingGeometry(stairNode, segments, transforms) {
|
|
|
479
471
|
for (let index = 1; index < segmentRailPaths.length; index++) {
|
|
480
472
|
const previousPath = segmentRailPaths[index - 1];
|
|
481
473
|
const currentPath = segmentRailPaths[index];
|
|
482
|
-
if (!(previousPath && currentPath
|
|
474
|
+
if (!(previousPath && currentPath?.connectFromPrevious))
|
|
483
475
|
continue;
|
|
484
476
|
if (previousPath.segment.segmentType === 'landing')
|
|
485
477
|
continue;
|
|
@@ -756,7 +748,7 @@ function computeAbsoluteHeight(node) {
|
|
|
756
748
|
if (!node.parentId)
|
|
757
749
|
return 0;
|
|
758
750
|
const parent = nodes[node.parentId];
|
|
759
|
-
if (
|
|
751
|
+
if (parent?.type !== 'stair')
|
|
760
752
|
return 0;
|
|
761
753
|
const stair = parent;
|
|
762
754
|
const segments = (stair.children ?? [])
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"surface-hole-geometry.d.ts","sourceRoot":"","sources":["../../src/systems/surface-hole-geometry.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,OAAO,EAAiB,MAAM,sBAAsB,CAAA;AAElE,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,OAAO,EAAE,EAAE,GAAG,OAAO,EAAE,EAAE,CAExE"}
|
|
@@ -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"}
|