@pascal-app/editor 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/package.json +12 -9
- package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
- package/src/components/editor/bake-exporter.tsx +47 -0
- package/src/components/editor/custom-camera-controls.tsx +63 -25
- package/src/components/editor/editor-layout-mobile.tsx +5 -0
- package/src/components/editor/editor-layout-v2.tsx +18 -0
- package/src/components/editor/export-manager.tsx +71 -63
- package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
- package/src/components/editor/first-person/build-collider-world.ts +32 -5
- package/src/components/editor/first-person-controls.tsx +118 -7
- package/src/components/editor/floating-action-menu.tsx +410 -33
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-background-selection.test.ts +58 -0
- package/src/components/editor/floorplan-background-selection.ts +3 -2
- package/src/components/editor/floorplan-panel.tsx +1804 -849
- package/src/components/editor/grid.tsx +166 -39
- package/src/components/editor/group-actions.ts +457 -0
- package/src/components/editor/group-floating-action-menu.tsx +130 -0
- package/src/components/editor/group-move-3d.ts +397 -0
- package/src/components/editor/group-rotate-handle.tsx +102 -64
- package/src/components/editor/group-selection-box-3d.tsx +173 -0
- package/src/components/editor/group-transform-shared.test.ts +192 -1
- package/src/components/editor/group-transform-shared.ts +240 -7
- package/src/components/editor/handles/handle-arrow.tsx +81 -23
- package/src/components/editor/handles/use-handle-drag.ts +37 -4
- package/src/components/editor/index.tsx +248 -27
- package/src/components/editor/measurement-pill.tsx +62 -22
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +366 -173
- package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
- package/src/components/editor/quick-measurement-card.tsx +70 -0
- package/src/components/editor/quick-measurement-hud.tsx +28 -0
- package/src/components/editor/riser-diagram-panel.tsx +137 -0
- package/src/components/editor/selection-manager.tsx +812 -466
- package/src/components/editor/site-edge-labels.tsx +4 -4
- package/src/components/editor/slab-hole-highlights.tsx +13 -5
- package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
- package/src/components/editor/three-context-bridge.ts +22 -0
- package/src/components/editor/thumbnail-generator.tsx +136 -43
- package/src/components/editor/use-floorplan-background-placement.ts +113 -45
- package/src/components/editor/use-floorplan-scene-data.ts +5 -5
- package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
- package/src/components/editor/wall-measurement-label.tsx +3 -13
- package/src/components/editor/wall-move-side-handles.tsx +36 -21
- package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
- package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
- package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
- package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
- package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
- package/src/components/systems/selection-affordance-manager.tsx +38 -0
- package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
- package/src/components/systems/zone/zone-system.tsx +42 -13
- package/src/components/tools/elevator/elevator-tool.tsx +34 -8
- package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
- package/src/components/tools/fence/fence-drafting.ts +80 -8
- package/src/components/tools/item/move-tool.tsx +17 -13
- package/src/components/tools/item/placement-math.test.ts +13 -1
- package/src/components/tools/item/placement-math.ts +28 -2
- package/src/components/tools/item/placement-strategies.ts +246 -5
- package/src/components/tools/item/placement-types.ts +13 -1
- package/src/components/tools/item/use-draft-node.ts +31 -1
- package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
- package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
- package/src/components/tools/roof/roof-tool.tsx +320 -94
- package/src/components/tools/select/box-select-state.ts +18 -7
- package/src/components/tools/select/box-select-tool.tsx +176 -40
- package/src/components/tools/select/marquee-geometry.test.ts +161 -0
- package/src/components/tools/select/marquee-geometry.ts +155 -0
- package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
- package/src/components/tools/select/select-candidates.ts +1 -1
- package/src/components/tools/shared/cursor-sphere.tsx +62 -26
- package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
- package/src/components/tools/shared/facing-indicator.tsx +87 -0
- package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
- package/src/components/tools/shared/placement-box.tsx +183 -1
- package/src/components/tools/shared/polygon-editor.tsx +276 -35
- package/src/components/tools/site/site-boundary-editor.tsx +88 -36
- package/src/components/tools/stair/stair-defaults.ts +1 -0
- package/src/components/tools/stair/stair-tool.tsx +82 -11
- package/src/components/tools/tool-manager.tsx +86 -25
- package/src/components/tools/wall/wall-drafting.test.ts +330 -0
- package/src/components/tools/wall/wall-drafting.ts +180 -103
- package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
- package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
- package/src/components/tools/zone/zone-tool.tsx +82 -88
- package/src/components/ui/action-menu/camera-actions.tsx +24 -17
- package/src/components/ui/action-menu/control-modes.tsx +40 -93
- package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
- package/src/components/ui/action-menu/index.tsx +2 -4
- package/src/components/ui/action-menu/measurement-control.tsx +188 -0
- package/src/components/ui/action-menu/structure-tools.tsx +22 -13
- package/src/components/ui/action-menu/view-toggles.tsx +42 -90
- package/src/components/ui/command-palette/editor-commands.tsx +2 -2
- package/src/components/ui/command-palette/index.tsx +4 -3
- package/src/components/ui/controls/material-paint-panel.tsx +86 -17
- package/src/components/ui/controls/material-picker.tsx +83 -152
- package/src/components/ui/controls/material-properties-editor.tsx +108 -0
- package/src/components/ui/controls/metric-control.tsx +118 -44
- package/src/components/ui/controls/scene-material-list.tsx +247 -0
- package/src/components/ui/controls/slider-control.tsx +88 -38
- package/src/components/ui/floating-level-selector.tsx +1 -1
- package/src/components/ui/helpers/building-helper.tsx +10 -23
- package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
- package/src/components/ui/helpers/helper-manager.tsx +227 -14
- package/src/components/ui/helpers/item-helper.tsx +28 -32
- package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
- package/src/components/ui/helpers/roof-helper.tsx +10 -12
- package/src/components/ui/icon-ref.tsx +47 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
- package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
- package/src/components/ui/level-duplicate-dialog.tsx +1 -1
- package/src/components/ui/panels/node-display.ts +17 -17
- package/src/components/ui/panels/panel-manager.tsx +22 -13
- package/src/components/ui/panels/panel-wrapper.tsx +24 -3
- package/src/components/ui/panels/parametric-inspector.tsx +15 -2
- package/src/components/ui/panels/reference-panel.tsx +54 -19
- package/src/components/ui/primitives/shortcut-token.tsx +39 -3
- package/src/components/ui/primitives/sidebar.tsx +1 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
- package/src/components/ui/sidebar/icon-rail.tsx +50 -31
- package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
- package/src/components/ui/sidebar/tab-bar.tsx +42 -28
- package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
- package/src/components/ui/snap-target-badge.test.tsx +40 -0
- package/src/components/ui/snap-target-badge.tsx +88 -0
- package/src/components/viewer-overlay.tsx +85 -10
- package/src/components/viewer-zone-system.tsx +6 -1
- package/src/hooks/use-auto-save.test.ts +14 -0
- package/src/hooks/use-auto-save.ts +54 -7
- package/src/hooks/use-ceiling-events.ts +3 -2
- package/src/hooks/use-drag-action.ts +4 -1
- package/src/hooks/use-keyboard.ts +385 -40
- package/src/index.tsx +219 -6
- package/src/lib/active-placement-surface.ts +35 -0
- package/src/lib/ceiling-plan-snap.ts +24 -0
- package/src/lib/contextual-help.test.ts +112 -0
- package/src/lib/contextual-help.ts +144 -0
- package/src/lib/continuation.ts +64 -0
- package/src/lib/direct-manipulation.test.ts +192 -0
- package/src/lib/direct-manipulation.ts +109 -0
- package/src/lib/editor-api.ts +23 -35
- package/src/lib/floorplan/apply-alignment.test.ts +25 -0
- package/src/lib/floorplan/apply-alignment.ts +18 -12
- package/src/lib/floorplan/floorplan-export.tsx +364 -0
- package/src/lib/floorplan/geometry.ts +53 -0
- package/src/lib/floorplan/index.ts +3 -0
- package/src/lib/floorplan/items.ts +5 -2
- package/src/lib/floorplan/plan-coords.ts +21 -0
- package/src/lib/fresh-planar-placement.test.ts +240 -3
- package/src/lib/fresh-planar-placement.ts +68 -1
- package/src/lib/glb-export.test.ts +441 -0
- package/src/lib/glb-export.ts +874 -0
- package/src/lib/history.ts +9 -0
- package/src/lib/interaction/hot-set.test.ts +133 -0
- package/src/lib/interaction/hot-set.ts +67 -0
- package/src/lib/interaction/overlay-policy.test.ts +51 -0
- package/src/lib/interaction/overlay-policy.ts +59 -0
- package/src/lib/interaction/scope.ts +211 -0
- package/src/lib/level-selection.ts +2 -2
- package/src/lib/material-paint.ts +36 -49
- package/src/lib/measurement-kind.test.ts +30 -0
- package/src/lib/measurement-kind.ts +19 -0
- package/src/lib/measurement-label.test.ts +47 -0
- package/src/lib/measurement-label.ts +68 -0
- package/src/lib/measurement-parser.ts +116 -0
- package/src/lib/measurements.test.ts +183 -0
- package/src/lib/measurements.ts +199 -0
- package/src/lib/paint-scope.test.ts +454 -0
- package/src/lib/paint-scope.ts +461 -0
- package/src/lib/placement-drag-release.ts +23 -0
- package/src/lib/planar-cursor-placement.test.ts +57 -0
- package/src/lib/plugin-panels.test.ts +19 -0
- package/src/lib/plugin-panels.ts +91 -0
- package/src/lib/quick-measurement.test.ts +77 -0
- package/src/lib/quick-measurement.ts +109 -0
- package/src/lib/roof-duplication.ts +6 -6
- package/src/lib/roof-hover-outline-proxy.ts +22 -0
- package/src/lib/roof-wall-hit.ts +164 -0
- package/src/lib/scene-clipboard.test.ts +196 -0
- package/src/lib/scene-clipboard.ts +73 -8
- package/src/lib/scene.ts +28 -5
- package/src/lib/selection-routing.test.ts +298 -0
- package/src/lib/selection-routing.ts +175 -0
- package/src/lib/sfx/index.ts +1 -0
- package/src/lib/sfx/movement-tick.test.ts +65 -0
- package/src/lib/sfx/movement-tick.ts +18 -0
- package/src/lib/sfx-bus.ts +50 -13
- package/src/lib/sfx-player.test.ts +124 -0
- package/src/lib/sfx-player.ts +111 -66
- package/src/lib/slab-plan-snap.test.ts +93 -0
- package/src/lib/slab-plan-snap.ts +108 -0
- package/src/lib/snapping-mode.test.ts +138 -0
- package/src/lib/snapping-mode.ts +179 -0
- package/src/lib/stair-duplication.ts +4 -4
- package/src/lib/surface-plan-snap.test.ts +71 -0
- package/src/lib/surface-plan-snap.ts +256 -0
- package/src/lib/use-linear-display.ts +40 -0
- package/src/lib/world-grid-snap.ts +37 -5
- package/src/store/use-direct-manipulation-feedback.ts +20 -0
- package/src/store/use-editor.tsx +484 -114
- package/src/store/use-facing-pose.ts +44 -0
- package/src/store/use-fence-curve-draft.ts +21 -0
- package/src/store/use-floorplan-draft-preview.ts +101 -0
- package/src/store/use-floorplan-marquee.ts +50 -0
- package/src/store/use-interaction-scope.test.ts +186 -0
- package/src/store/use-interaction-scope.ts +132 -0
- package/src/store/use-measurement-draft.test.ts +530 -0
- package/src/store/use-measurement-draft.ts +543 -0
- package/src/store/use-opening-guides.ts +41 -0
- package/src/store/use-placement-preview.ts +11 -3
- package/src/store/use-quick-measurement-hud.test.ts +53 -0
- package/src/store/use-quick-measurement-hud.ts +77 -0
- package/src/store/use-segment-draft-chain.ts +28 -0
- package/src/store/use-stair-build-preview.ts +43 -0
- package/src/store/use-wall-snap-indicator.ts +2 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
- package/src/components/editor/group-move-handle.tsx +0 -316
- package/src/components/ui/panels/paint-panel.tsx +0 -163
- package/src/lib/level-name.ts +0 -11
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import type { MeasurementPoint } from '@pascal-app/core'
|
|
2
|
+
|
|
3
|
+
export type LinearUnit = 'metric' | 'imperial'
|
|
4
|
+
|
|
5
|
+
export const MEASUREMENT_ACTIVE_COLOR = '#6366f1'
|
|
6
|
+
export const MEASUREMENT_DANGLING_COLOR = '#dc2626'
|
|
7
|
+
export const MEASUREMENT_FLOORPLAN_COLOR = '#4f46e5'
|
|
8
|
+
export const MEASUREMENT_PERSISTENT_COLOR = '#111827'
|
|
9
|
+
|
|
10
|
+
export function measurementPresentationColor(dangling: boolean, active: boolean): string {
|
|
11
|
+
if (dangling) return MEASUREMENT_DANGLING_COLOR
|
|
12
|
+
return active ? MEASUREMENT_ACTIVE_COLOR : MEASUREMENT_PERSISTENT_COLOR
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function measurementFloorplanPresentationColor(dangling: boolean, active: boolean): string {
|
|
16
|
+
if (dangling) return MEASUREMENT_DANGLING_COLOR
|
|
17
|
+
return active ? MEASUREMENT_ACTIVE_COLOR : MEASUREMENT_FLOORPLAN_COLOR
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type MeasurementAngleArcOptions = {
|
|
21
|
+
radius?: number
|
|
22
|
+
sampleCount?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const ANGLE_ARC_EPSILON = 1e-9
|
|
26
|
+
|
|
27
|
+
const subtractPoint = (point: MeasurementPoint, origin: MeasurementPoint): MeasurementPoint => [
|
|
28
|
+
point[0] - origin[0],
|
|
29
|
+
point[1] - origin[1],
|
|
30
|
+
point[2] - origin[2],
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
const pointLength = (point: MeasurementPoint): number => Math.hypot(...point)
|
|
34
|
+
|
|
35
|
+
const scalePoint = (point: MeasurementPoint, scale: number): MeasurementPoint => [
|
|
36
|
+
point[0] * scale,
|
|
37
|
+
point[1] * scale,
|
|
38
|
+
point[2] * scale,
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
const crossPoint = (first: MeasurementPoint, second: MeasurementPoint): MeasurementPoint => [
|
|
42
|
+
first[1] * second[2] - first[2] * second[1],
|
|
43
|
+
first[2] * second[0] - first[0] * second[2],
|
|
44
|
+
first[0] * second[1] - first[1] * second[0],
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
const dotPoint = (first: MeasurementPoint, second: MeasurementPoint): number =>
|
|
48
|
+
first[0] * second[0] + first[1] * second[1] + first[2] * second[2]
|
|
49
|
+
|
|
50
|
+
export function buildMeasurementAngleArcPoints(
|
|
51
|
+
start: MeasurementPoint,
|
|
52
|
+
vertex: MeasurementPoint,
|
|
53
|
+
end: MeasurementPoint,
|
|
54
|
+
options: MeasurementAngleArcOptions = {},
|
|
55
|
+
): MeasurementPoint[] {
|
|
56
|
+
if (![...start, ...vertex, ...end].every(Number.isFinite)) return []
|
|
57
|
+
|
|
58
|
+
const startVector = subtractPoint(start, vertex)
|
|
59
|
+
const endVector = subtractPoint(end, vertex)
|
|
60
|
+
const startLength = pointLength(startVector)
|
|
61
|
+
const endLength = pointLength(endVector)
|
|
62
|
+
if (startLength <= ANGLE_ARC_EPSILON || endLength <= ANGLE_ARC_EPSILON) return []
|
|
63
|
+
|
|
64
|
+
const startDirection = scalePoint(startVector, 1 / startLength)
|
|
65
|
+
const endDirection = scalePoint(endVector, 1 / endLength)
|
|
66
|
+
const cosine = Math.max(-1, Math.min(1, dotPoint(startDirection, endDirection)))
|
|
67
|
+
const angle = Math.acos(cosine)
|
|
68
|
+
if (angle <= 1e-4) return []
|
|
69
|
+
|
|
70
|
+
let normal = crossPoint(startDirection, endDirection)
|
|
71
|
+
let normalLength = pointLength(normal)
|
|
72
|
+
if (normalLength <= ANGLE_ARC_EPSILON) {
|
|
73
|
+
const reference: MeasurementPoint = Math.abs(startDirection[1]) < 0.9 ? [0, 1, 0] : [1, 0, 0]
|
|
74
|
+
normal = crossPoint(startDirection, reference)
|
|
75
|
+
normalLength = pointLength(normal)
|
|
76
|
+
}
|
|
77
|
+
if (normalLength <= ANGLE_ARC_EPSILON) return []
|
|
78
|
+
normal = scalePoint(normal, 1 / normalLength)
|
|
79
|
+
const tangent = crossPoint(normal, startDirection)
|
|
80
|
+
|
|
81
|
+
const shortestSide = Math.min(startLength, endLength)
|
|
82
|
+
const preferredRadius = options.radius ?? Math.min(Math.max(shortestSide * 0.28, 0.08), 0.75)
|
|
83
|
+
const radius = Math.min(Math.max(preferredRadius, 0), shortestSide * 0.45)
|
|
84
|
+
if (radius <= ANGLE_ARC_EPSILON) return []
|
|
85
|
+
|
|
86
|
+
const sampleCount = Math.min(
|
|
87
|
+
64,
|
|
88
|
+
Math.max(4, Math.round(options.sampleCount ?? Math.max(8, (angle / Math.PI) * 32))),
|
|
89
|
+
)
|
|
90
|
+
return Array.from({ length: sampleCount + 1 }, (_, index) => {
|
|
91
|
+
const sampleAngle = angle * (index / sampleCount)
|
|
92
|
+
const direction: MeasurementPoint = [
|
|
93
|
+
startDirection[0] * Math.cos(sampleAngle) + tangent[0] * Math.sin(sampleAngle),
|
|
94
|
+
startDirection[1] * Math.cos(sampleAngle) + tangent[1] * Math.sin(sampleAngle),
|
|
95
|
+
startDirection[2] * Math.cos(sampleAngle) + tangent[2] * Math.sin(sampleAngle),
|
|
96
|
+
]
|
|
97
|
+
return [
|
|
98
|
+
vertex[0] + direction[0] * radius,
|
|
99
|
+
vertex[1] + direction[1] * radius,
|
|
100
|
+
vertex[2] + direction[2] * radius,
|
|
101
|
+
]
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const METERS_PER_FOOT = 0.3048
|
|
106
|
+
const FEET_PER_METER = 1 / METERS_PER_FOOT
|
|
107
|
+
|
|
108
|
+
type LinearControlValueOptions = {
|
|
109
|
+
minMeters?: number
|
|
110
|
+
maxMeters?: number
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function metersToLinearUnit(meters: number, unit: LinearUnit): number {
|
|
114
|
+
return unit === 'imperial' ? meters * FEET_PER_METER : meters
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function linearUnitToMeters(value: number, unit: LinearUnit): number {
|
|
118
|
+
return unit === 'imperial' ? value * METERS_PER_FOOT : value
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function linearControlValueToMeters(
|
|
122
|
+
value: number,
|
|
123
|
+
unit: LinearUnit,
|
|
124
|
+
options: LinearControlValueOptions = {},
|
|
125
|
+
): number {
|
|
126
|
+
const meters = linearUnitToMeters(value, unit)
|
|
127
|
+
const minMeters = options.minMeters ?? Number.NEGATIVE_INFINITY
|
|
128
|
+
const maxMeters = options.maxMeters ?? Number.POSITIVE_INFINITY
|
|
129
|
+
|
|
130
|
+
return Math.min(Math.max(meters, minMeters), maxMeters)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function getLinearUnitLabel(unit: LinearUnit): string {
|
|
134
|
+
return unit === 'imperial' ? 'ft' : 'm'
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const SQUARE_FEET_PER_SQUARE_METER = FEET_PER_METER * FEET_PER_METER
|
|
138
|
+
const CUBIC_FEET_PER_CUBIC_METER = SQUARE_FEET_PER_SQUARE_METER * FEET_PER_METER
|
|
139
|
+
|
|
140
|
+
export function squareMetersToAreaUnit(squareMeters: number, unit: LinearUnit): number {
|
|
141
|
+
return unit === 'imperial' ? squareMeters * SQUARE_FEET_PER_SQUARE_METER : squareMeters
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function getAreaUnitLabel(unit: LinearUnit): string {
|
|
145
|
+
return unit === 'imperial' ? 'ft²' : 'm²'
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function formatAreaLabel(
|
|
149
|
+
squareMeters: number,
|
|
150
|
+
unit: LinearUnit,
|
|
151
|
+
fractionDigits = 1,
|
|
152
|
+
): string {
|
|
153
|
+
if (!Number.isFinite(squareMeters)) return '--'
|
|
154
|
+
|
|
155
|
+
return `${squareMetersToAreaUnit(squareMeters, unit).toFixed(fractionDigits)}${getAreaUnitLabel(unit)}`
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function cubicMetersToVolumeUnit(cubicMeters: number, unit: LinearUnit): number {
|
|
159
|
+
return unit === 'imperial' ? cubicMeters * CUBIC_FEET_PER_CUBIC_METER : cubicMeters
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function getVolumeUnitLabel(unit: LinearUnit): string {
|
|
163
|
+
return unit === 'imperial' ? 'ft³' : 'm³'
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function formatVolumeLabel(
|
|
167
|
+
cubicMeters: number,
|
|
168
|
+
unit: LinearUnit,
|
|
169
|
+
fractionDigits = 1,
|
|
170
|
+
): string {
|
|
171
|
+
if (!Number.isFinite(cubicMeters)) return '--'
|
|
172
|
+
|
|
173
|
+
return `${cubicMetersToVolumeUnit(cubicMeters, unit).toFixed(fractionDigits)}${getVolumeUnitLabel(unit)}`
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function formatLinearMeasurement(meters: number, unit: LinearUnit): string {
|
|
177
|
+
if (!Number.isFinite(meters)) return '--'
|
|
178
|
+
|
|
179
|
+
const absoluteMeters = Math.abs(meters)
|
|
180
|
+
|
|
181
|
+
if (unit === 'imperial') {
|
|
182
|
+
const feet = metersToLinearUnit(absoluteMeters, unit)
|
|
183
|
+
let wholeFeet = Math.floor(feet)
|
|
184
|
+
let inches = Math.round((feet - wholeFeet) * 12)
|
|
185
|
+
if (inches === 12) {
|
|
186
|
+
wholeFeet += 1
|
|
187
|
+
inches = 0
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const sign = meters < 0 && (wholeFeet !== 0 || inches !== 0) ? '-' : ''
|
|
191
|
+
|
|
192
|
+
return `${sign}${wholeFeet}'${inches}"`
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const roundedMeters = Number.parseFloat(absoluteMeters.toFixed(2))
|
|
196
|
+
const sign = meters < 0 && roundedMeters !== 0 ? '-' : ''
|
|
197
|
+
|
|
198
|
+
return `${sign}${roundedMeters}m`
|
|
199
|
+
}
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
type AnyNode,
|
|
4
|
+
detectSpacesForLevel,
|
|
5
|
+
type ItemNode,
|
|
6
|
+
type SlabNode,
|
|
7
|
+
type Space,
|
|
8
|
+
type WallNode,
|
|
9
|
+
} from '@pascal-app/core'
|
|
10
|
+
import {
|
|
11
|
+
availablePaintScopes,
|
|
12
|
+
cyclePaintScope,
|
|
13
|
+
type PaintHoverInfo,
|
|
14
|
+
type PaintScope,
|
|
15
|
+
paintScopeLabel,
|
|
16
|
+
resolvePaintScopeTargets,
|
|
17
|
+
type WallPaintHit,
|
|
18
|
+
} from './paint-scope'
|
|
19
|
+
|
|
20
|
+
describe('availablePaintScopes', () => {
|
|
21
|
+
it('every node offers single', () => {
|
|
22
|
+
expect(availablePaintScopes({ node: roof(), slotRoles: ['top'] })).toEqual(['single'])
|
|
23
|
+
})
|
|
24
|
+
it('more than one slot adds whole-object', () => {
|
|
25
|
+
expect(availablePaintScopes({ node: roof(), slotRoles: ['top', 'edge'] })).toEqual([
|
|
26
|
+
'single',
|
|
27
|
+
'object',
|
|
28
|
+
])
|
|
29
|
+
})
|
|
30
|
+
it('a single slot does not add whole-object', () => {
|
|
31
|
+
expect(availablePaintScopes({ node: roof(), slotRoles: ['top'] })).not.toContain('object')
|
|
32
|
+
})
|
|
33
|
+
it('an asset adds all-matching (items)', () => {
|
|
34
|
+
expect(availablePaintScopes({ node: item('a', 'sofa'), slotRoles: ['seat'] })).toContain(
|
|
35
|
+
'matching',
|
|
36
|
+
)
|
|
37
|
+
})
|
|
38
|
+
// `room` derives from the kind's registry `capabilities.paint.roomScope`, which
|
|
39
|
+
// isn't wired in this unit context; its resolver behaviour is covered below.
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('cyclePaintScope', () => {
|
|
43
|
+
it('wraps within the given set', () => {
|
|
44
|
+
const set: PaintScope[] = ['single', 'object', 'matching']
|
|
45
|
+
expect(cyclePaintScope('single', set)).toBe('object')
|
|
46
|
+
expect(cyclePaintScope('object', set)).toBe('matching')
|
|
47
|
+
expect(cyclePaintScope('matching', set)).toBe('single')
|
|
48
|
+
})
|
|
49
|
+
it('a scope foreign to the set restarts at the first entry', () => {
|
|
50
|
+
expect(cyclePaintScope('matching', ['single', 'room'])).toBe('single')
|
|
51
|
+
})
|
|
52
|
+
it('an empty set stays single', () => {
|
|
53
|
+
expect(cyclePaintScope('single', [])).toBe('single')
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('paintScopeLabel', () => {
|
|
58
|
+
const info = (over: Partial<PaintHoverInfo>): PaintHoverInfo => ({
|
|
59
|
+
scopes: ['single'],
|
|
60
|
+
slotLabel: 'Seat cushion',
|
|
61
|
+
nodeNoun: 'item',
|
|
62
|
+
...over,
|
|
63
|
+
})
|
|
64
|
+
it('single shows the hovered slot label', () => {
|
|
65
|
+
expect(paintScopeLabel('single', info({ slotLabel: 'Seat cushion' }))).toBe('Seat cushion')
|
|
66
|
+
})
|
|
67
|
+
it('single falls back when there is no slot label', () => {
|
|
68
|
+
expect(paintScopeLabel('single', info({ slotLabel: '' }))).toBe('This surface')
|
|
69
|
+
})
|
|
70
|
+
it('object reads "Whole <noun>"', () => {
|
|
71
|
+
expect(paintScopeLabel('object', info({ nodeNoun: 'shelf' }))).toBe('Whole shelf')
|
|
72
|
+
})
|
|
73
|
+
it('matching / room are kind-agnostic', () => {
|
|
74
|
+
expect(paintScopeLabel('matching', info({}))).toBe('All matching')
|
|
75
|
+
expect(paintScopeLabel('room', info({}))).toBe('Room')
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
// ── resolvePaintScopeTargets ────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
function item(id: string, assetId: string): ItemNode {
|
|
82
|
+
return { id, type: 'item', asset: { id: assetId } } as unknown as ItemNode
|
|
83
|
+
}
|
|
84
|
+
function slab(id: string, polygon: Array<[number, number]>): SlabNode {
|
|
85
|
+
return { id, type: 'slab', polygon } as unknown as SlabNode
|
|
86
|
+
}
|
|
87
|
+
function wall(
|
|
88
|
+
id: string,
|
|
89
|
+
start: [number, number],
|
|
90
|
+
end: [number, number],
|
|
91
|
+
levelId = 'l1',
|
|
92
|
+
): WallNode {
|
|
93
|
+
return {
|
|
94
|
+
id,
|
|
95
|
+
type: 'wall',
|
|
96
|
+
parentId: levelId,
|
|
97
|
+
start,
|
|
98
|
+
end,
|
|
99
|
+
thickness: 0.2,
|
|
100
|
+
frontSide: 'unknown',
|
|
101
|
+
backSide: 'unknown',
|
|
102
|
+
} as unknown as WallNode
|
|
103
|
+
}
|
|
104
|
+
function roof(): AnyNode {
|
|
105
|
+
return { id: 'r', type: 'roof' } as unknown as AnyNode
|
|
106
|
+
}
|
|
107
|
+
function asMap(nodes: AnyNode[]): Record<string, AnyNode> {
|
|
108
|
+
return Object.fromEntries(nodes.map((node) => [node.id, node]))
|
|
109
|
+
}
|
|
110
|
+
const noSlotRoles = () => [] as string[]
|
|
111
|
+
|
|
112
|
+
// `nodeId` is a branded id type; compare by plain `id:role` strings.
|
|
113
|
+
function keys(targets: Array<{ nodeId: string; role: string }>): string[] {
|
|
114
|
+
return targets.map((target) => `${target.nodeId}:${target.role}`)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function resolve(args: {
|
|
118
|
+
node: AnyNode
|
|
119
|
+
role?: string
|
|
120
|
+
scope: PaintScope
|
|
121
|
+
nodes: AnyNode[]
|
|
122
|
+
spaces?: Space[]
|
|
123
|
+
slotRolesOf?: (node: AnyNode) => string[]
|
|
124
|
+
wallHit?: WallPaintHit
|
|
125
|
+
}) {
|
|
126
|
+
return resolvePaintScopeTargets({
|
|
127
|
+
node: args.node,
|
|
128
|
+
role: args.role ?? 'surface',
|
|
129
|
+
scope: args.scope,
|
|
130
|
+
nodes: asMap(args.nodes),
|
|
131
|
+
spaces: Object.fromEntries((args.spaces ?? []).map((s) => [s.id, s])),
|
|
132
|
+
slotRolesOf: args.slotRolesOf ?? noSlotRoles,
|
|
133
|
+
wallHit: args.wallHit,
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function adjacentRooms(levelId = 'l1') {
|
|
138
|
+
const walls = [
|
|
139
|
+
wall('bottom-left', [0, 0], [4, 0], levelId),
|
|
140
|
+
wall('bottom-right', [4, 0], [8, 0], levelId),
|
|
141
|
+
wall('right', [8, 0], [8, 4], levelId),
|
|
142
|
+
wall('top-right', [8, 4], [4, 4], levelId),
|
|
143
|
+
wall('top-left', [4, 4], [0, 4], levelId),
|
|
144
|
+
wall('left', [0, 4], [0, 0], levelId),
|
|
145
|
+
wall('shared', [4, 0], [4, 4], levelId),
|
|
146
|
+
]
|
|
147
|
+
return { walls, spaces: detectSpacesForLevel(levelId, walls).spaces }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
describe('resolvePaintScopeTargets', () => {
|
|
151
|
+
it('single always returns just the clicked surface', () => {
|
|
152
|
+
const a = item('a', 'sofa')
|
|
153
|
+
expect(
|
|
154
|
+
keys(resolve({ node: a, role: 'seat', scope: 'single', nodes: [a, item('b', 'sofa')] })),
|
|
155
|
+
).toEqual(['a:seat'])
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('item matching fans the same slot across same-asset items only', () => {
|
|
159
|
+
const a = item('a', 'sofa')
|
|
160
|
+
const b = item('b', 'sofa')
|
|
161
|
+
const c = item('c', 'lamp')
|
|
162
|
+
const result = resolve({ node: a, role: 'seat', scope: 'matching', nodes: [a, b, c] })
|
|
163
|
+
expect(keys(result).sort()).toEqual(['a:seat', 'b:seat'])
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('item whole-item fans every enumerated slot of the clicked item', () => {
|
|
167
|
+
const a = item('a', 'sofa')
|
|
168
|
+
const result = resolve({
|
|
169
|
+
node: a,
|
|
170
|
+
role: 'seat',
|
|
171
|
+
scope: 'object',
|
|
172
|
+
nodes: [a],
|
|
173
|
+
slotRolesOf: () => ['seat', 'legs', 'cushion'],
|
|
174
|
+
})
|
|
175
|
+
expect(keys(result)).toEqual(['a:seat', 'a:legs', 'a:cushion'])
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('item whole-item falls back to the single slot when the subtree is unmounted', () => {
|
|
179
|
+
const a = item('a', 'sofa')
|
|
180
|
+
expect(keys(resolve({ node: a, role: 'seat', scope: 'object', nodes: [a] }))).toEqual([
|
|
181
|
+
'a:seat',
|
|
182
|
+
])
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('wall room selects the enclosed space on the clicked face of a shared wall', () => {
|
|
186
|
+
const { walls, spaces } = adjacentRooms()
|
|
187
|
+
const shared = walls.find((candidate) => String(candidate.id) === 'shared')!
|
|
188
|
+
|
|
189
|
+
const leftRoom = resolve({
|
|
190
|
+
node: shared,
|
|
191
|
+
role: 'interior',
|
|
192
|
+
scope: 'room',
|
|
193
|
+
nodes: walls,
|
|
194
|
+
spaces,
|
|
195
|
+
wallHit: { face: 'front', point: [3.9, 2] },
|
|
196
|
+
})
|
|
197
|
+
expect(keys(leftRoom).sort()).toEqual([
|
|
198
|
+
'bottom-left:interior',
|
|
199
|
+
'left:interior',
|
|
200
|
+
'shared:interior',
|
|
201
|
+
'top-left:interior',
|
|
202
|
+
])
|
|
203
|
+
|
|
204
|
+
const rightRoom = resolve({
|
|
205
|
+
node: shared,
|
|
206
|
+
role: 'exterior',
|
|
207
|
+
scope: 'room',
|
|
208
|
+
nodes: walls,
|
|
209
|
+
spaces,
|
|
210
|
+
wallHit: { face: 'back', point: [4.1, 2] },
|
|
211
|
+
})
|
|
212
|
+
expect(keys(rightRoom).sort()).toEqual([
|
|
213
|
+
'bottom-right:interior',
|
|
214
|
+
'right:interior',
|
|
215
|
+
'shared:exterior',
|
|
216
|
+
'top-right:interior',
|
|
217
|
+
])
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('wall room preserves the vertical band while mapping each boundary face side', () => {
|
|
221
|
+
const { walls, spaces } = adjacentRooms()
|
|
222
|
+
const shared = walls.find((candidate) => String(candidate.id) === 'shared')!
|
|
223
|
+
const result = resolve({
|
|
224
|
+
node: shared,
|
|
225
|
+
role: 'lowerExterior',
|
|
226
|
+
scope: 'room',
|
|
227
|
+
nodes: walls,
|
|
228
|
+
spaces,
|
|
229
|
+
wallHit: { face: 'back', point: [4.1, 2] },
|
|
230
|
+
})
|
|
231
|
+
expect(keys(result).sort()).toEqual([
|
|
232
|
+
'bottom-right:lowerInterior',
|
|
233
|
+
'right:lowerInterior',
|
|
234
|
+
'shared:lowerExterior',
|
|
235
|
+
'top-right:lowerInterior',
|
|
236
|
+
])
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('wall room maps a reversed boundary wall to its rendered side', () => {
|
|
240
|
+
const { walls } = adjacentRooms()
|
|
241
|
+
const topRight = walls.find((candidate) => String(candidate.id) === 'top-right')!
|
|
242
|
+
topRight.start = [4, 4]
|
|
243
|
+
topRight.end = [8, 4]
|
|
244
|
+
const spaces = detectSpacesForLevel('l1', walls).spaces
|
|
245
|
+
const shared = walls.find((candidate) => String(candidate.id) === 'shared')!
|
|
246
|
+
const result = resolve({
|
|
247
|
+
node: shared,
|
|
248
|
+
role: 'exterior',
|
|
249
|
+
scope: 'room',
|
|
250
|
+
nodes: walls,
|
|
251
|
+
spaces,
|
|
252
|
+
wallHit: { face: 'back', point: [4.1, 2] },
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
expect(keys(result)).toContain('top-right:exterior')
|
|
256
|
+
expect(keys(result)).not.toContain('top-right:interior')
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('wall room excludes duplicate geometry and spaces from another level', () => {
|
|
260
|
+
const levelA = adjacentRooms('l1')
|
|
261
|
+
const levelB = adjacentRooms('l2')
|
|
262
|
+
const levelBWalls = levelB.walls.map((candidate) => ({
|
|
263
|
+
...candidate,
|
|
264
|
+
id: `other-${candidate.id}`,
|
|
265
|
+
})) as unknown as WallNode[]
|
|
266
|
+
const otherSpaces = detectSpacesForLevel('l2', levelBWalls).spaces
|
|
267
|
+
const shared = levelA.walls.find((candidate) => String(candidate.id) === 'shared')!
|
|
268
|
+
const result = resolve({
|
|
269
|
+
node: shared,
|
|
270
|
+
role: 'interior',
|
|
271
|
+
scope: 'room',
|
|
272
|
+
nodes: [...levelA.walls, ...levelBWalls],
|
|
273
|
+
spaces: [...levelA.spaces, ...otherSpaces],
|
|
274
|
+
wallHit: { face: 'front', point: [3.9, 2] },
|
|
275
|
+
})
|
|
276
|
+
expect(keys(result).every((key) => !key.startsWith('other-'))).toBe(true)
|
|
277
|
+
expect(result).toHaveLength(4)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('wall room uses the hit subsegment when one long wall bounds adjacent bays', () => {
|
|
281
|
+
const long = wall('long', [0, 0], [8, 0])
|
|
282
|
+
const walls = [
|
|
283
|
+
long,
|
|
284
|
+
wall('left', [0, 0], [0, -3]),
|
|
285
|
+
wall('left-bottom', [0, -3], [4, -3]),
|
|
286
|
+
wall('divider', [4, -3], [4, 0]),
|
|
287
|
+
wall('right-bottom', [4, -3], [8, -3]),
|
|
288
|
+
wall('right', [8, -3], [8, 0]),
|
|
289
|
+
]
|
|
290
|
+
const spaces = detectSpacesForLevel('l1', walls).spaces
|
|
291
|
+
|
|
292
|
+
const leftBay = resolve({
|
|
293
|
+
node: long,
|
|
294
|
+
role: 'exterior',
|
|
295
|
+
scope: 'room',
|
|
296
|
+
nodes: walls,
|
|
297
|
+
spaces,
|
|
298
|
+
wallHit: { face: 'back', point: [2, -0.1] },
|
|
299
|
+
})
|
|
300
|
+
const rightBay = resolve({
|
|
301
|
+
node: long,
|
|
302
|
+
role: 'exterior',
|
|
303
|
+
scope: 'room',
|
|
304
|
+
nodes: walls,
|
|
305
|
+
spaces,
|
|
306
|
+
wallHit: { face: 'back', point: [6, -0.1] },
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
expect(keys(leftBay).some((key) => key.startsWith('left-bottom:'))).toBe(true)
|
|
310
|
+
expect(keys(leftBay).some((key) => key.startsWith('right-bottom:'))).toBe(false)
|
|
311
|
+
expect(keys(rightBay).some((key) => key.startsWith('right-bottom:'))).toBe(true)
|
|
312
|
+
expect(keys(rightBay).some((key) => key.startsWith('left-bottom:'))).toBe(false)
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('wall room with no enclosing space falls back to single', () => {
|
|
316
|
+
const w1 = wall('w1', [0, 0], [4, 0])
|
|
317
|
+
expect(
|
|
318
|
+
keys(resolve({ node: w1, role: 'interior', scope: 'room', nodes: [w1], spaces: [] })),
|
|
319
|
+
).toEqual(['w1:interior'])
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
it('wall room paints the connected exterior envelope from an exterior face', () => {
|
|
323
|
+
const walls = [
|
|
324
|
+
wall('bottom', [0, 0], [4, 0]),
|
|
325
|
+
wall('right', [4, 0], [4, 4]),
|
|
326
|
+
wall('top', [4, 4], [0, 4]),
|
|
327
|
+
wall('left', [0, 4], [0, 0]),
|
|
328
|
+
]
|
|
329
|
+
const spaces = detectSpacesForLevel('l1', walls).spaces
|
|
330
|
+
expect(
|
|
331
|
+
keys(
|
|
332
|
+
resolve({
|
|
333
|
+
node: walls[0]!,
|
|
334
|
+
role: 'exterior',
|
|
335
|
+
scope: 'room',
|
|
336
|
+
nodes: walls,
|
|
337
|
+
spaces,
|
|
338
|
+
wallHit: { face: 'back', point: [2, -0.1] },
|
|
339
|
+
}),
|
|
340
|
+
).sort(),
|
|
341
|
+
).toEqual(['bottom:exterior', 'left:exterior', 'right:exterior', 'top:exterior'])
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('wall room does not cross to a disconnected exterior envelope', () => {
|
|
345
|
+
const first = [
|
|
346
|
+
wall('a-bottom', [0, 0], [4, 0]),
|
|
347
|
+
wall('a-right', [4, 0], [4, 4]),
|
|
348
|
+
wall('a-top', [4, 4], [0, 4]),
|
|
349
|
+
wall('a-left', [0, 4], [0, 0]),
|
|
350
|
+
]
|
|
351
|
+
const second = [
|
|
352
|
+
wall('b-bottom', [10, 0], [14, 0]),
|
|
353
|
+
wall('b-right', [14, 0], [14, 4]),
|
|
354
|
+
wall('b-top', [14, 4], [10, 4]),
|
|
355
|
+
wall('b-left', [10, 4], [10, 0]),
|
|
356
|
+
]
|
|
357
|
+
const walls = [...first, ...second]
|
|
358
|
+
const spaces = detectSpacesForLevel('l1', walls).spaces
|
|
359
|
+
const result = resolve({
|
|
360
|
+
node: first[0]!,
|
|
361
|
+
role: 'exterior',
|
|
362
|
+
scope: 'room',
|
|
363
|
+
nodes: walls,
|
|
364
|
+
spaces,
|
|
365
|
+
wallHit: { face: 'back', point: [2, -0.1] },
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
expect(result).toHaveLength(4)
|
|
369
|
+
expect(keys(result).every((key) => key.startsWith('a-'))).toBe(true)
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it('wall room excludes shared interior walls from the exterior envelope', () => {
|
|
373
|
+
const { walls, spaces } = adjacentRooms()
|
|
374
|
+
const bottomLeft = walls.find((candidate) => String(candidate.id) === 'bottom-left')!
|
|
375
|
+
const result = resolve({
|
|
376
|
+
node: bottomLeft,
|
|
377
|
+
role: 'exterior',
|
|
378
|
+
scope: 'room',
|
|
379
|
+
nodes: walls,
|
|
380
|
+
spaces,
|
|
381
|
+
wallHit: { face: 'back', point: [2, -0.1] },
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
expect(result).toHaveLength(6)
|
|
385
|
+
expect(keys(result).some((key) => key.startsWith('shared:'))).toBe(false)
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
it('wall room follows an exterior wall that is logically split across rooms', () => {
|
|
389
|
+
const long = wall('long', [0, 0], [8, 0])
|
|
390
|
+
const walls = [
|
|
391
|
+
long,
|
|
392
|
+
wall('right', [8, 0], [8, 4]),
|
|
393
|
+
wall('top-right', [8, 4], [4, 4]),
|
|
394
|
+
wall('top-left', [4, 4], [0, 4]),
|
|
395
|
+
wall('left', [0, 4], [0, 0]),
|
|
396
|
+
wall('divider', [4, 0], [4, 4]),
|
|
397
|
+
]
|
|
398
|
+
const spaces = detectSpacesForLevel('l1', walls).spaces
|
|
399
|
+
const result = resolve({
|
|
400
|
+
node: long,
|
|
401
|
+
role: 'exterior',
|
|
402
|
+
scope: 'room',
|
|
403
|
+
nodes: walls,
|
|
404
|
+
spaces,
|
|
405
|
+
wallHit: { face: 'back', point: [2, -0.1] },
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
expect(keys(result).filter((key) => key === 'long:exterior')).toHaveLength(1)
|
|
409
|
+
expect(keys(result).some((key) => key.startsWith('divider:'))).toBe(false)
|
|
410
|
+
expect(result).toHaveLength(5)
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
it('slab room fans across slabs whose centroid sits in the same space', () => {
|
|
414
|
+
const inside = slab('inA', [
|
|
415
|
+
[1, 1],
|
|
416
|
+
[3, 1],
|
|
417
|
+
[3, 3],
|
|
418
|
+
[1, 3],
|
|
419
|
+
])
|
|
420
|
+
const alsoInside = slab('inB', [
|
|
421
|
+
[2, 2],
|
|
422
|
+
[2.5, 2],
|
|
423
|
+
[2.5, 2.5],
|
|
424
|
+
[2, 2.5],
|
|
425
|
+
])
|
|
426
|
+
const outside = slab('out', [
|
|
427
|
+
[20, 20],
|
|
428
|
+
[21, 20],
|
|
429
|
+
[21, 21],
|
|
430
|
+
[20, 21],
|
|
431
|
+
])
|
|
432
|
+
const space: Space = {
|
|
433
|
+
id: 's1',
|
|
434
|
+
levelId: 'l1',
|
|
435
|
+
polygon: [
|
|
436
|
+
[0, 0],
|
|
437
|
+
[10, 0],
|
|
438
|
+
[10, 10],
|
|
439
|
+
[0, 10],
|
|
440
|
+
],
|
|
441
|
+
wallIds: [],
|
|
442
|
+
boundaryFaces: [],
|
|
443
|
+
isExterior: false,
|
|
444
|
+
}
|
|
445
|
+
const result = resolve({
|
|
446
|
+
node: inside,
|
|
447
|
+
role: 'surface',
|
|
448
|
+
scope: 'room',
|
|
449
|
+
nodes: [inside, alsoInside, outside],
|
|
450
|
+
spaces: [space],
|
|
451
|
+
})
|
|
452
|
+
expect(keys(result).sort()).toEqual(['inA:surface', 'inB:surface'])
|
|
453
|
+
})
|
|
454
|
+
})
|