@pascal-app/editor 0.9.1 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +12 -9
- package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
- package/src/components/editor/bake-exporter.tsx +47 -0
- package/src/components/editor/custom-camera-controls.tsx +63 -25
- package/src/components/editor/editor-layout-mobile.tsx +5 -0
- package/src/components/editor/editor-layout-v2.tsx +18 -0
- package/src/components/editor/export-manager.tsx +71 -63
- package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
- package/src/components/editor/first-person/build-collider-world.ts +32 -5
- package/src/components/editor/first-person-controls.tsx +118 -7
- package/src/components/editor/floating-action-menu.tsx +410 -33
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-background-selection.test.ts +58 -0
- package/src/components/editor/floorplan-background-selection.ts +3 -2
- package/src/components/editor/floorplan-panel.tsx +1804 -849
- package/src/components/editor/grid.tsx +166 -39
- package/src/components/editor/group-actions.ts +457 -0
- package/src/components/editor/group-floating-action-menu.tsx +130 -0
- package/src/components/editor/group-move-3d.ts +397 -0
- package/src/components/editor/group-rotate-handle.tsx +102 -64
- package/src/components/editor/group-selection-box-3d.tsx +173 -0
- package/src/components/editor/group-transform-shared.test.ts +192 -1
- package/src/components/editor/group-transform-shared.ts +240 -7
- package/src/components/editor/handles/handle-arrow.tsx +81 -23
- package/src/components/editor/handles/use-handle-drag.ts +37 -4
- package/src/components/editor/index.tsx +248 -27
- package/src/components/editor/measurement-pill.tsx +62 -22
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +366 -173
- package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
- package/src/components/editor/quick-measurement-card.tsx +70 -0
- package/src/components/editor/quick-measurement-hud.tsx +28 -0
- package/src/components/editor/riser-diagram-panel.tsx +137 -0
- package/src/components/editor/selection-manager.tsx +812 -466
- package/src/components/editor/site-edge-labels.tsx +4 -4
- package/src/components/editor/slab-hole-highlights.tsx +13 -5
- package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
- package/src/components/editor/three-context-bridge.ts +22 -0
- package/src/components/editor/thumbnail-generator.tsx +136 -43
- package/src/components/editor/use-floorplan-background-placement.ts +113 -45
- package/src/components/editor/use-floorplan-scene-data.ts +5 -5
- package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
- package/src/components/editor/wall-measurement-label.tsx +3 -13
- package/src/components/editor/wall-move-side-handles.tsx +36 -21
- package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
- package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
- package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
- package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
- package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
- package/src/components/systems/selection-affordance-manager.tsx +38 -0
- package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
- package/src/components/systems/zone/zone-system.tsx +42 -13
- package/src/components/tools/elevator/elevator-tool.tsx +34 -8
- package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
- package/src/components/tools/fence/fence-drafting.ts +80 -8
- package/src/components/tools/item/move-tool.tsx +17 -13
- package/src/components/tools/item/placement-math.test.ts +13 -1
- package/src/components/tools/item/placement-math.ts +28 -2
- package/src/components/tools/item/placement-strategies.ts +246 -5
- package/src/components/tools/item/placement-types.ts +13 -1
- package/src/components/tools/item/use-draft-node.ts +31 -1
- package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
- package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
- package/src/components/tools/roof/roof-tool.tsx +320 -94
- package/src/components/tools/select/box-select-state.ts +18 -7
- package/src/components/tools/select/box-select-tool.tsx +176 -40
- package/src/components/tools/select/marquee-geometry.test.ts +161 -0
- package/src/components/tools/select/marquee-geometry.ts +155 -0
- package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
- package/src/components/tools/select/select-candidates.ts +1 -1
- package/src/components/tools/shared/cursor-sphere.tsx +62 -26
- package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
- package/src/components/tools/shared/facing-indicator.tsx +87 -0
- package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
- package/src/components/tools/shared/placement-box.tsx +183 -1
- package/src/components/tools/shared/polygon-editor.tsx +276 -35
- package/src/components/tools/site/site-boundary-editor.tsx +88 -36
- package/src/components/tools/stair/stair-defaults.ts +1 -0
- package/src/components/tools/stair/stair-tool.tsx +82 -11
- package/src/components/tools/tool-manager.tsx +86 -25
- package/src/components/tools/wall/wall-drafting.test.ts +330 -0
- package/src/components/tools/wall/wall-drafting.ts +180 -103
- package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
- package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
- package/src/components/tools/zone/zone-tool.tsx +82 -88
- package/src/components/ui/action-menu/camera-actions.tsx +24 -17
- package/src/components/ui/action-menu/control-modes.tsx +40 -93
- package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
- package/src/components/ui/action-menu/index.tsx +2 -4
- package/src/components/ui/action-menu/measurement-control.tsx +188 -0
- package/src/components/ui/action-menu/structure-tools.tsx +22 -13
- package/src/components/ui/action-menu/view-toggles.tsx +42 -90
- package/src/components/ui/command-palette/editor-commands.tsx +2 -2
- package/src/components/ui/command-palette/index.tsx +4 -3
- package/src/components/ui/controls/material-paint-panel.tsx +86 -17
- package/src/components/ui/controls/material-picker.tsx +83 -152
- package/src/components/ui/controls/material-properties-editor.tsx +108 -0
- package/src/components/ui/controls/metric-control.tsx +118 -44
- package/src/components/ui/controls/scene-material-list.tsx +247 -0
- package/src/components/ui/controls/slider-control.tsx +88 -38
- package/src/components/ui/floating-level-selector.tsx +1 -1
- package/src/components/ui/helpers/building-helper.tsx +10 -23
- package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
- package/src/components/ui/helpers/helper-manager.tsx +227 -14
- package/src/components/ui/helpers/item-helper.tsx +28 -32
- package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
- package/src/components/ui/helpers/roof-helper.tsx +10 -12
- package/src/components/ui/icon-ref.tsx +47 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
- package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
- package/src/components/ui/level-duplicate-dialog.tsx +1 -1
- package/src/components/ui/panels/node-display.ts +17 -17
- package/src/components/ui/panels/panel-manager.tsx +22 -13
- package/src/components/ui/panels/panel-wrapper.tsx +24 -3
- package/src/components/ui/panels/parametric-inspector.tsx +15 -2
- package/src/components/ui/panels/reference-panel.tsx +54 -19
- package/src/components/ui/primitives/shortcut-token.tsx +39 -3
- package/src/components/ui/primitives/sidebar.tsx +1 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
- package/src/components/ui/sidebar/icon-rail.tsx +50 -31
- package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
- package/src/components/ui/sidebar/tab-bar.tsx +42 -28
- package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
- package/src/components/ui/snap-target-badge.test.tsx +40 -0
- package/src/components/ui/snap-target-badge.tsx +88 -0
- package/src/components/viewer-overlay.tsx +85 -10
- package/src/components/viewer-zone-system.tsx +6 -1
- package/src/hooks/use-auto-save.test.ts +14 -0
- package/src/hooks/use-auto-save.ts +54 -7
- package/src/hooks/use-ceiling-events.ts +3 -2
- package/src/hooks/use-drag-action.ts +4 -1
- package/src/hooks/use-keyboard.ts +385 -40
- package/src/index.tsx +219 -6
- package/src/lib/active-placement-surface.ts +35 -0
- package/src/lib/ceiling-plan-snap.ts +24 -0
- package/src/lib/contextual-help.test.ts +112 -0
- package/src/lib/contextual-help.ts +144 -0
- package/src/lib/continuation.ts +64 -0
- package/src/lib/direct-manipulation.test.ts +192 -0
- package/src/lib/direct-manipulation.ts +109 -0
- package/src/lib/editor-api.ts +23 -35
- package/src/lib/floorplan/apply-alignment.test.ts +25 -0
- package/src/lib/floorplan/apply-alignment.ts +18 -12
- package/src/lib/floorplan/floorplan-export.tsx +364 -0
- package/src/lib/floorplan/geometry.ts +53 -0
- package/src/lib/floorplan/index.ts +3 -0
- package/src/lib/floorplan/items.ts +5 -2
- package/src/lib/floorplan/plan-coords.ts +21 -0
- package/src/lib/fresh-planar-placement.test.ts +240 -3
- package/src/lib/fresh-planar-placement.ts +68 -1
- package/src/lib/glb-export.test.ts +441 -0
- package/src/lib/glb-export.ts +874 -0
- package/src/lib/history.ts +9 -0
- package/src/lib/interaction/hot-set.test.ts +133 -0
- package/src/lib/interaction/hot-set.ts +67 -0
- package/src/lib/interaction/overlay-policy.test.ts +51 -0
- package/src/lib/interaction/overlay-policy.ts +59 -0
- package/src/lib/interaction/scope.ts +211 -0
- package/src/lib/level-selection.ts +2 -2
- package/src/lib/material-paint.ts +36 -49
- package/src/lib/measurement-kind.test.ts +30 -0
- package/src/lib/measurement-kind.ts +19 -0
- package/src/lib/measurement-label.test.ts +47 -0
- package/src/lib/measurement-label.ts +68 -0
- package/src/lib/measurement-parser.ts +116 -0
- package/src/lib/measurements.test.ts +183 -0
- package/src/lib/measurements.ts +199 -0
- package/src/lib/paint-scope.test.ts +454 -0
- package/src/lib/paint-scope.ts +461 -0
- package/src/lib/placement-drag-release.ts +23 -0
- package/src/lib/planar-cursor-placement.test.ts +57 -0
- package/src/lib/plugin-panels.test.ts +19 -0
- package/src/lib/plugin-panels.ts +91 -0
- package/src/lib/quick-measurement.test.ts +77 -0
- package/src/lib/quick-measurement.ts +109 -0
- package/src/lib/roof-duplication.ts +6 -6
- package/src/lib/roof-hover-outline-proxy.ts +22 -0
- package/src/lib/roof-wall-hit.ts +164 -0
- package/src/lib/scene-clipboard.test.ts +196 -0
- package/src/lib/scene-clipboard.ts +73 -8
- package/src/lib/scene.ts +28 -5
- package/src/lib/selection-routing.test.ts +298 -0
- package/src/lib/selection-routing.ts +175 -0
- package/src/lib/sfx/index.ts +1 -0
- package/src/lib/sfx/movement-tick.test.ts +65 -0
- package/src/lib/sfx/movement-tick.ts +18 -0
- package/src/lib/sfx-bus.ts +50 -13
- package/src/lib/sfx-player.test.ts +124 -0
- package/src/lib/sfx-player.ts +111 -66
- package/src/lib/slab-plan-snap.test.ts +93 -0
- package/src/lib/slab-plan-snap.ts +108 -0
- package/src/lib/snapping-mode.test.ts +138 -0
- package/src/lib/snapping-mode.ts +179 -0
- package/src/lib/stair-duplication.ts +4 -4
- package/src/lib/surface-plan-snap.test.ts +71 -0
- package/src/lib/surface-plan-snap.ts +256 -0
- package/src/lib/use-linear-display.ts +40 -0
- package/src/lib/world-grid-snap.ts +37 -5
- package/src/store/use-direct-manipulation-feedback.ts +20 -0
- package/src/store/use-editor.tsx +484 -114
- package/src/store/use-facing-pose.ts +44 -0
- package/src/store/use-fence-curve-draft.ts +21 -0
- package/src/store/use-floorplan-draft-preview.ts +101 -0
- package/src/store/use-floorplan-marquee.ts +50 -0
- package/src/store/use-interaction-scope.test.ts +186 -0
- package/src/store/use-interaction-scope.ts +132 -0
- package/src/store/use-measurement-draft.test.ts +530 -0
- package/src/store/use-measurement-draft.ts +543 -0
- package/src/store/use-opening-guides.ts +41 -0
- package/src/store/use-placement-preview.ts +11 -3
- package/src/store/use-quick-measurement-hud.test.ts +53 -0
- package/src/store/use-quick-measurement-hud.ts +77 -0
- package/src/store/use-segment-draft-chain.ts +28 -0
- package/src/store/use-stair-build-preview.ts +43 -0
- package/src/store/use-wall-snap-indicator.ts +2 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
- package/src/components/editor/group-move-handle.tsx +0 -316
- package/src/components/ui/panels/paint-panel.tsx +0 -163
- package/src/lib/level-name.ts +0 -11
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useScene } from '@pascal-app/core'
|
|
4
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
5
|
+
import { type RefObject, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
6
|
+
import {
|
|
7
|
+
createQuickMeasurementPointerScheduler,
|
|
8
|
+
resolveQuickMeasurementReport,
|
|
9
|
+
} from '../../lib/quick-measurement'
|
|
10
|
+
import {
|
|
11
|
+
activateQuickMeasurementHudSource,
|
|
12
|
+
clearQuickMeasurementHudSource,
|
|
13
|
+
publishQuickMeasurementHudSource,
|
|
14
|
+
} from '../../store/use-quick-measurement-hud'
|
|
15
|
+
import { useFloorplanRender } from './floorplan-render-context'
|
|
16
|
+
|
|
17
|
+
type FloorplanQuickMeasureHit = {
|
|
18
|
+
nodeId: string
|
|
19
|
+
point: { x: number; y: number }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function nodeIdFromElement(element: Element | null): string | null {
|
|
23
|
+
const entry = element?.closest<SVGGElement>('.floorplan-registry-entry[data-node-id]')
|
|
24
|
+
return entry?.dataset.nodeId ?? null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function nodeIdAtPointer(svg: SVGSVGElement, event: PointerEvent): string | null {
|
|
28
|
+
const direct = event.target instanceof Element ? nodeIdFromElement(event.target) : null
|
|
29
|
+
if (direct) return direct
|
|
30
|
+
for (const element of document.elementsFromPoint(event.clientX, event.clientY)) {
|
|
31
|
+
if (!svg.contains(element)) continue
|
|
32
|
+
const nodeId = nodeIdFromElement(element)
|
|
33
|
+
if (nodeId) return nodeId
|
|
34
|
+
}
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function pointAtPointer(
|
|
39
|
+
group: SVGGElement,
|
|
40
|
+
event: PointerEvent,
|
|
41
|
+
): FloorplanQuickMeasureHit['point'] | null {
|
|
42
|
+
const matrix = group.getScreenCTM()
|
|
43
|
+
if (!matrix) return null
|
|
44
|
+
const point = new DOMPoint(event.clientX, event.clientY).matrixTransform(matrix.inverse())
|
|
45
|
+
return { x: point.x, y: point.y }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function FloorplanSmartMarker({
|
|
49
|
+
hit,
|
|
50
|
+
pinned,
|
|
51
|
+
unitsPerPixel,
|
|
52
|
+
markerRef,
|
|
53
|
+
}: {
|
|
54
|
+
hit?: FloorplanQuickMeasureHit
|
|
55
|
+
pinned: boolean
|
|
56
|
+
unitsPerPixel: number
|
|
57
|
+
markerRef?: RefObject<SVGGElement | null>
|
|
58
|
+
}) {
|
|
59
|
+
const localRef = useRef<SVGGElement>(null)
|
|
60
|
+
const ref = markerRef ?? localRef
|
|
61
|
+
|
|
62
|
+
useLayoutEffect(() => {
|
|
63
|
+
if (!hit) ref.current?.setAttribute('display', 'none')
|
|
64
|
+
}, [hit, ref])
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<g ref={ref} transform={hit ? `translate(${hit.point.x} ${hit.point.y})` : undefined}>
|
|
68
|
+
<circle
|
|
69
|
+
cx={0}
|
|
70
|
+
cy={0}
|
|
71
|
+
fill="none"
|
|
72
|
+
r={(pinned ? 11 : 8) * unitsPerPixel}
|
|
73
|
+
stroke={pinned ? '#0e7490' : '#0891b2'}
|
|
74
|
+
strokeWidth={pinned ? 2.5 : 2}
|
|
75
|
+
vectorEffect="non-scaling-stroke"
|
|
76
|
+
/>
|
|
77
|
+
{pinned ? <circle cx={0} cy={0} fill="#0e7490" r={2.75 * unitsPerPixel} /> : null}
|
|
78
|
+
</g>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function showFloorplanSmartMarker(
|
|
83
|
+
marker: SVGGElement | null,
|
|
84
|
+
point: FloorplanQuickMeasureHit['point'],
|
|
85
|
+
) {
|
|
86
|
+
if (!marker) return
|
|
87
|
+
marker.setAttribute('transform', `translate(${point.x} ${point.y})`)
|
|
88
|
+
marker.removeAttribute('display')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function hideFloorplanSmartMarker(marker: SVGGElement | null) {
|
|
92
|
+
marker?.setAttribute('display', 'none')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function FloorplanQuickMeasureLayer() {
|
|
96
|
+
const groupRef = useRef<SVGGElement>(null)
|
|
97
|
+
const hoverRef = useRef<FloorplanQuickMeasureHit | null>(null)
|
|
98
|
+
const hoverNodeIdRef = useRef<string | null>(null)
|
|
99
|
+
const candidateNodeIdRef = useRef<string | null | undefined>(undefined)
|
|
100
|
+
const candidateHasReportRef = useRef(false)
|
|
101
|
+
const hoverMarkerRef = useRef<SVGGElement>(null)
|
|
102
|
+
const [hoverNodeId, setHoverNodeId] = useState<string | null>(null)
|
|
103
|
+
const [pinned, setPinned] = useState<FloorplanQuickMeasureHit | null>(null)
|
|
104
|
+
const nodes = useScene((state) => state.nodes)
|
|
105
|
+
const candidateNodesRef = useRef(nodes)
|
|
106
|
+
const levelId = useViewer((state) => state.selection.levelId)
|
|
107
|
+
const levelRef = useRef(levelId)
|
|
108
|
+
const renderContext = useFloorplanRender()
|
|
109
|
+
const hoverReport = useMemo(
|
|
110
|
+
() => resolveQuickMeasurementReport(hoverNodeId, nodes),
|
|
111
|
+
[hoverNodeId, nodes],
|
|
112
|
+
)
|
|
113
|
+
const pinnedReport = useMemo(
|
|
114
|
+
() => resolveQuickMeasurementReport(pinned?.nodeId ?? null, nodes),
|
|
115
|
+
[pinned?.nodeId, nodes],
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
if (levelRef.current === levelId) return
|
|
120
|
+
levelRef.current = levelId
|
|
121
|
+
hoverRef.current = null
|
|
122
|
+
hoverNodeIdRef.current = null
|
|
123
|
+
candidateNodeIdRef.current = undefined
|
|
124
|
+
candidateHasReportRef.current = false
|
|
125
|
+
hideFloorplanSmartMarker(hoverMarkerRef.current)
|
|
126
|
+
setHoverNodeId(null)
|
|
127
|
+
setPinned(null)
|
|
128
|
+
}, [levelId])
|
|
129
|
+
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
const group = groupRef.current
|
|
132
|
+
const svg = groupRef.current?.ownerSVGElement
|
|
133
|
+
if (!(group && svg)) return
|
|
134
|
+
const updateHover = (next: FloorplanQuickMeasureHit | null) => {
|
|
135
|
+
hoverRef.current = next
|
|
136
|
+
if (next) showFloorplanSmartMarker(hoverMarkerRef.current, next.point)
|
|
137
|
+
else hideFloorplanSmartMarker(hoverMarkerRef.current)
|
|
138
|
+
const nextNodeId = next?.nodeId ?? null
|
|
139
|
+
if (nextNodeId === hoverNodeIdRef.current) return
|
|
140
|
+
hoverNodeIdRef.current = nextNodeId
|
|
141
|
+
setHoverNodeId(nextNodeId)
|
|
142
|
+
}
|
|
143
|
+
const processPointerMove = (event: PointerEvent) => {
|
|
144
|
+
activateQuickMeasurementHudSource('2d')
|
|
145
|
+
const candidateNodeId = nodeIdAtPointer(svg, event)
|
|
146
|
+
const sceneNodes = useScene.getState().nodes
|
|
147
|
+
if (candidateNodesRef.current !== sceneNodes) {
|
|
148
|
+
candidateNodesRef.current = sceneNodes
|
|
149
|
+
candidateNodeIdRef.current = undefined
|
|
150
|
+
}
|
|
151
|
+
if (candidateNodeId !== candidateNodeIdRef.current) {
|
|
152
|
+
candidateNodeIdRef.current = candidateNodeId
|
|
153
|
+
candidateHasReportRef.current = Boolean(
|
|
154
|
+
resolveQuickMeasurementReport(candidateNodeId, sceneNodes),
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
const point = candidateHasReportRef.current ? pointAtPointer(group, event) : null
|
|
158
|
+
updateHover(candidateNodeId && point ? { nodeId: candidateNodeId, point } : null)
|
|
159
|
+
}
|
|
160
|
+
const pointerScheduler = createQuickMeasurementPointerScheduler(processPointerMove)
|
|
161
|
+
const onPointerMove = (event: PointerEvent) => pointerScheduler.enqueue(event)
|
|
162
|
+
const clear = () => {
|
|
163
|
+
pointerScheduler.clear()
|
|
164
|
+
updateHover(null)
|
|
165
|
+
}
|
|
166
|
+
const onClick = (event: MouseEvent) => {
|
|
167
|
+
const next = hoverRef.current
|
|
168
|
+
if (!(next && event.button === 0)) return
|
|
169
|
+
event.preventDefault()
|
|
170
|
+
event.stopImmediatePropagation()
|
|
171
|
+
activateQuickMeasurementHudSource('2d')
|
|
172
|
+
setPinned(next)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
svg.addEventListener('pointermove', onPointerMove, true)
|
|
176
|
+
svg.addEventListener('pointerleave', clear)
|
|
177
|
+
svg.addEventListener('click', onClick, true)
|
|
178
|
+
return () => {
|
|
179
|
+
svg.removeEventListener('pointermove', onPointerMove, true)
|
|
180
|
+
svg.removeEventListener('pointerleave', clear)
|
|
181
|
+
svg.removeEventListener('click', onClick, true)
|
|
182
|
+
pointerScheduler.clear()
|
|
183
|
+
}
|
|
184
|
+
}, [])
|
|
185
|
+
|
|
186
|
+
const unitsPerPixel = Math.max(renderContext?.unitsPerPixel ?? 0.01, 1e-6)
|
|
187
|
+
const activeHit = hoverReport ? hoverRef.current : pinnedReport ? pinned : null
|
|
188
|
+
const report = hoverReport ?? pinnedReport
|
|
189
|
+
const lensState =
|
|
190
|
+
pinnedReport && activeHit?.nodeId === pinned?.nodeId ? ('pinned' as const) : ('live' as const)
|
|
191
|
+
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
publishQuickMeasurementHudSource('2d', report ? { lensState, report } : null)
|
|
194
|
+
}, [lensState, report])
|
|
195
|
+
|
|
196
|
+
useEffect(() => () => clearQuickMeasurementHudSource('2d'), [])
|
|
197
|
+
|
|
198
|
+
return (
|
|
199
|
+
<g pointerEvents="none" ref={groupRef}>
|
|
200
|
+
{pinned && pinnedReport ? (
|
|
201
|
+
<FloorplanSmartMarker hit={pinned} pinned unitsPerPixel={unitsPerPixel} />
|
|
202
|
+
) : null}
|
|
203
|
+
<FloorplanSmartMarker
|
|
204
|
+
markerRef={hoverMarkerRef}
|
|
205
|
+
pinned={false}
|
|
206
|
+
unitsPerPixel={unitsPerPixel}
|
|
207
|
+
/>
|
|
208
|
+
</g>
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export default FloorplanQuickMeasureLayer
|
|
@@ -4,8 +4,11 @@ import {
|
|
|
4
4
|
type AnyNode,
|
|
5
5
|
type AnyNodeId,
|
|
6
6
|
type CeilingNode,
|
|
7
|
+
createSceneApi,
|
|
7
8
|
getWallMidpointHandlePoint,
|
|
9
|
+
type NodeQuickAction,
|
|
8
10
|
nodeRegistry,
|
|
11
|
+
runAsSingleSceneHistoryStep,
|
|
9
12
|
type SlabNode,
|
|
10
13
|
useLiveNodeOverrides,
|
|
11
14
|
useScene,
|
|
@@ -14,9 +17,90 @@ import {
|
|
|
14
17
|
import { useViewer } from '@pascal-app/viewer'
|
|
15
18
|
import { useEffect, useState } from 'react'
|
|
16
19
|
import { createPortal } from 'react-dom'
|
|
20
|
+
import { useShallow } from 'zustand/react/shallow'
|
|
21
|
+
import {
|
|
22
|
+
createFreshPlacementSubtree,
|
|
23
|
+
duplicatesAsFreshSubtree,
|
|
24
|
+
} from '../../lib/fresh-planar-placement'
|
|
17
25
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
|
18
26
|
import useEditor from '../../store/use-editor'
|
|
27
|
+
import { useMovingNode } from '../../store/use-interaction-scope'
|
|
19
28
|
import { NodeActionMenu } from '../editor/node-action-menu'
|
|
29
|
+
import { IconRefGlyph } from '../ui/icon-ref'
|
|
30
|
+
|
|
31
|
+
function SideAddGlyph({ direction }: { direction: 'left' | 'right' }) {
|
|
32
|
+
return (
|
|
33
|
+
<svg
|
|
34
|
+
aria-hidden="true"
|
|
35
|
+
className="h-3.5 w-3.5"
|
|
36
|
+
fill="none"
|
|
37
|
+
stroke="currentColor"
|
|
38
|
+
strokeLinecap="round"
|
|
39
|
+
strokeLinejoin="round"
|
|
40
|
+
strokeWidth="1.7"
|
|
41
|
+
viewBox="0 0 24 24"
|
|
42
|
+
>
|
|
43
|
+
<rect x="8" y="7" width="9" height="10" rx="1.75" />
|
|
44
|
+
<path d="M12.5 7v10" />
|
|
45
|
+
{direction === 'left' ? (
|
|
46
|
+
<>
|
|
47
|
+
<path d="M6.5 12H2.75" />
|
|
48
|
+
<path d="m5.5 9.75-2.75 2.25 2.75 2.25" />
|
|
49
|
+
</>
|
|
50
|
+
) : (
|
|
51
|
+
<>
|
|
52
|
+
<path d="M17.5 12h3.75" />
|
|
53
|
+
<path d="m18.5 9.75 2.75 2.25-2.75 2.25" />
|
|
54
|
+
</>
|
|
55
|
+
)}
|
|
56
|
+
</svg>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Builtin string tokens map to the generic glyphs above; kind-owned marks
|
|
61
|
+
// arrive as IconRef objects and render through the shared IconRefGlyph.
|
|
62
|
+
// Mirrors the 3D floating-action-menu (2D ↔ 3D parity).
|
|
63
|
+
function QuickActionIcon({ action }: { action: NodeQuickAction }) {
|
|
64
|
+
const icon = action.icon
|
|
65
|
+
if (!icon) return null
|
|
66
|
+
if (typeof icon === 'object') return <IconRefGlyph icon={icon} size={14} />
|
|
67
|
+
switch (icon) {
|
|
68
|
+
case 'add-left':
|
|
69
|
+
return <SideAddGlyph direction="left" />
|
|
70
|
+
case 'add-right':
|
|
71
|
+
return <SideAddGlyph direction="right" />
|
|
72
|
+
default:
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function collectQuickActionNodes(
|
|
78
|
+
nodes: Record<AnyNodeId, AnyNode>,
|
|
79
|
+
selectedId: string | null,
|
|
80
|
+
): Record<AnyNodeId, AnyNode> | null {
|
|
81
|
+
if (!selectedId) return null
|
|
82
|
+
const selected = nodes[selectedId as AnyNodeId]
|
|
83
|
+
if (!selected || !nodeRegistry.get(selected.type)?.quickActions) return null
|
|
84
|
+
|
|
85
|
+
const collected: Record<AnyNodeId, AnyNode> = { [selected.id as AnyNodeId]: selected }
|
|
86
|
+
const add = (id: string | null | undefined) => {
|
|
87
|
+
if (!id) return
|
|
88
|
+
const node = nodes[id as AnyNodeId]
|
|
89
|
+
if (node) collected[node.id as AnyNodeId] = node
|
|
90
|
+
}
|
|
91
|
+
const addChildren = (node: AnyNode | undefined) => {
|
|
92
|
+
for (const childId of (node as { children?: readonly string[] } | undefined)?.children ?? []) {
|
|
93
|
+
add(childId)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
add(selected.parentId ?? null)
|
|
98
|
+
addChildren(selected)
|
|
99
|
+
const parent = selected.parentId ? nodes[selected.parentId as AnyNodeId] : undefined
|
|
100
|
+
addChildren(parent)
|
|
101
|
+
|
|
102
|
+
return collected
|
|
103
|
+
}
|
|
20
104
|
|
|
21
105
|
/**
|
|
22
106
|
* Floating Move / Duplicate / Delete buttons that appear above the
|
|
@@ -45,9 +129,21 @@ import { NodeActionMenu } from '../editor/node-action-menu'
|
|
|
45
129
|
* Hidden while in a move state (so we don't show buttons over a ghost).
|
|
46
130
|
*/
|
|
47
131
|
export function FloorplanRegistryActionMenu() {
|
|
48
|
-
|
|
49
|
-
|
|
132
|
+
// Sole selection only — a multi-selection gets the group menu
|
|
133
|
+
// (`FloorplanGroupActionMenu`), whose actions target the whole selection.
|
|
134
|
+
const selectedId = useViewer((s) =>
|
|
135
|
+
s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : undefined,
|
|
136
|
+
) as AnyNodeId | undefined
|
|
137
|
+
const movingNode = useMovingNode()
|
|
50
138
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
|
139
|
+
const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
|
|
140
|
+
// Gate on floorplan hover so this 2D menu never coexists with the 3D
|
|
141
|
+
// FloatingActionMenu in split view — that menu hides while the floorplan
|
|
142
|
+
// is hovered, so this one must only show then. Mirrors the legacy
|
|
143
|
+
// FloorplanActionMenuLayer guard. Without it a registry kind (e.g. a
|
|
144
|
+
// duct) shows two Duplicate buttons whenever the pointer is outside the
|
|
145
|
+
// 2D panel.
|
|
146
|
+
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
|
|
51
147
|
|
|
52
148
|
const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
|
|
53
149
|
|
|
@@ -56,8 +152,12 @@ export function FloorplanRegistryActionMenu() {
|
|
|
56
152
|
const selectedKind = useScene((s) => (selectedId ? (s.nodes[selectedId]?.type ?? null) : null))
|
|
57
153
|
const def = selectedKind ? nodeRegistry.get(selectedKind) : null
|
|
58
154
|
const isRegistryKind = !!def
|
|
59
|
-
const isVisible =
|
|
155
|
+
const isVisible =
|
|
156
|
+
isRegistryKind && def?.presentation?.actionMenu !== false && !movingNode && isFloorplanHovered
|
|
60
157
|
const isWall = selectedKind === 'wall'
|
|
158
|
+
const quickActionNodes = useScene(
|
|
159
|
+
useShallow((s) => collectQuickActionNodes(s.nodes, selectedId ?? null)),
|
|
160
|
+
)
|
|
61
161
|
|
|
62
162
|
useEffect(() => {
|
|
63
163
|
if (!(isVisible && selectedId)) {
|
|
@@ -117,6 +217,14 @@ export function FloorplanRegistryActionMenu() {
|
|
|
117
217
|
const node = useScene.getState().nodes[selectedId]
|
|
118
218
|
if (!node) return null
|
|
119
219
|
|
|
220
|
+
const quickActions =
|
|
221
|
+
quickActionNodes && nodeRegistry.get(node.type)?.quickActions
|
|
222
|
+
? (nodeRegistry.get(node.type)?.quickActions?.({
|
|
223
|
+
node: node as never,
|
|
224
|
+
nodes: quickActionNodes,
|
|
225
|
+
}) ?? [])
|
|
226
|
+
: []
|
|
227
|
+
|
|
120
228
|
// Move button is enabled when any of:
|
|
121
229
|
// - `capabilities.movable` (generic translate-on-XZ — shelf / spawn / fence)
|
|
122
230
|
// - `def.floorplanMoveTarget` (anchor-aware 2D — door / window / item)
|
|
@@ -134,6 +242,11 @@ export function FloorplanRegistryActionMenu() {
|
|
|
134
242
|
const handleMove = () => {
|
|
135
243
|
sfxEmitter.emit('sfx:item-pick')
|
|
136
244
|
setMovingNode(node as never)
|
|
245
|
+
// 2D-owned move: `FloorplanRegistryMoveOverlay` runs the whole gesture.
|
|
246
|
+
// Mark the origin (after `setMovingNode`, which resets it to null) so
|
|
247
|
+
// `ToolManager` keeps the 3D affordance mover from also adopting the node
|
|
248
|
+
// and reverting it on unmount. Mirrors the orange move-dot path.
|
|
249
|
+
setMovingNodeOrigin('2d')
|
|
137
250
|
// Match the legacy 3D `floating-action-menu`: clear selection so
|
|
138
251
|
// selection-gated affordances unmount during the drag. Specifically
|
|
139
252
|
// the slab / ceiling boundary editor (`ToolManager` shows it when
|
|
@@ -185,12 +298,29 @@ export function FloorplanRegistryActionMenu() {
|
|
|
185
298
|
if (!node.parentId) return
|
|
186
299
|
sfxEmitter.emit('sfx:item-pick')
|
|
187
300
|
useScene.temporal.getState().pause()
|
|
301
|
+
if (duplicatesAsFreshSubtree(node as AnyNode)) {
|
|
302
|
+
const draftId = createFreshPlacementSubtree(node.id as AnyNodeId)
|
|
303
|
+
const draft = draftId ? useScene.getState().nodes[draftId] : null
|
|
304
|
+
if (draft) {
|
|
305
|
+
setMovingNode(draft as never)
|
|
306
|
+
setMovingNodeOrigin('2d')
|
|
307
|
+
useScene.temporal.getState().resume()
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
useScene.temporal.getState().resume()
|
|
311
|
+
return
|
|
312
|
+
}
|
|
188
313
|
const cloned = structuredClone(node) as AnyNode & { id?: AnyNodeId }
|
|
189
314
|
delete (cloned as { id?: AnyNodeId }).id
|
|
190
315
|
const prevMeta =
|
|
191
316
|
cloned.metadata && typeof cloned.metadata === 'object' && !Array.isArray(cloned.metadata)
|
|
192
317
|
? (cloned.metadata as Record<string, unknown>)
|
|
193
318
|
: {}
|
|
319
|
+
// Mark fresh + hand to the placement cursor so the copy follows the
|
|
320
|
+
// pointer and only lands on the next click — same gesture for every
|
|
321
|
+
// kind. Polyline runs (duct / pipe / lineset) ride the same path:
|
|
322
|
+
// `FloorplanRegistryMoveOverlay` translates their whole `path`, so they
|
|
323
|
+
// no longer need the old "offset + drop already-placed" special case.
|
|
194
324
|
cloned.metadata = { ...prevMeta, isNew: true }
|
|
195
325
|
const parsed = def.schema.parse(cloned) as AnyNode
|
|
196
326
|
useScene.getState().createNode(parsed, node.parentId as AnyNodeId)
|
|
@@ -204,9 +334,20 @@ export function FloorplanRegistryActionMenu() {
|
|
|
204
334
|
useViewer.getState().setSelection({ selectedIds: [] })
|
|
205
335
|
}
|
|
206
336
|
|
|
337
|
+
const handleQuickAction = (action: NodeQuickAction) => {
|
|
338
|
+
if (action.disabled) return
|
|
339
|
+
const run = () => action.run({ node, sceneApi: createSceneApi(useScene) })
|
|
340
|
+
const result = action.history === 'single' ? runAsSingleSceneHistoryStep(useScene, run) : run()
|
|
341
|
+
if (result?.selectedIds) useViewer.getState().setSelection({ selectedIds: result.selectedIds })
|
|
342
|
+
if (result?.selectedIds) {
|
|
343
|
+
const selectedDifferentNode = result.selectedIds.some((id) => id !== node.id)
|
|
344
|
+
sfxEmitter.emit(selectedDifferentNode ? 'sfx:item-place' : 'sfx:item-pick')
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
207
348
|
return createPortal(
|
|
208
349
|
<div
|
|
209
|
-
className="pointer-events-none fixed z-30"
|
|
350
|
+
className="pointer-events-none fixed z-30 flex w-max flex-col items-center"
|
|
210
351
|
style={{
|
|
211
352
|
left: position.left,
|
|
212
353
|
top: position.top,
|
|
@@ -221,6 +362,30 @@ export function FloorplanRegistryActionMenu() {
|
|
|
221
362
|
onPointerDown={(event) => event.stopPropagation()}
|
|
222
363
|
onPointerUp={(event) => event.stopPropagation()}
|
|
223
364
|
/>
|
|
365
|
+
{quickActions.length > 0 ? (
|
|
366
|
+
<div
|
|
367
|
+
className="pointer-events-auto mt-1 inline-flex w-max items-center justify-center gap-0.5 rounded-lg border border-border/50 bg-background/90 px-1.5 py-1 shadow-md backdrop-blur-md"
|
|
368
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
369
|
+
onPointerUp={(event) => event.stopPropagation()}
|
|
370
|
+
>
|
|
371
|
+
{quickActions.map((action) => (
|
|
372
|
+
<button
|
|
373
|
+
aria-label={action.title ?? action.label}
|
|
374
|
+
className="tooltip-trigger flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
|
|
375
|
+
disabled={action.disabled}
|
|
376
|
+
key={action.id}
|
|
377
|
+
onClick={() => handleQuickAction(action)}
|
|
378
|
+
title={action.title ?? action.label}
|
|
379
|
+
type="button"
|
|
380
|
+
>
|
|
381
|
+
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center text-current">
|
|
382
|
+
<QuickActionIcon action={action} />
|
|
383
|
+
</span>
|
|
384
|
+
<span className="whitespace-nowrap leading-none">{action.label}</span>
|
|
385
|
+
</button>
|
|
386
|
+
))}
|
|
387
|
+
</div>
|
|
388
|
+
) : null}
|
|
224
389
|
</div>,
|
|
225
390
|
document.body,
|
|
226
391
|
)
|