@pascal-app/editor 0.8.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 (223) hide show
  1. package/package.json +8 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +754 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +58 -51
  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 +201 -83
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +8 -8
  10. package/src/components/editor/first-person-controls.tsx +825 -20
  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 +5829 -12005
  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 +178 -149
  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 +433 -156
  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 +136 -62
  28. package/src/components/editor/thumbnail-generator.tsx +9 -3
  29. package/src/components/editor/use-floorplan-background-placement.ts +119 -32
  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-geometry-renderer.tsx +266 -0
  45. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  46. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  47. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  48. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  49. package/src/components/editor-2d/svg-paths.ts +10 -2
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +26 -3
  51. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  52. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  53. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  54. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  55. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  56. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  57. package/src/components/tools/fence/fence-drafting.ts +64 -22
  58. package/src/components/tools/item/move-tool.tsx +41 -105
  59. package/src/components/tools/item/placement-math.test.ts +10 -0
  60. package/src/components/tools/item/placement-math.ts +31 -5
  61. package/src/components/tools/item/placement-strategies.ts +190 -15
  62. package/src/components/tools/item/placement-types.ts +10 -1
  63. package/src/components/tools/item/use-draft-node.ts +20 -1
  64. package/src/components/tools/item/use-placement-coordinator.tsx +694 -191
  65. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  66. package/src/components/tools/roof/roof-tool.tsx +108 -12
  67. package/src/components/tools/select/box-select-state.ts +77 -0
  68. package/src/components/tools/select/box-select-tool.tsx +324 -538
  69. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  70. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  71. package/src/components/tools/select/select-candidates.test.ts +109 -0
  72. package/src/components/tools/select/select-candidates.ts +85 -0
  73. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  74. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  75. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  76. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  77. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  78. package/src/components/tools/shared/placement-box.tsx +123 -0
  79. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  80. package/src/components/tools/shared/segment-angle.ts +56 -0
  81. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  82. package/src/components/tools/site/site-flag-model.ts +18 -0
  83. package/src/components/tools/stair/stair-defaults.ts +1 -1
  84. package/src/components/tools/stair/stair-tool.tsx +269 -35
  85. package/src/components/tools/tool-manager.tsx +172 -56
  86. package/src/components/tools/wall/wall-drafting.ts +98 -98
  87. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  88. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  89. package/src/components/tools/zone/zone-tool.tsx +16 -6
  90. package/src/components/ui/action-menu/action-button.tsx +21 -1
  91. package/src/components/ui/action-menu/control-modes.tsx +5 -82
  92. package/src/components/ui/action-menu/index.tsx +1 -113
  93. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  94. package/src/components/ui/action-menu/view-toggles.tsx +243 -6
  95. package/src/components/ui/command-palette/editor-commands.tsx +18 -13
  96. package/src/components/ui/command-palette/index.tsx +2 -1
  97. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  98. package/src/components/ui/controls/material-picker.tsx +11 -32
  99. package/src/components/ui/controls/metric-control.tsx +33 -11
  100. package/src/components/ui/floating-level-selector.tsx +47 -10
  101. package/src/components/ui/helpers/helper-manager.tsx +16 -17
  102. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  103. package/src/components/ui/item-catalog/catalog-items.tsx +1 -0
  104. package/src/components/ui/item-catalog/item-catalog.tsx +3 -0
  105. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  106. package/src/components/ui/panels/node-display.ts +1 -0
  107. package/src/components/ui/panels/panel-manager.tsx +35 -47
  108. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  109. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  110. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  111. package/src/components/ui/scene-loader.tsx +1 -3
  112. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  113. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  114. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  115. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  116. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  117. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  118. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  119. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  120. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  121. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  122. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  123. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  124. package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
  125. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  126. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  127. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  128. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  129. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  132. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -36
  133. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  134. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  135. package/src/components/viewer-overlay.tsx +169 -52
  136. package/src/components/viewer-zone-system.tsx +1 -1
  137. package/src/hooks/use-auto-save.ts +10 -3
  138. package/src/hooks/use-ceiling-events.ts +175 -0
  139. package/src/hooks/use-drag-action.ts +124 -0
  140. package/src/hooks/use-keyboard.ts +115 -62
  141. package/src/hooks/use-selection.ts +64 -0
  142. package/src/index.tsx +237 -5
  143. package/src/lib/constants.ts +6 -2
  144. package/src/lib/editor-api.ts +62 -0
  145. package/src/lib/elevator-support.ts +96 -0
  146. package/src/lib/floorplan/apply-alignment.ts +111 -0
  147. package/src/lib/floorplan/index.ts +7 -0
  148. package/src/lib/floorplan/selection-tool.ts +40 -0
  149. package/src/lib/floorplan/stairs.ts +12 -1
  150. package/src/lib/fresh-planar-placement.test.ts +102 -0
  151. package/src/lib/fresh-planar-placement.ts +63 -0
  152. package/src/lib/history.ts +2 -1
  153. package/src/lib/level-duplication.ts +54 -42
  154. package/src/lib/level-name.ts +11 -0
  155. package/src/lib/material-paint.ts +211 -19
  156. package/src/lib/placement-metadata.ts +29 -0
  157. package/src/lib/planar-cursor-placement.test.ts +43 -0
  158. package/src/lib/planar-cursor-placement.ts +42 -0
  159. package/src/lib/roof-duplication.ts +1 -1
  160. package/src/lib/scene-clipboard.ts +267 -0
  161. package/src/lib/scene.ts +13 -4
  162. package/src/lib/sfx-bus.ts +23 -1
  163. package/src/lib/sfx-player.ts +70 -22
  164. package/src/lib/site-boundary.ts +1 -0
  165. package/src/lib/stair-levels.test.ts +146 -0
  166. package/src/lib/stair-levels.ts +177 -0
  167. package/src/lib/world-grid-snap.ts +199 -0
  168. package/src/lib/zone-content.ts +130 -0
  169. package/src/store/use-alignment-guides.ts +21 -0
  170. package/src/store/use-editor.tsx +320 -37
  171. package/src/store/use-placement-preview.ts +31 -0
  172. package/src/store/use-wall-move-ghosts.ts +36 -0
  173. package/src/store/use-wall-snap-indicator.ts +32 -0
  174. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  175. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  176. package/src/components/tools/building/move-building-tool.tsx +0 -157
  177. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  178. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  179. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  180. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  181. package/src/components/tools/column/column-tool.tsx +0 -97
  182. package/src/components/tools/column/move-column-tool.tsx +0 -105
  183. package/src/components/tools/door/door-math.ts +0 -110
  184. package/src/components/tools/door/door-tool.tsx +0 -324
  185. package/src/components/tools/door/move-door-tool.tsx +0 -412
  186. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  187. package/src/components/tools/fence/fence-tool.tsx +0 -346
  188. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  189. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  190. package/src/components/tools/item/item-tool.tsx +0 -26
  191. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  192. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  193. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  194. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  195. package/src/components/tools/slab/slab-tool.tsx +0 -322
  196. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  197. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  198. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  199. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  200. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  201. package/src/components/tools/wall/wall-tool.tsx +0 -332
  202. package/src/components/tools/window/move-window-tool.tsx +0 -447
  203. package/src/components/tools/window/window-math.ts +0 -117
  204. package/src/components/tools/window/window-tool.tsx +0 -332
  205. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  206. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  207. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  208. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  209. package/src/components/ui/panels/column-panel.tsx +0 -759
  210. package/src/components/ui/panels/door-panel.tsx +0 -1299
  211. package/src/components/ui/panels/fence-panel.tsx +0 -222
  212. package/src/components/ui/panels/item-panel.tsx +0 -306
  213. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  214. package/src/components/ui/panels/roof-panel.tsx +0 -280
  215. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  216. package/src/components/ui/panels/slab-panel.tsx +0 -250
  217. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  218. package/src/components/ui/panels/stair-panel.tsx +0 -577
  219. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  220. package/src/components/ui/panels/wall-panel.tsx +0 -273
  221. package/src/components/ui/panels/window-panel.tsx +0 -968
  222. package/src/contexts/presets-context.tsx +0 -121
  223. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -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
+ })
@@ -38,7 +38,7 @@ export const FenceTreeNode = memo(function FenceTreeNode({
38
38
 
39
39
  return (
40
40
  <TreeNodeWrapper
41
- actions={<TreeNodeActions node={node} />}
41
+ actions={<TreeNodeActions nodeId={node.id} />}
42
42
  depth={depth}
43
43
  expanded={false}
44
44
  hasChildren={false}
@@ -53,7 +53,7 @@ export const FenceTreeNode = memo(function FenceTreeNode({
53
53
  <InlineRenameInput
54
54
  defaultName="Fence"
55
55
  isEditing={isEditing}
56
- node={node}
56
+ nodeId={node.id}
57
57
  onStartEditing={() => setIsEditing(true)}
58
58
  onStopEditing={() => setIsEditing(false)}
59
59
  />
@@ -0,0 +1,83 @@
1
+ import { type AnyNodeId, type GutterNode, 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 GutterTreeNodeProps {
11
+ nodeId: AnyNodeId
12
+ depth: number
13
+ isLast?: boolean
14
+ }
15
+
16
+ export const GutterTreeNode = memo(function GutterTreeNode({
17
+ nodeId,
18
+ depth,
19
+ isLast,
20
+ }: GutterTreeNodeProps) {
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 GutterNode | 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 || 'Gutter'
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
+ })
@@ -36,6 +36,7 @@ import {
36
36
  buildLevelDuplicateCreateOps,
37
37
  type LevelDuplicatePreset,
38
38
  } from './../../../../../lib/level-duplication'
39
+ import { getDefaultLevelName } from './../../../../../lib/level-name'
39
40
  import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection'
40
41
  import { createLocalGuideImage } from './../../../../../lib/local-guide-image'
41
42
  import { cn } from './../../../../../lib/utils'
@@ -723,7 +724,7 @@ const LevelItem = memo(function LevelItem({
723
724
  src="/icons/level.png"
724
725
  />
725
726
  <InlineRenameInput
726
- defaultName={`Level ${level.level}`}
727
+ defaultName={getDefaultLevelName(level.level)}
727
728
  isEditing={isEditing}
728
729
  nodeId={level.id}
729
730
  onStartEditing={() => setIsEditing(true)}
@@ -1541,10 +1542,7 @@ export function SitePanel({ projectId, onUploadAsset, onDeleteAsset }: SitePanel
1541
1542
  useShallow((s) => {
1542
1543
  if (!siteNode) return []
1543
1544
  return siteNode.children
1544
- .map((child) => {
1545
- const id = typeof child === 'string' ? child : child.id
1546
- return s.nodes[id] as BuildingNode | undefined
1547
- })
1545
+ .map((childId) => s.nodes[childId as AnyNodeId] as BuildingNode | undefined)
1548
1546
  .filter((node): node is BuildingNode => node?.type === 'building')
1549
1547
  }),
1550
1548
  )
@@ -1571,7 +1569,7 @@ export function SitePanel({ projectId, onUploadAsset, onDeleteAsset }: SitePanel
1571
1569
  'h-5 w-5 object-contain transition-all',
1572
1570
  phase !== 'site' && 'opacity-60 grayscale',
1573
1571
  )}
1574
- src="/icons/site.png"
1572
+ src="/icons/site-flag.png"
1575
1573
  />
1576
1574
  <span className="font-medium text-sm">{siteNode.name || 'Site'}</span>
1577
1575
  </div>