@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
|
@@ -2,6 +2,7 @@ import { type AnyNodeId, type SolarPanelNode, useScene } from '@pascal-app/core'
|
|
|
2
2
|
import { useViewer } from '@pascal-app/viewer'
|
|
3
3
|
import Image from 'next/image'
|
|
4
4
|
import { memo, useCallback, useState } from 'react'
|
|
5
|
+
import { SnapTargetIcon } from '../../../snap-target-badge'
|
|
5
6
|
import useEditor from './../../../../../store/use-editor'
|
|
6
7
|
import { InlineRenameInput } from './inline-rename-input'
|
|
7
8
|
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
|
@@ -51,13 +52,15 @@ export const SolarPanelTreeNode = memo(function SolarPanelTreeNode({
|
|
|
51
52
|
expanded={false}
|
|
52
53
|
hasChildren={false}
|
|
53
54
|
icon={
|
|
54
|
-
<
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
<SnapTargetIcon target="roof">
|
|
56
|
+
<Image
|
|
57
|
+
alt=""
|
|
58
|
+
className="object-contain opacity-60"
|
|
59
|
+
height={14}
|
|
60
|
+
src="/icons/roof.webp"
|
|
61
|
+
width={14}
|
|
62
|
+
/>
|
|
63
|
+
</SnapTargetIcon>
|
|
61
64
|
}
|
|
62
65
|
isHovered={isHovered}
|
|
63
66
|
isLast={isLast}
|
|
@@ -98,7 +98,7 @@ export const StairTreeNode = memo(function StairTreeNode({
|
|
|
98
98
|
expanded={expanded}
|
|
99
99
|
hasChildren={segments.length > 0}
|
|
100
100
|
icon={
|
|
101
|
-
<Image alt="" className="object-contain" height={14} src="/icons/stairs.
|
|
101
|
+
<Image alt="" className="object-contain" height={14} src="/icons/stairs.webp" width={14} />
|
|
102
102
|
}
|
|
103
103
|
isDropTarget={isValidDropTarget && isDropTarget}
|
|
104
104
|
isHovered={isHovered || isDropTarget}
|
|
@@ -206,7 +206,7 @@ function StairSegmentTreeNode({
|
|
|
206
206
|
alt=""
|
|
207
207
|
className="object-contain opacity-60"
|
|
208
208
|
height={14}
|
|
209
|
-
src="/icons/stairs.
|
|
209
|
+
src="/icons/stairs.webp"
|
|
210
210
|
width={14}
|
|
211
211
|
/>
|
|
212
212
|
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'
|
|
1
|
+
import { type AnyNode, type AnyNodeId, emitter, nodeRegistry, useScene } from '@pascal-app/core'
|
|
2
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
2
3
|
import { ChevronRight } from 'lucide-react'
|
|
3
4
|
import { AnimatePresence, motion } from 'motion/react'
|
|
4
5
|
import { forwardRef, memo, useEffect, useRef } from 'react'
|
|
6
|
+
import { resolveNodeSelectionTarget } from '../../../../../lib/selection-routing'
|
|
7
|
+
import useEditor from '../../../../../store/use-editor'
|
|
5
8
|
|
|
6
9
|
export function handleTreeSelection(
|
|
7
10
|
e: React.MouseEvent,
|
|
@@ -53,6 +56,31 @@ export function focusTreeNode(nodeId: AnyNodeId) {
|
|
|
53
56
|
emitter.emit('camera-controls:focus', { nodeId })
|
|
54
57
|
}
|
|
55
58
|
|
|
59
|
+
export function routeTreeSelectionToNode(node: AnyNode | null | undefined) {
|
|
60
|
+
const target = node ? resolveNodeSelectionTarget(node) : null
|
|
61
|
+
if (!target) return
|
|
62
|
+
|
|
63
|
+
const selectedIdsAfterClick = useViewer.getState().selection.selectedIds
|
|
64
|
+
const editor = useEditor.getState()
|
|
65
|
+
let didRoute = false
|
|
66
|
+
|
|
67
|
+
if (target.phase !== editor.phase) {
|
|
68
|
+
editor.setPhase(target.phase)
|
|
69
|
+
didRoute = true
|
|
70
|
+
}
|
|
71
|
+
if (
|
|
72
|
+
target.phase === 'structure' &&
|
|
73
|
+
target.structureLayer &&
|
|
74
|
+
target.structureLayer !== useEditor.getState().structureLayer
|
|
75
|
+
) {
|
|
76
|
+
useEditor.getState().setStructureLayer(target.structureLayer)
|
|
77
|
+
didRoute = true
|
|
78
|
+
}
|
|
79
|
+
if (didRoute) {
|
|
80
|
+
useViewer.getState().setSelection({ selectedIds: selectedIdsAfterClick })
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
56
84
|
import { cn } from '../../../../../lib/utils'
|
|
57
85
|
import { BuildingTreeNode } from './building-tree-node'
|
|
58
86
|
import { CeilingTreeNode } from './ceiling-tree-node'
|
|
@@ -98,10 +126,13 @@ const treeNodeByType: Record<
|
|
|
98
126
|
isLast?: boolean
|
|
99
127
|
nodeId: AnyNodeId
|
|
100
128
|
}>,
|
|
129
|
+
cabinet: RegistryTreeNode,
|
|
130
|
+
'cabinet-module': RegistryTreeNode,
|
|
101
131
|
'box-vent': RegistryTreeNode,
|
|
102
132
|
ceiling: CeilingTreeNode,
|
|
103
133
|
chimney: ChimneyTreeNode,
|
|
104
134
|
dormer: DormerTreeNode,
|
|
135
|
+
downspout: RegistryTreeNode,
|
|
105
136
|
'solar-panel': SolarPanelTreeNode,
|
|
106
137
|
column: ColumnTreeNode,
|
|
107
138
|
elevator: ElevatorTreeNode,
|
|
@@ -124,10 +155,12 @@ const treeNodeByType: Record<
|
|
|
124
155
|
wall: WallTreeNode,
|
|
125
156
|
fence: FenceTreeNode,
|
|
126
157
|
gutter: GutterTreeNode,
|
|
158
|
+
measurement: RegistryTreeNode,
|
|
127
159
|
'ridge-vent': RegistryTreeNode,
|
|
128
160
|
'turbine-vent': RegistryTreeNode,
|
|
129
161
|
cupola: RegistryTreeNode,
|
|
130
162
|
'eyebrow-vent': RegistryTreeNode,
|
|
163
|
+
skylight: RegistryTreeNode,
|
|
131
164
|
roof: RoofTreeNode,
|
|
132
165
|
stair: StairTreeNode,
|
|
133
166
|
door: DoorTreeNode,
|
|
@@ -141,7 +174,15 @@ const treeNodeByType: Record<
|
|
|
141
174
|
}
|
|
142
175
|
|
|
143
176
|
export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
|
|
177
|
+
// Registry-driven row hiding (`def.tree.hidden`) — primitive boolean
|
|
178
|
+
// selector so unrelated scene updates don't re-render every row.
|
|
179
|
+
const shouldHide = useScene((state) => {
|
|
180
|
+
const node = state.nodes[nodeId]
|
|
181
|
+
if (!node) return false
|
|
182
|
+
return nodeRegistry.get(node.type)?.tree?.hidden?.(node, state.nodes) ?? false
|
|
183
|
+
})
|
|
144
184
|
const nodeType = useScene((state) => state.nodes[nodeId]?.type)
|
|
185
|
+
if (shouldHide) return null
|
|
145
186
|
if (!nodeType) return null
|
|
146
187
|
const Component = treeNodeByType[nodeType]
|
|
147
188
|
if (!Component) return null
|
|
@@ -266,10 +307,10 @@ export const TreeNodeWrapper = forwardRef<HTMLDivElement, TreeNodeWrapperProps>(
|
|
|
266
307
|
</motion.div>
|
|
267
308
|
) : null}
|
|
268
309
|
</button>
|
|
269
|
-
<div className="flex min-w-0 flex-1 items-center gap-
|
|
310
|
+
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
270
311
|
<span
|
|
271
312
|
className={cn(
|
|
272
|
-
'flex h-
|
|
313
|
+
'flex h-5 w-5 shrink-0 items-center justify-center transition-all duration-200',
|
|
273
314
|
!isSelected && 'opacity-60 grayscale',
|
|
274
315
|
)}
|
|
275
316
|
>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type AnyNode, type AnyNodeId, nodeRegistry } from '@pascal-app/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Child ids the sidebar tree renders under a node: the kind's
|
|
5
|
+
* `def.tree.childIds` override when declared, otherwise the node's own
|
|
6
|
+
* `children`. Kind-agnostic — kinds that reshape their subtree (hidden
|
|
7
|
+
* derived nodes, flattened containers) do so via the registry hook.
|
|
8
|
+
*/
|
|
9
|
+
export function resolveTreeChildIds(
|
|
10
|
+
nodeId: AnyNodeId,
|
|
11
|
+
nodes: Readonly<Partial<Record<AnyNodeId, AnyNode>>>,
|
|
12
|
+
): AnyNodeId[] {
|
|
13
|
+
const node = nodes[nodeId]
|
|
14
|
+
if (!node) return []
|
|
15
|
+
const childIds = nodeRegistry.get(node.type)?.tree?.childIds
|
|
16
|
+
if (childIds) return childIds(node, nodes)
|
|
17
|
+
const children = (node as { children?: unknown }).children
|
|
18
|
+
return Array.isArray(children) ? (children as AnyNodeId[]) : []
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Whether `targetId` appears anywhere under `nodeId` in the *rendered*
|
|
23
|
+
* sidebar tree (i.e. walking registry-overridden child ids, not the raw
|
|
24
|
+
* scene graph). Drives auto-expansion when a descendant gets selected.
|
|
25
|
+
*/
|
|
26
|
+
export function treeContainsDescendant(
|
|
27
|
+
nodeId: AnyNodeId,
|
|
28
|
+
targetId: AnyNodeId,
|
|
29
|
+
nodes: Readonly<Partial<Record<AnyNodeId, AnyNode>>>,
|
|
30
|
+
): boolean {
|
|
31
|
+
for (const childId of resolveTreeChildIds(nodeId, nodes)) {
|
|
32
|
+
if (childId === targetId) return true
|
|
33
|
+
if (treeContainsDescendant(childId, targetId, nodes)) return true
|
|
34
|
+
}
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
@@ -79,7 +79,7 @@ export const WallTreeNode = memo(function WallTreeNode({
|
|
|
79
79
|
expanded={expanded}
|
|
80
80
|
hasChildren={children.length > 0}
|
|
81
81
|
icon={
|
|
82
|
-
<Image alt="" className="object-contain" height={14} src="/icons/wall.
|
|
82
|
+
<Image alt="" className="object-contain" height={14} src="/icons/wall.webp" width={14} />
|
|
83
83
|
}
|
|
84
84
|
isHovered={isHovered}
|
|
85
85
|
isLast={isLast}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { type AnyNodeId, useScene } from '@pascal-app/core'
|
|
3
|
+
import { type AnyNodeId, type WindowNode, useScene } from '@pascal-app/core'
|
|
4
4
|
import { useViewer } from '@pascal-app/viewer'
|
|
5
5
|
import Image from 'next/image'
|
|
6
6
|
import { memo, useCallback, useState } from 'react'
|
|
7
|
+
import { resolveNodeSnapTarget, SnapTargetIcon } from '../../../snap-target-badge'
|
|
7
8
|
import useEditor from './../../../../../store/use-editor'
|
|
8
9
|
import { InlineRenameInput } from './inline-rename-input'
|
|
9
10
|
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
|
@@ -22,6 +23,7 @@ export const WindowTreeNode = memo(function WindowTreeNode({
|
|
|
22
23
|
}: WindowTreeNodeProps) {
|
|
23
24
|
const [isEditing, setIsEditing] = useState(false)
|
|
24
25
|
const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
|
|
26
|
+
const node = useScene((s) => s.nodes[nodeId] as WindowNode | undefined)
|
|
25
27
|
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
|
26
28
|
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
|
27
29
|
const setSelection = useViewer((state) => state.setSelection)
|
|
@@ -45,6 +47,7 @@ export const WindowTreeNode = memo(function WindowTreeNode({
|
|
|
45
47
|
|
|
46
48
|
const handleStartEditing = useCallback(() => setIsEditing(true), [])
|
|
47
49
|
const handleStopEditing = useCallback(() => setIsEditing(false), [])
|
|
50
|
+
const snapTarget = resolveNodeSnapTarget(node) ?? 'wall'
|
|
48
51
|
|
|
49
52
|
return (
|
|
50
53
|
<TreeNodeWrapper
|
|
@@ -53,7 +56,9 @@ export const WindowTreeNode = memo(function WindowTreeNode({
|
|
|
53
56
|
expanded={false}
|
|
54
57
|
hasChildren={false}
|
|
55
58
|
icon={
|
|
56
|
-
<
|
|
59
|
+
<SnapTargetIcon target={snapTarget}>
|
|
60
|
+
<Image alt="" className="object-contain" height={14} src="/icons/window.webp" width={14} />
|
|
61
|
+
</SnapTargetIcon>
|
|
57
62
|
}
|
|
58
63
|
isHovered={isHovered}
|
|
59
64
|
isLast={isLast}
|
|
@@ -2,6 +2,7 @@ import { useScene, type ZoneNode } from '@pascal-app/core'
|
|
|
2
2
|
import { useViewer } from '@pascal-app/viewer'
|
|
3
3
|
import { memo, useCallback, useState } from 'react'
|
|
4
4
|
import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
|
|
5
|
+
import { formatAreaLabel } from './../../../../../lib/measurements'
|
|
5
6
|
import { InlineRenameInput } from './inline-rename-input'
|
|
6
7
|
import { focusTreeNode, TreeNodeWrapper } from './tree-node'
|
|
7
8
|
import { TreeNodeActions } from './tree-node-actions'
|
|
@@ -26,6 +27,7 @@ export const ZoneTreeNode = memo(function ZoneTreeNode({
|
|
|
26
27
|
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
|
27
28
|
const setSelection = useViewer((state) => state.setSelection)
|
|
28
29
|
const setHoveredId = useViewer((state) => state.setHoveredId)
|
|
30
|
+
const unit = useViewer((state) => state.unit)
|
|
29
31
|
|
|
30
32
|
const handleClick = useCallback(() => setSelection({ zoneId: nodeId }), [nodeId, setSelection])
|
|
31
33
|
const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId])
|
|
@@ -34,9 +36,7 @@ export const ZoneTreeNode = memo(function ZoneTreeNode({
|
|
|
34
36
|
const handleStartEditing = useCallback(() => setIsEditing(true), [])
|
|
35
37
|
const handleStopEditing = useCallback(() => setIsEditing(false), [])
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
const area = calculatePolygonArea(polygon).toFixed(1)
|
|
39
|
-
const defaultName = `Zone (${area}m²)`
|
|
39
|
+
const defaultName = `Zone (${formatAreaLabel(calculatePolygonArea(polygon), unit)})`
|
|
40
40
|
|
|
41
41
|
return (
|
|
42
42
|
<TreeNodeWrapper
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import type { ReactNode } from 'react'
|
|
4
|
+
import { editorHostPanelRegistry } from '../../../lib/plugin-panels'
|
|
4
5
|
import { triggerSFX } from './../../../lib/sfx-bus'
|
|
5
6
|
import { cn } from './../../../lib/utils'
|
|
6
7
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../primitives/tooltip'
|
|
@@ -65,37 +66,50 @@ interface IconRailProps {
|
|
|
65
66
|
* The label renders as a hover tooltip on the right.
|
|
66
67
|
*/
|
|
67
68
|
export function IconRail({ tabs, activeTab, collapsed, onIconClick }: IconRailProps) {
|
|
69
|
+
const pluginPanelIds = new Set(
|
|
70
|
+
editorHostPanelRegistry.getSnapshot().flatMap((panel) =>
|
|
71
|
+
panel.pluginId ? [panel.id] : [],
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
const defaultTabs = tabs.filter((tab) => !pluginPanelIds.has(tab.id) && tab.id !== 'plugins')
|
|
75
|
+
const pluginTabs = tabs.filter((tab) => pluginPanelIds.has(tab.id) || tab.id === 'plugins')
|
|
76
|
+
|
|
77
|
+
const renderTab = (tab: SidebarTab) => {
|
|
78
|
+
const showActive = activeTab === tab.id && !collapsed
|
|
79
|
+
return (
|
|
80
|
+
<Tooltip key={tab.id}>
|
|
81
|
+
<TooltipTrigger asChild>
|
|
82
|
+
<button
|
|
83
|
+
className={cn(
|
|
84
|
+
'group flex h-11 w-11 items-center justify-center rounded-xl transition-all duration-200 [&_img]:transition-[opacity,filter] [&_img]:duration-200',
|
|
85
|
+
showActive
|
|
86
|
+
? 'bg-accent text-foreground shadow-sm [&_img]:opacity-100 [&_img]:grayscale-0'
|
|
87
|
+
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground [&_img]:opacity-60 [&_img]:grayscale hover:[&_img]:opacity-100 hover:[&_img]:grayscale-0',
|
|
88
|
+
)}
|
|
89
|
+
onClick={() => {
|
|
90
|
+
triggerSFX('sfx:menu-click')
|
|
91
|
+
onIconClick(tab.id)
|
|
92
|
+
}}
|
|
93
|
+
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
|
|
94
|
+
type="button"
|
|
95
|
+
>
|
|
96
|
+
{tab.icon ?? tab.label.charAt(0)}
|
|
97
|
+
</button>
|
|
98
|
+
</TooltipTrigger>
|
|
99
|
+
<TooltipContent side="right">{tab.label}</TooltipContent>
|
|
100
|
+
</Tooltip>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
68
104
|
return (
|
|
69
105
|
<TooltipProvider delayDuration={0} disableHoverableContent>
|
|
70
106
|
<div className="flex h-full w-14 shrink-0 flex-col items-center gap-1 border-border/50 border-r py-2">
|
|
71
|
-
{
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
<TooltipTrigger asChild>
|
|
78
|
-
<button
|
|
79
|
-
className={cn(
|
|
80
|
-
'group flex h-11 w-11 items-center justify-center rounded-xl transition-all duration-200 [&_img]:transition-[opacity,filter] [&_img]:duration-200',
|
|
81
|
-
showActive
|
|
82
|
-
? 'bg-accent text-foreground shadow-sm [&_img]:opacity-100 [&_img]:grayscale-0'
|
|
83
|
-
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground [&_img]:opacity-60 [&_img]:grayscale hover:[&_img]:opacity-100 hover:[&_img]:grayscale-0',
|
|
84
|
-
)}
|
|
85
|
-
onClick={() => {
|
|
86
|
-
triggerSFX('sfx:menu-click')
|
|
87
|
-
onIconClick(tab.id)
|
|
88
|
-
}}
|
|
89
|
-
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
|
|
90
|
-
type="button"
|
|
91
|
-
>
|
|
92
|
-
{tab.icon ?? tab.label.charAt(0)}
|
|
93
|
-
</button>
|
|
94
|
-
</TooltipTrigger>
|
|
95
|
-
<TooltipContent side="right">{tab.label}</TooltipContent>
|
|
96
|
-
</Tooltip>
|
|
97
|
-
)
|
|
98
|
-
})}
|
|
107
|
+
{defaultTabs.map(renderTab)}
|
|
108
|
+
{pluginTabs.length > 0 && (
|
|
109
|
+
<div className="mt-1 flex w-11 flex-col items-center gap-1 border-border/70 border-t pt-2">
|
|
110
|
+
{pluginTabs.map(renderTab)}
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
99
113
|
</div>
|
|
100
114
|
</TooltipProvider>
|
|
101
115
|
)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Icon } from '@iconify/react'
|
|
4
|
+
import { type IconRef, useScene } from '@pascal-app/core'
|
|
5
|
+
import { Plus } from 'lucide-react'
|
|
6
|
+
import {
|
|
7
|
+
type ComponentType,
|
|
8
|
+
lazy,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
Suspense,
|
|
11
|
+
useEffect,
|
|
12
|
+
useSyncExternalStore,
|
|
13
|
+
} from 'react'
|
|
14
|
+
import useEditor from '../../../store/use-editor'
|
|
15
|
+
import { editorHostPanelRegistry, type EditorHostPanel } from '../../../lib/plugin-panels'
|
|
16
|
+
import { ErrorBoundary } from '../primitives/error-boundary'
|
|
17
|
+
import type { ExtraPanel } from './icon-rail'
|
|
18
|
+
import { PluginsPanel } from './panels/plugins-panel'
|
|
19
|
+
|
|
20
|
+
const pluginsManagerPanel: ExtraPanel = {
|
|
21
|
+
id: 'plugins',
|
|
22
|
+
label: 'Plugins',
|
|
23
|
+
icon: <Plus className="h-5 w-5" />,
|
|
24
|
+
component: PluginsPanel,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Resolve a plugin's {@link IconRef} into a rail-sized React node. Mirrors the
|
|
28
|
+
* inspector's `renderIcon`, sized for the 24px icon-rail button. */
|
|
29
|
+
function renderIconRef(ref: IconRef): ReactNode {
|
|
30
|
+
if (ref.kind === 'url') {
|
|
31
|
+
return <img alt="" className="h-5 w-5 object-contain" src={ref.src} />
|
|
32
|
+
}
|
|
33
|
+
if (ref.kind === 'iconify') {
|
|
34
|
+
return <Icon height={20} icon={ref.name} width={20} />
|
|
35
|
+
}
|
|
36
|
+
if (ref.kind === 'svg') {
|
|
37
|
+
return (
|
|
38
|
+
<svg height={20} viewBox={ref.viewBox} width={20}>
|
|
39
|
+
<path d={ref.path} fill="currentColor" />
|
|
40
|
+
</svg>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
const LazyIcon = lazy(ref.module)
|
|
44
|
+
return (
|
|
45
|
+
<Suspense fallback={null}>
|
|
46
|
+
<LazyIcon />
|
|
47
|
+
</Suspense>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function PluginPanelCrashed({ label }: { label: string }) {
|
|
52
|
+
return (
|
|
53
|
+
<div className="flex flex-col gap-2 p-4 text-sm">
|
|
54
|
+
<p className="font-medium text-sidebar-foreground">"{label}" plugin crashed</p>
|
|
55
|
+
<p className="text-sidebar-foreground/50 text-xs">
|
|
56
|
+
This panel hit an error and was unloaded for this session. The rest of the editor is
|
|
57
|
+
unaffected — reload to try again.
|
|
58
|
+
</p>
|
|
59
|
+
</div>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// `React.lazy` must be called once per loader so the resolved component keeps a
|
|
64
|
+
// stable identity across renders (otherwise switching panels remounts the tree
|
|
65
|
+
// every time the sidebar re-renders). Cache the wrapped component by its loader.
|
|
66
|
+
const wrappedPanelCache = new WeakMap<EditorHostPanel['component'], ComponentType>()
|
|
67
|
+
|
|
68
|
+
function resolvePanelComponent(panel: EditorHostPanel): ComponentType {
|
|
69
|
+
const cached = wrappedPanelCache.get(panel.component)
|
|
70
|
+
if (cached) return cached
|
|
71
|
+
const Lazy = lazy(panel.component)
|
|
72
|
+
const Wrapped: ComponentType = () => (
|
|
73
|
+
<ErrorBoundary fallback={<PluginPanelCrashed label={panel.label} />}>
|
|
74
|
+
<Suspense fallback={<div className="p-4 text-sidebar-foreground/50 text-sm">Loading…</div>}>
|
|
75
|
+
<Lazy />
|
|
76
|
+
</Suspense>
|
|
77
|
+
</ErrorBoundary>
|
|
78
|
+
)
|
|
79
|
+
Wrapped.displayName = `PluginPanel(${panel.id})`
|
|
80
|
+
wrappedPanelCache.set(panel.component, Wrapped)
|
|
81
|
+
return Wrapped
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Merge host-registered panels (from the observable {@link editorHostPanelRegistry})
|
|
86
|
+
* with the host's `extraPanels`, returning the combined list the icon rail and
|
|
87
|
+
* panel-content area render. Host panels keep their leading order and win on id
|
|
88
|
+
* collisions. Subscribes to the registry so a panel that registers after the
|
|
89
|
+
* first render makes the rail re-render.
|
|
90
|
+
*
|
|
91
|
+
* Panels are filtered by the current workspace: a panel surfaces only in the
|
|
92
|
+
* workspaces it declares (`EditorHostPanel.workspaces`, default `['edit']`), so an
|
|
93
|
+
* authoring panel like Nature doesn't ride into the studio rail.
|
|
94
|
+
*/
|
|
95
|
+
export function useHostPanels(hostPanels?: ExtraPanel[]): ExtraPanel[] {
|
|
96
|
+
const registered = useSyncExternalStore(
|
|
97
|
+
editorHostPanelRegistry.subscribe,
|
|
98
|
+
editorHostPanelRegistry.getSnapshot,
|
|
99
|
+
editorHostPanelRegistry.getSnapshot,
|
|
100
|
+
)
|
|
101
|
+
const workspaceMode = useEditor((s) => s.workspaceMode)
|
|
102
|
+
const installedPlugins = useScene((s) => s.installedPlugins)
|
|
103
|
+
const hostIds = new Set(hostPanels?.map((p) => p.id))
|
|
104
|
+
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
const scene = useScene.getState()
|
|
107
|
+
if (scene.hasExplicitPluginInstallState) return
|
|
108
|
+
const defaults = editorHostPanelRegistry.getDefaultInstalledPluginIds()
|
|
109
|
+
if (defaults.every((pluginId) => scene.installedPlugins.includes(pluginId))) return
|
|
110
|
+
scene.setInstalledPlugins([...scene.installedPlugins, ...defaults], { explicit: false })
|
|
111
|
+
}, [registered])
|
|
112
|
+
|
|
113
|
+
const fromRegistry = registered
|
|
114
|
+
.filter(
|
|
115
|
+
(p) =>
|
|
116
|
+
!hostIds.has(p.id) &&
|
|
117
|
+
(p.workspaces ?? ['edit']).includes(workspaceMode) &&
|
|
118
|
+
(!p.pluginId || installedPlugins.includes(p.pluginId)),
|
|
119
|
+
)
|
|
120
|
+
.map(
|
|
121
|
+
(p): ExtraPanel => ({
|
|
122
|
+
id: p.id,
|
|
123
|
+
label: p.label,
|
|
124
|
+
icon: renderIconRef(p.icon),
|
|
125
|
+
component: resolvePanelComponent(p),
|
|
126
|
+
pluginId: p.pluginId,
|
|
127
|
+
}),
|
|
128
|
+
)
|
|
129
|
+
const manager =
|
|
130
|
+
workspaceMode === 'edit' && !hostIds.has(pluginsManagerPanel.id) ? [pluginsManagerPanel] : []
|
|
131
|
+
return [...(hostPanels ?? []), ...fromRegistry, ...manager]
|
|
132
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { AnyNode } from '@pascal-app/core'
|
|
3
|
+
import { resolveAssetSnapTarget, resolveNodeSnapTarget } from './snap-target-badge'
|
|
4
|
+
|
|
5
|
+
describe('resolveAssetSnapTarget', () => {
|
|
6
|
+
test('maps wall-hosted catalog assets to a wall badge', () => {
|
|
7
|
+
expect(resolveAssetSnapTarget('wall')).toBe('wall')
|
|
8
|
+
expect(resolveAssetSnapTarget('wall-side')).toBe('wall')
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test('maps ceiling-hosted catalog assets to a ceiling badge', () => {
|
|
12
|
+
expect(resolveAssetSnapTarget('ceiling')).toBe('ceiling')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('does not badge floor assets', () => {
|
|
16
|
+
expect(resolveAssetSnapTarget(undefined)).toBeNull()
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('resolveNodeSnapTarget', () => {
|
|
21
|
+
test('prefers roof attachment when a node is hosted by a roof segment', () => {
|
|
22
|
+
const node = {
|
|
23
|
+
id: 'window_1',
|
|
24
|
+
type: 'window',
|
|
25
|
+
roofSegmentId: 'roof-segment_1',
|
|
26
|
+
} as unknown as AnyNode
|
|
27
|
+
|
|
28
|
+
expect(resolveNodeSnapTarget(node)).toBe('roof')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('badges gutter-hosted downspouts as roof accessories', () => {
|
|
32
|
+
const node = {
|
|
33
|
+
id: 'downspout_1',
|
|
34
|
+
type: 'downspout',
|
|
35
|
+
gutterId: 'gutter_1',
|
|
36
|
+
} as unknown as AnyNode
|
|
37
|
+
|
|
38
|
+
expect(resolveNodeSnapTarget(node)).toBe('roof')
|
|
39
|
+
})
|
|
40
|
+
})
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { AnyNode, AssetInput } from '@pascal-app/core'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { cn } from '../../lib/utils'
|
|
4
|
+
|
|
5
|
+
export type SnapTarget = 'wall' | 'ceiling' | 'roof'
|
|
6
|
+
export type SnapTargetBadgeSize = 'tile' | 'tree'
|
|
7
|
+
|
|
8
|
+
const SNAP_TARGET_ICONS: Record<SnapTarget, string> = {
|
|
9
|
+
wall: '/icons/wall.webp',
|
|
10
|
+
ceiling: '/icons/ceiling.webp',
|
|
11
|
+
roof: '/icons/roof.webp',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const SNAP_TARGET_LABELS: Record<SnapTarget, string> = {
|
|
15
|
+
wall: 'Wall attachment',
|
|
16
|
+
ceiling: 'Ceiling attachment',
|
|
17
|
+
roof: 'Roof attachment',
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const SNAP_TARGET_BADGE_SIZE_CLASSES: Record<SnapTargetBadgeSize, string> = {
|
|
21
|
+
tile: 'h-6 w-6 rounded-md',
|
|
22
|
+
tree: 'h-3.5 w-3.5 rounded-[3px]',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const SNAP_TARGET_ICON_SIZE_CLASSES: Record<SnapTargetBadgeSize, string> = {
|
|
26
|
+
tile: 'h-[18px] w-[18px]',
|
|
27
|
+
tree: 'h-2.5 w-2.5',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function resolveAssetSnapTarget(attachTo: AssetInput['attachTo']): SnapTarget | null {
|
|
31
|
+
if (attachTo === 'wall' || attachTo === 'wall-side') return 'wall'
|
|
32
|
+
if (attachTo === 'ceiling') return 'ceiling'
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function resolveNodeSnapTarget(node: AnyNode | null | undefined): SnapTarget | null {
|
|
37
|
+
if (!node) return null
|
|
38
|
+
if ('roofSegmentId' in node && typeof node.roofSegmentId === 'string') return 'roof'
|
|
39
|
+
if (node.type === 'downspout') return 'roof'
|
|
40
|
+
if (node.type === 'door' || node.type === 'window') return 'wall'
|
|
41
|
+
if (node.type === 'item') return resolveAssetSnapTarget(node.asset?.attachTo)
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function SnapTargetBadge({
|
|
46
|
+
className,
|
|
47
|
+
size = 'tile',
|
|
48
|
+
target,
|
|
49
|
+
}: {
|
|
50
|
+
className?: string
|
|
51
|
+
size?: SnapTargetBadgeSize
|
|
52
|
+
target: SnapTarget
|
|
53
|
+
}) {
|
|
54
|
+
return (
|
|
55
|
+
<span
|
|
56
|
+
className={cn(
|
|
57
|
+
'flex items-center justify-center bg-black/65 ring-1 ring-white/20',
|
|
58
|
+
SNAP_TARGET_BADGE_SIZE_CLASSES[size],
|
|
59
|
+
className,
|
|
60
|
+
)}
|
|
61
|
+
>
|
|
62
|
+
<img
|
|
63
|
+
alt={SNAP_TARGET_LABELS[target]}
|
|
64
|
+
className={cn('object-contain', SNAP_TARGET_ICON_SIZE_CLASSES[size])}
|
|
65
|
+
src={SNAP_TARGET_ICONS[target]}
|
|
66
|
+
/>
|
|
67
|
+
</span>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function SnapTargetIcon({
|
|
72
|
+
children,
|
|
73
|
+
target,
|
|
74
|
+
}: {
|
|
75
|
+
children: ReactNode
|
|
76
|
+
target: SnapTarget
|
|
77
|
+
}) {
|
|
78
|
+
return (
|
|
79
|
+
<span className="relative inline-flex h-5 w-5 items-center justify-center">
|
|
80
|
+
{children}
|
|
81
|
+
<SnapTargetBadge
|
|
82
|
+
className="-right-1.5 -bottom-1.5 absolute"
|
|
83
|
+
size="tree"
|
|
84
|
+
target={target}
|
|
85
|
+
/>
|
|
86
|
+
</span>
|
|
87
|
+
)
|
|
88
|
+
}
|