@pascal-app/editor 0.9.2 → 1.0.0-beta.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 +9 -7
- package/src/components/editor/bake-exporter.tsx +3 -1
- package/src/components/editor/bake-thumbnail.tsx +95 -0
- package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
- package/src/components/editor/custom-camera-controls.tsx +307 -212
- package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
- package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +66 -2
- package/src/components/editor/first-person/build-collider-world.ts +20 -0
- package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
- package/src/components/editor/first-person/terrain-collider.ts +212 -0
- package/src/components/editor/first-person-controls.tsx +295 -132
- package/src/components/editor/floating-action-menu.tsx +59 -65
- package/src/components/editor/floorplan-background-selection.test.ts +16 -2
- package/src/components/editor/floorplan-background-selection.ts +4 -18
- package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
- package/src/components/editor/floorplan-camera-sync.ts +213 -0
- package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
- package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
- package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
- package/src/components/editor/floorplan-panel.tsx +931 -861
- package/src/components/editor/group-actions.ts +155 -15
- package/src/components/editor/group-floating-action-menu.tsx +1 -1
- package/src/components/editor/group-rotate-handle.tsx +1 -1
- package/src/components/editor/group-selection-box-3d.tsx +1 -1
- package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
- package/src/components/editor/handles/handle-drag-history.ts +14 -0
- package/src/components/editor/handles/preview-overrides.test.ts +26 -0
- package/src/components/editor/handles/preview-overrides.ts +13 -0
- package/src/components/editor/handles/resize-snap.test.ts +52 -0
- package/src/components/editor/handles/resize-snap.ts +23 -0
- package/src/components/editor/handles/use-handle-drag.ts +17 -7
- package/src/components/editor/index.tsx +42 -9
- package/src/components/editor/measurement-pill.tsx +6 -1
- package/src/components/editor/node-arrow-handles.tsx +51 -15
- package/src/components/editor/quick-measurement-card.tsx +9 -3
- package/src/components/editor/quick-measurement-hud.tsx +7 -1
- package/src/components/editor/selection-manager.tsx +3 -1
- package/src/components/editor/site-edge-labels.tsx +24 -2
- package/src/components/editor/snapshot-capture-overlay.tsx +4 -1
- package/src/components/editor/thumbnail-generator.tsx +66 -294
- package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
- package/src/components/editor/use-mesh-settle-epoch.ts +1 -0
- package/src/components/editor/wall-measurement-label.tsx +8 -16
- package/src/components/editor/wall-move-side-handles.tsx +193 -18
- package/src/components/editor/wall-opening-highlights.tsx +63 -32
- package/src/components/editor/wall-snap-beacon-layer.tsx +6 -4
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +39 -8
- package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +47 -4
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +21 -5
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +93 -59
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +27 -0
- package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +75 -1
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +92 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +93 -28
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +279 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +621 -281
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +12 -7
- package/src/components/tools/fence/fence-drafting.ts +27 -0
- package/src/components/tools/item/use-draft-node.ts +131 -72
- package/src/components/tools/item/use-placement-coordinator.tsx +52 -4
- package/src/components/tools/registry/move-registry-node-tool.tsx +75 -4
- package/src/components/tools/roof/roof-tool.tsx +16 -0
- package/src/components/tools/shared/floor-stack-preview.ts +4 -0
- package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
- package/src/components/tools/shared/pointer-support-cap.ts +273 -0
- package/src/components/tools/site/site-boundary-editor.tsx +13 -6
- package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
- package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
- package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
- package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
- package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
- package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
- package/src/components/tools/stair/stair-click-guard.ts +72 -0
- package/src/components/tools/stair/stair-tool.tsx +98 -47
- package/src/components/tools/tool-manager.tsx +67 -9
- package/src/components/tools/wall/wall-drafting.test.ts +118 -1
- package/src/components/tools/wall/wall-drafting.ts +128 -9
- package/src/components/tools/wall/wall-snap-geometry.test.ts +8 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +70 -3
- package/src/components/tools/zone/zone-tool.tsx +16 -0
- package/src/components/ui/action-menu/control-modes.tsx +4 -1
- package/src/components/ui/action-menu/measurement-control.tsx +107 -28
- package/src/components/ui/command-palette/editor-commands.tsx +14 -1
- package/src/components/ui/controls/material-paint-panel.tsx +7 -1
- package/src/components/ui/controls/material-picker.tsx +86 -7
- package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
- package/src/components/ui/floating-level-selector.tsx +73 -19
- package/src/components/ui/helpers/helper-manager.tsx +46 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
- package/src/components/ui/panels/panel-manager.tsx +14 -1
- package/src/components/ui/panels/parametric-inspector.tsx +4 -2
- package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
- package/src/components/ui/panels/selection-breakdown.ts +20 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +14 -4
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +16 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -0
- package/src/components/viewer/viewer-controls-bar.tsx +466 -0
- package/src/components/viewer/viewer-scene-header.tsx +235 -0
- package/src/components/viewer-overlay.tsx +20 -655
- package/src/components/walkthrough-hud.tsx +111 -0
- package/src/hooks/use-grid-events.ts +24 -0
- package/src/hooks/use-keyboard.ts +53 -30
- package/src/index.tsx +137 -4
- package/src/lib/active-placement-surface.test.ts +24 -0
- package/src/lib/active-placement-surface.ts +8 -1
- package/src/lib/camera-pose.test.ts +237 -0
- package/src/lib/camera-pose.ts +172 -0
- package/src/lib/direct-manipulation.test.ts +83 -0
- package/src/lib/direct-manipulation.ts +9 -0
- package/src/lib/door-interaction.ts +1 -1
- package/src/lib/elevation-guides.test.ts +107 -0
- package/src/lib/elevation-guides.ts +265 -0
- package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
- package/src/lib/floorplan/annotation-visibility.ts +133 -0
- package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
- package/src/lib/floorplan/drawing-coordination.ts +13 -0
- package/src/lib/floorplan/floorplan-export.test.ts +319 -0
- package/src/lib/floorplan/floorplan-export.tsx +745 -131
- package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
- package/src/lib/floorplan/floorplan-extension.ts +181 -0
- package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
- package/src/lib/floorplan/floorplan-mode.ts +73 -0
- package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
- package/src/lib/floorplan/geometry.ts +2 -3
- package/src/lib/fresh-planar-placement.test.ts +44 -1
- package/src/lib/fresh-planar-placement.ts +21 -4
- package/src/lib/glb-export.test.ts +116 -1
- package/src/lib/glb-export.ts +204 -7
- package/src/lib/ground-surface.ts +95 -0
- package/src/lib/history.test.ts +96 -0
- package/src/lib/history.ts +22 -0
- package/src/lib/interaction/overlay-policy.test.ts +2 -1
- package/src/lib/interaction/scope.ts +40 -8
- package/src/lib/level-duplication.test.ts +2 -1
- package/src/lib/measurements.test.ts +5 -0
- package/src/lib/measurements.ts +12 -1
- package/src/lib/quick-action-feedback.ts +33 -0
- package/src/lib/quick-action-nodes.test.ts +94 -0
- package/src/lib/quick-action-nodes.ts +47 -0
- package/src/lib/scene-clipboard.test.ts +130 -1
- package/src/lib/scene-clipboard.ts +237 -13
- package/src/lib/selection-routing.test.ts +16 -1
- package/src/lib/selection-routing.ts +5 -0
- package/src/lib/sfx-bus.ts +33 -2
- package/src/lib/sfx-player.test.ts +148 -6
- package/src/lib/sfx-player.ts +156 -8
- package/src/lib/site-boundary.test.ts +53 -0
- package/src/lib/site-boundary.ts +27 -0
- package/src/lib/snapping-mode.test.ts +29 -2
- package/src/lib/snapping-mode.ts +15 -3
- package/src/lib/stair-duplication.test.ts +103 -0
- package/src/lib/stair-duplication.ts +57 -85
- package/src/lib/stair-levels.ts +2 -0
- package/src/lib/terrain-sculpt.test.ts +429 -0
- package/src/lib/terrain-sculpt.ts +301 -0
- package/src/lib/terrain-verb-color.test.ts +51 -0
- package/src/lib/terrain-verb-color.ts +58 -0
- package/src/lib/touch-gesture-priority.test.ts +45 -0
- package/src/lib/touch-gesture-priority.ts +30 -0
- package/src/lib/window-interaction.ts +1 -1
- package/src/store/camera-pose-store.test.ts +29 -0
- package/src/store/camera-pose-store.ts +27 -0
- package/src/store/live-draft-preview-stores.test.ts +136 -0
- package/src/store/navigation-sync-pose-store.test.ts +34 -0
- package/src/store/navigation-sync-pose-store.ts +27 -0
- package/src/store/terrain-sculpt-mode.test.ts +223 -0
- package/src/store/use-delete-confirmation.ts +27 -0
- package/src/store/use-drawing-view.test.ts +23 -0
- package/src/store/use-drawing-view.ts +84 -0
- package/src/store/use-editor.tsx +184 -15
- package/src/store/use-elevation-guides.ts +40 -0
- package/src/store/use-fence-curve-draft.ts +38 -8
- package/src/store/use-first-person-hud.ts +40 -0
- package/src/store/use-floorplan-annotation-visibility.ts +60 -0
- package/src/store/use-floorplan-draft-preview.ts +54 -4
- package/src/store/use-floorplan-mode.ts +110 -0
- package/src/store/use-interaction-scope.test.ts +23 -3
- package/src/store/use-interaction-scope.ts +8 -0
- package/src/store/use-measurement-draft.test.ts +29 -0
- package/src/store/use-measurement-draft.ts +33 -6
- package/src/store/use-path-draft-preview.ts +90 -0
- package/src/store/use-stair-build-preview.ts +12 -2
- package/src/lib/floorplan/selection-tool.ts +0 -271
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNode,
|
|
3
|
+
type AnyNodeId,
|
|
4
|
+
findLevelAncestorId,
|
|
5
|
+
getWallBaseElevationForNodes,
|
|
6
|
+
getWallEffectiveHeightForNodes,
|
|
7
|
+
levelBaseElevationAt,
|
|
8
|
+
resolveCeilingHeight,
|
|
9
|
+
} from '@pascal-app/core'
|
|
10
|
+
import useElevationGuides from '../store/use-elevation-guides'
|
|
11
|
+
|
|
12
|
+
export const ELEVATION_ALIGNMENT_THRESHOLD_M = 0.08
|
|
13
|
+
const GUIDE_MATCH_EPSILON_M = 1e-4
|
|
14
|
+
|
|
15
|
+
export type ElevationGuideSource = {
|
|
16
|
+
nodeId: string
|
|
17
|
+
levelId: string | null | undefined
|
|
18
|
+
anchor: readonly [number, number]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type ElevationSnapTarget = {
|
|
22
|
+
id: string
|
|
23
|
+
elevation: number
|
|
24
|
+
anchor: readonly [number, number]
|
|
25
|
+
label: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ElevationSnapMatch = {
|
|
29
|
+
target: ElevationSnapTarget
|
|
30
|
+
elevation: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function polygonCenter(polygon: ReadonlyArray<readonly [number, number]>): [number, number] {
|
|
34
|
+
if (polygon.length === 0) return [0, 0]
|
|
35
|
+
let x = 0
|
|
36
|
+
let z = 0
|
|
37
|
+
for (const point of polygon) {
|
|
38
|
+
x += point[0]
|
|
39
|
+
z += point[1]
|
|
40
|
+
}
|
|
41
|
+
return [x / polygon.length, z / polygon.length]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function segmentCenter(
|
|
45
|
+
start: readonly [number, number],
|
|
46
|
+
end: readonly [number, number],
|
|
47
|
+
): [number, number] {
|
|
48
|
+
return [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Mirrors `resolveFenceLiftElevationForNodes` in `@pascal-app/nodes`, which this
|
|
52
|
+
// package cannot import (the fence definition imports the guides from here).
|
|
53
|
+
// A stale host resolves to the level base — the sculpted ground under the
|
|
54
|
+
// fence's start point, sampled where the builder samples it, so the guide line
|
|
55
|
+
// lands on the rail it claims to describe.
|
|
56
|
+
function fenceBaseElevation(node: AnyNode, nodes: Record<string, AnyNode>): number {
|
|
57
|
+
if (node.type !== 'fence') return 0
|
|
58
|
+
const host = node.supportSlabId ? nodes[node.supportSlabId as AnyNodeId] : undefined
|
|
59
|
+
const hosted = host?.type === 'slab' && (host.parentId ?? null) === (node.parentId ?? null)
|
|
60
|
+
const levelId = findLevelAncestorId(node.id as AnyNodeId, nodes)
|
|
61
|
+
const support = hosted
|
|
62
|
+
? (host.elevation ?? 0)
|
|
63
|
+
: levelId
|
|
64
|
+
? levelBaseElevationAt(nodes, levelId, node.start[0], node.start[1])
|
|
65
|
+
: 0
|
|
66
|
+
return support + (node.supportOffset ?? 0)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Structural Y datums on the source node's level. This is editor-runtime
|
|
71
|
+
* collection over authoritative vertical resolvers; matching remains a pure
|
|
72
|
+
* scalar operation in {@link resolveElevationSnapMatch}.
|
|
73
|
+
*/
|
|
74
|
+
export function collectElevationSnapTargets(
|
|
75
|
+
source: ElevationGuideSource,
|
|
76
|
+
nodes: Record<string, AnyNode>,
|
|
77
|
+
): ElevationSnapTarget[] {
|
|
78
|
+
if (!source.levelId) return []
|
|
79
|
+
|
|
80
|
+
const targets: ElevationSnapTarget[] = [
|
|
81
|
+
{
|
|
82
|
+
id: `${source.levelId}:level`,
|
|
83
|
+
elevation: 0,
|
|
84
|
+
anchor: source.anchor,
|
|
85
|
+
label: 'Level',
|
|
86
|
+
},
|
|
87
|
+
]
|
|
88
|
+
// Sculpted ground under the thing being dragged. A separate target rather than
|
|
89
|
+
// a redefinition of `Level`: the storey plane is still a real datum a user may
|
|
90
|
+
// want (a fence sunk to the building's floor line), and on a hillside the
|
|
91
|
+
// ground is a second, different one. Emitted only when they actually differ,
|
|
92
|
+
// so a flat scene keeps exactly one target at 0.
|
|
93
|
+
const groundElevation = levelBaseElevationAt(
|
|
94
|
+
nodes,
|
|
95
|
+
source.levelId,
|
|
96
|
+
source.anchor[0],
|
|
97
|
+
source.anchor[1],
|
|
98
|
+
)
|
|
99
|
+
if (Math.abs(groundElevation) > GUIDE_MATCH_EPSILON_M) {
|
|
100
|
+
targets.push({
|
|
101
|
+
id: `${source.levelId}:ground`,
|
|
102
|
+
elevation: groundElevation,
|
|
103
|
+
anchor: source.anchor,
|
|
104
|
+
label: 'Ground',
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
const level = nodes[source.levelId as AnyNodeId]
|
|
108
|
+
if (level?.type !== 'level') return targets
|
|
109
|
+
|
|
110
|
+
for (const childId of level.children) {
|
|
111
|
+
if (childId === source.nodeId) continue
|
|
112
|
+
const node = nodes[childId as AnyNodeId]
|
|
113
|
+
if (!node) continue
|
|
114
|
+
|
|
115
|
+
if (node.type === 'slab') {
|
|
116
|
+
const center = polygonCenter(node.polygon)
|
|
117
|
+
const top = node.elevation ?? 0.05
|
|
118
|
+
targets.push({
|
|
119
|
+
id: `${node.id}:top`,
|
|
120
|
+
elevation: top,
|
|
121
|
+
anchor: center,
|
|
122
|
+
label: 'Slab top',
|
|
123
|
+
})
|
|
124
|
+
if (!node.recessed) {
|
|
125
|
+
targets.push({
|
|
126
|
+
id: `${node.id}:base`,
|
|
127
|
+
elevation: top - (node.thickness ?? 0.05),
|
|
128
|
+
anchor: center,
|
|
129
|
+
label: 'Slab underside',
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (node.type === 'ceiling') {
|
|
136
|
+
targets.push({
|
|
137
|
+
id: `${node.id}:ceiling`,
|
|
138
|
+
elevation: resolveCeilingHeight(node, nodes as Record<AnyNodeId, AnyNode>),
|
|
139
|
+
anchor: polygonCenter(node.polygon),
|
|
140
|
+
label: 'Ceiling',
|
|
141
|
+
})
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (node.type === 'wall') {
|
|
146
|
+
const base = getWallBaseElevationForNodes(node, nodes)
|
|
147
|
+
const center = segmentCenter(node.start, node.end)
|
|
148
|
+
targets.push({
|
|
149
|
+
id: `${node.id}:base`,
|
|
150
|
+
elevation: base,
|
|
151
|
+
anchor: center,
|
|
152
|
+
label: 'Wall base',
|
|
153
|
+
})
|
|
154
|
+
targets.push({
|
|
155
|
+
id: `${node.id}:top`,
|
|
156
|
+
elevation: base + getWallEffectiveHeightForNodes(node, nodes),
|
|
157
|
+
anchor: center,
|
|
158
|
+
label: 'Wall top',
|
|
159
|
+
})
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (node.type === 'fence') {
|
|
164
|
+
const base = fenceBaseElevation(node, nodes)
|
|
165
|
+
const center = segmentCenter(node.start, node.end)
|
|
166
|
+
targets.push({
|
|
167
|
+
id: `${node.id}:base`,
|
|
168
|
+
elevation: base,
|
|
169
|
+
anchor: center,
|
|
170
|
+
label: 'Fence base',
|
|
171
|
+
})
|
|
172
|
+
targets.push({
|
|
173
|
+
id: `${node.id}:top`,
|
|
174
|
+
elevation: base + (node.height ?? 1.8),
|
|
175
|
+
anchor: center,
|
|
176
|
+
label: 'Fence top',
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return targets
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function resolveElevationSnapMatch(
|
|
185
|
+
proposedElevation: number,
|
|
186
|
+
sourceAnchor: readonly [number, number],
|
|
187
|
+
targets: readonly ElevationSnapTarget[],
|
|
188
|
+
threshold = ELEVATION_ALIGNMENT_THRESHOLD_M,
|
|
189
|
+
): ElevationSnapMatch | null {
|
|
190
|
+
let best: ElevationSnapTarget | null = null
|
|
191
|
+
let bestDelta = Number.POSITIVE_INFINITY
|
|
192
|
+
let bestPlanDistance = Number.POSITIVE_INFINITY
|
|
193
|
+
|
|
194
|
+
for (const target of targets) {
|
|
195
|
+
const delta = Math.abs(target.elevation - proposedElevation)
|
|
196
|
+
if (delta > threshold) continue
|
|
197
|
+
const dx = target.anchor[0] - sourceAnchor[0]
|
|
198
|
+
const dz = target.anchor[1] - sourceAnchor[1]
|
|
199
|
+
const planDistance = dx * dx + dz * dz
|
|
200
|
+
if (
|
|
201
|
+
delta < bestDelta - 1e-9 ||
|
|
202
|
+
(Math.abs(delta - bestDelta) <= 1e-9 && planDistance < bestPlanDistance)
|
|
203
|
+
) {
|
|
204
|
+
best = target
|
|
205
|
+
bestDelta = delta
|
|
206
|
+
bestPlanDistance = planDistance
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return best ? { target: best, elevation: best.elevation } : null
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function resolveStructuralElevationSnap(
|
|
214
|
+
source: ElevationGuideSource,
|
|
215
|
+
proposedElevation: number,
|
|
216
|
+
nodes: Record<string, AnyNode>,
|
|
217
|
+
): number {
|
|
218
|
+
return (
|
|
219
|
+
resolveElevationSnapMatch(
|
|
220
|
+
proposedElevation,
|
|
221
|
+
source.anchor,
|
|
222
|
+
collectElevationSnapTargets(source, nodes),
|
|
223
|
+
)?.elevation ?? proposedElevation
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function publishStructuralElevationGuide(
|
|
228
|
+
source: ElevationGuideSource,
|
|
229
|
+
elevation: number,
|
|
230
|
+
nodes: Record<string, AnyNode>,
|
|
231
|
+
): void {
|
|
232
|
+
if (!source.levelId) {
|
|
233
|
+
clearStructuralElevationGuide(source.nodeId)
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const match = resolveElevationSnapMatch(
|
|
238
|
+
elevation,
|
|
239
|
+
source.anchor,
|
|
240
|
+
collectElevationSnapTargets(source, nodes),
|
|
241
|
+
GUIDE_MATCH_EPSILON_M,
|
|
242
|
+
)
|
|
243
|
+
if (!match) {
|
|
244
|
+
clearStructuralElevationGuide(source.nodeId)
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const dx = match.target.anchor[0] - source.anchor[0]
|
|
249
|
+
const dz = match.target.anchor[1] - source.anchor[1]
|
|
250
|
+
const length = Math.hypot(dx, dz)
|
|
251
|
+
const direction: [number, number] = length > 1e-6 ? [dx / length, dz / length] : [1, 0]
|
|
252
|
+
|
|
253
|
+
useElevationGuides.getState().publish({
|
|
254
|
+
ownerId: source.nodeId,
|
|
255
|
+
levelId: source.levelId,
|
|
256
|
+
center: source.anchor,
|
|
257
|
+
direction,
|
|
258
|
+
elevation: match.elevation,
|
|
259
|
+
label: match.target.label,
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function clearStructuralElevationGuide(ownerId: string): void {
|
|
264
|
+
useElevationGuides.getState().clear(ownerId)
|
|
265
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { FloorplanGeometry } from '@pascal-app/core'
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
|
|
5
|
+
filterFloorplanAnnotationGeometry,
|
|
6
|
+
normalizeFloorplanAnnotationVisibility,
|
|
7
|
+
} from './annotation-visibility'
|
|
8
|
+
import { floorplanGeometryMetadata, withFloorplanGeometryMetadata } from './floorplan-extension'
|
|
9
|
+
|
|
10
|
+
describe('floor-plan annotation visibility', () => {
|
|
11
|
+
test('fills missing persisted categories with visible defaults', () => {
|
|
12
|
+
expect(normalizeFloorplanAnnotationVisibility({ measurements: false })).toEqual({
|
|
13
|
+
...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
|
|
14
|
+
measurements: false,
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('removes automatic dimension primitives without removing plan geometry', () => {
|
|
19
|
+
const line = { kind: 'line', x1: 0, y1: 0, x2: 2, y2: 0 } satisfies FloorplanGeometry
|
|
20
|
+
const geometry = {
|
|
21
|
+
kind: 'group',
|
|
22
|
+
children: [
|
|
23
|
+
line,
|
|
24
|
+
{
|
|
25
|
+
kind: 'dimension-string',
|
|
26
|
+
segments: [{ start: [0, 0], end: [2, 0], text: '2.00m' }],
|
|
27
|
+
offsetNormal: [0, 1],
|
|
28
|
+
offsetDistance: 0.3,
|
|
29
|
+
extensionOvershoot: 0.1,
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
} satisfies FloorplanGeometry
|
|
33
|
+
|
|
34
|
+
expect(
|
|
35
|
+
filterFloorplanAnnotationGeometry(geometry, {
|
|
36
|
+
...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
|
|
37
|
+
automaticDimensions: false,
|
|
38
|
+
}),
|
|
39
|
+
).toEqual({ kind: 'group', children: [line] })
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('removes a complete curved automatic dimension group', () => {
|
|
43
|
+
const curvedDimension = {
|
|
44
|
+
kind: 'group',
|
|
45
|
+
metadata: floorplanGeometryMetadata({ annotationRole: 'automatic-dimension' }),
|
|
46
|
+
children: [
|
|
47
|
+
{ kind: 'line', x1: 0, y1: 0, x2: 2, y2: 2 },
|
|
48
|
+
{ kind: 'dimension-label', cx: 1, cy: 1, text: 'R 2m', angle: 0 },
|
|
49
|
+
],
|
|
50
|
+
} satisfies FloorplanGeometry
|
|
51
|
+
|
|
52
|
+
expect(
|
|
53
|
+
filterFloorplanAnnotationGeometry(curvedDimension, {
|
|
54
|
+
...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
|
|
55
|
+
automaticDimensions: false,
|
|
56
|
+
}),
|
|
57
|
+
).toBeNull()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('removes only the opening mark from door geometry', () => {
|
|
61
|
+
const body = {
|
|
62
|
+
kind: 'polygon',
|
|
63
|
+
points: [
|
|
64
|
+
[0, 0],
|
|
65
|
+
[1, 0],
|
|
66
|
+
[1, 0.1],
|
|
67
|
+
],
|
|
68
|
+
} satisfies FloorplanGeometry
|
|
69
|
+
const mark = {
|
|
70
|
+
kind: 'group',
|
|
71
|
+
metadata: floorplanGeometryMetadata({ annotationRole: 'opening-mark' }),
|
|
72
|
+
children: [
|
|
73
|
+
{ kind: 'line', x1: 0.5, y1: 0, x2: 0.5, y2: 0.5 },
|
|
74
|
+
{ kind: 'rect', x: 0.3, y: 0.5, width: 0.4, height: 0.3 },
|
|
75
|
+
{ kind: 'text', x: 0.5, y: 0.65, text: '101', fontSize: 0.15, upright: true },
|
|
76
|
+
],
|
|
77
|
+
} satisfies FloorplanGeometry
|
|
78
|
+
const geometry = { kind: 'group', children: [body, mark] } satisfies FloorplanGeometry
|
|
79
|
+
|
|
80
|
+
expect(
|
|
81
|
+
filterFloorplanAnnotationGeometry(geometry, {
|
|
82
|
+
...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
|
|
83
|
+
openingMarks: false,
|
|
84
|
+
}),
|
|
85
|
+
).toEqual({ kind: 'group', children: [body] })
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('hides manual dimensions and measurements independently', () => {
|
|
89
|
+
const manualDimension = {
|
|
90
|
+
kind: 'text',
|
|
91
|
+
x: 0,
|
|
92
|
+
y: 0,
|
|
93
|
+
text: 'Annotation',
|
|
94
|
+
fontSize: 0.15,
|
|
95
|
+
metadata: floorplanGeometryMetadata({ annotationRole: 'manual-dimension' }),
|
|
96
|
+
} satisfies FloorplanGeometry
|
|
97
|
+
const measurement = {
|
|
98
|
+
...manualDimension,
|
|
99
|
+
metadata: floorplanGeometryMetadata({ annotationRole: 'measurement' }),
|
|
100
|
+
} satisfies FloorplanGeometry
|
|
101
|
+
|
|
102
|
+
expect(
|
|
103
|
+
filterFloorplanAnnotationGeometry(manualDimension, {
|
|
104
|
+
...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
|
|
105
|
+
manualDimensions: false,
|
|
106
|
+
}),
|
|
107
|
+
).toBeNull()
|
|
108
|
+
expect(
|
|
109
|
+
filterFloorplanAnnotationGeometry(
|
|
110
|
+
{
|
|
111
|
+
kind: 'group',
|
|
112
|
+
metadata: floorplanGeometryMetadata({ annotationRole: 'manual-dimension' }),
|
|
113
|
+
children: [
|
|
114
|
+
{
|
|
115
|
+
kind: 'dimension',
|
|
116
|
+
start: [0, 0],
|
|
117
|
+
end: [1, 0],
|
|
118
|
+
offsetNormal: [0, 1],
|
|
119
|
+
offsetDistance: 0.5,
|
|
120
|
+
extensionOvershoot: 0.1,
|
|
121
|
+
text: '1m',
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
|
|
127
|
+
automaticDimensions: false,
|
|
128
|
+
},
|
|
129
|
+
),
|
|
130
|
+
).not.toBeNull()
|
|
131
|
+
expect(
|
|
132
|
+
filterFloorplanAnnotationGeometry(measurement, {
|
|
133
|
+
...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
|
|
134
|
+
measurements: false,
|
|
135
|
+
}),
|
|
136
|
+
).toBeNull()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('keeps a contextual dimension when automatic dimensions are hidden', () => {
|
|
140
|
+
const contextualDimension = {
|
|
141
|
+
kind: 'group',
|
|
142
|
+
metadata: floorplanGeometryMetadata({ annotationRole: 'contextual-dimension' }),
|
|
143
|
+
children: [
|
|
144
|
+
{
|
|
145
|
+
kind: 'dimension',
|
|
146
|
+
start: [0, 0],
|
|
147
|
+
end: [2, 0],
|
|
148
|
+
offsetNormal: [0, 1],
|
|
149
|
+
offsetDistance: 0.3,
|
|
150
|
+
extensionOvershoot: 0.08,
|
|
151
|
+
text: '2m',
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
} satisfies FloorplanGeometry
|
|
155
|
+
|
|
156
|
+
expect(
|
|
157
|
+
filterFloorplanAnnotationGeometry(contextualDimension, {
|
|
158
|
+
...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
|
|
159
|
+
automaticDimensions: false,
|
|
160
|
+
contextualDimensions: true,
|
|
161
|
+
}),
|
|
162
|
+
).toEqual(contextualDimension)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('removes a nested automatic wall string beside a contextual dimension', () => {
|
|
166
|
+
const wallBody = {
|
|
167
|
+
kind: 'polygon',
|
|
168
|
+
points: [
|
|
169
|
+
[0, 0],
|
|
170
|
+
[0.2, 0],
|
|
171
|
+
[0.2, 6],
|
|
172
|
+
[0, 6],
|
|
173
|
+
],
|
|
174
|
+
} satisfies FloorplanGeometry
|
|
175
|
+
const automaticDimension = {
|
|
176
|
+
kind: 'dimension-string',
|
|
177
|
+
segments: [{ start: [0, 0], end: [0, 18], text: '18m' }],
|
|
178
|
+
offsetNormal: [1, 0],
|
|
179
|
+
offsetDistance: 0.55,
|
|
180
|
+
extensionOvershoot: 0.12,
|
|
181
|
+
} satisfies FloorplanGeometry
|
|
182
|
+
const contextualDimension = withFloorplanGeometryMetadata(
|
|
183
|
+
{
|
|
184
|
+
kind: 'dimension',
|
|
185
|
+
start: [0, 0],
|
|
186
|
+
end: [0, 6],
|
|
187
|
+
offsetNormal: [1, 0],
|
|
188
|
+
offsetDistance: 0.34,
|
|
189
|
+
extensionOvershoot: 0.08,
|
|
190
|
+
text: '6m',
|
|
191
|
+
} satisfies FloorplanGeometry,
|
|
192
|
+
{ annotationRole: 'contextual-dimension' },
|
|
193
|
+
)
|
|
194
|
+
const geometry = {
|
|
195
|
+
kind: 'group',
|
|
196
|
+
children: [
|
|
197
|
+
{
|
|
198
|
+
kind: 'group',
|
|
199
|
+
children: [wallBody, automaticDimension],
|
|
200
|
+
},
|
|
201
|
+
contextualDimension,
|
|
202
|
+
],
|
|
203
|
+
} satisfies FloorplanGeometry
|
|
204
|
+
|
|
205
|
+
expect(
|
|
206
|
+
filterFloorplanAnnotationGeometry(geometry, {
|
|
207
|
+
...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
|
|
208
|
+
automaticDimensions: false,
|
|
209
|
+
contextualDimensions: true,
|
|
210
|
+
}),
|
|
211
|
+
).toEqual({
|
|
212
|
+
kind: 'group',
|
|
213
|
+
children: [{ kind: 'group', children: [wallBody] }, contextualDimension],
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
test('hides structural grids and only the center marks within column geometry', () => {
|
|
218
|
+
const centerMark = {
|
|
219
|
+
kind: 'line',
|
|
220
|
+
x1: -0.1,
|
|
221
|
+
y1: -0.1,
|
|
222
|
+
x2: 0.1,
|
|
223
|
+
y2: 0.1,
|
|
224
|
+
metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }),
|
|
225
|
+
} satisfies FloorplanGeometry
|
|
226
|
+
const gridReference = {
|
|
227
|
+
kind: 'text',
|
|
228
|
+
x: 0,
|
|
229
|
+
y: 0.3,
|
|
230
|
+
text: 'B-2',
|
|
231
|
+
fontSize: 0.13,
|
|
232
|
+
metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }),
|
|
233
|
+
} satisfies FloorplanGeometry
|
|
234
|
+
const footprint = {
|
|
235
|
+
kind: 'rect',
|
|
236
|
+
x: -0.2,
|
|
237
|
+
y: -0.2,
|
|
238
|
+
width: 0.4,
|
|
239
|
+
height: 0.4,
|
|
240
|
+
} satisfies FloorplanGeometry
|
|
241
|
+
const visibility = {
|
|
242
|
+
...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
|
|
243
|
+
structuralGrids: false,
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
expect(
|
|
247
|
+
filterFloorplanAnnotationGeometry(
|
|
248
|
+
{
|
|
249
|
+
kind: 'group',
|
|
250
|
+
children: [footprint, centerMark, gridReference],
|
|
251
|
+
},
|
|
252
|
+
visibility,
|
|
253
|
+
),
|
|
254
|
+
).toEqual({ kind: 'group', children: [footprint] })
|
|
255
|
+
expect(
|
|
256
|
+
filterFloorplanAnnotationGeometry(
|
|
257
|
+
{
|
|
258
|
+
kind: 'group',
|
|
259
|
+
metadata: floorplanGeometryMetadata({ annotationRole: 'structural-grid' }),
|
|
260
|
+
children: [footprint],
|
|
261
|
+
},
|
|
262
|
+
visibility,
|
|
263
|
+
),
|
|
264
|
+
).toBeNull()
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
test('hides room labels without removing the room footprint', () => {
|
|
268
|
+
const footprint = {
|
|
269
|
+
kind: 'polygon',
|
|
270
|
+
points: [
|
|
271
|
+
[0, 0],
|
|
272
|
+
[4, 0],
|
|
273
|
+
[4, 3],
|
|
274
|
+
],
|
|
275
|
+
} satisfies FloorplanGeometry
|
|
276
|
+
const roomName = {
|
|
277
|
+
kind: 'text',
|
|
278
|
+
x: 2,
|
|
279
|
+
y: 1.5,
|
|
280
|
+
text: 'Office',
|
|
281
|
+
fontSize: 0.2,
|
|
282
|
+
metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }),
|
|
283
|
+
} satisfies FloorplanGeometry
|
|
284
|
+
|
|
285
|
+
expect(
|
|
286
|
+
filterFloorplanAnnotationGeometry(
|
|
287
|
+
{ kind: 'group', children: [footprint, roomName] },
|
|
288
|
+
{ ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, roomLabels: false },
|
|
289
|
+
),
|
|
290
|
+
).toEqual({ kind: 'group', children: [footprint] })
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
test('hides stair notes and break lines without removing stair geometry', () => {
|
|
294
|
+
const footprint = {
|
|
295
|
+
kind: 'polygon',
|
|
296
|
+
points: [
|
|
297
|
+
[0, 0],
|
|
298
|
+
[1, 0],
|
|
299
|
+
[1, 3],
|
|
300
|
+
[0, 3],
|
|
301
|
+
],
|
|
302
|
+
} satisfies FloorplanGeometry
|
|
303
|
+
const direction = {
|
|
304
|
+
kind: 'text',
|
|
305
|
+
x: 0.5,
|
|
306
|
+
y: 0.5,
|
|
307
|
+
text: 'UP',
|
|
308
|
+
fontSize: 0.16,
|
|
309
|
+
metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }),
|
|
310
|
+
} satisfies FloorplanGeometry
|
|
311
|
+
const breakLine = {
|
|
312
|
+
kind: 'polyline',
|
|
313
|
+
points: [
|
|
314
|
+
[0, 2],
|
|
315
|
+
[1, 2],
|
|
316
|
+
],
|
|
317
|
+
metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }),
|
|
318
|
+
} satisfies FloorplanGeometry
|
|
319
|
+
|
|
320
|
+
expect(
|
|
321
|
+
filterFloorplanAnnotationGeometry(
|
|
322
|
+
{ kind: 'group', children: [footprint, direction, breakLine] },
|
|
323
|
+
{ ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, stairAnnotations: false },
|
|
324
|
+
),
|
|
325
|
+
).toEqual({ kind: 'group', children: [footprint] })
|
|
326
|
+
})
|
|
327
|
+
})
|