@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.
Files changed (97) hide show
  1. package/package.json +7 -6
  2. package/src/components/editor/bake-exporter.tsx +4 -0
  3. package/src/components/editor/bake-thumbnail.tsx +4 -1
  4. package/src/components/editor/floating-action-menu.tsx +14 -1
  5. package/src/components/editor/floorplan-panel.tsx +23 -3
  6. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  7. package/src/components/editor/handles/handle-placement.ts +69 -0
  8. package/src/components/editor/handles/linear-resize-drag.ts +13 -2
  9. package/src/components/editor/handles/use-handle-drag.ts +5 -2
  10. package/src/components/editor/index.tsx +12 -4
  11. package/src/components/editor/node-arrow-handles.tsx +21 -62
  12. package/src/components/editor/selection-manager.tsx +49 -0
  13. package/src/components/editor/thumbnail-generator.tsx +11 -2
  14. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  15. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +46 -3
  16. package/src/components/procedural-items/inspector.tsx +223 -0
  17. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  18. package/src/components/tools/item/item-surface-placement.test.ts +175 -0
  19. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
  20. package/src/components/tools/item/placement-strategies.ts +169 -169
  21. package/src/components/tools/item/placement-types.ts +4 -0
  22. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  23. package/src/components/tools/item/use-draft-node.ts +95 -45
  24. package/src/components/tools/item/use-placement-coordinator.tsx +247 -106
  25. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  26. package/src/components/tools/registry/item-surface-move.test.ts +501 -0
  27. package/src/components/tools/registry/item-surface-move.ts +441 -0
  28. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  29. package/src/components/tools/registry/move-registry-node-tool.tsx +215 -70
  30. package/src/components/tools/shared/pointer-support-cap.ts +11 -9
  31. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  32. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  33. package/src/components/tools/shared/surface-hit.ts +23 -0
  34. package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
  35. package/src/components/tools/shared/surface-rejection.tsx +29 -0
  36. package/src/components/tools/zone/zone-tool.tsx +13 -4
  37. package/src/components/ui/command-palette/editor-commands.tsx +54 -1
  38. package/src/components/ui/controls/metric-control.tsx +29 -13
  39. package/src/components/ui/controls/slider-control.tsx +25 -18
  40. package/src/components/ui/floating-level-selector.tsx +81 -5
  41. package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
  42. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  43. package/src/components/ui/panels/parametric-inspector.tsx +6 -2
  44. package/src/components/ui/panels/reference-panel.tsx +3 -3
  45. package/src/components/ui/primitives/checkbox.tsx +34 -0
  46. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +120 -3
  47. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
  48. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +29 -10
  49. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  50. package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
  51. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  52. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  53. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
  54. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  55. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  56. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  57. package/src/components/ui/snap-target-badge.tsx +2 -0
  58. package/src/components/viewer/viewer-scene-header.tsx +3 -0
  59. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  60. package/src/hooks/use-ceiling-events.test.ts +24 -2
  61. package/src/hooks/use-ceiling-events.ts +5 -1
  62. package/src/hooks/use-keyboard.ts +3 -1
  63. package/src/index.tsx +5 -0
  64. package/src/lib/export-texture-utils.test.ts +167 -0
  65. package/src/lib/export-texture-utils.ts +121 -0
  66. package/src/lib/export-user-data.test.ts +48 -0
  67. package/src/lib/export-user-data.ts +69 -0
  68. package/src/lib/floorplan/floorplan-export.test.ts +139 -1
  69. package/src/lib/floorplan/floorplan-export.tsx +9 -3
  70. package/src/lib/floorplan/floorplan-readonly.ts +4 -0
  71. package/src/lib/fresh-planar-placement.ts +13 -5
  72. package/src/lib/glb-export-serialize.test.ts +280 -0
  73. package/src/lib/glb-export.test.ts +206 -0
  74. package/src/lib/glb-export.ts +171 -24
  75. package/src/lib/level-duplication.test.ts +73 -0
  76. package/src/lib/level-duplication.ts +32 -2
  77. package/src/lib/linear-display.test.ts +59 -0
  78. package/src/lib/linear-display.ts +39 -0
  79. package/src/lib/local-guide-image.test.ts +24 -0
  80. package/src/lib/local-guide-image.ts +34 -1
  81. package/src/lib/node-action-movement.ts +21 -0
  82. package/src/lib/portable-export.test.ts +89 -0
  83. package/src/lib/portable-export.ts +84 -11
  84. package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
  85. package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
  86. package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
  87. package/src/lib/print-shell-compiler-protocol.ts +8 -0
  88. package/src/lib/scene-clipboard.ts +1 -1
  89. package/src/lib/send-to-app.test.ts +108 -0
  90. package/src/lib/send-to-app.ts +121 -0
  91. package/src/lib/sfx/movement-tick.ts +37 -0
  92. package/src/lib/surface-attachment.ts +77 -0
  93. package/src/lib/units.ts +254 -0
  94. package/src/lib/usdz-export.ts +43 -16
  95. package/src/lib/use-linear-display.ts +8 -32
  96. package/src/store/use-handle-group.ts +4 -0
  97. 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) => (s.nodes[nodeId] as BuildingNode | undefined)?.children ?? []),
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 { focusTreeNode, TreeNode } from './tree-node'
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?.(projectId, levelId, file, type)
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={Math.round((level.baseElevation ?? 0) * 100) / 100}
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: ItemNode[`id`]`
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
- <InlineRenameInput
56
- defaultName={defaultName}
57
- isEditing={isEditing}
58
- nodeId={nodeId}
59
- onStartEditing={handleStartEditing}
60
- onStopEditing={handleStopEditing}
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
  }