@pascal-app/editor 0.9.2 → 1.0.0-beta.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 +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 +236 -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
|
@@ -12,3 +12,95 @@ export function resolveFloorplanLabelAngle(
|
|
|
12
12
|
else if (screenAngleDeg <= -90) localAngleDeg += 180
|
|
13
13
|
return ((((localAngleDeg + 180) % 360) + 360) % 360) - 180
|
|
14
14
|
}
|
|
15
|
+
|
|
16
|
+
export function shouldUpdateFloorplanLabelRotation(
|
|
17
|
+
previousRotationDeg: number | null,
|
|
18
|
+
nextRotationDeg: number,
|
|
19
|
+
minimumDeltaDeg = 1,
|
|
20
|
+
): boolean {
|
|
21
|
+
if (previousRotationDeg === null) return true
|
|
22
|
+
const deltaDeg = ((((nextRotationDeg - previousRotationDeg + 180) % 360) + 360) % 360) - 180
|
|
23
|
+
return Math.abs(deltaDeg) >= minimumDeltaDeg
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function resolveFloorplanAnnotationUpdate({
|
|
27
|
+
layoutInputsChanged,
|
|
28
|
+
previousRotationDeg,
|
|
29
|
+
nextRotationDeg,
|
|
30
|
+
}: {
|
|
31
|
+
layoutInputsChanged: boolean
|
|
32
|
+
previousRotationDeg: number | null
|
|
33
|
+
nextRotationDeg: number
|
|
34
|
+
}): {
|
|
35
|
+
resolveCollisions: boolean
|
|
36
|
+
updateLabelPresentation: boolean
|
|
37
|
+
} {
|
|
38
|
+
return {
|
|
39
|
+
resolveCollisions: layoutInputsChanged,
|
|
40
|
+
updateLabelPresentation:
|
|
41
|
+
layoutInputsChanged ||
|
|
42
|
+
shouldUpdateFloorplanLabelRotation(previousRotationDeg, nextRotationDeg),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function resolveFloorplanAnnotationLabelTransform({
|
|
47
|
+
angleRadians,
|
|
48
|
+
sceneRotationDeg,
|
|
49
|
+
screenUpright,
|
|
50
|
+
beforeRotation,
|
|
51
|
+
afterRotation,
|
|
52
|
+
layoutDx = 0,
|
|
53
|
+
layoutDy = 0,
|
|
54
|
+
}: {
|
|
55
|
+
angleRadians: number
|
|
56
|
+
sceneRotationDeg: number
|
|
57
|
+
screenUpright: boolean
|
|
58
|
+
beforeRotation: string
|
|
59
|
+
afterRotation: string
|
|
60
|
+
layoutDx?: number
|
|
61
|
+
layoutDy?: number
|
|
62
|
+
}): {
|
|
63
|
+
defaultTransform: string
|
|
64
|
+
transform: string
|
|
65
|
+
} {
|
|
66
|
+
const degrees = resolveFloorplanLabelAngle(angleRadians, sceneRotationDeg, screenUpright)
|
|
67
|
+
const defaultTransform = [beforeRotation, `rotate(${degrees})`, afterRotation]
|
|
68
|
+
.filter(Boolean)
|
|
69
|
+
.join(' ')
|
|
70
|
+
return {
|
|
71
|
+
defaultTransform,
|
|
72
|
+
transform:
|
|
73
|
+
layoutDx === 0 && layoutDy === 0
|
|
74
|
+
? defaultTransform
|
|
75
|
+
: `${defaultTransform} translate(${layoutDx} ${layoutDy})`,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function updateSvgFloorplanLabelOrientations(
|
|
80
|
+
labels: Iterable<SVGGElement>,
|
|
81
|
+
sceneRotationDeg: number,
|
|
82
|
+
): number {
|
|
83
|
+
let updated = 0
|
|
84
|
+
|
|
85
|
+
for (const label of labels) {
|
|
86
|
+
const angleRadians = Number(label.dataset.floorplanAnnotationAngleRadians)
|
|
87
|
+
if (!Number.isFinite(angleRadians)) continue
|
|
88
|
+
|
|
89
|
+
const { defaultTransform, transform } = resolveFloorplanAnnotationLabelTransform({
|
|
90
|
+
angleRadians,
|
|
91
|
+
sceneRotationDeg,
|
|
92
|
+
screenUpright: label.dataset.floorplanAnnotationScreenUpright === 'true',
|
|
93
|
+
beforeRotation: label.dataset.floorplanAnnotationTransformBeforeRotation ?? '',
|
|
94
|
+
afterRotation: label.dataset.floorplanAnnotationTransformAfterRotation ?? '',
|
|
95
|
+
layoutDx: Number(label.dataset.floorplanAnnotationLayoutDx ?? 0),
|
|
96
|
+
layoutDy: Number(label.dataset.floorplanAnnotationLayoutDy ?? 0),
|
|
97
|
+
})
|
|
98
|
+
if (label.getAttribute('transform') === transform) continue
|
|
99
|
+
|
|
100
|
+
label.dataset.floorplanAnnotationDefaultTransform = defaultTransform
|
|
101
|
+
label.setAttribute('transform', transform)
|
|
102
|
+
updated += 1
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return updated
|
|
106
|
+
}
|
|
@@ -8,10 +8,98 @@ import {
|
|
|
8
8
|
nodeRegistry,
|
|
9
9
|
useScene,
|
|
10
10
|
} from '@pascal-app/core'
|
|
11
|
-
import {
|
|
11
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
12
|
+
import { memo, useMemo } from 'react'
|
|
12
13
|
import usePlacementPreview from '../../../store/use-placement-preview'
|
|
14
|
+
import { useFloorplanRender } from '../floorplan-render-context'
|
|
13
15
|
import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
|
|
14
16
|
|
|
17
|
+
export interface FloorplanNodePreviewProps {
|
|
18
|
+
node: AnyNode
|
|
19
|
+
parentNode?: AnyNode | null
|
|
20
|
+
opacity?: number
|
|
21
|
+
className?: string
|
|
22
|
+
selected?: boolean
|
|
23
|
+
highlighted?: boolean
|
|
24
|
+
hovered?: boolean
|
|
25
|
+
moving?: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Stateless floor-plan ghost for an already-positioned node. Hosts can use
|
|
30
|
+
* this to render host-owned placement previews without publishing transient
|
|
31
|
+
* state into the editor's local placement-preview store.
|
|
32
|
+
*/
|
|
33
|
+
export const FloorplanNodePreview = memo(function FloorplanNodePreview({
|
|
34
|
+
node,
|
|
35
|
+
parentNode = null,
|
|
36
|
+
opacity = 0.5,
|
|
37
|
+
className,
|
|
38
|
+
selected = false,
|
|
39
|
+
highlighted = false,
|
|
40
|
+
hovered = false,
|
|
41
|
+
moving = false,
|
|
42
|
+
}: FloorplanNodePreviewProps) {
|
|
43
|
+
const nodes = useScene((state) => state.nodes)
|
|
44
|
+
const unit = useViewer((state) => state.unit)
|
|
45
|
+
const renderContext = useFloorplanRender()
|
|
46
|
+
|
|
47
|
+
const geometry = useMemo(() => {
|
|
48
|
+
const definition = nodeRegistry.get(node.type)
|
|
49
|
+
const builder = definition?.floorplan
|
|
50
|
+
if (!builder) return null
|
|
51
|
+
|
|
52
|
+
const contextNodes: Record<string, AnyNode> = {
|
|
53
|
+
...(nodes as Record<string, AnyNode>),
|
|
54
|
+
[node.id]: node,
|
|
55
|
+
}
|
|
56
|
+
if (parentNode) contextNodes[parentNode.id] = parentNode
|
|
57
|
+
const resolvedParent =
|
|
58
|
+
parentNode ?? (node.parentId ? (contextNodes[node.parentId] ?? null) : null)
|
|
59
|
+
const childIds = (node as AnyNode & { children?: AnyNodeId[] }).children ?? []
|
|
60
|
+
const children = childIds.flatMap((id) => {
|
|
61
|
+
const child = contextNodes[id]
|
|
62
|
+
return child ? [child] : []
|
|
63
|
+
})
|
|
64
|
+
const siblings = Object.values(contextNodes).filter(
|
|
65
|
+
(candidate) =>
|
|
66
|
+
candidate.id !== node.id &&
|
|
67
|
+
candidate.type === node.type &&
|
|
68
|
+
candidate.parentId === node.parentId,
|
|
69
|
+
)
|
|
70
|
+
const levelData = definition.computeFloorplanLevelData?.({
|
|
71
|
+
siblings: [node, ...siblings],
|
|
72
|
+
nodes: contextNodes,
|
|
73
|
+
})
|
|
74
|
+
const ctx: GeometryContext = {
|
|
75
|
+
resolve: <N = AnyNode>(id: AnyNodeId) => contextNodes[id] as N | undefined,
|
|
76
|
+
children,
|
|
77
|
+
siblings,
|
|
78
|
+
parent: resolvedParent,
|
|
79
|
+
levelData,
|
|
80
|
+
viewState: renderContext
|
|
81
|
+
? {
|
|
82
|
+
selected,
|
|
83
|
+
unit,
|
|
84
|
+
highlighted,
|
|
85
|
+
hovered,
|
|
86
|
+
moving,
|
|
87
|
+
palette: renderContext.palette,
|
|
88
|
+
}
|
|
89
|
+
: undefined,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(node, ctx)
|
|
93
|
+
}, [highlighted, hovered, moving, node, nodes, parentNode, renderContext, selected, unit])
|
|
94
|
+
if (!geometry) return null
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<g className={className} opacity={opacity} pointerEvents="none">
|
|
98
|
+
<FloorplanGeometryRenderer geometry={geometry} pointerEventsOverride="none" />
|
|
99
|
+
</g>
|
|
100
|
+
)
|
|
101
|
+
})
|
|
102
|
+
|
|
15
103
|
/**
|
|
16
104
|
* Renders a faint, non-interactive ghost of the node being placed by a
|
|
17
105
|
* registry placement tool (e.g. column), following the cursor in the floor
|
|
@@ -20,7 +108,8 @@ import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
|
|
|
20
108
|
* cursor dot + alignment guides — no sense of the footprint they were about
|
|
21
109
|
* to drop. The placement tool publishes a transient, already-positioned +
|
|
22
110
|
* aligned node to `usePlacementPreview`; we build its `def.floorplan`
|
|
23
|
-
* footprint with
|
|
111
|
+
* footprint with active sibling, level-data, and theme context so kind-specific
|
|
112
|
+
* shapes match the committed renderer.
|
|
24
113
|
*
|
|
25
114
|
* Mounted inside the floor-plan scene `<g>` so the geometry's level-local
|
|
26
115
|
* meters get the same world→SVG transform every other entry does.
|
|
@@ -30,33 +119,9 @@ export const FloorplanPlacementPreviewLayer = memo(function FloorplanPlacementPr
|
|
|
30
119
|
const parentNode = usePlacementPreview((s) => s.parentNode)
|
|
31
120
|
if (!node) return null
|
|
32
121
|
|
|
33
|
-
const builder = nodeRegistry.get(node.type)?.floorplan
|
|
34
|
-
if (!builder) return null
|
|
35
|
-
|
|
36
|
-
// Minimal, unselected context — preview never shows selection chrome
|
|
37
|
-
// (move handles / resize arrows / hatch live behind `viewState.selected`).
|
|
38
|
-
// `resolve` reads the scene lazily (a builder rarely calls it for a ghost,
|
|
39
|
-
// and `parent: null` short-circuits the elevator's level walk) so the layer
|
|
40
|
-
// never subscribes to / bulk-reads the nodes map during render.
|
|
41
|
-
// `parentNode` is the synthetic wall for an off-wall door/window ghost so
|
|
42
|
-
// its builder draws the real swing-arc / pane symbol (see use-placement-preview).
|
|
43
|
-
const ctx = {
|
|
44
|
-
resolve: (id: AnyNodeId) => useScene.getState().nodes[id],
|
|
45
|
-
children: [],
|
|
46
|
-
siblings: [],
|
|
47
|
-
parent: parentNode ?? null,
|
|
48
|
-
viewState: undefined,
|
|
49
|
-
} as unknown as GeometryContext
|
|
50
|
-
|
|
51
|
-
const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
|
|
52
|
-
node,
|
|
53
|
-
ctx,
|
|
54
|
-
)
|
|
55
|
-
if (!geometry) return null
|
|
56
|
-
|
|
57
122
|
return (
|
|
58
|
-
<g data-floorplan-placement-preview
|
|
59
|
-
<
|
|
123
|
+
<g data-floorplan-placement-preview>
|
|
124
|
+
<FloorplanNodePreview node={node} parentNode={parentNode} />
|
|
60
125
|
</g>
|
|
61
126
|
)
|
|
62
127
|
})
|
|
@@ -3,18 +3,100 @@ import type {
|
|
|
3
3
|
AnyNode,
|
|
4
4
|
AnyNodeId,
|
|
5
5
|
FloorplanAffordanceSession,
|
|
6
|
+
FloorplanGeometry,
|
|
7
|
+
FloorplanPalette,
|
|
6
8
|
LiveNodeOverrides,
|
|
7
9
|
} from '@pascal-app/core'
|
|
8
10
|
import { type AnyNodeDefinition, emitter, nodeRegistry, registerNode } from '@pascal-app/core'
|
|
11
|
+
import { createElement } from 'react'
|
|
12
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
9
13
|
import { z } from 'zod'
|
|
14
|
+
import {
|
|
15
|
+
FLOORPLAN_NODE_EXTENSION_KEY,
|
|
16
|
+
floorplanGeometryMetadata,
|
|
17
|
+
} from '../../../lib/floorplan/floorplan-extension'
|
|
10
18
|
import {
|
|
11
19
|
cancelFloorplanAffordanceDrag,
|
|
12
20
|
collectFloorplanDependencyNodes,
|
|
21
|
+
collectFloorplanLinkedLevelNodes,
|
|
13
22
|
computeAffectedSiblingIds,
|
|
23
|
+
floorplanAffordanceReshapeScope,
|
|
14
24
|
floorplanHandleDoubleClickAffordance,
|
|
25
|
+
InteractiveGeometry,
|
|
26
|
+
isFloorplanOpeningPlacementState,
|
|
27
|
+
resolveFloorplanHandleUnitsPerPixel,
|
|
28
|
+
splitFloorplanOverlay,
|
|
15
29
|
subscribeFloorplanAffordanceToolCancel,
|
|
16
30
|
} from './floorplan-registry-layer'
|
|
17
31
|
|
|
32
|
+
describe('floorplan selection handle sizing', () => {
|
|
33
|
+
test('caps visual handle growth at extreme zoom-out', () => {
|
|
34
|
+
expect(resolveFloorplanHandleUnitsPerPixel(0.01)).toBe(0.01)
|
|
35
|
+
expect(resolveFloorplanHandleUnitsPerPixel(0.1)).toBe(0.015)
|
|
36
|
+
|
|
37
|
+
const palette = {
|
|
38
|
+
selectedStroke: '#111111',
|
|
39
|
+
selectedFill: '#ffffff',
|
|
40
|
+
selectedHatch: '#111111',
|
|
41
|
+
wallHoverStroke: '#111111',
|
|
42
|
+
endpointHandleFill: '#ffffff',
|
|
43
|
+
endpointHandleStroke: '#111111',
|
|
44
|
+
endpointHandleHoverStroke: '#222222',
|
|
45
|
+
endpointHandleActiveFill: '#333333',
|
|
46
|
+
endpointHandleActiveStroke: '#444444',
|
|
47
|
+
curveHandleFill: '#ffffff',
|
|
48
|
+
curveHandleStroke: '#008080',
|
|
49
|
+
curveHandleHoverStroke: '#00aaaa',
|
|
50
|
+
measurementStroke: '#111111',
|
|
51
|
+
measurementLabelBackground: '#ffffff',
|
|
52
|
+
measurementLabelText: '#111111',
|
|
53
|
+
} satisfies FloorplanPalette
|
|
54
|
+
const noop = () => {}
|
|
55
|
+
const markup = renderToStaticMarkup(
|
|
56
|
+
createElement(
|
|
57
|
+
'svg',
|
|
58
|
+
null,
|
|
59
|
+
createElement(InteractiveGeometry, {
|
|
60
|
+
activeDragId: null,
|
|
61
|
+
activeRotateNodeId: null,
|
|
62
|
+
geometry: {
|
|
63
|
+
kind: 'endpoint-handle',
|
|
64
|
+
point: [0, 0],
|
|
65
|
+
state: 'idle',
|
|
66
|
+
affordance: 'move-endpoint',
|
|
67
|
+
payload: { endpoint: 'start' },
|
|
68
|
+
},
|
|
69
|
+
hatchPatternId: undefined,
|
|
70
|
+
hoveredHandleId: null,
|
|
71
|
+
isMarqueeSelectionActive: false,
|
|
72
|
+
nodeId: 'wall_test' as AnyNodeId,
|
|
73
|
+
onHandleDoubleClick: noop,
|
|
74
|
+
onHandleHoverChange: noop,
|
|
75
|
+
onHandlePointerDown: noop,
|
|
76
|
+
onMoveHandlePointerDown: noop,
|
|
77
|
+
palette,
|
|
78
|
+
sceneRotationDeg: 0,
|
|
79
|
+
unitsPerPixel: 0.1,
|
|
80
|
+
}),
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
expect(markup).toContain('r="0.12"')
|
|
85
|
+
expect(markup).not.toContain('r="0.8"')
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('floorplan affordance ownership', () => {
|
|
90
|
+
test('keeps the wall center curve drag owned by the floorplan dispatcher', () => {
|
|
91
|
+
expect(floorplanAffordanceReshapeScope('wall-curve', 'wall_1', undefined)).toEqual({
|
|
92
|
+
kind: 'reshaping',
|
|
93
|
+
nodeId: 'wall_1',
|
|
94
|
+
reshape: 'curve',
|
|
95
|
+
driver: 'floorplan',
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
18
100
|
function cabinetRun(id: string, children: string[] = [], parentId: string | null = 'level_test') {
|
|
19
101
|
return {
|
|
20
102
|
id,
|
|
@@ -185,6 +267,35 @@ describe('floorplan affordance cancellation', () => {
|
|
|
185
267
|
})
|
|
186
268
|
})
|
|
187
269
|
|
|
270
|
+
describe('floorplan opening placement interaction routing', () => {
|
|
271
|
+
test('passes entries through only while an opening tool or moving opening is active', () => {
|
|
272
|
+
expect(
|
|
273
|
+
isFloorplanOpeningPlacementState({
|
|
274
|
+
phase: 'structure',
|
|
275
|
+
mode: 'build',
|
|
276
|
+
tool: 'window',
|
|
277
|
+
movingNodeHasWallOpeningPlacement: false,
|
|
278
|
+
}),
|
|
279
|
+
).toBe(true)
|
|
280
|
+
expect(
|
|
281
|
+
isFloorplanOpeningPlacementState({
|
|
282
|
+
phase: 'structure',
|
|
283
|
+
mode: 'select',
|
|
284
|
+
tool: null,
|
|
285
|
+
movingNodeHasWallOpeningPlacement: true,
|
|
286
|
+
}),
|
|
287
|
+
).toBe(true)
|
|
288
|
+
expect(
|
|
289
|
+
isFloorplanOpeningPlacementState({
|
|
290
|
+
phase: 'structure',
|
|
291
|
+
mode: 'select',
|
|
292
|
+
tool: null,
|
|
293
|
+
movingNodeHasWallOpeningPlacement: false,
|
|
294
|
+
}),
|
|
295
|
+
).toBe(false)
|
|
296
|
+
})
|
|
297
|
+
})
|
|
298
|
+
|
|
188
299
|
describe('floorplan vertex double-click routing', () => {
|
|
189
300
|
test('routes polygon vertex handles to the kind-owned delete affordance', () => {
|
|
190
301
|
expect(
|
|
@@ -209,6 +320,132 @@ describe('floorplan vertex double-click routing', () => {
|
|
|
209
320
|
})
|
|
210
321
|
})
|
|
211
322
|
|
|
323
|
+
describe('floorplan annotation overlay routing', () => {
|
|
324
|
+
test('keeps explicitly layered selection chrome above selected body fills', () => {
|
|
325
|
+
const selectionHatch = {
|
|
326
|
+
kind: 'line',
|
|
327
|
+
x1: 0,
|
|
328
|
+
y1: 0,
|
|
329
|
+
x2: 0.2,
|
|
330
|
+
y2: 0.2,
|
|
331
|
+
stroke: '#3b82f6',
|
|
332
|
+
metadata: floorplanGeometryMetadata({ renderPass: 'overlay' }),
|
|
333
|
+
} satisfies FloorplanGeometry
|
|
334
|
+
|
|
335
|
+
expect(splitFloorplanOverlay(selectionHatch)).toEqual({
|
|
336
|
+
base: null,
|
|
337
|
+
overlay: selectionHatch,
|
|
338
|
+
})
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
test('registers upright zone labels for rotation-only presentation updates', () => {
|
|
342
|
+
const noop = () => {}
|
|
343
|
+
const markup = renderToStaticMarkup(
|
|
344
|
+
createElement(
|
|
345
|
+
'svg',
|
|
346
|
+
null,
|
|
347
|
+
createElement(InteractiveGeometry, {
|
|
348
|
+
activeDragId: null,
|
|
349
|
+
activeRotateNodeId: null,
|
|
350
|
+
geometry: {
|
|
351
|
+
kind: 'text',
|
|
352
|
+
x: 4,
|
|
353
|
+
y: 6,
|
|
354
|
+
text: 'Kitchen',
|
|
355
|
+
fontSize: 0.2,
|
|
356
|
+
upright: true,
|
|
357
|
+
},
|
|
358
|
+
hatchPatternId: undefined,
|
|
359
|
+
hoveredHandleId: null,
|
|
360
|
+
isMarqueeSelectionActive: false,
|
|
361
|
+
nodeId: 'zone_test' as AnyNodeId,
|
|
362
|
+
onHandleDoubleClick: noop,
|
|
363
|
+
onHandleHoverChange: noop,
|
|
364
|
+
onHandlePointerDown: noop,
|
|
365
|
+
onMoveHandlePointerDown: noop,
|
|
366
|
+
palette: undefined,
|
|
367
|
+
sceneRotationDeg: 180,
|
|
368
|
+
unitsPerPixel: 0.01,
|
|
369
|
+
}),
|
|
370
|
+
),
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
expect(markup).not.toContain('data-floorplan-annotation-label=""')
|
|
374
|
+
expect(markup).toContain('data-floorplan-annotation-angle-radians="0"')
|
|
375
|
+
expect(markup).toContain('data-floorplan-annotation-screen-upright="true"')
|
|
376
|
+
expect(markup).toContain('data-floorplan-annotation-transform-before-rotation="translate(4 6)"')
|
|
377
|
+
expect(markup).toContain('transform="translate(4 6) rotate(-180)"')
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
test('keeps automatic dimension strings left-to-right and top-to-bottom after rotation', () => {
|
|
381
|
+
const noop = () => {}
|
|
382
|
+
const renderAt180Degrees = (geometry: FloorplanGeometry) =>
|
|
383
|
+
renderToStaticMarkup(
|
|
384
|
+
createElement(
|
|
385
|
+
'svg',
|
|
386
|
+
null,
|
|
387
|
+
createElement(InteractiveGeometry, {
|
|
388
|
+
activeDragId: null,
|
|
389
|
+
activeRotateNodeId: null,
|
|
390
|
+
geometry,
|
|
391
|
+
hatchPatternId: undefined,
|
|
392
|
+
hoveredHandleId: null,
|
|
393
|
+
isMarqueeSelectionActive: false,
|
|
394
|
+
nodeId: 'wall_test' as AnyNodeId,
|
|
395
|
+
onHandleDoubleClick: noop,
|
|
396
|
+
onHandleHoverChange: noop,
|
|
397
|
+
onHandlePointerDown: noop,
|
|
398
|
+
onMoveHandlePointerDown: noop,
|
|
399
|
+
palette: undefined,
|
|
400
|
+
sceneRotationDeg: 180,
|
|
401
|
+
unitsPerPixel: 0.01,
|
|
402
|
+
}),
|
|
403
|
+
),
|
|
404
|
+
)
|
|
405
|
+
const dimensionString = (
|
|
406
|
+
end: readonly [number, number],
|
|
407
|
+
offsetNormal: readonly [number, number],
|
|
408
|
+
): FloorplanGeometry => ({
|
|
409
|
+
kind: 'dimension-string',
|
|
410
|
+
segments: [{ start: [0, 0], end, text: '2m' }],
|
|
411
|
+
offsetNormal,
|
|
412
|
+
offsetDistance: 0.55,
|
|
413
|
+
extensionOvershoot: 0.12,
|
|
414
|
+
textPosition: 'above',
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
expect(renderAt180Degrees(dimensionString([2, 0], [0, 1]))).toContain('rotate(-180)')
|
|
418
|
+
expect(renderAt180Degrees(dimensionString([0, 2], [1, 0]))).toContain('rotate(-90)')
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
test('keeps a fixed mark pill together in the overlay pass', () => {
|
|
422
|
+
const mark = {
|
|
423
|
+
kind: 'group',
|
|
424
|
+
metadata: floorplanGeometryMetadata({ annotationRole: 'opening-mark' }),
|
|
425
|
+
children: [
|
|
426
|
+
{ kind: 'line', x1: 0, y1: 0, x2: 0, y2: 0.4 },
|
|
427
|
+
{ kind: 'rect', x: -0.2, y: 0.4, width: 0.4, height: 0.32 },
|
|
428
|
+
{ kind: 'text', x: 0, y: 0.56, text: '107', fontSize: 0.15, upright: true },
|
|
429
|
+
],
|
|
430
|
+
} satisfies FloorplanGeometry
|
|
431
|
+
|
|
432
|
+
expect(splitFloorplanOverlay(mark)).toEqual({ base: null, overlay: mark })
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
test('keeps fixed annotation symbols in the overlay pass for collision layout', () => {
|
|
436
|
+
const columnCenter = {
|
|
437
|
+
kind: 'line',
|
|
438
|
+
x1: 0,
|
|
439
|
+
y1: 0,
|
|
440
|
+
x2: 1,
|
|
441
|
+
y2: 0,
|
|
442
|
+
metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }),
|
|
443
|
+
} satisfies FloorplanGeometry
|
|
444
|
+
|
|
445
|
+
expect(splitFloorplanOverlay(columnCenter)).toEqual({ base: null, overlay: columnCenter })
|
|
446
|
+
})
|
|
447
|
+
})
|
|
448
|
+
|
|
212
449
|
describe('computeAffectedSiblingIds', () => {
|
|
213
450
|
beforeEach(() => {
|
|
214
451
|
nodeRegistry._reset()
|
|
@@ -306,3 +543,45 @@ describe('collectFloorplanDependencyNodes', () => {
|
|
|
306
543
|
])
|
|
307
544
|
})
|
|
308
545
|
})
|
|
546
|
+
|
|
547
|
+
describe('collectFloorplanLinkedLevelNodes', () => {
|
|
548
|
+
test('projects a node onto a linked destination level with its real children', () => {
|
|
549
|
+
nodeRegistry._reset()
|
|
550
|
+
registerNode({
|
|
551
|
+
kind: 'linked-floorplan-test',
|
|
552
|
+
schemaVersion: 1,
|
|
553
|
+
schema: z.object({ type: z.literal('linked-floorplan-test') }) as never,
|
|
554
|
+
category: 'structure',
|
|
555
|
+
defaults: () => ({}) as never,
|
|
556
|
+
floorplan: () => null,
|
|
557
|
+
extensions: {
|
|
558
|
+
[FLOORPLAN_NODE_EXTENSION_KEY]: {
|
|
559
|
+
linkedLevelIds: () => ['level_upper' as AnyNodeId],
|
|
560
|
+
},
|
|
561
|
+
},
|
|
562
|
+
} as unknown as AnyNodeDefinition)
|
|
563
|
+
const child = {
|
|
564
|
+
id: 'linked_child',
|
|
565
|
+
type: 'linked-child',
|
|
566
|
+
parentId: 'linked_parent',
|
|
567
|
+
} as unknown as AnyNode
|
|
568
|
+
const parent = {
|
|
569
|
+
id: 'linked_parent',
|
|
570
|
+
type: 'linked-floorplan-test',
|
|
571
|
+
parentId: 'level_lower',
|
|
572
|
+
children: [child.id],
|
|
573
|
+
} as unknown as AnyNode
|
|
574
|
+
const nodes = { [parent.id]: parent, [child.id]: child }
|
|
575
|
+
|
|
576
|
+
expect(collectFloorplanLinkedLevelNodes(nodes, 'level_upper' as AnyNodeId)).toEqual([
|
|
577
|
+
{ id: parent.id, node: parent, children: [child] },
|
|
578
|
+
])
|
|
579
|
+
expect(
|
|
580
|
+
collectFloorplanLinkedLevelNodes(
|
|
581
|
+
nodes,
|
|
582
|
+
'level_upper' as AnyNodeId,
|
|
583
|
+
new Set([parent.id as AnyNodeId]),
|
|
584
|
+
),
|
|
585
|
+
).toEqual([])
|
|
586
|
+
})
|
|
587
|
+
})
|