@pascal-app/editor 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -9
- package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
- package/src/components/editor/custom-camera-controls.tsx +754 -25
- package/src/components/editor/editor-layout-mobile.tsx +3 -3
- package/src/components/editor/editor-layout-v2.tsx +58 -51
- package/src/components/editor/export-manager.tsx +32 -3
- package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
- package/src/components/editor/first-person/build-collider-world.ts +201 -83
- package/src/components/editor/first-person/bvh-ecctrl.tsx +8 -8
- package/src/components/editor/first-person-controls.tsx +825 -20
- package/src/components/editor/floating-action-menu.tsx +287 -196
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-panel.tsx +5829 -12005
- package/src/components/editor/grid.tsx +51 -17
- package/src/components/editor/group-move-handle.tsx +316 -0
- package/src/components/editor/group-rotate-handle.tsx +382 -0
- package/src/components/editor/group-transform-shared.test.ts +205 -0
- package/src/components/editor/group-transform-shared.ts +256 -0
- package/src/components/editor/handles/handle-arrow.tsx +523 -0
- package/src/components/editor/handles/use-handle-drag.ts +213 -0
- package/src/components/editor/index.tsx +178 -149
- package/src/components/editor/measurement-pill.tsx +75 -0
- package/src/components/editor/node-arrow-handles.tsx +1453 -0
- package/src/components/editor/selection-manager.tsx +433 -156
- package/src/components/editor/site-edge-labels.tsx +44 -20
- package/src/components/editor/slab-hole-highlights.tsx +466 -0
- package/src/components/editor/snapshot-capture-overlay.tsx +136 -62
- package/src/components/editor/thumbnail-generator.tsx +9 -3
- package/src/components/editor/use-floorplan-background-placement.ts +119 -32
- package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
- package/src/components/editor/use-floorplan-scene-data.ts +14 -2
- package/src/components/editor/wall-measurement-label.tsx +11 -6
- package/src/components/editor/wall-move-side-handles.tsx +855 -0
- package/src/components/editor/wall-opening-highlights.tsx +181 -0
- package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
- package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
- package/src/components/editor-2d/svg-paths.ts +10 -2
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +26 -3
- package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
- package/src/components/systems/roof/roof-edit-system.tsx +86 -12
- package/src/components/systems/stair/stair-edit-system.tsx +1 -1
- package/src/components/tools/elevator/elevator-defaults.ts +8 -0
- package/src/components/tools/elevator/elevator-tool.tsx +349 -0
- package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
- package/src/components/tools/fence/fence-drafting.ts +64 -22
- package/src/components/tools/item/move-tool.tsx +41 -105
- package/src/components/tools/item/placement-math.test.ts +10 -0
- package/src/components/tools/item/placement-math.ts +31 -5
- package/src/components/tools/item/placement-strategies.ts +190 -15
- package/src/components/tools/item/placement-types.ts +10 -1
- package/src/components/tools/item/use-draft-node.ts +20 -1
- package/src/components/tools/item/use-placement-coordinator.tsx +694 -191
- package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
- package/src/components/tools/roof/roof-tool.tsx +108 -12
- package/src/components/tools/select/box-select-state.ts +77 -0
- package/src/components/tools/select/box-select-tool.tsx +324 -538
- package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
- package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
- package/src/components/tools/select/select-candidates.test.ts +109 -0
- package/src/components/tools/select/select-candidates.ts +85 -0
- package/src/components/tools/shared/affordance-dispatch.ts +30 -0
- package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
- package/src/components/tools/shared/floor-stack-preview.ts +25 -0
- package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
- package/src/components/tools/shared/placement-box-geometry.ts +123 -0
- package/src/components/tools/shared/placement-box.tsx +123 -0
- package/src/components/tools/shared/polygon-editor.tsx +617 -167
- package/src/components/tools/shared/segment-angle.ts +56 -0
- package/src/components/tools/site/site-boundary-editor.tsx +464 -12
- package/src/components/tools/site/site-flag-model.ts +18 -0
- package/src/components/tools/stair/stair-defaults.ts +1 -1
- package/src/components/tools/stair/stair-tool.tsx +269 -35
- package/src/components/tools/tool-manager.tsx +172 -56
- package/src/components/tools/wall/wall-drafting.ts +98 -98
- package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
- package/src/components/tools/zone/zone-tool.tsx +16 -6
- package/src/components/ui/action-menu/action-button.tsx +21 -1
- package/src/components/ui/action-menu/control-modes.tsx +5 -82
- package/src/components/ui/action-menu/index.tsx +1 -113
- package/src/components/ui/action-menu/structure-tools.tsx +13 -88
- package/src/components/ui/action-menu/view-toggles.tsx +243 -6
- package/src/components/ui/command-palette/editor-commands.tsx +18 -13
- package/src/components/ui/command-palette/index.tsx +2 -1
- package/src/components/ui/controls/material-paint-panel.tsx +83 -0
- package/src/components/ui/controls/material-picker.tsx +11 -32
- package/src/components/ui/controls/metric-control.tsx +33 -11
- package/src/components/ui/floating-level-selector.tsx +47 -10
- package/src/components/ui/helpers/helper-manager.tsx +16 -17
- package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1 -0
- package/src/components/ui/item-catalog/item-catalog.tsx +3 -0
- package/src/components/ui/level-duplicate-dialog.tsx +2 -1
- package/src/components/ui/panels/node-display.ts +1 -0
- package/src/components/ui/panels/panel-manager.tsx +35 -47
- package/src/components/ui/panels/panel-wrapper.tsx +217 -12
- package/src/components/ui/panels/parametric-inspector.tsx +469 -0
- package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
- package/src/components/ui/scene-loader.tsx +1 -3
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -36
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
- package/src/components/ui/sidebar/tab-bar.tsx +61 -1
- package/src/components/viewer-overlay.tsx +169 -52
- package/src/components/viewer-zone-system.tsx +1 -1
- package/src/hooks/use-auto-save.ts +10 -3
- package/src/hooks/use-ceiling-events.ts +175 -0
- package/src/hooks/use-drag-action.ts +124 -0
- package/src/hooks/use-keyboard.ts +115 -62
- package/src/hooks/use-selection.ts +64 -0
- package/src/index.tsx +237 -5
- package/src/lib/constants.ts +6 -2
- package/src/lib/editor-api.ts +62 -0
- package/src/lib/elevator-support.ts +96 -0
- package/src/lib/floorplan/apply-alignment.ts +111 -0
- package/src/lib/floorplan/index.ts +7 -0
- package/src/lib/floorplan/selection-tool.ts +40 -0
- package/src/lib/floorplan/stairs.ts +12 -1
- package/src/lib/fresh-planar-placement.test.ts +102 -0
- package/src/lib/fresh-planar-placement.ts +63 -0
- package/src/lib/history.ts +2 -1
- package/src/lib/level-duplication.ts +54 -42
- package/src/lib/level-name.ts +11 -0
- package/src/lib/material-paint.ts +211 -19
- package/src/lib/placement-metadata.ts +29 -0
- package/src/lib/planar-cursor-placement.test.ts +43 -0
- package/src/lib/planar-cursor-placement.ts +42 -0
- package/src/lib/roof-duplication.ts +1 -1
- package/src/lib/scene-clipboard.ts +267 -0
- package/src/lib/scene.ts +13 -4
- package/src/lib/sfx-bus.ts +23 -1
- package/src/lib/sfx-player.ts +70 -22
- package/src/lib/site-boundary.ts +1 -0
- package/src/lib/stair-levels.test.ts +146 -0
- package/src/lib/stair-levels.ts +177 -0
- package/src/lib/world-grid-snap.ts +199 -0
- package/src/lib/zone-content.ts +130 -0
- package/src/store/use-alignment-guides.ts +21 -0
- package/src/store/use-editor.tsx +320 -37
- package/src/store/use-placement-preview.ts +31 -0
- package/src/store/use-wall-move-ghosts.ts +36 -0
- package/src/store/use-wall-snap-indicator.ts +32 -0
- package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
- package/src/components/tools/building/move-building-tool.tsx +0 -157
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
- package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
- package/src/components/tools/column/column-tool.tsx +0 -97
- package/src/components/tools/column/move-column-tool.tsx +0 -105
- package/src/components/tools/door/door-math.ts +0 -110
- package/src/components/tools/door/door-tool.tsx +0 -324
- package/src/components/tools/door/move-door-tool.tsx +0 -412
- package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
- package/src/components/tools/fence/fence-tool.tsx +0 -346
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
- package/src/components/tools/fence/move-fence-tool.tsx +0 -302
- package/src/components/tools/item/item-tool.tsx +0 -26
- package/src/components/tools/roof/move-roof-tool.tsx +0 -364
- package/src/components/tools/slab/move-slab-tool.tsx +0 -182
- package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
- package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
- package/src/components/tools/slab/slab-tool.tsx +0 -322
- package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
- package/src/components/tools/spawn/spawn-tool.tsx +0 -130
- package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
- package/src/components/tools/wall/move-wall-tool.tsx +0 -358
- package/src/components/tools/wall/wall-tool.tsx +0 -332
- package/src/components/tools/window/move-window-tool.tsx +0 -447
- package/src/components/tools/window/window-math.ts +0 -117
- package/src/components/tools/window/window-tool.tsx +0 -332
- package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
- package/src/components/ui/helpers/slab-helper.tsx +0 -20
- package/src/components/ui/helpers/wall-helper.tsx +0 -20
- package/src/components/ui/panels/ceiling-panel.tsx +0 -249
- package/src/components/ui/panels/column-panel.tsx +0 -759
- package/src/components/ui/panels/door-panel.tsx +0 -1299
- package/src/components/ui/panels/fence-panel.tsx +0 -222
- package/src/components/ui/panels/item-panel.tsx +0 -306
- package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
- package/src/components/ui/panels/roof-panel.tsx +0 -280
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
- package/src/components/ui/panels/slab-panel.tsx +0 -250
- package/src/components/ui/panels/spawn-panel.tsx +0 -161
- package/src/components/ui/panels/stair-panel.tsx +0 -577
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
- package/src/components/ui/panels/wall-panel.tsx +0 -273
- package/src/components/ui/panels/window-panel.tsx +0 -968
- package/src/contexts/presets-context.tsx +0 -121
- package/src/hooks/use-contextual-tools.ts +0 -61
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
type AnyNode,
|
|
4
|
+
type AnyNodeId,
|
|
5
|
+
BuildingNode,
|
|
6
|
+
LevelNode,
|
|
7
|
+
StairNode,
|
|
8
|
+
} from '@pascal-app/core/schema'
|
|
9
|
+
import {
|
|
10
|
+
getBuildingLevelsForLevel,
|
|
11
|
+
getStairLevelOptions,
|
|
12
|
+
resolveStairDestinationLevel,
|
|
13
|
+
resolveStairFromLevelId,
|
|
14
|
+
resolveStairPlacementLevelId,
|
|
15
|
+
resolveStairToLevelId,
|
|
16
|
+
} from './stair-levels'
|
|
17
|
+
|
|
18
|
+
describe('stair level helpers', () => {
|
|
19
|
+
test('creates a missing upper level in the same building', () => {
|
|
20
|
+
const ground = LevelNode.parse({ level: 0, children: [] })
|
|
21
|
+
const building = BuildingNode.parse({ children: [ground.id] })
|
|
22
|
+
const nodes = {
|
|
23
|
+
[building.id]: building,
|
|
24
|
+
[ground.id]: ground,
|
|
25
|
+
} as Record<AnyNodeId, AnyNode>
|
|
26
|
+
|
|
27
|
+
const plan = resolveStairDestinationLevel({
|
|
28
|
+
createMissing: true,
|
|
29
|
+
fromLevelId: ground.id,
|
|
30
|
+
nodes,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
expect(plan?.buildingId).toBe(building.id)
|
|
34
|
+
expect(plan?.fromLevel.id).toBe(ground.id)
|
|
35
|
+
expect(plan?.toLevel.level).toBe(1)
|
|
36
|
+
expect(plan?.toLevel.id).toBe(plan?.createdLevel?.id)
|
|
37
|
+
expect(plan?.createdLevel?.parentId).toBe(building.id)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('uses the nearest higher sibling level instead of creating one', () => {
|
|
41
|
+
const building = BuildingNode.parse({})
|
|
42
|
+
const ground = LevelNode.parse({ level: 0, parentId: building.id })
|
|
43
|
+
const second = LevelNode.parse({ level: 1, parentId: building.id })
|
|
44
|
+
const third = LevelNode.parse({ level: 2, parentId: building.id })
|
|
45
|
+
const nodes = {
|
|
46
|
+
[building.id]: { ...building, children: [ground.id, third.id, second.id] },
|
|
47
|
+
[ground.id]: ground,
|
|
48
|
+
[second.id]: second,
|
|
49
|
+
[third.id]: third,
|
|
50
|
+
} as Record<AnyNodeId, AnyNode>
|
|
51
|
+
|
|
52
|
+
const plan = resolveStairDestinationLevel({
|
|
53
|
+
createMissing: true,
|
|
54
|
+
fromLevelId: ground.id,
|
|
55
|
+
nodes,
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
expect(plan?.createdLevel).toBeNull()
|
|
59
|
+
expect(plan?.toLevel.id).toBe(second.id)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('ignores levels from other buildings', () => {
|
|
63
|
+
const buildingA = BuildingNode.parse({})
|
|
64
|
+
const buildingB = BuildingNode.parse({})
|
|
65
|
+
const groundA = LevelNode.parse({ level: 0, parentId: buildingA.id })
|
|
66
|
+
const upperA = LevelNode.parse({ level: 1, parentId: buildingA.id })
|
|
67
|
+
const upperB = LevelNode.parse({ level: 1, parentId: buildingB.id })
|
|
68
|
+
const nodes = {
|
|
69
|
+
[buildingA.id]: { ...buildingA, children: [groundA.id, upperA.id] },
|
|
70
|
+
[buildingB.id]: { ...buildingB, children: [upperB.id] },
|
|
71
|
+
[groundA.id]: groundA,
|
|
72
|
+
[upperA.id]: upperA,
|
|
73
|
+
[upperB.id]: upperB,
|
|
74
|
+
} as Record<AnyNodeId, AnyNode>
|
|
75
|
+
|
|
76
|
+
expect(getBuildingLevelsForLevel(nodes, groundA.id).map((level) => level.id)).toEqual([
|
|
77
|
+
groundA.id,
|
|
78
|
+
upperA.id,
|
|
79
|
+
])
|
|
80
|
+
expect(
|
|
81
|
+
resolveStairDestinationLevel({ createMissing: true, fromLevelId: groundA.id, nodes })?.toLevel
|
|
82
|
+
.id,
|
|
83
|
+
).toBe(upperA.id)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('includes source and parent-linked sibling levels when building children are stale', () => {
|
|
87
|
+
const building = BuildingNode.parse({ children: [] })
|
|
88
|
+
const ground = LevelNode.parse({ level: 0, parentId: building.id })
|
|
89
|
+
const upper = LevelNode.parse({ level: 1, parentId: building.id })
|
|
90
|
+
const nodes = {
|
|
91
|
+
[building.id]: building,
|
|
92
|
+
[ground.id]: ground,
|
|
93
|
+
[upper.id]: upper,
|
|
94
|
+
} as Record<AnyNodeId, AnyNode>
|
|
95
|
+
|
|
96
|
+
const levels = getBuildingLevelsForLevel(nodes, ground.id)
|
|
97
|
+
const plan = resolveStairDestinationLevel({
|
|
98
|
+
createMissing: true,
|
|
99
|
+
fromLevelId: ground.id,
|
|
100
|
+
nodes,
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
expect(levels.map((level) => level.id)).toEqual([ground.id, upper.id])
|
|
104
|
+
expect(plan?.createdLevel).toBeNull()
|
|
105
|
+
expect(plan?.toLevel.id).toBe(upper.id)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('falls back from stale placement level ids to a valid level in the selected building', () => {
|
|
109
|
+
const buildingA = BuildingNode.parse({})
|
|
110
|
+
const groundA = LevelNode.parse({ level: 0, parentId: buildingA.id })
|
|
111
|
+
const buildingB = BuildingNode.parse({})
|
|
112
|
+
const groundB = LevelNode.parse({ level: 0, parentId: buildingB.id })
|
|
113
|
+
const nodes = {
|
|
114
|
+
[buildingA.id]: { ...buildingA, children: [groundA.id] },
|
|
115
|
+
[groundA.id]: groundA,
|
|
116
|
+
[buildingB.id]: { ...buildingB, children: [groundB.id] },
|
|
117
|
+
[groundB.id]: groundB,
|
|
118
|
+
} as Record<AnyNodeId, AnyNode>
|
|
119
|
+
|
|
120
|
+
expect(resolveStairPlacementLevelId(nodes, 'level_missing', buildingB.id)).toBe(groundB.id)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('repairs panel level ids for stairs with stale from-level data', () => {
|
|
124
|
+
const building = BuildingNode.parse({})
|
|
125
|
+
const ground = LevelNode.parse({ level: 0, parentId: building.id })
|
|
126
|
+
const upper = LevelNode.parse({ level: 1, parentId: building.id })
|
|
127
|
+
const stair = StairNode.parse({
|
|
128
|
+
parentId: ground.id,
|
|
129
|
+
fromLevelId: 'default',
|
|
130
|
+
toLevelId: upper.id,
|
|
131
|
+
})
|
|
132
|
+
const nodes = {
|
|
133
|
+
[building.id]: { ...building, children: [ground.id, upper.id] },
|
|
134
|
+
[ground.id]: ground,
|
|
135
|
+
[upper.id]: upper,
|
|
136
|
+
[stair.id]: stair,
|
|
137
|
+
} as Record<AnyNodeId, AnyNode>
|
|
138
|
+
|
|
139
|
+
const levels = getStairLevelOptions(nodes, stair)
|
|
140
|
+
const fromLevelId = resolveStairFromLevelId(nodes, stair, levels)
|
|
141
|
+
|
|
142
|
+
expect(levels.map((level) => level.id)).toEqual([ground.id, upper.id])
|
|
143
|
+
expect(fromLevelId).toBe(ground.id)
|
|
144
|
+
expect(resolveStairToLevelId(nodes, stair, fromLevelId, levels)).toBe(upper.id)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNode,
|
|
3
|
+
type AnyNodeId,
|
|
4
|
+
LevelNode,
|
|
5
|
+
type LevelNode as LevelNodeType,
|
|
6
|
+
resolveBuildingForLevel,
|
|
7
|
+
type StairNode,
|
|
8
|
+
} from '@pascal-app/core'
|
|
9
|
+
|
|
10
|
+
function sortLevelsByHeight(levels: LevelNodeType[]) {
|
|
11
|
+
return [...levels].sort((left, right) => left.level - right.level)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isLevelNode(node: AnyNode | undefined): node is LevelNodeType {
|
|
15
|
+
return node?.type === 'level'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getAllSceneLevels(nodes: Record<string, AnyNode>) {
|
|
19
|
+
return sortLevelsByHeight(
|
|
20
|
+
Object.values(nodes).filter((entry): entry is LevelNodeType => entry?.type === 'level'),
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getBuildingLevels(
|
|
25
|
+
nodes: Record<string, AnyNode>,
|
|
26
|
+
buildingId: AnyNodeId | string | null | undefined,
|
|
27
|
+
source?: LevelNodeType,
|
|
28
|
+
) {
|
|
29
|
+
if (!buildingId) return source ? [source] : []
|
|
30
|
+
const building = nodes[buildingId as AnyNodeId]
|
|
31
|
+
if (building?.type !== 'building') return source ? [source] : []
|
|
32
|
+
|
|
33
|
+
const levels = new Map<string, LevelNodeType>()
|
|
34
|
+
if (source) levels.set(source.id, source)
|
|
35
|
+
|
|
36
|
+
for (const childId of building.children ?? []) {
|
|
37
|
+
const child = nodes[childId as AnyNodeId]
|
|
38
|
+
if (isLevelNode(child)) levels.set(child.id, child)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
for (const candidate of Object.values(nodes)) {
|
|
42
|
+
if (isLevelNode(candidate) && candidate.parentId === building.id) {
|
|
43
|
+
levels.set(candidate.id, candidate)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return sortLevelsByHeight(Array.from(levels.values()))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getBuildingLevelsForLevel(
|
|
51
|
+
nodes: Record<string, AnyNode>,
|
|
52
|
+
levelId: AnyNodeId | string | null | undefined,
|
|
53
|
+
) {
|
|
54
|
+
if (!levelId) return []
|
|
55
|
+
const source = nodes[levelId as AnyNodeId]
|
|
56
|
+
if (!isLevelNode(source)) return []
|
|
57
|
+
|
|
58
|
+
const buildingId = resolveBuildingForLevel(
|
|
59
|
+
source.id as AnyNodeId,
|
|
60
|
+
nodes as Record<AnyNodeId, AnyNode>,
|
|
61
|
+
)
|
|
62
|
+
return getBuildingLevels(nodes, buildingId, source)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function getStairLevelOptions(nodes: Record<string, AnyNode>, stair: StairNode) {
|
|
66
|
+
for (const candidateId of [stair.fromLevelId, stair.parentId, stair.toLevelId]) {
|
|
67
|
+
if (isLevelNode(nodes[candidateId as AnyNodeId])) {
|
|
68
|
+
return getBuildingLevelsForLevel(nodes, candidateId)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return getAllSceneLevels(nodes)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function resolveStairPlacementLevelId(
|
|
76
|
+
nodes: Record<string, AnyNode>,
|
|
77
|
+
preferredLevelId: AnyNodeId | string | null | undefined,
|
|
78
|
+
preferredBuildingId?: AnyNodeId | string | null,
|
|
79
|
+
) {
|
|
80
|
+
if (isLevelNode(nodes[preferredLevelId as AnyNodeId])) {
|
|
81
|
+
return preferredLevelId as LevelNodeType['id']
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const buildingLevels = getBuildingLevels(nodes, preferredBuildingId)
|
|
85
|
+
return buildingLevels[0]?.id ?? getAllSceneLevels(nodes)[0]?.id ?? null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function resolveStairFromLevelId(
|
|
89
|
+
nodes: Record<string, AnyNode>,
|
|
90
|
+
stair: StairNode,
|
|
91
|
+
levels = getStairLevelOptions(nodes, stair),
|
|
92
|
+
) {
|
|
93
|
+
const optionIds = new Set<string>(levels.map((level) => level.id))
|
|
94
|
+
if (stair.fromLevelId && optionIds.has(stair.fromLevelId)) return stair.fromLevelId
|
|
95
|
+
if (stair.parentId && optionIds.has(stair.parentId)) return stair.parentId
|
|
96
|
+
|
|
97
|
+
const toLevel = stair.toLevelId ? nodes[stair.toLevelId as AnyNodeId] : undefined
|
|
98
|
+
if (isLevelNode(toLevel)) {
|
|
99
|
+
const lowerLevel = [...levels].reverse().find((level) => level.level < toLevel.level)
|
|
100
|
+
if (lowerLevel) return lowerLevel.id
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return levels[0]?.id ?? null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function resolveStairToLevelId(
|
|
107
|
+
nodes: Record<string, AnyNode>,
|
|
108
|
+
stair: StairNode,
|
|
109
|
+
fromLevelId: AnyNodeId | string | null | undefined,
|
|
110
|
+
levels = getStairLevelOptions(nodes, stair),
|
|
111
|
+
) {
|
|
112
|
+
const optionIds = new Set<string>(levels.map((level) => level.id))
|
|
113
|
+
if (stair.toLevelId && stair.toLevelId !== fromLevelId && optionIds.has(stair.toLevelId)) {
|
|
114
|
+
return stair.toLevelId
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const fromLevel = fromLevelId ? nodes[fromLevelId as AnyNodeId] : undefined
|
|
118
|
+
if (isLevelNode(fromLevel)) {
|
|
119
|
+
return levels.find((level) => level.level > fromLevel.level)?.id ?? fromLevel.id
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return levels[0]?.id ?? null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function resolveStairDestinationLevel({
|
|
126
|
+
createMissing,
|
|
127
|
+
fromLevelId,
|
|
128
|
+
nodes,
|
|
129
|
+
}: {
|
|
130
|
+
createMissing?: boolean
|
|
131
|
+
fromLevelId: AnyNodeId | string | null | undefined
|
|
132
|
+
nodes: Record<string, AnyNode>
|
|
133
|
+
}) {
|
|
134
|
+
if (!fromLevelId) return null
|
|
135
|
+
const fromLevel = nodes[fromLevelId as AnyNodeId]
|
|
136
|
+
if (!isLevelNode(fromLevel)) return null
|
|
137
|
+
|
|
138
|
+
const buildingId = resolveBuildingForLevel(
|
|
139
|
+
fromLevel.id as AnyNodeId,
|
|
140
|
+
nodes as Record<AnyNodeId, AnyNode>,
|
|
141
|
+
)
|
|
142
|
+
const levels = getBuildingLevelsForLevel(nodes, fromLevel.id)
|
|
143
|
+
const nextExistingLevel = levels.find((level) => level.level > fromLevel.level) ?? null
|
|
144
|
+
if (nextExistingLevel) {
|
|
145
|
+
return {
|
|
146
|
+
buildingId,
|
|
147
|
+
createdLevel: null,
|
|
148
|
+
fromLevel,
|
|
149
|
+
levels,
|
|
150
|
+
toLevel: nextExistingLevel,
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (createMissing && buildingId) {
|
|
155
|
+
const createdLevel = LevelNode.parse({
|
|
156
|
+
children: [],
|
|
157
|
+
level: fromLevel.level + 1,
|
|
158
|
+
parentId: buildingId,
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
buildingId,
|
|
163
|
+
createdLevel,
|
|
164
|
+
fromLevel,
|
|
165
|
+
levels: sortLevelsByHeight([...levels, createdLevel]),
|
|
166
|
+
toLevel: createdLevel,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
buildingId,
|
|
172
|
+
createdLevel: null,
|
|
173
|
+
fromLevel,
|
|
174
|
+
levels,
|
|
175
|
+
toLevel: fromLevel,
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* World-grid snap for tools that consume `grid:move` / `grid:click`.
|
|
3
|
+
*
|
|
4
|
+
* Tools historically snapped on `event.localPosition` (the cursor in the
|
|
5
|
+
* active building's local frame). After the floor-plan grid was pulled
|
|
6
|
+
* out of the rotated scene group, snapping has to follow the WORLD XZ
|
|
7
|
+
* grid — otherwise a rotated building drags every placement off the
|
|
8
|
+
* visible grid lines. This helper resolves the active building's pose
|
|
9
|
+
* and projects the world snap back into local coords for storage.
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
type AlignmentAnchor,
|
|
13
|
+
type AnyNodeId,
|
|
14
|
+
type BuildingPose,
|
|
15
|
+
type ResolveAlignmentInBuildingResult,
|
|
16
|
+
resolveAlignmentInBuildingWorld,
|
|
17
|
+
snapWorldXZToBuildingLocal,
|
|
18
|
+
useLiveTransforms,
|
|
19
|
+
useScene,
|
|
20
|
+
} from '@pascal-app/core'
|
|
21
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Look up the active building's pose, or null when we're at the site root.
|
|
25
|
+
* Used by tools that need to resolve alignment in world coords.
|
|
26
|
+
*
|
|
27
|
+
* Falls back through the active level's `parentId` because the 2D floor-plan
|
|
28
|
+
* often runs with `selection.buildingId === null` while still operating
|
|
29
|
+
* inside a specific building (the user is editing a level, not the building
|
|
30
|
+
* shell itself). Without the fallback, alignment in the floor plan saw a
|
|
31
|
+
* `buildingRotY === 0` pose and emitted world-axis guides — which appeared
|
|
32
|
+
* diagonal once the building was rotated.
|
|
33
|
+
*
|
|
34
|
+
* Honours `useLiveTransforms` overrides: while the building is being moved
|
|
35
|
+
* or rotated, the floor-plan scene <g> is driven by the live transform
|
|
36
|
+
* (see `use-floorplan-scene-data.ts`), so alignment has to read the same
|
|
37
|
+
* pose or the rotated anchors fall out of sync with the SVG transform and
|
|
38
|
+
* guides drift off the visible grid mid-drag (and through any post-drag
|
|
39
|
+
* frame where the live override is still set).
|
|
40
|
+
*/
|
|
41
|
+
export function getActiveBuildingPose(): BuildingPose | null {
|
|
42
|
+
const sel = useViewer.getState().selection
|
|
43
|
+
const nodes = useScene.getState().nodes
|
|
44
|
+
// Match `use-floorplan-scene-data.ts`: prefer the active level's
|
|
45
|
+
// owning building, fall back to selection.buildingId. If the user
|
|
46
|
+
// has a stale building selected from another scope while editing a
|
|
47
|
+
// different level, the level path is the authoritative one.
|
|
48
|
+
let buildingId: AnyNodeId | null = null
|
|
49
|
+
if (sel.levelId) {
|
|
50
|
+
const level = nodes[sel.levelId]
|
|
51
|
+
if (level && level.type === 'level' && level.parentId) {
|
|
52
|
+
buildingId = level.parentId as AnyNodeId
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (!buildingId) buildingId = sel.buildingId ?? null
|
|
56
|
+
const building = buildingId ? nodes[buildingId] : null
|
|
57
|
+
if (!building || building.type !== 'building') return null
|
|
58
|
+
const live = useLiveTransforms.getState().transforms.get(buildingId as string)
|
|
59
|
+
return {
|
|
60
|
+
position: live?.position ?? building.position,
|
|
61
|
+
rotationY: live?.rotation ?? building.rotation[1] ?? 0,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resolve Figma-style alignment for tools whose anchors are in the active
|
|
67
|
+
* building's local frame, but where alignment must run on the WORLD axes
|
|
68
|
+
* (the frame the user sees the grid in). Wraps `resolveAlignmentInBuildingWorld`
|
|
69
|
+
* with the active building lookup so callers don't repeat the boilerplate.
|
|
70
|
+
*
|
|
71
|
+
* Returns:
|
|
72
|
+
* - `guides` in WORLD coords (renderer must live in a world-space group),
|
|
73
|
+
* - `snap` in BUILDING-LOCAL coords (ready to add to a local position).
|
|
74
|
+
*/
|
|
75
|
+
export function resolveAlignmentForActiveBuilding(args: {
|
|
76
|
+
moving: readonly AlignmentAnchor[]
|
|
77
|
+
candidates: readonly AlignmentAnchor[]
|
|
78
|
+
threshold: number
|
|
79
|
+
}): ResolveAlignmentInBuildingResult {
|
|
80
|
+
return resolveAlignmentInBuildingWorld({ ...args, pose: getActiveBuildingPose() })
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Baseline rotation the floor-plan view applies on top of the building
|
|
85
|
+
* rotation. Mirrors `FLOORPLAN_VIEW_ROTATION_DEG = 90` in floorplan-panel.tsx —
|
|
86
|
+
* the scene group reads it via `floorplanSceneRotationDeg = FVR - buildingRot`.
|
|
87
|
+
*/
|
|
88
|
+
const FLOORPLAN_VIEW_ROTATION_RAD = Math.PI / 2
|
|
89
|
+
|
|
90
|
+
function rotateAnchorsBy(
|
|
91
|
+
anchors: readonly AlignmentAnchor[],
|
|
92
|
+
cos: number,
|
|
93
|
+
sin: number,
|
|
94
|
+
): AlignmentAnchor[] {
|
|
95
|
+
return anchors.map((a) => ({
|
|
96
|
+
nodeId: a.nodeId,
|
|
97
|
+
kind: a.kind,
|
|
98
|
+
x: a.x * cos - a.z * sin,
|
|
99
|
+
z: a.x * sin + a.z * cos,
|
|
100
|
+
}))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Resolve alignment in the 2D floor-plan view frame — the frame the user
|
|
105
|
+
* sees the (always axis-aligned) grid lines in, regardless of how the
|
|
106
|
+
* building has been rotated. Use this from EVERY 2D floor-plan path so
|
|
107
|
+
* alignment guides stay parallel to the visible grid.
|
|
108
|
+
*
|
|
109
|
+
* Why it differs from `resolveAlignmentForActiveBuilding`: the 3D viewport
|
|
110
|
+
* shows the world XZ grid, so world-frame alignment matches the visible
|
|
111
|
+
* grid there. The 2D floor plan, however, rotates the scene `<g>` by
|
|
112
|
+
* `floorplanSceneRotationDeg = FVR − buildingRot` and renders the grid
|
|
113
|
+
* OUTSIDE that rotated group — so the visible axes are
|
|
114
|
+
* `R(FVR − buildingRot) · local`. World-frame alignment would land on
|
|
115
|
+
* world axes, which appear diagonal in this view; view-frame alignment
|
|
116
|
+
* lands on the SVG axes the user actually reads.
|
|
117
|
+
*
|
|
118
|
+
* Returns guides in view-frame coords (correct input for the floor-plan
|
|
119
|
+
* alignment-guide layer, which is mounted outside the rotated scene
|
|
120
|
+
* group) and a snap delta projected back into building-local (so callers
|
|
121
|
+
* can add it to a local position as-is).
|
|
122
|
+
*/
|
|
123
|
+
export function resolveAlignmentForFloorplanView(args: {
|
|
124
|
+
moving: readonly AlignmentAnchor[]
|
|
125
|
+
candidates: readonly AlignmentAnchor[]
|
|
126
|
+
threshold: number
|
|
127
|
+
}): ResolveAlignmentInBuildingResult {
|
|
128
|
+
const pose = getActiveBuildingPose()
|
|
129
|
+
const buildingRotY = pose?.rotationY ?? 0
|
|
130
|
+
const rot = FLOORPLAN_VIEW_ROTATION_RAD - buildingRotY
|
|
131
|
+
const cos = Math.cos(rot)
|
|
132
|
+
const sin = Math.sin(rot)
|
|
133
|
+
const result = resolveAlignmentInBuildingWorld({
|
|
134
|
+
moving: rotateAnchorsBy(args.moving, cos, sin),
|
|
135
|
+
candidates: rotateAnchorsBy(args.candidates, cos, sin),
|
|
136
|
+
threshold: args.threshold,
|
|
137
|
+
// Pose `null` keeps `resolveAlignmentInBuildingWorld` in its no-op
|
|
138
|
+
// path: it just runs `resolveAlignment` on the already-rotated
|
|
139
|
+
// anchors and returns the snap delta in the same view frame.
|
|
140
|
+
pose: null,
|
|
141
|
+
})
|
|
142
|
+
if (!result.snap) return result
|
|
143
|
+
// View-frame delta → local-frame delta (transpose of R(rot)).
|
|
144
|
+
const { dx, dz } = result.snap
|
|
145
|
+
return {
|
|
146
|
+
guides: result.guides,
|
|
147
|
+
snap: { dx: dx * cos + dz * sin, dz: -dx * sin + dz * cos },
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Snap a world XZ position to the grid, then express it in the active
|
|
153
|
+
* building's local frame. When no building is active, world == local.
|
|
154
|
+
*/
|
|
155
|
+
export function snapWorldXZForActiveBuilding(
|
|
156
|
+
worldX: number,
|
|
157
|
+
worldZ: number,
|
|
158
|
+
step: number,
|
|
159
|
+
): { world: [number, number]; local: [number, number] } {
|
|
160
|
+
const buildingId = useViewer.getState().selection.buildingId
|
|
161
|
+
const building = buildingId ? useScene.getState().nodes[buildingId] : null
|
|
162
|
+
if (!building || building.type !== 'building') {
|
|
163
|
+
if (step <= 0) return { world: [worldX, worldZ], local: [worldX, worldZ] }
|
|
164
|
+
const sx = Math.round(worldX / step) * step
|
|
165
|
+
const sz = Math.round(worldZ / step) * step
|
|
166
|
+
return { world: [sx, sz], local: [sx, sz] }
|
|
167
|
+
}
|
|
168
|
+
return snapWorldXZToBuildingLocal(
|
|
169
|
+
worldX,
|
|
170
|
+
worldZ,
|
|
171
|
+
building.position,
|
|
172
|
+
building.rotation[1] ?? 0,
|
|
173
|
+
step,
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Snap a building-local plan point so the resulting position sits on the
|
|
179
|
+
* world XZ grid. The returned point is still in building-local coords —
|
|
180
|
+
* useful as a `gridSnap` callback for snapWallDraftPoint / snapFenceDraftPoint
|
|
181
|
+
* etc., which operate entirely in the local frame.
|
|
182
|
+
*/
|
|
183
|
+
export function snapBuildingLocalToWorldGrid(
|
|
184
|
+
local: readonly [number, number],
|
|
185
|
+
step: number,
|
|
186
|
+
): [number, number] {
|
|
187
|
+
const buildingId = useViewer.getState().selection.buildingId
|
|
188
|
+
const building = buildingId ? useScene.getState().nodes[buildingId] : null
|
|
189
|
+
if (!building || building.type !== 'building') {
|
|
190
|
+
if (step <= 0) return [local[0], local[1]]
|
|
191
|
+
return [Math.round(local[0] / step) * step, Math.round(local[1] / step) * step]
|
|
192
|
+
}
|
|
193
|
+
const rotY = building.rotation[1] ?? 0
|
|
194
|
+
const cos = Math.cos(rotY)
|
|
195
|
+
const sin = Math.sin(rotY)
|
|
196
|
+
const worldX = building.position[0] + local[0] * cos + local[1] * sin
|
|
197
|
+
const worldZ = building.position[2] - local[0] * sin + local[1] * cos
|
|
198
|
+
return snapWorldXZToBuildingLocal(worldX, worldZ, building.position, rotY, step).local
|
|
199
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNode,
|
|
3
|
+
type AnyNodeId,
|
|
4
|
+
type CeilingNode,
|
|
5
|
+
type ItemNode,
|
|
6
|
+
pointInPolygon2D,
|
|
7
|
+
pointOnSegment,
|
|
8
|
+
type SlabNode,
|
|
9
|
+
type WallNode,
|
|
10
|
+
type ZoneNode,
|
|
11
|
+
} from '@pascal-app/core'
|
|
12
|
+
|
|
13
|
+
type Point2D = [number, number]
|
|
14
|
+
|
|
15
|
+
const POINT_TOLERANCE = 0.5
|
|
16
|
+
const COLLINEAR_TOLERANCE = 1e-6
|
|
17
|
+
const SURFACE_POLYGON_TOLERANCE = 0.15
|
|
18
|
+
|
|
19
|
+
function getPointToSegmentDistance(point: Point2D, start: Point2D, end: Point2D): number {
|
|
20
|
+
const dx = end[0] - start[0]
|
|
21
|
+
const dz = end[1] - start[1]
|
|
22
|
+
const lengthSq = dx * dx + dz * dz
|
|
23
|
+
if (lengthSq === 0) return Math.hypot(point[0] - start[0], point[1] - start[1])
|
|
24
|
+
|
|
25
|
+
const rawT = ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSq
|
|
26
|
+
const t = Math.max(0, Math.min(1, rawT))
|
|
27
|
+
const projected: Point2D = [start[0] + t * dx, start[1] + t * dz]
|
|
28
|
+
return Math.hypot(point[0] - projected[0], point[1] - projected[1])
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function pointInPolygonWithTolerance(point: Point2D, polygon: Point2D[]): boolean {
|
|
32
|
+
if (pointInPolygon2D(point, polygon, { includeBoundary: true })) return true
|
|
33
|
+
return polygon.some((start, index) => {
|
|
34
|
+
const end = polygon[(index + 1) % polygon.length]
|
|
35
|
+
return end ? getPointToSegmentDistance(point, start, end) <= POINT_TOLERANCE : false
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function polygonContainsWithTolerance(
|
|
40
|
+
outer: Point2D[],
|
|
41
|
+
inner: Point2D[],
|
|
42
|
+
tolerance: number,
|
|
43
|
+
): boolean {
|
|
44
|
+
return inner.every((point) => {
|
|
45
|
+
if (pointInPolygon2D(point, outer, { includeBoundary: true })) return true
|
|
46
|
+
return outer.some((start, index) => {
|
|
47
|
+
const end = outer[(index + 1) % outer.length]
|
|
48
|
+
return end ? getPointToSegmentDistance(point, start, end) <= tolerance : false
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function polygonMatchesZoneFootprint(surfacePolygon: Point2D[], footprint: Point2D[]): boolean {
|
|
54
|
+
if (surfacePolygon.length < 3) return false
|
|
55
|
+
return (
|
|
56
|
+
polygonContainsWithTolerance(footprint, surfacePolygon, SURFACE_POLYGON_TOLERANCE) &&
|
|
57
|
+
polygonContainsWithTolerance(surfacePolygon, footprint, SURFACE_POLYGON_TOLERANCE)
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function areSegmentsCollinear(a: Point2D, b: Point2D, c: Point2D, d: Point2D): boolean {
|
|
62
|
+
const abx = b[0] - a[0]
|
|
63
|
+
const abz = b[1] - a[1]
|
|
64
|
+
const acx = c[0] - a[0]
|
|
65
|
+
const acz = c[1] - a[1]
|
|
66
|
+
const adx = d[0] - a[0]
|
|
67
|
+
const adz = d[1] - a[1]
|
|
68
|
+
const crossC = abx * acz - abz * acx
|
|
69
|
+
const crossD = abx * adz - abz * adx
|
|
70
|
+
return Math.abs(crossC) <= COLLINEAR_TOLERANCE && Math.abs(crossD) <= COLLINEAR_TOLERANCE
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function segmentsOverlap(a: Point2D, b: Point2D, c: Point2D, d: Point2D): boolean {
|
|
74
|
+
const useX = Math.abs(b[0] - a[0]) >= Math.abs(b[1] - a[1])
|
|
75
|
+
const a0 = useX ? a[0] : a[1]
|
|
76
|
+
const a1 = useX ? b[0] : b[1]
|
|
77
|
+
const c0 = useX ? c[0] : c[1]
|
|
78
|
+
const c1 = useX ? d[0] : d[1]
|
|
79
|
+
const minA = Math.min(a0, a1)
|
|
80
|
+
const maxA = Math.max(a0, a1)
|
|
81
|
+
const minC = Math.min(c0, c1)
|
|
82
|
+
const maxC = Math.max(c0, c1)
|
|
83
|
+
return Math.max(minA, minC) <= Math.min(maxA, maxC) + COLLINEAR_TOLERANCE
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function wallLiesOnZoneBoundary(wall: WallNode, polygon: Point2D[]): boolean {
|
|
87
|
+
return polygon.some((start, index) => {
|
|
88
|
+
const end = polygon[(index + 1) % polygon.length]
|
|
89
|
+
if (!end) return false
|
|
90
|
+
return (
|
|
91
|
+
areSegmentsCollinear(wall.start, wall.end, start, end) &&
|
|
92
|
+
segmentsOverlap(wall.start, wall.end, start, end) &&
|
|
93
|
+
pointOnSegment(wall.start, start, end, POINT_TOLERANCE) &&
|
|
94
|
+
pointOnSegment(wall.end, start, end, POINT_TOLERANCE)
|
|
95
|
+
)
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function collectZoneContentIds(
|
|
100
|
+
nodes: Readonly<Record<AnyNodeId, AnyNode>>,
|
|
101
|
+
zone: ZoneNode,
|
|
102
|
+
): AnyNodeId[] {
|
|
103
|
+
const levelId = zone.parentId
|
|
104
|
+
if (!levelId) return []
|
|
105
|
+
|
|
106
|
+
const footprint = zone.polygon.map((point) => [point[0], point[1]] as Point2D)
|
|
107
|
+
const boundaryWalls = Object.values(nodes)
|
|
108
|
+
.filter((node): node is WallNode => node.type === 'wall' && node.parentId === levelId)
|
|
109
|
+
.filter((wall) => wallLiesOnZoneBoundary(wall, footprint))
|
|
110
|
+
const surfaces = Object.values(nodes)
|
|
111
|
+
.filter(
|
|
112
|
+
(node): node is SlabNode | CeilingNode =>
|
|
113
|
+
(node.type === 'slab' || node.type === 'ceiling') && node.parentId === levelId,
|
|
114
|
+
)
|
|
115
|
+
.filter((surface) => {
|
|
116
|
+
const polygon = surface.polygon.map((point) => [point[0], point[1]] as Point2D)
|
|
117
|
+
return polygonMatchesZoneFootprint(polygon, footprint)
|
|
118
|
+
})
|
|
119
|
+
const floorItems = Object.values(nodes)
|
|
120
|
+
.filter((node): node is ItemNode => node.type === 'item' && node.parentId === levelId)
|
|
121
|
+
.filter((item) => pointInPolygonWithTolerance([item.position[0], item.position[2]], footprint))
|
|
122
|
+
|
|
123
|
+
return Array.from(
|
|
124
|
+
new Set<AnyNodeId>([
|
|
125
|
+
...boundaryWalls.map((wall) => wall.id as AnyNodeId),
|
|
126
|
+
...surfaces.map((surface) => surface.id as AnyNodeId),
|
|
127
|
+
...floorItems.map((item) => item.id as AnyNodeId),
|
|
128
|
+
]),
|
|
129
|
+
)
|
|
130
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Ephemeral store for Figma-style alignment guides published during a
|
|
2
|
+
// move / placement drag. The producer (a tool or move overlay) writes
|
|
3
|
+
// guides on pointermove; the renderer (a 2D / 3D guide layer) subscribes
|
|
4
|
+
// and draws them. Both sides clear on commit, cancel, and unmount.
|
|
5
|
+
|
|
6
|
+
import type { AlignmentGuide } from '@pascal-app/core'
|
|
7
|
+
import { create } from 'zustand'
|
|
8
|
+
|
|
9
|
+
type AlignmentGuidesState = {
|
|
10
|
+
guides: AlignmentGuide[]
|
|
11
|
+
set(guides: AlignmentGuide[]): void
|
|
12
|
+
clear(): void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const useAlignmentGuides = create<AlignmentGuidesState>((set) => ({
|
|
16
|
+
guides: [],
|
|
17
|
+
set: (guides) => set({ guides }),
|
|
18
|
+
clear: () => set({ guides: [] }),
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
export default useAlignmentGuides
|