@pascal-app/editor 0.7.0 → 0.9.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 (244) hide show
  1. package/package.json +12 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +755 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +62 -54
  6. package/src/components/editor/export-manager.tsx +32 -3
  7. package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
  8. package/src/components/editor/first-person/build-collider-world.ts +203 -87
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +127 -62
  10. package/src/components/editor/first-person-controls.tsx +836 -31
  11. package/src/components/editor/floating-action-menu.tsx +287 -196
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floorplan-panel.tsx +5837 -12006
  14. package/src/components/editor/grid.tsx +51 -17
  15. package/src/components/editor/group-move-handle.tsx +316 -0
  16. package/src/components/editor/group-rotate-handle.tsx +382 -0
  17. package/src/components/editor/group-transform-shared.test.ts +205 -0
  18. package/src/components/editor/group-transform-shared.ts +256 -0
  19. package/src/components/editor/handles/handle-arrow.tsx +523 -0
  20. package/src/components/editor/handles/use-handle-drag.ts +213 -0
  21. package/src/components/editor/index.tsx +234 -190
  22. package/src/components/editor/measurement-pill.tsx +75 -0
  23. package/src/components/editor/node-arrow-handles.tsx +1453 -0
  24. package/src/components/editor/selection-manager.tsx +435 -158
  25. package/src/components/editor/site-edge-labels.tsx +44 -20
  26. package/src/components/editor/slab-hole-highlights.tsx +466 -0
  27. package/src/components/editor/snapshot-capture-overlay.tsx +539 -0
  28. package/src/components/editor/thumbnail-generator.tsx +27 -64
  29. package/src/components/editor/use-floorplan-background-placement.ts +122 -35
  30. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  31. package/src/components/editor/use-floorplan-scene-data.ts +14 -2
  32. package/src/components/editor/wall-measurement-label.tsx +11 -6
  33. package/src/components/editor/wall-move-side-handles.tsx +855 -0
  34. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  35. package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
  36. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
  37. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  38. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  39. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
  41. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  42. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
  43. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  44. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
  45. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  46. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -61
  47. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  49. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +29 -14
  50. package/src/components/editor-2d/svg-paths.ts +10 -2
  51. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +30 -9
  52. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  53. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  54. package/src/components/systems/stair/stair-edit-system.tsx +2 -2
  55. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  56. package/src/components/systems/zone/zone-system.tsx +0 -0
  57. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  58. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  59. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  60. package/src/components/tools/fence/fence-drafting.ts +64 -22
  61. package/src/components/tools/item/move-tool.tsx +40 -107
  62. package/src/components/tools/item/placement-math.test.ts +10 -0
  63. package/src/components/tools/item/placement-math.ts +33 -9
  64. package/src/components/tools/item/placement-strategies.ts +201 -25
  65. package/src/components/tools/item/placement-types.ts +10 -1
  66. package/src/components/tools/item/use-draft-node.ts +20 -2
  67. package/src/components/tools/item/use-placement-coordinator.tsx +700 -299
  68. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  69. package/src/components/tools/roof/roof-tool.tsx +108 -12
  70. package/src/components/tools/select/box-select-state.ts +77 -0
  71. package/src/components/tools/select/box-select-tool.tsx +324 -543
  72. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  73. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  74. package/src/components/tools/select/select-candidates.test.ts +109 -0
  75. package/src/components/tools/select/select-candidates.ts +85 -0
  76. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  77. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  78. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  79. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  80. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  81. package/src/components/tools/shared/placement-box.tsx +123 -0
  82. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  83. package/src/components/tools/shared/segment-angle.ts +57 -1
  84. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  85. package/src/components/tools/site/site-flag-model.ts +18 -0
  86. package/src/components/tools/stair/stair-defaults.ts +1 -1
  87. package/src/components/tools/stair/stair-tool.tsx +269 -35
  88. package/src/components/tools/tool-manager.tsx +172 -56
  89. package/src/components/tools/wall/wall-drafting.ts +98 -98
  90. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  91. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  92. package/src/components/tools/zone/zone-tool.tsx +36 -11
  93. package/src/components/ui/action-menu/action-button.tsx +21 -1
  94. package/src/components/ui/action-menu/camera-actions.tsx +0 -0
  95. package/src/components/ui/action-menu/control-modes.tsx +5 -76
  96. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  97. package/src/components/ui/action-menu/index.tsx +33 -196
  98. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  99. package/src/components/ui/action-menu/view-toggles.tsx +262 -37
  100. package/src/components/ui/command-palette/editor-commands.tsx +24 -17
  101. package/src/components/ui/command-palette/index.tsx +6 -256
  102. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  103. package/src/components/ui/controls/material-picker.tsx +17 -35
  104. package/src/components/ui/controls/metric-control.tsx +33 -11
  105. package/src/components/ui/floating-level-selector.tsx +48 -11
  106. package/src/components/ui/helpers/helper-manager.tsx +21 -17
  107. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  108. package/src/components/ui/item-catalog/catalog-items.tsx +1743 -315
  109. package/src/components/ui/item-catalog/item-catalog.tsx +91 -46
  110. package/src/components/ui/level-duplicate-dialog.tsx +5 -6
  111. package/src/components/ui/panels/node-display.ts +1 -0
  112. package/src/components/ui/panels/paint-panel.tsx +66 -41
  113. package/src/components/ui/panels/panel-manager.tsx +33 -74
  114. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  115. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  116. package/src/components/ui/panels/reference-panel.tsx +28 -13
  117. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  118. package/src/components/ui/primitives/number-input.tsx +1 -1
  119. package/src/components/ui/primitives/sidebar.tsx +0 -0
  120. package/src/components/ui/scene-loader.tsx +1 -3
  121. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  122. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  123. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  124. package/src/components/ui/sidebar/panels/items-panel/index.tsx +404 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  127. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  128. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  129. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  132. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  133. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  134. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  135. package/src/components/ui/sidebar/panels/site-panel/index.tsx +8 -12
  136. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  137. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  138. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  139. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  140. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  141. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  142. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  143. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -34
  144. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
  145. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +52 -3
  146. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  147. package/src/components/ui/slider.tsx +1 -1
  148. package/src/components/viewer-overlay.tsx +169 -52
  149. package/src/components/viewer-zone-system.tsx +1 -1
  150. package/src/hooks/use-auto-save.ts +24 -3
  151. package/src/hooks/use-ceiling-events.ts +175 -0
  152. package/src/hooks/use-drag-action.ts +124 -0
  153. package/src/hooks/use-keyboard.ts +125 -62
  154. package/src/hooks/use-selection.ts +64 -0
  155. package/src/index.tsx +243 -4
  156. package/src/lib/constants.ts +6 -2
  157. package/src/lib/editor-api.ts +62 -0
  158. package/src/lib/elevator-support.ts +96 -0
  159. package/src/lib/floorplan/apply-alignment.ts +111 -0
  160. package/src/lib/floorplan/index.ts +7 -0
  161. package/src/lib/floorplan/selection-tool.ts +40 -0
  162. package/src/lib/floorplan/stairs.ts +12 -1
  163. package/src/lib/fresh-planar-placement.test.ts +102 -0
  164. package/src/lib/fresh-planar-placement.ts +63 -0
  165. package/src/lib/history.ts +2 -1
  166. package/src/lib/level-duplication.test.ts +0 -2
  167. package/src/lib/level-duplication.ts +55 -43
  168. package/src/lib/level-name.ts +11 -0
  169. package/src/lib/material-paint.ts +212 -20
  170. package/src/lib/placement-metadata.ts +29 -0
  171. package/src/lib/planar-cursor-placement.test.ts +43 -0
  172. package/src/lib/planar-cursor-placement.ts +42 -0
  173. package/src/lib/roof-duplication.ts +2 -2
  174. package/src/lib/scene-bounds.ts +1 -1
  175. package/src/lib/scene-clipboard.ts +267 -0
  176. package/src/lib/scene.ts +13 -4
  177. package/src/lib/sfx-bus.ts +25 -1
  178. package/src/lib/sfx-player.ts +75 -27
  179. package/src/lib/site-boundary.ts +1 -0
  180. package/src/lib/stair-duplication.ts +2 -2
  181. package/src/lib/stair-levels.test.ts +146 -0
  182. package/src/lib/stair-levels.ts +177 -0
  183. package/src/lib/world-grid-snap.ts +199 -0
  184. package/src/lib/zone-content.ts +130 -0
  185. package/src/store/use-alignment-guides.ts +21 -0
  186. package/src/store/use-editor.tsx +342 -91
  187. package/src/store/use-placement-preview.ts +31 -0
  188. package/src/store/use-wall-move-ghosts.ts +36 -0
  189. package/src/store/use-wall-snap-indicator.ts +32 -0
  190. package/tsconfig.json +2 -1
  191. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  192. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  193. package/src/components/feedback-dialog.tsx +0 -265
  194. package/src/components/pascal-radio.tsx +0 -280
  195. package/src/components/preview-button.tsx +0 -16
  196. package/src/components/tools/building/move-building-tool.tsx +0 -157
  197. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  198. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  199. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  200. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -257
  201. package/src/components/tools/column/column-tool.tsx +0 -97
  202. package/src/components/tools/column/move-column-tool.tsx +0 -105
  203. package/src/components/tools/door/door-math.ts +0 -110
  204. package/src/components/tools/door/door-tool.tsx +0 -324
  205. package/src/components/tools/door/move-door-tool.tsx +0 -412
  206. package/src/components/tools/fence/curve-fence-tool.tsx +0 -179
  207. package/src/components/tools/fence/fence-tool.tsx +0 -346
  208. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -438
  209. package/src/components/tools/fence/move-fence-tool.tsx +0 -298
  210. package/src/components/tools/item/item-tool.tsx +0 -26
  211. package/src/components/tools/roof/move-roof-tool.tsx +0 -359
  212. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  213. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  214. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  215. package/src/components/tools/slab/slab-tool.tsx +0 -322
  216. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  217. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  218. package/src/components/tools/wall/curve-wall-tool.tsx +0 -176
  219. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -423
  220. package/src/components/tools/wall/move-wall-tool.tsx +0 -356
  221. package/src/components/tools/wall/wall-tool.tsx +0 -332
  222. package/src/components/tools/window/move-window-tool.tsx +0 -447
  223. package/src/components/tools/window/window-math.ts +0 -117
  224. package/src/components/tools/window/window-tool.tsx +0 -332
  225. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  226. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  227. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  228. package/src/components/ui/panels/ceiling-panel.tsx +0 -250
  229. package/src/components/ui/panels/column-panel.tsx +0 -715
  230. package/src/components/ui/panels/door-panel.tsx +0 -1292
  231. package/src/components/ui/panels/fence-panel.tsx +0 -227
  232. package/src/components/ui/panels/item-panel.tsx +0 -306
  233. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  234. package/src/components/ui/panels/roof-panel.tsx +0 -230
  235. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  236. package/src/components/ui/panels/slab-panel.tsx +0 -250
  237. package/src/components/ui/panels/spawn-panel.tsx +0 -155
  238. package/src/components/ui/panels/stair-panel.tsx +0 -525
  239. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  240. package/src/components/ui/panels/wall-panel.tsx +0 -177
  241. package/src/components/ui/panels/window-panel.tsx +0 -960
  242. package/src/components/ui/viewer-toolbar.tsx +0 -436
  243. package/src/contexts/presets-context.tsx +0 -121
  244. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -1,4 +1,4 @@
1
- import { emitter, useScene } from '@pascal-app/core'
1
+ import { emitter, useScene, validateBuildJson } from '@pascal-app/core'
2
2
  import { useViewer } from '@pascal-app/viewer'
3
3
  import { TreeView, VisualJson } from '@visual-json/react'
4
4
  import { Camera, Download, Save, Trash2, Upload } from 'lucide-react'
@@ -21,6 +21,7 @@ import { Switch } from './../../../../../components/ui/primitives/switch'
21
21
  import useEditor, { selectDefaultBuildingAndLevel } from './../../../../../store/use-editor'
22
22
  import { AudioSettingsDialog } from './audio-settings-dialog'
23
23
  import { KeyboardShortcutsDialog } from './keyboard-shortcuts-dialog'
24
+ import { LoadBuildDialog, type PendingImport } from './load-build-dialog'
24
25
 
25
26
  type SceneNode = Record<string, unknown> & {
26
27
  id?: unknown
@@ -183,8 +184,10 @@ export function SettingsPanel({
183
184
  const resetSelection = useViewer((state) => state.resetSelection)
184
185
  const exportScene = useViewer((state) => state.exportScene)
185
186
  const showGrid = useViewer((state) => state.showGrid)
187
+ const shadows = useViewer((state) => state.shadows)
186
188
  const setPhase = useEditor((state) => state.setPhase)
187
189
  const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false)
190
+ const [pendingImport, setPendingImport] = useState<PendingImport | null>(null)
188
191
  const sceneGraphValue = useMemo(
189
192
  () => buildSceneGraphValue(nodes as Record<string, SceneNode>, rootNodeIds),
190
193
  [nodes, rootNodeIds],
@@ -221,16 +224,37 @@ export function SettingsPanel({
221
224
 
222
225
  const reader = new FileReader()
223
226
  reader.onload = (event) => {
227
+ const text = event.target?.result as string
228
+ let parsed: unknown
224
229
  try {
225
- const data = JSON.parse(event.target?.result as string)
226
- if (data.nodes && data.rootNodeIds) {
227
- setScene(data.nodes, data.rootNodeIds)
228
- resetSelection()
229
- setPhase('site')
230
- }
231
- } catch (err) {
232
- console.error('Failed to load build:', err)
230
+ parsed = JSON.parse(text)
231
+ } catch {
232
+ setPendingImport({
233
+ fileName: file.name,
234
+ fileSizeBytes: file.size,
235
+ result: {
236
+ ok: false,
237
+ parsed: null,
238
+ stats: { total: 0, byType: {}, unknownTypes: {}, floorAreaM2: 0 },
239
+ errors: [
240
+ {
241
+ severity: 'error',
242
+ code: 'invalid_json',
243
+ message: 'File could not be parsed as JSON.',
244
+ },
245
+ ],
246
+ warnings: [],
247
+ schemaIssues: [],
248
+ schemaIssueCount: 0,
249
+ },
250
+ })
251
+ return
233
252
  }
253
+ setPendingImport({
254
+ fileName: file.name,
255
+ fileSizeBytes: file.size,
256
+ result: validateBuildJson(parsed),
257
+ })
234
258
  }
235
259
  reader.readAsText(file)
236
260
 
@@ -238,6 +262,16 @@ export function SettingsPanel({
238
262
  e.target.value = ''
239
263
  }
240
264
 
265
+ const handleConfirmImport = (parsed: { nodes: Record<string, unknown>; rootNodeIds: string[] }) => {
266
+ setScene(
267
+ parsed.nodes as Parameters<typeof setScene>[0],
268
+ parsed.rootNodeIds as Parameters<typeof setScene>[1],
269
+ )
270
+ resetSelection()
271
+ setPhase('site')
272
+ setPendingImport(null)
273
+ }
274
+
241
275
  const handleResetToDefault = () => {
242
276
  clearScene()
243
277
  resetSelection()
@@ -307,6 +341,16 @@ export function SettingsPanel({
307
341
  onCheckedChange={(checked) => useViewer.getState().setShowGrid(checked)}
308
342
  />
309
343
  </div>
344
+ <div className="flex items-center justify-between">
345
+ <div>
346
+ <div className="font-medium text-sm">Shadows</div>
347
+ <div className="text-muted-foreground text-xs">Cast shadows from lights</div>
348
+ </div>
349
+ <Switch
350
+ checked={shadows}
351
+ onCheckedChange={(checked) => useViewer.getState().setShadows(checked)}
352
+ />
353
+ </div>
310
354
  </div>
311
355
  )}
312
356
 
@@ -380,6 +424,12 @@ export function SettingsPanel({
380
424
  ref={fileInputRef}
381
425
  type="file"
382
426
  />
427
+
428
+ <LoadBuildDialog
429
+ onCancel={() => setPendingImport(null)}
430
+ onConfirm={handleConfirmImport}
431
+ pending={pendingImport}
432
+ />
383
433
  </div>
384
434
 
385
435
  {/* Audio Section */}
@@ -37,7 +37,6 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
37
37
  { keys: ['1'], action: 'Switch to Site phase' },
38
38
  { keys: ['2'], action: 'Switch to Structure phase' },
39
39
  { keys: ['3'], action: 'Switch to Furnish phase' },
40
- { keys: ['S'], action: 'Switch to Structure layer' },
41
40
  { keys: ['F'], action: 'Switch to Furnish layer' },
42
41
  { keys: ['Z'], action: 'Switch to Zones layer' },
43
42
  {
@@ -56,6 +55,7 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
56
55
  shortcuts: [
57
56
  { keys: ['V'], action: 'Switch to Select mode' },
58
57
  { keys: ['B'], action: 'Switch to Build mode' },
58
+ { keys: ['X'], action: 'Switch to Delete mode' },
59
59
  {
60
60
  keys: ['Esc'],
61
61
  action: 'Cancel the active tool and return to Select mode',
@@ -100,6 +100,11 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
100
100
  {
101
101
  title: 'Camera',
102
102
  shortcuts: [
103
+ {
104
+ keys: ['W', 'A', 'S', 'D'],
105
+ action: 'Pan camera',
106
+ note: 'Moves in screen space, similar to dragging the camera view.',
107
+ },
103
108
  {
104
109
  keys: ['Middle click'],
105
110
  action: 'Pan camera',
@@ -0,0 +1,259 @@
1
+ import type { BuildStats, SchemaIssue, ValidateBuildJsonResult } from '@pascal-app/core'
2
+ import {
3
+ AlertTriangle,
4
+ AppWindow,
5
+ Box,
6
+ Building2,
7
+ CheckCircle2,
8
+ DoorOpen,
9
+ Layers,
10
+ MapPin,
11
+ Scan,
12
+ Square,
13
+ XCircle,
14
+ } from 'lucide-react'
15
+ import { useState } from 'react'
16
+ import { Button } from '../../../../../components/ui/primitives/button'
17
+ import {
18
+ Dialog,
19
+ DialogContent,
20
+ DialogDescription,
21
+ DialogFooter,
22
+ DialogHeader,
23
+ DialogTitle,
24
+ } from '../../../../../components/ui/primitives/dialog'
25
+
26
+ export type PendingImport = {
27
+ fileName: string
28
+ fileSizeBytes: number
29
+ result: ValidateBuildJsonResult
30
+ }
31
+
32
+ type Props = {
33
+ pending: PendingImport | null
34
+ onCancel: () => void
35
+ onConfirm: (parsed: NonNullable<ValidateBuildJsonResult['parsed']>) => void
36
+ }
37
+
38
+ type StatRow = {
39
+ icon: typeof Building2
40
+ label: string
41
+ count: number
42
+ }
43
+
44
+ function statsRows(stats: BuildStats): StatRow[] {
45
+ return (
46
+ [
47
+ { icon: MapPin, label: 'Sites', count: stats.byType.site ?? 0 },
48
+ { icon: Building2, label: 'Buildings', count: stats.byType.building ?? 0 },
49
+ { icon: Layers, label: 'Levels', count: stats.byType.level ?? 0 },
50
+ { icon: Square, label: 'Walls', count: stats.byType.wall ?? 0 },
51
+ { icon: DoorOpen, label: 'Doors', count: stats.byType.door ?? 0 },
52
+ { icon: AppWindow, label: 'Windows', count: stats.byType.window ?? 0 },
53
+ { icon: Box, label: 'Items', count: stats.byType.item ?? 0 },
54
+ { icon: Square, label: 'Slabs', count: stats.byType.slab ?? 0 },
55
+ { icon: Square, label: 'Ceilings', count: stats.byType.ceiling ?? 0 },
56
+ { icon: Square, label: 'Zones', count: stats.byType.zone ?? 0 },
57
+ { icon: Scan, label: 'Scans', count: stats.byType.scan ?? 0 },
58
+ ] satisfies StatRow[]
59
+ ).filter((row) => row.count > 0)
60
+ }
61
+
62
+ function groupSchemaIssuesByType(
63
+ issues: SchemaIssue[],
64
+ ): { type: string; issues: SchemaIssue[] }[] {
65
+ const groups = new Map<string, SchemaIssue[]>()
66
+ for (const issue of issues) {
67
+ const existing = groups.get(issue.nodeType)
68
+ if (existing) {
69
+ existing.push(issue)
70
+ } else {
71
+ groups.set(issue.nodeType, [issue])
72
+ }
73
+ }
74
+ return Array.from(groups, ([type, list]) => ({ type, issues: list }))
75
+ }
76
+
77
+ function formatFileSize(bytes: number): string {
78
+ if (bytes < 1024) return `${bytes} B`
79
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
80
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
81
+ }
82
+
83
+ function formatFloorArea(m2: number): string {
84
+ if (m2 === 0) return '—'
85
+ if (m2 < 10) return `${m2.toFixed(2)} m²`
86
+ return `${m2.toFixed(1)} m²`
87
+ }
88
+
89
+ export function LoadBuildDialog({ pending, onCancel, onConfirm }: Props) {
90
+ const [showAllWarnings, setShowAllWarnings] = useState(false)
91
+ const [showSchemaIssues, setShowSchemaIssues] = useState(false)
92
+
93
+ if (!pending) return null
94
+
95
+ const { fileName, fileSizeBytes, result } = pending
96
+ const { ok, parsed, stats, errors, warnings, schemaIssues, schemaIssueCount } = result
97
+ const rows = statsRows(stats)
98
+ const visibleWarnings = showAllWarnings ? warnings : warnings.slice(0, 3)
99
+ const hiddenWarningCount = warnings.length - visibleWarnings.length
100
+ const schemaIssuesByType = groupSchemaIssuesByType(schemaIssues)
101
+
102
+ return (
103
+ <Dialog
104
+ open
105
+ onOpenChange={(open) => {
106
+ if (!open) onCancel()
107
+ }}
108
+ >
109
+ <DialogContent className="sm:max-w-lg">
110
+ <DialogHeader>
111
+ <DialogTitle className="flex items-center gap-2">
112
+ {ok ? (
113
+ <CheckCircle2 className="size-5 text-emerald-600" />
114
+ ) : (
115
+ <XCircle className="size-5 text-red-600" />
116
+ )}
117
+ {ok ? 'Ready to import' : 'Cannot import this file'}
118
+ </DialogTitle>
119
+ <DialogDescription>
120
+ {fileName} · {formatFileSize(fileSizeBytes)} · {stats.total} node
121
+ {stats.total === 1 ? '' : 's'}
122
+ </DialogDescription>
123
+ </DialogHeader>
124
+
125
+ <div className="max-h-[60vh] space-y-4 overflow-y-auto py-2">
126
+ {errors.length > 0 && (
127
+ <div className="space-y-2 rounded-md border border-red-200 bg-red-50 p-3">
128
+ <div className="flex items-center gap-2 font-medium text-red-800 text-sm">
129
+ <XCircle className="size-4" />
130
+ {errors.length} error{errors.length === 1 ? '' : 's'}
131
+ </div>
132
+ <ul className="space-y-1 text-red-700 text-xs">
133
+ {errors.map((e) => (
134
+ <li key={`${e.code}-${e.nodeId ?? ''}`}>· {e.message}</li>
135
+ ))}
136
+ </ul>
137
+ </div>
138
+ )}
139
+
140
+ {stats.total > 0 && (
141
+ <div className="rounded-md border bg-card">
142
+ <div className="border-b px-3 py-2 font-medium text-muted-foreground text-xs uppercase">
143
+ Structure
144
+ </div>
145
+ {rows.length > 0 ? (
146
+ <div>
147
+ {rows.map((row, i) => {
148
+ const Icon = row.icon
149
+ return (
150
+ <div
151
+ className={`flex items-center justify-between px-3 py-2 ${
152
+ i === rows.length - 1 ? '' : 'border-b'
153
+ }`}
154
+ key={row.label}
155
+ >
156
+ <div className="flex items-center gap-2">
157
+ <Icon className="size-4 text-muted-foreground" />
158
+ <span className="text-sm">{row.label}</span>
159
+ </div>
160
+ <span className="font-medium text-sm">{row.count}</span>
161
+ </div>
162
+ )
163
+ })}
164
+ {stats.floorAreaM2 > 0 && (
165
+ <div className="flex items-center justify-between border-t px-3 py-2">
166
+ <span className="text-muted-foreground text-sm">Floor area</span>
167
+ <span className="font-medium text-sm">
168
+ {formatFloorArea(stats.floorAreaM2)}
169
+ </span>
170
+ </div>
171
+ )}
172
+ </div>
173
+ ) : (
174
+ <div className="px-3 py-4 text-center text-muted-foreground text-xs">
175
+ The file contains no recognised nodes.
176
+ </div>
177
+ )}
178
+ </div>
179
+ )}
180
+
181
+ {warnings.length > 0 && (
182
+ <div className="space-y-2 rounded-md border border-amber-200 bg-amber-50 p-3">
183
+ <div className="flex items-center gap-2 font-medium text-amber-800 text-sm">
184
+ <AlertTriangle className="size-4" />
185
+ {warnings.length} warning{warnings.length === 1 ? '' : 's'}
186
+ </div>
187
+ <ul className="space-y-1 text-amber-700 text-xs">
188
+ {visibleWarnings.map((w, i) => (
189
+ <li key={`${w.code}-${w.nodeId ?? ''}-${i}`}>· {w.message}</li>
190
+ ))}
191
+ </ul>
192
+ {hiddenWarningCount > 0 && (
193
+ <button
194
+ className="text-amber-800 text-xs underline hover:no-underline"
195
+ onClick={() => setShowAllWarnings(true)}
196
+ type="button"
197
+ >
198
+ Show {hiddenWarningCount} more
199
+ </button>
200
+ )}
201
+ </div>
202
+ )}
203
+
204
+ {schemaIssues.length > 0 && (
205
+ <div className="space-y-2 rounded-md border bg-card p-3">
206
+ <button
207
+ className="flex w-full items-center justify-between text-left"
208
+ onClick={() => setShowSchemaIssues((v) => !v)}
209
+ type="button"
210
+ >
211
+ <span className="font-medium text-muted-foreground text-xs uppercase">
212
+ Schema details ({schemaIssueCount} node
213
+ {schemaIssueCount === 1 ? '' : 's'})
214
+ </span>
215
+ <span className="text-muted-foreground text-xs">
216
+ {showSchemaIssues ? 'Hide' : 'Show'}
217
+ </span>
218
+ </button>
219
+ {showSchemaIssues && (
220
+ <div className="space-y-3 pt-1">
221
+ {schemaIssuesByType.map(({ type, issues }) => (
222
+ <div className="space-y-1" key={type}>
223
+ <div className="font-medium text-xs">
224
+ {type} · {issues.length}
225
+ </div>
226
+ <ul className="space-y-0.5 text-muted-foreground text-xs">
227
+ {issues.map((issue) => (
228
+ <li className="font-mono" key={issue.nodeId}>
229
+ <span className="text-foreground">{issue.nodeId}</span>
230
+ {issue.path && <span> · {issue.path}</span>}
231
+ <span> — {issue.message}</span>
232
+ </li>
233
+ ))}
234
+ </ul>
235
+ </div>
236
+ ))}
237
+ </div>
238
+ )}
239
+ </div>
240
+ )}
241
+ </div>
242
+
243
+ <DialogFooter>
244
+ <Button onClick={onCancel} variant="outline">
245
+ Cancel
246
+ </Button>
247
+ <Button
248
+ disabled={!ok || !parsed}
249
+ onClick={() => {
250
+ if (parsed) onConfirm(parsed)
251
+ }}
252
+ >
253
+ Replace current scene
254
+ </Button>
255
+ </DialogFooter>
256
+ </DialogContent>
257
+ </Dialog>
258
+ )
259
+ }
@@ -39,8 +39,10 @@ export const BuildingTreeNode = memo(function BuildingTreeNode({
39
39
 
40
40
  const handleAddLevel = (e: React.MouseEvent) => {
41
41
  e.stopPropagation()
42
+ const nodes = useScene.getState().nodes
43
+ const levelCount = children.filter((childId) => nodes[childId]?.type === 'level').length
42
44
  const newLevel = LevelNode.parse({
43
- level: children.length,
45
+ level: levelCount,
44
46
  children: [],
45
47
  parentId: nodeId,
46
48
  })
@@ -130,8 +130,11 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number {
130
130
 
131
131
  for (let i = 0; i < n; i++) {
132
132
  const j = (i + 1) % n
133
- area += polygon[i]?.[0] * polygon[j]?.[1]
134
- area -= polygon[j]?.[0] * polygon[i]?.[1]
133
+ const pi = polygon[i]
134
+ const pj = polygon[j]
135
+ if (!(pi && pj)) continue
136
+ area += pi[0] * pj[1]
137
+ area -= pj[0] * pi[1]
135
138
  }
136
139
 
137
140
  return Math.abs(area) / 2
@@ -0,0 +1,83 @@
1
+ import { type AnyNodeId, type ChimneyNode, useScene } from '@pascal-app/core'
2
+ import { useViewer } from '@pascal-app/viewer'
3
+ import Image from 'next/image'
4
+ import { memo, useCallback, useState } from 'react'
5
+ import useEditor from './../../../../../store/use-editor'
6
+ import { InlineRenameInput } from './inline-rename-input'
7
+ import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
8
+ import { TreeNodeActions } from './tree-node-actions'
9
+
10
+ interface ChimneyTreeNodeProps {
11
+ nodeId: AnyNodeId
12
+ depth: number
13
+ isLast?: boolean
14
+ }
15
+
16
+ export const ChimneyTreeNode = memo(function ChimneyTreeNode({
17
+ nodeId,
18
+ depth,
19
+ isLast,
20
+ }: ChimneyTreeNodeProps) {
21
+ const [isEditing, setIsEditing] = useState(false)
22
+ const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
23
+ const node = useScene((s) => s.nodes[nodeId] as ChimneyNode | undefined)
24
+ const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
25
+ const isHovered = useViewer((state) => state.hoveredId === nodeId)
26
+ const setSelection = useViewer((state) => state.setSelection)
27
+ const setHoveredId = useViewer((state) => state.setHoveredId)
28
+
29
+ const handleClick = useCallback(
30
+ (e: React.MouseEvent) => {
31
+ e.stopPropagation()
32
+ const handled = handleTreeSelection(
33
+ e,
34
+ nodeId,
35
+ useViewer.getState().selection.selectedIds,
36
+ setSelection,
37
+ )
38
+ if (!handled && useEditor.getState().phase === 'furnish') {
39
+ useEditor.getState().setPhase('structure')
40
+ }
41
+ },
42
+ [nodeId, setSelection],
43
+ )
44
+
45
+ const defaultName = node?.name || 'Chimney'
46
+
47
+ return (
48
+ <TreeNodeWrapper
49
+ actions={<TreeNodeActions nodeId={nodeId} />}
50
+ depth={depth}
51
+ expanded={false}
52
+ hasChildren={false}
53
+ icon={
54
+ <Image
55
+ alt=""
56
+ className="object-contain opacity-60"
57
+ height={14}
58
+ src="/icons/roof.png"
59
+ width={14}
60
+ />
61
+ }
62
+ isHovered={isHovered}
63
+ isLast={isLast}
64
+ isSelected={isSelected}
65
+ isVisible={isVisible}
66
+ label={
67
+ <InlineRenameInput
68
+ defaultName={defaultName}
69
+ isEditing={isEditing}
70
+ nodeId={nodeId}
71
+ onStartEditing={() => setIsEditing(true)}
72
+ onStopEditing={() => setIsEditing(false)}
73
+ />
74
+ }
75
+ nodeId={nodeId}
76
+ onClick={handleClick}
77
+ onDoubleClick={() => focusTreeNode(nodeId)}
78
+ onMouseEnter={() => setHoveredId(nodeId)}
79
+ onMouseLeave={() => setHoveredId(null)}
80
+ onToggle={() => {}}
81
+ />
82
+ )
83
+ })
@@ -0,0 +1,89 @@
1
+ import { type AnyNodeId, type DormerNode, useScene } from '@pascal-app/core'
2
+ import { useViewer } from '@pascal-app/viewer'
3
+ import Image from 'next/image'
4
+ import { memo, useCallback, useState } from 'react'
5
+ import useEditor from './../../../../../store/use-editor'
6
+ import { InlineRenameInput } from './inline-rename-input'
7
+ import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
8
+ import { TreeNodeActions } from './tree-node-actions'
9
+
10
+ interface DormerTreeNodeProps {
11
+ nodeId: AnyNodeId
12
+ depth: number
13
+ isLast?: boolean
14
+ }
15
+
16
+ /**
17
+ * Sidebar tree-node entry for a dormer. Mirrors `ChimneyTreeNode`
18
+ * exactly — dormers are leaf entries (no children) parented under
19
+ * their host roof segment. The `roof.png` icon matches the rest of
20
+ * the roof-accessory kinds.
21
+ */
22
+ export const DormerTreeNode = memo(function DormerTreeNode({
23
+ nodeId,
24
+ depth,
25
+ isLast,
26
+ }: DormerTreeNodeProps) {
27
+ const [isEditing, setIsEditing] = useState(false)
28
+ const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
29
+ const node = useScene((s) => s.nodes[nodeId] as DormerNode | undefined)
30
+ const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
31
+ const isHovered = useViewer((state) => state.hoveredId === nodeId)
32
+ const setSelection = useViewer((state) => state.setSelection)
33
+ const setHoveredId = useViewer((state) => state.setHoveredId)
34
+
35
+ const handleClick = useCallback(
36
+ (e: React.MouseEvent) => {
37
+ e.stopPropagation()
38
+ const handled = handleTreeSelection(
39
+ e,
40
+ nodeId,
41
+ useViewer.getState().selection.selectedIds,
42
+ setSelection,
43
+ )
44
+ if (!handled && useEditor.getState().phase === 'furnish') {
45
+ useEditor.getState().setPhase('structure')
46
+ }
47
+ },
48
+ [nodeId, setSelection],
49
+ )
50
+
51
+ const defaultName = node?.name || 'Dormer'
52
+
53
+ return (
54
+ <TreeNodeWrapper
55
+ actions={<TreeNodeActions nodeId={nodeId} />}
56
+ depth={depth}
57
+ expanded={false}
58
+ hasChildren={false}
59
+ icon={
60
+ <Image
61
+ alt=""
62
+ className="object-contain opacity-60"
63
+ height={14}
64
+ src="/icons/roof.png"
65
+ width={14}
66
+ />
67
+ }
68
+ isHovered={isHovered}
69
+ isLast={isLast}
70
+ isSelected={isSelected}
71
+ isVisible={isVisible}
72
+ label={
73
+ <InlineRenameInput
74
+ defaultName={defaultName}
75
+ isEditing={isEditing}
76
+ nodeId={nodeId}
77
+ onStartEditing={() => setIsEditing(true)}
78
+ onStopEditing={() => setIsEditing(false)}
79
+ />
80
+ }
81
+ nodeId={nodeId}
82
+ onClick={handleClick}
83
+ onDoubleClick={() => focusTreeNode(nodeId)}
84
+ onMouseEnter={() => setHoveredId(nodeId)}
85
+ onMouseLeave={() => setHoveredId(null)}
86
+ onToggle={() => {}}
87
+ />
88
+ )
89
+ })
@@ -0,0 +1,75 @@
1
+ import { type AnyNodeId, type ElevatorNode, useScene } from '@pascal-app/core'
2
+ import { useViewer } from '@pascal-app/viewer'
3
+ import Image from 'next/image'
4
+ import { memo, useCallback, useState } from 'react'
5
+ import useEditor from './../../../../../store/use-editor'
6
+ import { InlineRenameInput } from './inline-rename-input'
7
+ import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
8
+ import { TreeNodeActions } from './tree-node-actions'
9
+
10
+ interface ElevatorTreeNodeProps {
11
+ nodeId: AnyNodeId
12
+ depth: number
13
+ isLast?: boolean
14
+ }
15
+
16
+ export const ElevatorTreeNode = memo(function ElevatorTreeNode({
17
+ nodeId,
18
+ depth,
19
+ isLast,
20
+ }: ElevatorTreeNodeProps) {
21
+ const [isEditing, setIsEditing] = useState(false)
22
+ const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
23
+ const node = useScene((s) => s.nodes[nodeId] as ElevatorNode | undefined)
24
+ const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
25
+ const isHovered = useViewer((state) => state.hoveredId === nodeId)
26
+ const setSelection = useViewer((state) => state.setSelection)
27
+ const setHoveredId = useViewer((state) => state.setHoveredId)
28
+
29
+ const handleClick = useCallback(
30
+ (event: React.MouseEvent) => {
31
+ event.stopPropagation()
32
+ const handled = handleTreeSelection(
33
+ event,
34
+ nodeId,
35
+ useViewer.getState().selection.selectedIds,
36
+ setSelection,
37
+ )
38
+ if (!handled && useEditor.getState().phase === 'furnish') {
39
+ useEditor.getState().setPhase('structure')
40
+ }
41
+ },
42
+ [nodeId, setSelection],
43
+ )
44
+
45
+ return (
46
+ <TreeNodeWrapper
47
+ actions={<TreeNodeActions nodeId={nodeId} />}
48
+ depth={depth}
49
+ expanded={false}
50
+ hasChildren={false}
51
+ icon={
52
+ <Image alt="" className="object-contain" height={14} src="/icons/elevator.png" width={14} />
53
+ }
54
+ isHovered={isHovered}
55
+ isLast={isLast}
56
+ isSelected={isSelected}
57
+ isVisible={isVisible}
58
+ label={
59
+ <InlineRenameInput
60
+ defaultName={node?.name || 'Elevator'}
61
+ isEditing={isEditing}
62
+ nodeId={nodeId}
63
+ onStartEditing={() => setIsEditing(true)}
64
+ onStopEditing={() => setIsEditing(false)}
65
+ />
66
+ }
67
+ nodeId={nodeId}
68
+ onClick={handleClick}
69
+ onDoubleClick={() => focusTreeNode(nodeId)}
70
+ onMouseEnter={() => setHoveredId(nodeId)}
71
+ onMouseLeave={() => setHoveredId(null)}
72
+ onToggle={() => {}}
73
+ />
74
+ )
75
+ })