@pascal-app/editor 1.0.0 → 1.0.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 +7 -6
- package/src/components/editor/bake-exporter.tsx +4 -0
- package/src/components/editor/bake-thumbnail.tsx +4 -1
- package/src/components/editor/floating-action-menu.tsx +14 -1
- package/src/components/editor/floorplan-panel.tsx +23 -3
- package/src/components/editor/handles/handle-placement.test.ts +111 -0
- package/src/components/editor/handles/handle-placement.ts +69 -0
- package/src/components/editor/handles/linear-resize-drag.ts +13 -2
- package/src/components/editor/handles/use-handle-drag.ts +5 -2
- package/src/components/editor/index.tsx +12 -4
- package/src/components/editor/node-arrow-handles.tsx +21 -62
- package/src/components/editor/selection-manager.tsx +49 -0
- package/src/components/editor/thumbnail-generator.tsx +11 -2
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +46 -3
- package/src/components/procedural-items/inspector.tsx +223 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
- package/src/components/tools/item/item-surface-placement.test.ts +175 -0
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
- package/src/components/tools/item/placement-strategies.ts +169 -169
- package/src/components/tools/item/placement-types.ts +4 -0
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
- package/src/components/tools/item/use-draft-node.ts +95 -45
- package/src/components/tools/item/use-placement-coordinator.tsx +247 -106
- package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
- package/src/components/tools/registry/item-surface-move.test.ts +501 -0
- package/src/components/tools/registry/item-surface-move.ts +441 -0
- package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +215 -70
- package/src/components/tools/shared/pointer-support-cap.ts +11 -9
- package/src/components/tools/shared/shelf-stickiness.ts +54 -0
- package/src/components/tools/shared/surface-hit.test.ts +56 -0
- package/src/components/tools/shared/surface-hit.ts +23 -0
- package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
- package/src/components/tools/shared/surface-rejection.tsx +29 -0
- package/src/components/tools/zone/zone-tool.tsx +13 -4
- package/src/components/ui/command-palette/editor-commands.tsx +54 -1
- package/src/components/ui/controls/metric-control.tsx +29 -13
- package/src/components/ui/controls/slider-control.tsx +25 -18
- package/src/components/ui/floating-level-selector.tsx +81 -5
- package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
- package/src/components/ui/panels/parametric-field-control.tsx +1 -1
- package/src/components/ui/panels/parametric-inspector.tsx +6 -2
- package/src/components/ui/panels/reference-panel.tsx +3 -3
- package/src/components/ui/primitives/checkbox.tsx +34 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +120 -3
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +29 -10
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
- package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
- package/src/components/ui/snap-target-badge.tsx +2 -0
- package/src/components/viewer/viewer-scene-header.tsx +3 -0
- package/src/components/viewer/viewer-units-panel.tsx +116 -0
- package/src/hooks/use-ceiling-events.test.ts +24 -2
- package/src/hooks/use-ceiling-events.ts +5 -1
- package/src/hooks/use-keyboard.ts +3 -1
- package/src/index.tsx +5 -0
- package/src/lib/export-texture-utils.test.ts +167 -0
- package/src/lib/export-texture-utils.ts +121 -0
- package/src/lib/export-user-data.test.ts +48 -0
- package/src/lib/export-user-data.ts +69 -0
- package/src/lib/floorplan/floorplan-export.test.ts +139 -1
- package/src/lib/floorplan/floorplan-export.tsx +9 -3
- package/src/lib/floorplan/floorplan-readonly.ts +4 -0
- package/src/lib/fresh-planar-placement.ts +13 -5
- package/src/lib/glb-export-serialize.test.ts +280 -0
- package/src/lib/glb-export.test.ts +206 -0
- package/src/lib/glb-export.ts +171 -24
- package/src/lib/level-duplication.test.ts +73 -0
- package/src/lib/level-duplication.ts +32 -2
- package/src/lib/linear-display.test.ts +59 -0
- package/src/lib/linear-display.ts +39 -0
- package/src/lib/local-guide-image.test.ts +24 -0
- package/src/lib/local-guide-image.ts +34 -1
- package/src/lib/node-action-movement.ts +21 -0
- package/src/lib/portable-export.test.ts +89 -0
- package/src/lib/portable-export.ts +84 -11
- package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
- package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
- package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
- package/src/lib/print-shell-compiler-protocol.ts +8 -0
- package/src/lib/scene-clipboard.ts +1 -1
- package/src/lib/send-to-app.test.ts +108 -0
- package/src/lib/send-to-app.ts +121 -0
- package/src/lib/sfx/movement-tick.ts +37 -0
- package/src/lib/surface-attachment.ts +77 -0
- package/src/lib/units.ts +254 -0
- package/src/lib/usdz-export.ts +43 -16
- package/src/lib/use-linear-display.ts +8 -32
- package/src/store/use-handle-group.ts +4 -0
- package/src/store/use-send-to-app.ts +21 -0
|
@@ -26,8 +26,15 @@ export const BuildingTreeNode = memo(function BuildingTreeNode({
|
|
|
26
26
|
const createNode = useScene((state) => state.createNode)
|
|
27
27
|
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
|
|
28
28
|
const name = useScene((s) => s.nodes[nodeId]?.name)
|
|
29
|
+
// Levels and elevators first, units after, so the units read as one group.
|
|
29
30
|
const children = useScene(
|
|
30
|
-
useShallow((s) =>
|
|
31
|
+
useShallow((s) => {
|
|
32
|
+
const ids = (s.nodes[nodeId] as BuildingNode | undefined)?.children ?? []
|
|
33
|
+
return [
|
|
34
|
+
...ids.filter((id) => s.nodes[id]?.type !== 'unit'),
|
|
35
|
+
...ids.filter((id) => s.nodes[id]?.type === 'unit'),
|
|
36
|
+
]
|
|
37
|
+
}),
|
|
31
38
|
)
|
|
32
39
|
const isSelected = useViewer((state) => state.selection.buildingId === nodeId)
|
|
33
40
|
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
Copy,
|
|
20
20
|
Eye,
|
|
21
21
|
EyeOff,
|
|
22
|
+
Group,
|
|
22
23
|
Loader2,
|
|
23
24
|
MoreHorizontal,
|
|
24
25
|
Pencil,
|
|
@@ -50,15 +51,17 @@ import {
|
|
|
50
51
|
metersToLinearUnit,
|
|
51
52
|
squareMetersToAreaUnit,
|
|
52
53
|
} from './../../../../../lib/measurements'
|
|
53
|
-
import { createLocalGuideImage } from './../../../../../lib/local-guide-image'
|
|
54
|
+
import { createLocalGuideImage, createLocalScan } from './../../../../../lib/local-guide-image'
|
|
54
55
|
import { editorHostTreeChildrenRegistry } from './../../../../../lib/host-tree-children'
|
|
56
|
+
import { createUnitInBuilding, toggleZoneMembership } from './../../../../../lib/units'
|
|
55
57
|
import { cn } from './../../../../../lib/utils'
|
|
56
58
|
import useEditor from './../../../../../store/use-editor'
|
|
57
59
|
import { useUploadStore } from '../../../../../store/use-upload'
|
|
58
60
|
import { MetricControl } from '../../../controls/metric-control'
|
|
59
61
|
import { LevelDuplicateDialog } from '../../../level-duplicate-dialog'
|
|
60
62
|
import { InlineRenameInput } from './inline-rename-input'
|
|
61
|
-
import {
|
|
63
|
+
import { ZoneMembershipCheckbox } from './zone-membership-checkbox'
|
|
64
|
+
import { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node'
|
|
62
65
|
import { TreeNodeDragProvider } from './tree-node-drag'
|
|
63
66
|
|
|
64
67
|
// ============================================================================
|
|
@@ -497,6 +500,7 @@ const LevelReferences = memo(function LevelReferences({
|
|
|
497
500
|
const deleteNode = useScene((s) => s.deleteNode)
|
|
498
501
|
const setSelection = useViewer((s) => s.setSelection)
|
|
499
502
|
const setShowGuides = useViewer((s) => s.setShowGuides)
|
|
503
|
+
const setShowScans = useViewer((s) => s.setShowScans)
|
|
500
504
|
const references = useScene(
|
|
501
505
|
useShallow((s) =>
|
|
502
506
|
Object.values(s.nodes).filter(
|
|
@@ -566,6 +570,23 @@ const LevelReferences = memo(function LevelReferences({
|
|
|
566
570
|
return
|
|
567
571
|
}
|
|
568
572
|
|
|
573
|
+
if (!onUploadAsset) {
|
|
574
|
+
useUploadStore.getState().startUpload(levelId, 'scan', file.name)
|
|
575
|
+
useUploadStore.getState().setStatus(levelId, 'uploading')
|
|
576
|
+
|
|
577
|
+
try {
|
|
578
|
+
const { scan, url } = await createLocalScan({ createNode, file, levelId })
|
|
579
|
+
setShowScans(true)
|
|
580
|
+
setSelectedReferenceId(scan.id)
|
|
581
|
+
setSelection({ selectedIds: [], zoneId: null })
|
|
582
|
+
useUploadStore.getState().setResult(levelId, url)
|
|
583
|
+
window.setTimeout(() => useUploadStore.getState().clearUpload(levelId), 600)
|
|
584
|
+
} catch {
|
|
585
|
+
useUploadStore.getState().setError(levelId, 'Could not add that scan.')
|
|
586
|
+
}
|
|
587
|
+
return
|
|
588
|
+
}
|
|
589
|
+
|
|
569
590
|
if (!projectId) {
|
|
570
591
|
useUploadStore.getState().startUpload(levelId, 'scan', file.name)
|
|
571
592
|
useUploadStore.getState().setError(levelId, 'No active project. Please open a project first.')
|
|
@@ -573,7 +594,7 @@ const LevelReferences = memo(function LevelReferences({
|
|
|
573
594
|
}
|
|
574
595
|
|
|
575
596
|
clearUpload(levelId)
|
|
576
|
-
onUploadAsset
|
|
597
|
+
onUploadAsset(projectId, levelId, file, type)
|
|
577
598
|
}
|
|
578
599
|
|
|
579
600
|
const handleDelete = async (nodeId: string, e: React.MouseEvent) => {
|
|
@@ -949,7 +970,7 @@ const LevelItem = memo(function LevelItem({
|
|
|
949
970
|
precision={2}
|
|
950
971
|
step={0.05}
|
|
951
972
|
unit="m"
|
|
952
|
-
value={
|
|
973
|
+
value={(level.baseElevation ?? 0)}
|
|
953
974
|
/>
|
|
954
975
|
</div>
|
|
955
976
|
<LevelReferences
|
|
@@ -1060,6 +1081,74 @@ const LevelsSection = memo(function LevelsSection({
|
|
|
1060
1081
|
)
|
|
1061
1082
|
})
|
|
1062
1083
|
|
|
1084
|
+
const UnitsSection = memo(function UnitsSection({
|
|
1085
|
+
buildingId,
|
|
1086
|
+
}: {
|
|
1087
|
+
buildingId: BuildingNode['id']
|
|
1088
|
+
}) {
|
|
1089
|
+
const unitIds = useScene(
|
|
1090
|
+
useShallow((s) => {
|
|
1091
|
+
const building = s.nodes[buildingId] as BuildingNode | undefined
|
|
1092
|
+
return (building?.children ?? []).filter((id) => s.nodes[id]?.type === 'unit')
|
|
1093
|
+
}),
|
|
1094
|
+
)
|
|
1095
|
+
const hasUnits = unitIds.length > 0
|
|
1096
|
+
const [expanded, setExpanded] = useState(hasUnits)
|
|
1097
|
+
|
|
1098
|
+
useEffect(() => {
|
|
1099
|
+
if (hasUnits) setExpanded(true)
|
|
1100
|
+
}, [hasUnits])
|
|
1101
|
+
|
|
1102
|
+
const handleNewUnit = (event: React.MouseEvent) => {
|
|
1103
|
+
event.stopPropagation()
|
|
1104
|
+
createUnitInBuilding(buildingId)
|
|
1105
|
+
setExpanded(true)
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
return (
|
|
1109
|
+
<div className="subtle-scrollbar max-h-72 shrink-0 overflow-y-auto overflow-x-hidden">
|
|
1110
|
+
<TreeNodeWrapper
|
|
1111
|
+
actions={
|
|
1112
|
+
<button
|
|
1113
|
+
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10"
|
|
1114
|
+
onClick={handleNewUnit}
|
|
1115
|
+
title="New unit"
|
|
1116
|
+
type="button"
|
|
1117
|
+
>
|
|
1118
|
+
<Plus className="h-3 w-3" />
|
|
1119
|
+
</button>
|
|
1120
|
+
}
|
|
1121
|
+
depth={1}
|
|
1122
|
+
expanded={expanded}
|
|
1123
|
+
hasChildren
|
|
1124
|
+
icon={<Group className="h-3.5 w-3.5" />}
|
|
1125
|
+
label={
|
|
1126
|
+
<span className="flex items-center gap-1.5">
|
|
1127
|
+
Units
|
|
1128
|
+
{hasUnits && <span className="text-muted-foreground text-xs">{unitIds.length}</span>}
|
|
1129
|
+
</span>
|
|
1130
|
+
}
|
|
1131
|
+
onClick={() => setExpanded((value) => !value)}
|
|
1132
|
+
onToggle={() => setExpanded((value) => !value)}
|
|
1133
|
+
>
|
|
1134
|
+
{unitIds.map((unitId) => (
|
|
1135
|
+
<TreeNode depth={2} key={unitId} nodeId={unitId} />
|
|
1136
|
+
))}
|
|
1137
|
+
<TreeNodeWrapper
|
|
1138
|
+
depth={2}
|
|
1139
|
+
expanded={false}
|
|
1140
|
+
hasChildren={false}
|
|
1141
|
+
icon={<Plus className="h-3.5 w-3.5" />}
|
|
1142
|
+
isLast
|
|
1143
|
+
label="New unit"
|
|
1144
|
+
onClick={handleNewUnit}
|
|
1145
|
+
onToggle={() => {}}
|
|
1146
|
+
/>
|
|
1147
|
+
</TreeNodeWrapper>
|
|
1148
|
+
</div>
|
|
1149
|
+
)
|
|
1150
|
+
})
|
|
1151
|
+
|
|
1063
1152
|
const LayerToggle = memo(function LayerToggle() {
|
|
1064
1153
|
const structureLayer = useEditor((state) => state.structureLayer)
|
|
1065
1154
|
const setStructureLayer = useEditor((state) => state.setStructureLayer)
|
|
@@ -1195,6 +1284,11 @@ const ZoneItem = memo(function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLa
|
|
|
1195
1284
|
const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false)
|
|
1196
1285
|
const deleteNode = useScene((state) => state.deleteNode)
|
|
1197
1286
|
const updateNode = useScene((state) => state.updateNode)
|
|
1287
|
+
const focusedUnitId = useViewer((state) => state.focusedUnitId)
|
|
1288
|
+
const focusedUnit = useScene((s) => {
|
|
1289
|
+
const unit = focusedUnitId ? s.nodes[focusedUnitId] : undefined
|
|
1290
|
+
return unit?.type === 'unit' ? unit : null
|
|
1291
|
+
})
|
|
1198
1292
|
const selectedZoneId = useViewer((state) => state.selection.zoneId)
|
|
1199
1293
|
const hoveredId = useViewer((state) => state.hoveredId)
|
|
1200
1294
|
const setSelection = useViewer((state) => state.setSelection)
|
|
@@ -1268,6 +1362,13 @@ const ZoneItem = memo(function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLa
|
|
|
1268
1362
|
style={{ left: 8, width: 4 }}
|
|
1269
1363
|
/>
|
|
1270
1364
|
|
|
1365
|
+
{focusedUnit && (
|
|
1366
|
+
<ZoneMembershipCheckbox
|
|
1367
|
+
checked={focusedUnit.members.includes(zone.id)}
|
|
1368
|
+
onToggle={() => toggleZoneMembership(focusedUnit.id, zone.id)}
|
|
1369
|
+
unitName={focusedUnit.name || 'Unit'}
|
|
1370
|
+
/>
|
|
1371
|
+
)}
|
|
1271
1372
|
<span className={cn('mr-2', !isSelected && 'opacity-40')}>
|
|
1272
1373
|
<ColorDot color={zone.color} onChange={handleColorChange} />
|
|
1273
1374
|
</span>
|
|
@@ -1611,6 +1712,7 @@ const BuildingItem = memo(function BuildingItem({
|
|
|
1611
1712
|
onUploadAsset={onUploadAsset}
|
|
1612
1713
|
projectId={projectId}
|
|
1613
1714
|
/>
|
|
1715
|
+
<UnitsSection buildingId={building.id} />
|
|
1614
1716
|
<LayerToggle />
|
|
1615
1717
|
</div>
|
|
1616
1718
|
<div className="subtle-scrollbar relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
|
|
@@ -70,12 +70,7 @@ export const ItemTreeNode = memo(function ItemTreeNode({
|
|
|
70
70
|
const handleClick = useCallback(
|
|
71
71
|
(e: React.MouseEvent) => {
|
|
72
72
|
e.stopPropagation()
|
|
73
|
-
handleTreeSelection(
|
|
74
|
-
e,
|
|
75
|
-
nodeId,
|
|
76
|
-
useViewer.getState().selection.selectedIds,
|
|
77
|
-
setSelection,
|
|
78
|
-
)
|
|
73
|
+
handleTreeSelection(e, nodeId, useViewer.getState().selection.selectedIds, setSelection)
|
|
79
74
|
routeTreeSelectionToNode(node)
|
|
80
75
|
},
|
|
81
76
|
[node, nodeId, setSelection],
|
|
@@ -134,7 +129,7 @@ export const ItemTreeNode = memo(function ItemTreeNode({
|
|
|
134
129
|
depth={depth + 1}
|
|
135
130
|
isLast={index === children.length - 1}
|
|
136
131
|
key={childId}
|
|
137
|
-
nodeId={childId}
|
|
132
|
+
nodeId={childId as AnyNodeId}
|
|
138
133
|
/>
|
|
139
134
|
))}
|
|
140
135
|
</TreeNodeWrapper>
|
|
@@ -24,7 +24,7 @@ interface ShelfTreeNodeProps {
|
|
|
24
24
|
/**
|
|
25
25
|
* Sidebar tree entry for shelf. Mirrors `item-tree-node`'s shape so the
|
|
26
26
|
* shelf's hosted items list as collapsible children — same pattern items
|
|
27
|
-
* use for their nested items. The shelf has its own `children:
|
|
27
|
+
* use for their nested items. The shelf has its own `children: string[]`
|
|
28
28
|
* field on the schema; items reparent into it via `def.surfaces` + the
|
|
29
29
|
* placement coordinator's shelf strategy.
|
|
30
30
|
*/
|
|
@@ -69,12 +69,7 @@ export const ShelfTreeNode = memo(function ShelfTreeNode({
|
|
|
69
69
|
const handleClick = useCallback(
|
|
70
70
|
(e: React.MouseEvent) => {
|
|
71
71
|
e.stopPropagation()
|
|
72
|
-
handleTreeSelection(
|
|
73
|
-
e,
|
|
74
|
-
nodeId,
|
|
75
|
-
useViewer.getState().selection.selectedIds,
|
|
76
|
-
setSelection,
|
|
77
|
-
)
|
|
72
|
+
handleTreeSelection(e, nodeId, useViewer.getState().selection.selectedIds, setSelection)
|
|
78
73
|
routeTreeSelectionToNode(node)
|
|
79
74
|
},
|
|
80
75
|
[node, nodeId, setSelection],
|
|
@@ -124,7 +119,7 @@ export const ShelfTreeNode = memo(function ShelfTreeNode({
|
|
|
124
119
|
depth={depth + 1}
|
|
125
120
|
isLast={index === children.length - 1}
|
|
126
121
|
key={childId}
|
|
127
|
-
nodeId={childId}
|
|
122
|
+
nodeId={childId as AnyNodeId}
|
|
128
123
|
/>
|
|
129
124
|
))}
|
|
130
125
|
</TreeNodeWrapper>
|
|
@@ -107,6 +107,7 @@ import { SlabTreeNode } from './slab-tree-node'
|
|
|
107
107
|
import { SolarPanelTreeNode } from './solar-panel-tree-node'
|
|
108
108
|
import { SpawnTreeNode } from './spawn-tree-node'
|
|
109
109
|
import { StairTreeNode } from './stair-tree-node'
|
|
110
|
+
import { UnitTreeNode } from './unit-tree-node'
|
|
110
111
|
import { WallTreeNode } from './wall-tree-node'
|
|
111
112
|
import { WindowTreeNode } from './window-tree-node'
|
|
112
113
|
import { ZoneTreeNode } from './zone-tree-node'
|
|
@@ -175,6 +176,7 @@ const treeNodeByType: Record<string, TreeNodeComponent> = {
|
|
|
175
176
|
roof: RoofTreeNode,
|
|
176
177
|
scan: RegistryTreeNode,
|
|
177
178
|
stair: StairTreeNode,
|
|
179
|
+
unit: UnitTreeNode,
|
|
178
180
|
door: DoorTreeNode,
|
|
179
181
|
window: WindowTreeNode,
|
|
180
182
|
zone: ZoneTreeNode as React.ComponentType<{
|
|
@@ -207,6 +209,8 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr
|
|
|
207
209
|
interface TreeNodeWrapperProps {
|
|
208
210
|
nodeId?: string
|
|
209
211
|
icon: React.ReactNode
|
|
212
|
+
/** Keep the icon's own color when unselected (color dots are the identity). */
|
|
213
|
+
keepIconColor?: boolean
|
|
210
214
|
label: React.ReactNode
|
|
211
215
|
depth: number
|
|
212
216
|
hasChildren: boolean
|
|
@@ -232,6 +236,7 @@ export const TreeNodeWrapper = forwardRef<HTMLDivElement, TreeNodeWrapperProps>(
|
|
|
232
236
|
{
|
|
233
237
|
nodeId,
|
|
234
238
|
icon,
|
|
239
|
+
keepIconColor,
|
|
235
240
|
label,
|
|
236
241
|
depth,
|
|
237
242
|
hasChildren,
|
|
@@ -326,7 +331,7 @@ export const TreeNodeWrapper = forwardRef<HTMLDivElement, TreeNodeWrapperProps>(
|
|
|
326
331
|
<span
|
|
327
332
|
className={cn(
|
|
328
333
|
'flex h-5 w-5 shrink-0 items-center justify-center transition-all duration-200',
|
|
329
|
-
!isSelected && 'opacity-60 grayscale',
|
|
334
|
+
!isSelected && !keepIconColor && 'opacity-60 grayscale',
|
|
330
335
|
)}
|
|
331
336
|
>
|
|
332
337
|
{icon}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNodeId,
|
|
3
|
+
getLevelDisplayName,
|
|
4
|
+
type LevelNode,
|
|
5
|
+
type UnitNode,
|
|
6
|
+
unitWarnings,
|
|
7
|
+
useScene,
|
|
8
|
+
type ZoneNode,
|
|
9
|
+
} from '@pascal-app/core'
|
|
10
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
11
|
+
import { AlertTriangle, Trash2, X } from 'lucide-react'
|
|
12
|
+
import { memo, useCallback, useState } from 'react'
|
|
13
|
+
import { useShallow } from 'zustand/react/shallow'
|
|
14
|
+
import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
|
|
15
|
+
import { assignZoneToUnit, enterUnitFocus } from './../../../../../lib/units'
|
|
16
|
+
import { InlineRenameInput } from './inline-rename-input'
|
|
17
|
+
import { routeTreeSelectionToNode, TreeNodeWrapper } from './tree-node'
|
|
18
|
+
|
|
19
|
+
type UnitWarningCode = ReturnType<typeof unitWarnings>[number]['code']
|
|
20
|
+
|
|
21
|
+
const WARNING_LABELS: Record<UnitWarningCode, string> = {
|
|
22
|
+
empty: 'No zones in this unit',
|
|
23
|
+
'non-adjacent-levels': 'Members sit on non-adjacent levels',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const ACTION_BUTTON_CLASS =
|
|
27
|
+
'flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10'
|
|
28
|
+
|
|
29
|
+
interface UnitZoneRowProps {
|
|
30
|
+
zoneId: ZoneNode['id']
|
|
31
|
+
depth: number
|
|
32
|
+
isLast?: boolean
|
|
33
|
+
onRemove?: (zoneId: ZoneNode['id']) => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One member zone under a unit: name + level, click selects it. */
|
|
37
|
+
export const UnitZoneRow = memo(function UnitZoneRow({
|
|
38
|
+
zoneId,
|
|
39
|
+
depth,
|
|
40
|
+
isLast,
|
|
41
|
+
onRemove,
|
|
42
|
+
}: UnitZoneRowProps) {
|
|
43
|
+
const zone = useScene((s) => s.nodes[zoneId] as ZoneNode | undefined)
|
|
44
|
+
const levelLabel = useScene((s) => {
|
|
45
|
+
const level = zone?.parentId ? (s.nodes[zone.parentId as AnyNodeId] as LevelNode | undefined) : undefined
|
|
46
|
+
return level?.type === 'level' ? getLevelDisplayName(level) : null
|
|
47
|
+
})
|
|
48
|
+
const isSelected = useViewer((s) => s.selection.zoneId === zoneId)
|
|
49
|
+
const isHovered = useViewer((s) => s.hoveredId === zoneId)
|
|
50
|
+
const setSelection = useViewer((s) => s.setSelection)
|
|
51
|
+
const setHoveredId = useViewer((s) => s.setHoveredId)
|
|
52
|
+
|
|
53
|
+
const handleClick = useCallback(
|
|
54
|
+
(event: React.MouseEvent) => {
|
|
55
|
+
event.stopPropagation()
|
|
56
|
+
if (!zone) return
|
|
57
|
+
setSelection({ levelId: zone.parentId as LevelNode['id'], zoneId })
|
|
58
|
+
routeTreeSelectionToNode(zone)
|
|
59
|
+
},
|
|
60
|
+
[zone, zoneId, setSelection],
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
if (!zone) return null
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<TreeNodeWrapper
|
|
67
|
+
actions={
|
|
68
|
+
onRemove ? (
|
|
69
|
+
<button
|
|
70
|
+
className={ACTION_BUTTON_CLASS}
|
|
71
|
+
onClick={(event) => {
|
|
72
|
+
event.stopPropagation()
|
|
73
|
+
onRemove(zoneId)
|
|
74
|
+
}}
|
|
75
|
+
title="Remove from unit"
|
|
76
|
+
type="button"
|
|
77
|
+
>
|
|
78
|
+
<X className="h-3 w-3" />
|
|
79
|
+
</button>
|
|
80
|
+
) : undefined
|
|
81
|
+
}
|
|
82
|
+
depth={depth}
|
|
83
|
+
expanded={false}
|
|
84
|
+
hasChildren={false}
|
|
85
|
+
icon={
|
|
86
|
+
<span
|
|
87
|
+
className="h-3 w-3 rounded-sm border border-border/50"
|
|
88
|
+
style={{ backgroundColor: zone.color }}
|
|
89
|
+
/>
|
|
90
|
+
}
|
|
91
|
+
isHovered={isHovered}
|
|
92
|
+
isLast={isLast}
|
|
93
|
+
isSelected={isSelected}
|
|
94
|
+
keepIconColor
|
|
95
|
+
label={
|
|
96
|
+
<span className="flex min-w-0 items-baseline gap-1.5">
|
|
97
|
+
<span className="truncate">{zone.name || 'Zone'}</span>
|
|
98
|
+
{levelLabel && (
|
|
99
|
+
<span className="shrink-0 text-muted-foreground text-xs">{levelLabel}</span>
|
|
100
|
+
)}
|
|
101
|
+
</span>
|
|
102
|
+
}
|
|
103
|
+
nodeId={zoneId}
|
|
104
|
+
onClick={handleClick}
|
|
105
|
+
onMouseEnter={() => setHoveredId(zoneId)}
|
|
106
|
+
onMouseLeave={() => setHoveredId(null)}
|
|
107
|
+
onToggle={() => {}}
|
|
108
|
+
/>
|
|
109
|
+
)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
interface UnitTreeNodeProps {
|
|
113
|
+
nodeId: AnyNodeId
|
|
114
|
+
depth: number
|
|
115
|
+
isLast?: boolean
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export const UnitTreeNode = memo(function UnitTreeNode({
|
|
119
|
+
nodeId,
|
|
120
|
+
depth,
|
|
121
|
+
isLast,
|
|
122
|
+
}: UnitTreeNodeProps) {
|
|
123
|
+
const unitId = nodeId as UnitNode['id']
|
|
124
|
+
const [isEditing, setIsEditing] = useState(false)
|
|
125
|
+
const [expanded, setExpanded] = useState(false)
|
|
126
|
+
const updateNode = useScene((s) => s.updateNode)
|
|
127
|
+
const deleteNode = useScene((s) => s.deleteNode)
|
|
128
|
+
const color = useScene((s) => (s.nodes[nodeId] as UnitNode | undefined)?.color)
|
|
129
|
+
const members = useScene(
|
|
130
|
+
useShallow((s) => (s.nodes[nodeId] as UnitNode | undefined)?.members ?? []),
|
|
131
|
+
)
|
|
132
|
+
const warningCodes = useScene(
|
|
133
|
+
useShallow((s) => {
|
|
134
|
+
const unit = s.nodes[nodeId]
|
|
135
|
+
return unit?.type === 'unit' ? unitWarnings(unit, s.nodes).map((w) => w.code) : []
|
|
136
|
+
}),
|
|
137
|
+
)
|
|
138
|
+
const isSelected = useViewer((s) => s.selection.selectedIds.includes(nodeId))
|
|
139
|
+
const isHovered = useViewer((s) => s.hoveredId === nodeId)
|
|
140
|
+
const setHoveredId = useViewer((s) => s.setHoveredId)
|
|
141
|
+
const isFocused = useViewer((s) => s.focusedUnitId === unitId)
|
|
142
|
+
|
|
143
|
+
const handleClick = useCallback(
|
|
144
|
+
(event: React.MouseEvent) => {
|
|
145
|
+
event.stopPropagation()
|
|
146
|
+
enterUnitFocus(unitId)
|
|
147
|
+
},
|
|
148
|
+
[unitId],
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
const handleRemoveMember = useCallback(
|
|
152
|
+
(zoneId: ZoneNode['id']) => assignZoneToUnit(zoneId, null),
|
|
153
|
+
[],
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
const warningTitle = [...new Set(warningCodes)].map((code) => WARNING_LABELS[code]).join('\n')
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<TreeNodeWrapper
|
|
160
|
+
actions={
|
|
161
|
+
<button
|
|
162
|
+
className={ACTION_BUTTON_CLASS}
|
|
163
|
+
onClick={(event) => {
|
|
164
|
+
event.stopPropagation()
|
|
165
|
+
deleteNode(unitId)
|
|
166
|
+
}}
|
|
167
|
+
title="Delete unit"
|
|
168
|
+
type="button"
|
|
169
|
+
>
|
|
170
|
+
<Trash2 className="h-3 w-3" />
|
|
171
|
+
</button>
|
|
172
|
+
}
|
|
173
|
+
depth={depth}
|
|
174
|
+
expanded={expanded}
|
|
175
|
+
hasChildren={members.length > 0}
|
|
176
|
+
icon={
|
|
177
|
+
<ColorDot
|
|
178
|
+
color={color ?? '#f59e0b'}
|
|
179
|
+
onChange={(next) => updateNode(unitId, { color: next })}
|
|
180
|
+
/>
|
|
181
|
+
}
|
|
182
|
+
isHovered={isHovered}
|
|
183
|
+
isLast={isLast}
|
|
184
|
+
isSelected={isSelected}
|
|
185
|
+
keepIconColor
|
|
186
|
+
label={
|
|
187
|
+
<span className="flex min-w-0 items-center gap-1.5">
|
|
188
|
+
<InlineRenameInput
|
|
189
|
+
defaultName="Unit"
|
|
190
|
+
isEditing={isEditing}
|
|
191
|
+
nodeId={nodeId}
|
|
192
|
+
onStartEditing={() => setIsEditing(true)}
|
|
193
|
+
onStopEditing={() => setIsEditing(false)}
|
|
194
|
+
/>
|
|
195
|
+
{isFocused && (
|
|
196
|
+
<span className="shrink-0 rounded-full bg-primary/15 px-1.5 text-[10px] text-primary leading-4">
|
|
197
|
+
Focused
|
|
198
|
+
</span>
|
|
199
|
+
)}
|
|
200
|
+
{warningCodes.length > 0 && (
|
|
201
|
+
<span className="shrink-0 text-muted-foreground" title={warningTitle}>
|
|
202
|
+
<AlertTriangle className="h-3 w-3" />
|
|
203
|
+
</span>
|
|
204
|
+
)}
|
|
205
|
+
</span>
|
|
206
|
+
}
|
|
207
|
+
nodeId={nodeId}
|
|
208
|
+
onClick={handleClick}
|
|
209
|
+
onMouseEnter={() => setHoveredId(nodeId)}
|
|
210
|
+
onMouseLeave={() => setHoveredId(null)}
|
|
211
|
+
onToggle={() => setExpanded((value) => !value)}
|
|
212
|
+
>
|
|
213
|
+
{members.map((zoneId, index) => (
|
|
214
|
+
<UnitZoneRow
|
|
215
|
+
depth={depth + 1}
|
|
216
|
+
isLast={index === members.length - 1}
|
|
217
|
+
key={zoneId}
|
|
218
|
+
onRemove={handleRemoveMember}
|
|
219
|
+
zoneId={zoneId}
|
|
220
|
+
/>
|
|
221
|
+
))}
|
|
222
|
+
</TreeNodeWrapper>
|
|
223
|
+
)
|
|
224
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Checkbox } from './../../../primitives/checkbox'
|
|
2
|
+
|
|
3
|
+
/** Membership toggle shown on zone rows while a unit is focused. */
|
|
4
|
+
export function ZoneMembershipCheckbox({
|
|
5
|
+
checked,
|
|
6
|
+
onToggle,
|
|
7
|
+
unitName,
|
|
8
|
+
}: {
|
|
9
|
+
checked: boolean
|
|
10
|
+
onToggle: () => void
|
|
11
|
+
unitName: string
|
|
12
|
+
}) {
|
|
13
|
+
return (
|
|
14
|
+
<Checkbox
|
|
15
|
+
aria-label={`In ${unitName}`}
|
|
16
|
+
checked={checked}
|
|
17
|
+
className="mr-2"
|
|
18
|
+
onCheckedChange={onToggle}
|
|
19
|
+
onDoubleClick={(event) => event.stopPropagation()}
|
|
20
|
+
/>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
@@ -3,9 +3,11 @@ 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
5
|
import { formatAreaLabel } from './../../../../../lib/measurements'
|
|
6
|
+
import { toggleZoneMembership } from './../../../../../lib/units'
|
|
6
7
|
import { InlineRenameInput } from './inline-rename-input'
|
|
7
8
|
import { focusTreeNode, TreeNodeWrapper } from './tree-node'
|
|
8
9
|
import { TreeNodeActions } from './tree-node-actions'
|
|
10
|
+
import { ZoneMembershipCheckbox } from './zone-membership-checkbox'
|
|
9
11
|
|
|
10
12
|
interface ZoneTreeNodeProps {
|
|
11
13
|
nodeId: ZoneNode['id']
|
|
@@ -28,6 +30,11 @@ export const ZoneTreeNode = memo(function ZoneTreeNode({
|
|
|
28
30
|
const setSelection = useViewer((state) => state.setSelection)
|
|
29
31
|
const setHoveredId = useViewer((state) => state.setHoveredId)
|
|
30
32
|
const unit = useViewer((state) => state.unit)
|
|
33
|
+
const focusedUnitId = useViewer((state) => state.focusedUnitId)
|
|
34
|
+
const focusedUnit = useScene((s) => {
|
|
35
|
+
const focused = focusedUnitId ? s.nodes[focusedUnitId] : undefined
|
|
36
|
+
return focused?.type === 'unit' ? focused : null
|
|
37
|
+
})
|
|
31
38
|
|
|
32
39
|
const handleClick = useCallback(() => setSelection({ zoneId: nodeId }), [nodeId, setSelection])
|
|
33
40
|
const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId])
|
|
@@ -50,15 +57,25 @@ export const ZoneTreeNode = memo(function ZoneTreeNode({
|
|
|
50
57
|
isHovered={isHovered}
|
|
51
58
|
isLast={isLast}
|
|
52
59
|
isSelected={isSelected}
|
|
60
|
+
keepIconColor
|
|
53
61
|
isVisible={isVisible}
|
|
54
62
|
label={
|
|
55
|
-
<
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
<span className="flex min-w-0 items-center">
|
|
64
|
+
{focusedUnit && (
|
|
65
|
+
<ZoneMembershipCheckbox
|
|
66
|
+
checked={focusedUnit.members.includes(nodeId)}
|
|
67
|
+
onToggle={() => toggleZoneMembership(focusedUnit.id, nodeId)}
|
|
68
|
+
unitName={focusedUnit.name || 'Unit'}
|
|
69
|
+
/>
|
|
70
|
+
)}
|
|
71
|
+
<InlineRenameInput
|
|
72
|
+
defaultName={defaultName}
|
|
73
|
+
isEditing={isEditing}
|
|
74
|
+
nodeId={nodeId}
|
|
75
|
+
onStartEditing={handleStartEditing}
|
|
76
|
+
onStopEditing={handleStopEditing}
|
|
77
|
+
/>
|
|
78
|
+
</span>
|
|
62
79
|
}
|
|
63
80
|
onClick={handleClick}
|
|
64
81
|
onDoubleClick={handleDoubleClick}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AnyNode, AssetInput } from '@pascal-app/core'
|
|
2
|
+
import { isProceduralItem } from '@pascal-app/core/procedural-items'
|
|
2
3
|
import type { ReactNode } from 'react'
|
|
3
4
|
import { cn } from '../../lib/utils'
|
|
4
5
|
|
|
@@ -35,6 +36,7 @@ export function resolveAssetSnapTarget(attachTo: AssetInput['attachTo']): SnapTa
|
|
|
35
36
|
|
|
36
37
|
export function resolveNodeSnapTarget(node: AnyNode | null | undefined): SnapTarget | null {
|
|
37
38
|
if (!node) return null
|
|
39
|
+
if (isProceduralItem(node)) return resolveAssetSnapTarget(node.recipe.mounting?.attachTo)
|
|
38
40
|
if ('roofSegmentId' in node && typeof node.roofSegmentId === 'string') return 'roof'
|
|
39
41
|
if (node.type === 'downspout') return 'roof'
|
|
40
42
|
if (node.type === 'door' || node.type === 'window') return 'wall'
|
|
@@ -15,6 +15,7 @@ import Link from 'next/link'
|
|
|
15
15
|
import type { ReactNode } from 'react'
|
|
16
16
|
import { useShallow } from 'zustand/react/shallow'
|
|
17
17
|
import { cn } from '../../lib/utils'
|
|
18
|
+
import { ViewerUnitsPanel } from './viewer-units-panel'
|
|
18
19
|
|
|
19
20
|
const getNodeName = (node: AnyNode): string => {
|
|
20
21
|
if ('name' in node && node.name) return node.name
|
|
@@ -63,6 +64,7 @@ export const ViewerSceneHeader = ({
|
|
|
63
64
|
const selectedNode = useScene((s) =>
|
|
64
65
|
firstSelectedId ? (s.nodes[firstSelectedId as AnyNodeId] as AnyNode | undefined) : null,
|
|
65
66
|
)
|
|
67
|
+
const sceneNodes = useScene((s) => s.nodes)
|
|
66
68
|
// Highest first so the list reads top-down like a building section.
|
|
67
69
|
const levels = useScene(
|
|
68
70
|
useShallow((s) => {
|
|
@@ -231,6 +233,7 @@ export const ViewerSceneHeader = ({
|
|
|
231
233
|
</div>
|
|
232
234
|
</div>
|
|
233
235
|
)}
|
|
236
|
+
{building && <ViewerUnitsPanel nodes={sceneNodes} />}
|
|
234
237
|
</div>
|
|
235
238
|
)
|
|
236
239
|
}
|