@pascal-app/editor 1.0.0 → 1.0.3

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 (177) hide show
  1. package/package.json +12 -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/custom-camera-controls.tsx +22 -74
  5. package/src/components/editor/editor-layout-mobile.tsx +1 -0
  6. package/src/components/editor/floating-action-menu.tsx +14 -1
  7. package/src/components/editor/floorplan-panel.tsx +233 -166
  8. package/src/components/editor/grid.tsx +31 -7
  9. package/src/components/editor/group-actions.ts +5 -9
  10. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  11. package/src/components/editor/group-move-3d.ts +4 -6
  12. package/src/components/editor/group-rotate-handle.tsx +81 -35
  13. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  14. package/src/components/editor/group-transform-shared.test.ts +126 -1
  15. package/src/components/editor/group-transform-shared.ts +79 -73
  16. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  17. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  18. package/src/components/editor/handles/handle-placement.ts +69 -0
  19. package/src/components/editor/handles/linear-resize-drag.ts +13 -2
  20. package/src/components/editor/handles/use-handle-drag.ts +80 -8
  21. package/src/components/editor/index.tsx +36 -13
  22. package/src/components/editor/node-action-menu.tsx +2 -0
  23. package/src/components/editor/node-arrow-handles.tsx +162 -69
  24. package/src/components/editor/registry-action-contributions.tsx +53 -0
  25. package/src/components/editor/selection-manager.tsx +49 -0
  26. package/src/components/editor/thumbnail-generator.tsx +11 -2
  27. package/src/components/editor/wall-move-side-handles.tsx +71 -21
  28. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  29. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  30. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  31. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  32. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
  33. package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
  34. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
  35. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +113 -24
  36. package/src/components/procedural-items/inspector.tsx +223 -0
  37. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  38. package/src/components/systems/zone/zone-system.tsx +8 -4
  39. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  40. package/src/components/tools/item/item-surface-placement.test.ts +169 -0
  41. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +204 -0
  42. package/src/components/tools/item/placement-strategies.ts +212 -200
  43. package/src/components/tools/item/placement-types.ts +4 -0
  44. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  45. package/src/components/tools/item/use-draft-node.ts +136 -47
  46. package/src/components/tools/item/use-placement-coordinator.tsx +391 -157
  47. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  48. package/src/components/tools/registry/item-surface-move.test.ts +509 -0
  49. package/src/components/tools/registry/item-surface-move.ts +463 -0
  50. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  51. package/src/components/tools/registry/move-registry-node-tool.tsx +237 -78
  52. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  53. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  54. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  55. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  56. package/src/components/tools/shared/pointer-support-cap.ts +27 -13
  57. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  58. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  59. package/src/components/tools/shared/surface-hit.ts +23 -0
  60. package/src/components/tools/shared/surface-rejection.test.tsx +107 -0
  61. package/src/components/tools/shared/surface-rejection.tsx +43 -0
  62. package/src/components/tools/tool-manager.tsx +36 -3
  63. package/src/components/tools/zone/zone-tool.tsx +13 -4
  64. package/src/components/ui/command-palette/editor-commands.tsx +54 -1
  65. package/src/components/ui/controls/material-paint-panel.tsx +6 -67
  66. package/src/components/ui/controls/material-picker.tsx +5 -67
  67. package/src/components/ui/controls/metric-control.tsx +29 -13
  68. package/src/components/ui/controls/slider-control.tsx +87 -35
  69. package/src/components/ui/floating-level-selector.tsx +81 -5
  70. package/src/components/ui/helpers/helper-manager.tsx +24 -6
  71. package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
  72. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  73. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  74. package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
  75. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  76. package/src/components/ui/panels/parametric-inspector.tsx +9 -17
  77. package/src/components/ui/panels/reference-panel.tsx +3 -3
  78. package/src/components/ui/primitives/checkbox.tsx +34 -0
  79. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  80. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  81. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +669 -336
  82. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  83. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
  84. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +30 -11
  85. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  86. package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
  87. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  88. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  89. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
  90. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  91. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  92. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  93. package/src/components/ui/snap-target-badge.tsx +2 -0
  94. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  95. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  96. package/src/components/viewer/floorplan-preview.tsx +1 -12
  97. package/src/components/viewer/viewer-scene-header.tsx +3 -0
  98. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  99. package/src/components/viewer-zone-system.tsx +7 -2
  100. package/src/hooks/use-auto-save.integration.test.tsx +197 -0
  101. package/src/hooks/use-auto-save.ts +21 -6
  102. package/src/hooks/use-ceiling-events.test.ts +25 -2
  103. package/src/hooks/use-ceiling-events.ts +7 -1
  104. package/src/hooks/use-grid-events.test.ts +2 -0
  105. package/src/hooks/use-grid-events.ts +16 -11
  106. package/src/hooks/use-keyboard.test.ts +7 -0
  107. package/src/hooks/use-keyboard.ts +43 -28
  108. package/src/index.tsx +68 -3
  109. package/src/lib/catalog-panel-model.ts +39 -0
  110. package/src/lib/catalog-selection.test.ts +27 -0
  111. package/src/lib/catalog-selection.ts +8 -0
  112. package/src/lib/contextual-help.test.ts +27 -1
  113. package/src/lib/contextual-help.ts +16 -9
  114. package/src/lib/export-texture-utils.test.ts +167 -0
  115. package/src/lib/export-texture-utils.ts +121 -0
  116. package/src/lib/export-user-data.test.ts +48 -0
  117. package/src/lib/export-user-data.ts +69 -0
  118. package/src/lib/floorplan/floorplan-export.test.ts +142 -2
  119. package/src/lib/floorplan/floorplan-export.tsx +9 -3
  120. package/src/lib/floorplan/floorplan-extension.ts +16 -0
  121. package/src/lib/floorplan/floorplan-readonly.ts +4 -0
  122. package/src/lib/fresh-planar-placement.ts +173 -33
  123. package/src/lib/glb-export-serialize.test.ts +280 -0
  124. package/src/lib/glb-export.test.ts +265 -16
  125. package/src/lib/glb-export.ts +197 -37
  126. package/src/lib/grid-frame.test.ts +19 -0
  127. package/src/lib/grid-frame.ts +18 -0
  128. package/src/lib/interaction/scope.ts +9 -1
  129. package/src/lib/keyboard-pan.test.ts +62 -0
  130. package/src/lib/keyboard-pan.ts +89 -0
  131. package/src/lib/level-duplication.test.ts +73 -0
  132. package/src/lib/level-duplication.ts +32 -2
  133. package/src/lib/linear-display.test.ts +59 -0
  134. package/src/lib/linear-display.ts +39 -0
  135. package/src/lib/local-guide-image.test.ts +24 -0
  136. package/src/lib/local-guide-image.ts +34 -1
  137. package/src/lib/material-catalog-model.ts +72 -0
  138. package/src/lib/material-paint-panel-model.ts +76 -0
  139. package/src/lib/node-action-movement.ts +21 -0
  140. package/src/lib/overlay-transform.test.ts +28 -0
  141. package/src/lib/overlay-transform.ts +12 -0
  142. package/src/lib/panel-rows.ts +75 -0
  143. package/src/lib/panel-tool-options.test.ts +34 -0
  144. package/src/lib/panel-tool-options.ts +46 -0
  145. package/src/lib/parametric-node-update.ts +37 -0
  146. package/src/lib/portable-export.test.ts +89 -0
  147. package/src/lib/portable-export.ts +84 -11
  148. package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
  149. package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
  150. package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
  151. package/src/lib/print-shell-compiler-protocol.ts +8 -0
  152. package/src/lib/scene-clipboard.ts +1 -1
  153. package/src/lib/scene.test.ts +99 -0
  154. package/src/lib/scene.ts +21 -3
  155. package/src/lib/send-to-app.test.ts +108 -0
  156. package/src/lib/send-to-app.ts +121 -0
  157. package/src/lib/sfx/movement-tick.ts +37 -0
  158. package/src/lib/snapping-mode.test.ts +13 -2
  159. package/src/lib/snapping-mode.ts +11 -6
  160. package/src/lib/spatial-drag-plane.test.ts +53 -0
  161. package/src/lib/spatial-drag-plane.ts +19 -0
  162. package/src/lib/spatial-pointer-input.test.ts +72 -0
  163. package/src/lib/spatial-pointer-input.ts +62 -0
  164. package/src/lib/spatial-snap.test.ts +47 -0
  165. package/src/lib/surface-attachment.ts +77 -0
  166. package/src/lib/terrain-panel-model.ts +117 -0
  167. package/src/lib/units.ts +254 -0
  168. package/src/lib/usdz-export.ts +43 -16
  169. package/src/lib/use-linear-display.ts +8 -32
  170. package/src/lib/world-grid-snap.test.ts +48 -0
  171. package/src/lib/world-grid-snap.ts +9 -5
  172. package/src/store/rotation-snapping.test.ts +53 -0
  173. package/src/store/use-editor.tsx +10 -2
  174. package/src/store/use-floorplan-draft-preview.ts +10 -1
  175. package/src/store/use-handle-group.ts +4 -0
  176. package/src/store/use-interaction-scope.ts +155 -6
  177. package/src/store/use-send-to-app.ts +21 -0
@@ -227,12 +227,12 @@ export function KeyboardShortcutsDialog() {
227
227
  <DialogTrigger asChild>
228
228
  <Button className="w-full justify-start gap-2" variant="outline">
229
229
  <Keyboard className="size-4" />
230
- Keyboard Shortcuts
230
+ Keyboard shortcuts
231
231
  </Button>
232
232
  </DialogTrigger>
233
233
  <DialogContent className="flex max-h-[85vh] flex-col overflow-hidden p-0 sm:max-w-3xl">
234
234
  <DialogHeader className="shrink-0 border-b px-6 py-4">
235
- <DialogTitle>Keyboard Shortcuts</DialogTitle>
235
+ <DialogTitle>Keyboard shortcuts</DialogTitle>
236
236
  <DialogDescription>
237
237
  Shortcuts are context-aware. Guided constraints are enabled by default; hold Shift
238
238
  during an active gesture to build freely.
@@ -40,7 +40,7 @@ describe('simple 3D print export', () => {
40
40
  return artifact
41
41
  }
42
42
 
43
- const prepared = await preparePrintExport(modelExport, true)
43
+ const prepared = await preparePrintExport(modelExport, true, 'print-3mf')
44
44
 
45
45
  expect(calls).toEqual([
46
46
  {
@@ -58,6 +58,33 @@ describe('simple 3D print export', () => {
58
58
  expect(prepared).toEqual({ artifact, report })
59
59
  })
60
60
 
61
+ test('uses the same safe profile for printable STL files', async () => {
62
+ const calls: { format?: string; options?: ModelExportOptions }[] = []
63
+ const stlReport: PrintExportReport = { ...report, format: 'stl' }
64
+ const artifact = { blob: new Blob(['stl']), filename: 'house.zip', metadata: stlReport }
65
+ const modelExport: ModelExport = async (format, options) => {
66
+ calls.push({ format, options })
67
+ return artifact
68
+ }
69
+
70
+ const prepared = await preparePrintExport(modelExport, false, 'print-stl')
71
+
72
+ expect(calls).toEqual([
73
+ {
74
+ format: 'print-stl',
75
+ options: {
76
+ onlyVisible: false,
77
+ download: false,
78
+ printScale: 100,
79
+ printScope: 'levels',
80
+ printContent: 'structure',
81
+ printBase: 'none',
82
+ },
83
+ },
84
+ ])
85
+ expect(prepared).toEqual({ artifact, report: stlReport })
86
+ })
87
+
61
88
  test('blocks the download when preflight finds invalid geometry', async () => {
62
89
  const blockedReport: PrintExportReport = {
63
90
  ...report,
@@ -76,7 +103,7 @@ describe('simple 3D print export', () => {
76
103
  metadata: blockedReport,
77
104
  })
78
105
 
79
- await expect(preparePrintExport(modelExport, true)).rejects.toThrow(
106
+ await expect(preparePrintExport(modelExport, true, 'print-3mf')).rejects.toThrow(
80
107
  'One wall has an open edge.',
81
108
  )
82
109
  })
@@ -122,7 +149,7 @@ describe('simple 3D print export', () => {
122
149
  metadata: blockedBundleReport,
123
150
  })
124
151
 
125
- await expect(preparePrintExport(modelExport, true)).rejects.toThrow(
152
+ await expect(preparePrintExport(modelExport, true, 'print-3mf')).rejects.toThrow(
126
153
  'The upper level has an open edge.',
127
154
  )
128
155
  })
@@ -133,7 +160,7 @@ describe('simple 3D print export', () => {
133
160
  filename: 'house.3mf',
134
161
  })
135
162
 
136
- await expect(preparePrintExport(modelExport, false)).rejects.toThrow(
163
+ await expect(preparePrintExport(modelExport, false, 'print-3mf')).rejects.toThrow(
137
164
  'did not return a valid file',
138
165
  )
139
166
  })
@@ -5,7 +5,11 @@ import {
5
5
  isPrintLevelBundleReport,
6
6
  type PrintLevelBundleReport,
7
7
  } from '../../../../../lib/level-print-export'
8
- import type { ModelExport, ModelExportArtifact } from '../../../../../lib/model-export'
8
+ import type {
9
+ ModelExport,
10
+ ModelExportArtifact,
11
+ ModelExportFormat,
12
+ } from '../../../../../lib/model-export'
9
13
  import {
10
14
  isPrintExportReport,
11
15
  type PrintExportReport,
@@ -17,6 +21,8 @@ type PreparedPrintExport = {
17
21
  report: PrintExportReport | PrintLevelBundleReport
18
22
  }
19
23
 
24
+ type PrintModelExportFormat = Extract<ModelExportFormat, 'print-3mf' | 'print-stl'>
25
+
20
26
  function downloadArtifact(artifact: ModelExportArtifact) {
21
27
  const url = URL.createObjectURL(artifact.blob)
22
28
  const link = document.createElement('a')
@@ -39,8 +45,9 @@ function firstBlockingMessage(report: PrintExportReport | PrintLevelBundleReport
39
45
  export async function preparePrintExport(
40
46
  modelExport: ModelExport,
41
47
  onlyVisible: boolean,
48
+ format: PrintModelExportFormat,
42
49
  ): Promise<PreparedPrintExport> {
43
- const artifact = await modelExport('print-3mf', {
50
+ const artifact = await modelExport(format, {
44
51
  onlyVisible,
45
52
  download: false,
46
53
  printScale: 100,
@@ -68,38 +75,50 @@ export async function preparePrintExport(
68
75
 
69
76
  export function PrintExportButton({ onlyVisible }: { onlyVisible: boolean }) {
70
77
  const modelExport = useEditor((state) => state.modelExport)
71
- const [isExporting, setIsExporting] = useState(false)
78
+ const [exportingFormat, setExportingFormat] = useState<PrintModelExportFormat | null>(null)
72
79
  const [error, setError] = useState<string | null>(null)
73
80
 
74
- const handleExport = async () => {
81
+ const handleExport = async (format: PrintModelExportFormat) => {
75
82
  if (!modelExport) return
76
83
 
77
- setIsExporting(true)
84
+ setExportingFormat(format)
78
85
  setError(null)
79
86
  try {
80
- const prepared = await preparePrintExport(modelExport, onlyVisible)
87
+ const prepared = await preparePrintExport(modelExport, onlyVisible, format)
81
88
  downloadArtifact(prepared.artifact)
82
89
  } catch (reason) {
83
90
  setError(reason instanceof Error ? reason.message : '3D print export failed.')
84
91
  } finally {
85
- setIsExporting(false)
92
+ setExportingFormat(null)
86
93
  }
87
94
  }
88
95
 
96
+ const isExporting = exportingFormat !== null
97
+
89
98
  return (
90
99
  <>
91
100
  <Button
92
- aria-busy={isExporting}
101
+ aria-busy={exportingFormat === 'print-3mf'}
102
+ className="w-full justify-start gap-2"
103
+ disabled={isExporting || !modelExport}
104
+ onClick={() => void handleExport('print-3mf')}
105
+ variant="outline"
106
+ >
107
+ <Printer className="size-4" />
108
+ Export 3D print 3MF
109
+ </Button>
110
+ <Button
111
+ aria-busy={exportingFormat === 'print-stl'}
93
112
  className="w-full justify-start gap-2"
94
113
  disabled={isExporting || !modelExport}
95
- onClick={handleExport}
114
+ onClick={() => void handleExport('print-stl')}
96
115
  variant="outline"
97
116
  >
98
117
  <Printer className="size-4" />
99
- Export 3D print files
118
+ Export 3D print STL
100
119
  </Button>
101
120
  {error && (
102
- <div className="flex gap-2 text-destructive text-xs">
121
+ <div className="col-span-full flex gap-2 text-destructive text-xs">
103
122
  <AlertTriangle className="mt-0.5 size-4 shrink-0" />
104
123
  <span>{error}</span>
105
124
  </div>
@@ -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}