@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
@@ -0,0 +1,469 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeId,
6
+ type IconRef,
7
+ nodeRegistry,
8
+ type ParamAction,
9
+ type ParamField,
10
+ useScene,
11
+ type ZoneNode,
12
+ } from '@pascal-app/core'
13
+ import { useViewer } from '@pascal-app/viewer'
14
+ import { Icon } from '@iconify/react'
15
+ import { Move, Trash2 } from 'lucide-react'
16
+ import { type ComponentType, lazy, Suspense, useCallback } from 'react'
17
+ import { sfxEmitter } from '../../../lib/sfx-bus'
18
+ import { collectZoneContentIds } from '../../../lib/zone-content'
19
+ import useEditor from '../../../store/use-editor'
20
+ import { ActionButton, ActionGroup } from '../controls/action-button'
21
+ import { PanelSection } from '../controls/panel-section'
22
+ import { SegmentedControl } from '../controls/segmented-control'
23
+ import { SliderControl } from '../controls/slider-control'
24
+ import { ToggleControl } from '../controls/toggle-control'
25
+ import { InspectorFooterContext, PanelWrapper } from './panel-wrapper'
26
+
27
+ /**
28
+ * Auto-derived right-panel inspector for any registry-backed node.
29
+ *
30
+ * Reads `definition.parametrics` from the registry and renders one
31
+ * `<PanelSection>` per group, one control per field. Field kinds supported:
32
+ * - `number` → SliderControl with min/max/step/unit from the descriptor
33
+ * - `enum` → dark-themed `<select>`
34
+ * - `color` → native color picker + hex input
35
+ * - `vec3` → three SliderControls for X / Y / Z
36
+ *
37
+ * Generic Actions section appends Move / Delete based on `capabilities`.
38
+ *
39
+ * Phase 4 will expand this with per-field `customEditor` support and a
40
+ * `parametrics.customPanel?` escape hatch for kinds whose parametric editor
41
+ * can't be auto-generated (topology editors etc.).
42
+ */
43
+ export function ParametricInspector({
44
+ footer,
45
+ nodeId,
46
+ onClose,
47
+ }: { footer?: React.ReactNode; nodeId?: AnyNodeId; onClose?: () => void } = {}) {
48
+ const selectedIdFromSelection = useViewer((s) => s.selection.selectedIds[0]) as
49
+ | AnyNodeId
50
+ | undefined
51
+ const selectedId = nodeId ?? selectedIdFromSelection
52
+ const setSelection = useViewer((s) => s.setSelection)
53
+ // Subscribe only to the *type* — a string primitive that doesn't change
54
+ // when slider values change. Without this, every updateNode tick during
55
+ // a drag re-renders the entire panel + every field + every SliderControl.
56
+ // Per-field subscriptions live on FieldRenderer below.
57
+ const nodeType = useScene((s) => (selectedId ? (s.nodes[selectedId]?.type ?? null) : null))
58
+
59
+ const def = nodeType ? nodeRegistry.get(nodeType) : undefined
60
+ const parametrics = def?.parametrics
61
+
62
+ const handleUpdate = useCallback(
63
+ (patch: Partial<AnyNode>) => {
64
+ if (!selectedId) return
65
+ useScene.getState().updateNode(selectedId, patch)
66
+ },
67
+ [selectedId],
68
+ )
69
+
70
+ const clearSelection = useCallback(() => {
71
+ if (onClose) {
72
+ onClose()
73
+ return
74
+ }
75
+ setSelection({ selectedIds: [] })
76
+ }, [onClose, setSelection])
77
+
78
+ const handleMove = useCallback(() => {
79
+ if (!selectedId) return
80
+ const node = useScene.getState().nodes[selectedId]
81
+ if (!node) return
82
+ sfxEmitter.emit('sfx:item-pick')
83
+ useEditor.getState().setMovingNode(node as any)
84
+ clearSelection()
85
+ }, [selectedId, clearSelection])
86
+
87
+ const handleDelete = useCallback(
88
+ (withZoneContent = false) => {
89
+ if (!selectedId) return
90
+ const scene = useScene.getState()
91
+ const node = scene.nodes[selectedId]
92
+ if (!node) return
93
+
94
+ const ids =
95
+ withZoneContent && node.type === 'zone'
96
+ ? [selectedId, ...collectZoneContentIds(scene.nodes, node as ZoneNode)]
97
+ : [selectedId]
98
+
99
+ sfxEmitter.emit('sfx:structure-delete')
100
+ scene.deleteNodes(Array.from(new Set(ids)))
101
+ clearSelection()
102
+ },
103
+ [selectedId, clearSelection],
104
+ )
105
+
106
+ if (!selectedId || !def || !parametrics) return null
107
+
108
+ // `parametrics.customPanel` escape hatch — kind owns its panel
109
+ // entirely (loaded lazily so the bundle isn't eager). Used by kinds
110
+ // whose editor has non-parametric concerns (slab holes list, ceiling
111
+ // height presets, etc.) until per-field `customEditor` + missing
112
+ // field kinds (list/action/computed) graduate the auto-derived
113
+ // panel to cover them.
114
+ if (parametrics.customPanel) {
115
+ const CustomPanel = resolveCustomPanel(parametrics.customPanel)
116
+ // Custom panels render their own `<PanelWrapper>` and don't thread a
117
+ // `footer` prop, so hand the host footer down via context.
118
+ return (
119
+ <InspectorFooterContext.Provider value={footer}>
120
+ <Suspense fallback={null}>
121
+ <CustomPanel />
122
+ </Suspense>
123
+ </InspectorFooterContext.Provider>
124
+ )
125
+ }
126
+
127
+ const presentation = def.presentation
128
+ const title = presentation?.label ?? nodeType ?? ''
129
+ const iconNode = renderIcon(presentation?.icon)
130
+ const canMove = !!def.capabilities.movable
131
+ const canDelete = def.capabilities.deletable !== false
132
+ const isZone = nodeType === 'zone'
133
+
134
+ const TrailingSection = parametrics.trailingSection
135
+ ? resolveCustomPanel(parametrics.trailingSection)
136
+ : null
137
+
138
+ return (
139
+ <PanelWrapper
140
+ footer={footer}
141
+ icon={iconNode}
142
+ onClose={clearSelection}
143
+ title={title}
144
+ width={320}
145
+ >
146
+ {parametrics.groups.map((group, gi) => (
147
+ <PanelSection key={`group-${gi}`} title={group.label}>
148
+ {group.fields.map((field, fi) => (
149
+ <FieldRenderer
150
+ key={`field-${gi}-${fi}-${String(field.key)}`}
151
+ field={field as ParamField<AnyNode>}
152
+ nodeId={selectedId}
153
+ onUpdate={handleUpdate}
154
+ />
155
+ ))}
156
+ </PanelSection>
157
+ ))}
158
+ {TrailingSection && (
159
+ <Suspense fallback={null}>
160
+ <TrailingSection />
161
+ </Suspense>
162
+ )}
163
+ {(canMove || canDelete || (parametrics.actions && parametrics.actions.length > 0)) && (
164
+ <PanelSection title="Actions">
165
+ <ActionGroup className={isZone ? 'flex-col' : undefined}>
166
+ {canMove && (
167
+ <ActionButton icon={<Move className="h-4 w-4" />} label="Move" onClick={handleMove} />
168
+ )}
169
+ {parametrics.actions?.map((action, i) => (
170
+ <ParamActionButton action={action} key={`paramaction-${i}`} nodeId={selectedId} />
171
+ ))}
172
+ {canDelete &&
173
+ (isZone ? (
174
+ <>
175
+ <ActionButton
176
+ className="w-full flex-none"
177
+ icon={<Trash2 className="h-4 w-4 text-red-400" />}
178
+ label="Delete"
179
+ onClick={() => handleDelete(false)}
180
+ />
181
+ <ActionButton
182
+ className="w-full flex-none"
183
+ icon={<Trash2 className="h-4 w-4 text-red-400" />}
184
+ label="Delete with contents"
185
+ onClick={() => handleDelete(true)}
186
+ />
187
+ </>
188
+ ) : (
189
+ <ActionButton
190
+ className="border-red-500/40 text-red-200 hover:bg-red-500/15"
191
+ icon={<Trash2 className="h-4 w-4" />}
192
+ label="Delete"
193
+ onClick={() => handleDelete()}
194
+ />
195
+ ))}
196
+ </ActionGroup>
197
+ </PanelSection>
198
+ )}
199
+ </PanelWrapper>
200
+ )
201
+ }
202
+
203
+ // One inspector action button. Subscribes to `enabledIf`'s boolean result
204
+ // (same pattern as FieldRenderer's `visible`) so the disabled state stays
205
+ // live as the scene mutates — `===` on the boolean keeps unrelated ticks
206
+ // from re-rendering it. The click handler re-reads the live node so the
207
+ // handler always acts on current state.
208
+ function ParamActionButton({ action, nodeId }: { action: ParamAction<AnyNode>; nodeId: AnyNodeId }) {
209
+ const disabled = useScene((s) => {
210
+ if (!action.enabledIf) return false
211
+ const n = s.nodes[nodeId]
212
+ return n ? !action.enabledIf(n as AnyNode) : false
213
+ })
214
+ return (
215
+ <ActionButton
216
+ className={disabled ? 'opacity-40 pointer-events-none' : ''}
217
+ icon={
218
+ action.iconSrc ? (
219
+ <img alt="" className="h-4 w-4 shrink-0 object-contain" src={action.iconSrc} />
220
+ ) : undefined
221
+ }
222
+ label={action.label}
223
+ onClick={() => {
224
+ const live = useScene.getState().nodes[nodeId]
225
+ if (live) action.onClick(live as AnyNode)
226
+ }}
227
+ />
228
+ )
229
+ }
230
+
231
+ function renderIcon(ref: IconRef | undefined): React.ReactNode | undefined {
232
+ if (!ref) return undefined
233
+ if (ref.kind === 'url') {
234
+ // Plain <img> here so the inspector doesn't pull in next/image's
235
+ // server-only requirements (the file is `'use client'`). Same
236
+ // 16x16 box the legacy panels use.
237
+ return <img alt="" className="h-4 w-4 shrink-0 object-contain" src={ref.src} />
238
+ }
239
+ if (ref.kind === 'iconify') {
240
+ return <Icon height={16} icon={ref.name} width={16} />
241
+ }
242
+ if (ref.kind === 'svg') {
243
+ return (
244
+ <svg height={16} viewBox={ref.viewBox} width={16}>
245
+ <path d={ref.path} fill="currentColor" />
246
+ </svg>
247
+ )
248
+ }
249
+ // `component`: lazy-loaded custom icon component. Suspense-safe.
250
+ const LazyIcon = lazy(ref.module)
251
+ return (
252
+ <Suspense fallback={null}>
253
+ <LazyIcon />
254
+ </Suspense>
255
+ )
256
+ }
257
+
258
+ // Cache lazy custom panel components by their loader so React.lazy isn't
259
+ // re-invoked across renders.
260
+ const customPanelCache = new WeakMap<() => Promise<unknown>, ComponentType>()
261
+
262
+ function resolveCustomPanel(loader: () => Promise<{ default: ComponentType<any> }>): ComponentType {
263
+ const cached = customPanelCache.get(loader)
264
+ if (cached) return cached
265
+ const Comp = lazy(loader)
266
+ customPanelCache.set(loader, Comp as ComponentType)
267
+ return Comp as ComponentType
268
+ }
269
+
270
+ // ─── Per-field renderers ─────────────────────────────────────────────
271
+
272
+ interface FieldRendererProps {
273
+ field: ParamField<AnyNode>
274
+ nodeId: AnyNodeId
275
+ onUpdate: (patch: Partial<AnyNode>) => void
276
+ }
277
+
278
+ function FieldRenderer({ field, nodeId, onUpdate }: FieldRendererProps) {
279
+ const key = String(field.key)
280
+ // Subscribe only to this field's value. Zustand compares with ===, so when
281
+ // another field on the same node changes (which produces a new node object
282
+ // reference), this primitive value stays equal and the field doesn't
283
+ // re-render. Vec3 arrays get a new reference only when the array itself
284
+ // changes — same outcome.
285
+ const value = useScene((s) => {
286
+ const n = s.nodes[nodeId]
287
+ return n ? (n as Record<string, unknown>)[key] : undefined
288
+ })
289
+ // visibleIf may consult other fields on the node — subscribe to its boolean
290
+ // result so we re-evaluate when relevant.
291
+ const visible = useScene((s) => {
292
+ const visibleIf = (field as { visibleIf?: (n: AnyNode) => boolean }).visibleIf
293
+ if (!visibleIf) return true
294
+ const n = s.nodes[nodeId]
295
+ return n ? visibleIf(n as AnyNode) : false
296
+ })
297
+ if (!visible) return null
298
+
299
+ switch (field.kind) {
300
+ case 'number': {
301
+ const num = typeof value === 'number' ? value : 0
302
+ const step = field.step ?? 0.01
303
+ const precision = precisionForStep(step)
304
+ return (
305
+ <SliderControl
306
+ label={prettifyKey(key)}
307
+ max={field.max}
308
+ min={field.min}
309
+ onChange={(next) => onUpdate({ [key]: next } as Partial<AnyNode>)}
310
+ precision={precision}
311
+ step={step}
312
+ unit={field.unit ?? ''}
313
+ value={num}
314
+ />
315
+ )
316
+ }
317
+
318
+ case 'boolean': {
319
+ const checked = value === true
320
+ return (
321
+ <ToggleControl
322
+ checked={checked}
323
+ label={prettifyKey(key)}
324
+ onChange={(next) => onUpdate({ [key]: next } as Partial<AnyNode>)}
325
+ />
326
+ )
327
+ }
328
+
329
+ case 'enum': {
330
+ const str = typeof value === 'string' ? value : (field.options[0] ?? '')
331
+ if (field.display === 'segmented') {
332
+ return (
333
+ <SegmentedControl
334
+ onChange={(next) => onUpdate({ [key]: next } as Partial<AnyNode>)}
335
+ options={field.options.map((opt) => ({ label: prettifyEnumValue(opt), value: opt }))}
336
+ value={str}
337
+ />
338
+ )
339
+ }
340
+ return (
341
+ <div className="flex items-center justify-between px-3 py-2">
342
+ <span className="text-foreground/80 text-xs">{prettifyKey(key)}</span>
343
+ <select
344
+ className="rounded-md border border-border/50 bg-[#2C2C2E] px-2 py-1 text-foreground text-xs focus:outline-none focus:ring-1 focus:ring-foreground/30"
345
+ onChange={(e) => onUpdate({ [key]: e.target.value } as Partial<AnyNode>)}
346
+ value={str}
347
+ >
348
+ {field.options.map((opt) => (
349
+ <option key={opt} value={opt}>
350
+ {prettifyEnumValue(opt)}
351
+ </option>
352
+ ))}
353
+ </select>
354
+ </div>
355
+ )
356
+ }
357
+
358
+ case 'color': {
359
+ const str = typeof value === 'string' ? value : '#888888'
360
+ return (
361
+ <div className="flex items-center justify-between px-3 py-2">
362
+ <span className="text-foreground/80 text-xs">{prettifyKey(key)}</span>
363
+ <div className="flex items-center gap-2">
364
+ <input
365
+ className="h-6 w-8 cursor-pointer rounded border border-border/50 bg-transparent"
366
+ onChange={(e) => onUpdate({ [key]: e.target.value } as Partial<AnyNode>)}
367
+ type="color"
368
+ value={str}
369
+ />
370
+ <input
371
+ className="w-20 rounded-md border border-border/50 bg-[#2C2C2E] px-2 py-1 text-foreground text-xs focus:outline-none focus:ring-1 focus:ring-foreground/30"
372
+ onChange={(e) => onUpdate({ [key]: e.target.value } as Partial<AnyNode>)}
373
+ type="text"
374
+ value={str}
375
+ />
376
+ </div>
377
+ </div>
378
+ )
379
+ }
380
+
381
+ case 'vec3': {
382
+ const v = Array.isArray(value) && value.length >= 3
383
+ ? (value as [number, number, number])
384
+ : [0, 0, 0]
385
+ const axes: Array<{ label: string; index: 0 | 1 | 2 }> = [
386
+ { label: 'X', index: 0 },
387
+ { label: 'Y', index: 1 },
388
+ { label: 'Z', index: 2 },
389
+ ]
390
+ return (
391
+ <>
392
+ {axes.map(({ label, index }) => {
393
+ // v is a [number, number, number] tuple; the explicit local
394
+ // resolves TS's noUncheckedIndexedAccess concern that v[index]
395
+ // could be undefined.
396
+ const axisValue = v[index] ?? 0
397
+ return (
398
+ <SliderControl
399
+ key={`${key}-${label}`}
400
+ label={label}
401
+ max={axisValue + 5}
402
+ min={axisValue - 5}
403
+ onChange={(next) => {
404
+ const updated = [...v] as [number, number, number]
405
+ updated[index] = next
406
+ onUpdate({ [key]: updated } as Partial<AnyNode>)
407
+ }}
408
+ precision={2}
409
+ step={0.05}
410
+ unit="m"
411
+ value={Math.round(axisValue * 100) / 100}
412
+ />
413
+ )
414
+ })}
415
+ </>
416
+ )
417
+ }
418
+
419
+ case 'custom':
420
+ // The field owns its rendering and update logic — used for
421
+ // derived values (length from start/end), dynamic-bounded
422
+ // sliders (curve sagitta), composed editors.
423
+ return <CustomFieldRenderer Comp={field.component} nodeId={nodeId} onUpdate={onUpdate} />
424
+
425
+ default:
426
+ // material / ref / unrecognized kinds — not implemented in v1.
427
+ return null
428
+ }
429
+ }
430
+
431
+ function CustomFieldRenderer({
432
+ Comp,
433
+ nodeId,
434
+ onUpdate,
435
+ }: {
436
+ Comp: ComponentType<{ node: AnyNode; onUpdate: (patch: Partial<AnyNode>) => void }>
437
+ nodeId: AnyNodeId
438
+ onUpdate: (patch: Partial<AnyNode>) => void
439
+ }) {
440
+ // Subscribe to the full node — the custom editor may read any
441
+ // field. Tools that don't want this churn should write narrower
442
+ // selectors inside Comp itself.
443
+ const node = useScene((s) => s.nodes[nodeId])
444
+ if (!node) return null
445
+ return <Comp node={node} onUpdate={onUpdate} />
446
+ }
447
+
448
+ // ─── helpers ─────────────────────────────────────────────────────────
449
+
450
+ function precisionForStep(step: number): number {
451
+ if (step <= 0) return 0
452
+ return Math.max(0, Math.ceil(-Math.log10(step)))
453
+ }
454
+
455
+ function prettifyKey(key: string): string {
456
+ // 'bracketStyle' → 'Bracket style'
457
+ const spaced = key.replace(/([A-Z])/g, ' $1').toLowerCase()
458
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1)
459
+ }
460
+
461
+ function prettifyEnumValue(value: string): string {
462
+ // 'minimal' → 'Minimal'; 'roof-segment' → 'Roof segment'
463
+ return value
464
+ .split(/[-_\s]/)
465
+ .map((word, i) =>
466
+ i === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word.toLowerCase(),
467
+ )
468
+ .join(' ')
469
+ }
@@ -4,11 +4,21 @@ import {
4
4
  type AnyNode,
5
5
  type GuideNode,
6
6
  loadAssetUrl,
7
- saveAsset,
8
7
  type ScanNode,
8
+ saveAsset,
9
9
  useScene,
10
10
  } from '@pascal-app/core'
11
- import { Eye, EyeOff, LocateFixed, Lock, RotateCcw, Ruler, Trash2, Unlock, Upload } from 'lucide-react'
11
+ import {
12
+ Eye,
13
+ EyeOff,
14
+ LocateFixed,
15
+ Lock,
16
+ RotateCcw,
17
+ Ruler,
18
+ Trash2,
19
+ Unlock,
20
+ Upload,
21
+ } from 'lucide-react'
12
22
  import { useCallback, useEffect, useRef, useState } from 'react'
13
23
  import { guideEmitter } from '../../../lib/guide-events'
14
24
  import { getGuideImageName } from '../../../lib/local-guide-image'
@@ -32,7 +42,9 @@ function getScaleStatus(guide: GuideNode, scaleReferenceVisible: boolean) {
32
42
  export function ReferencePanel() {
33
43
  const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
34
44
  const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
35
- const guideUi = useEditor((s) => (selectedReferenceId ? s.guideUi[selectedReferenceId] : undefined))
45
+ const guideUi = useEditor((s) =>
46
+ selectedReferenceId ? s.guideUi[selectedReferenceId] : undefined,
47
+ )
36
48
  const setGuideLocked = useEditor((s) => s.setGuideLocked)
37
49
  const setGuideScaleReferenceVisible = useEditor((s) => s.setGuideScaleReferenceVisible)
38
50
  const clearGuideUi = useEditor((s) => s.clearGuideUi)
@@ -77,11 +89,14 @@ export function ReferencePanel() {
77
89
 
78
90
  try {
79
91
  const assetUrl = await saveAsset(file)
80
- updateNode(selectedReferenceId as AnyNode['id'], {
81
- name: getGuideImageName(file.name),
82
- url: assetUrl,
83
- scaleReference: null,
84
- } as Partial<GuideNode>)
92
+ updateNode(
93
+ selectedReferenceId as AnyNode['id'],
94
+ {
95
+ name: getGuideImageName(file.name),
96
+ url: assetUrl,
97
+ scaleReference: null,
98
+ } as Partial<GuideNode>,
99
+ )
85
100
  setGuideScaleReferenceVisible(selectedReferenceId, true)
86
101
  } catch {
87
102
  setReplaceError('Could not replace that image.')
@@ -138,7 +153,7 @@ export function ReferencePanel() {
138
153
  const isScan = node.type === 'scan'
139
154
  const guideLocked = !isScan && guideUi?.locked === true
140
155
  const scaleReferenceVisible = !isScan && guideUi?.scaleReferenceVisible !== false
141
- const scaleStatus = !isScan ? getScaleStatus(node, scaleReferenceVisible) : null
156
+ const scaleStatus = isScan ? null : getScaleStatus(node, scaleReferenceVisible)
142
157
 
143
158
  return (
144
159
  <PanelWrapper
@@ -165,16 +180,16 @@ export function ReferencePanel() {
165
180
 
166
181
  <ActionGroup>
167
182
  <ActionButton
183
+ disabled={isReplacing}
168
184
  icon={<Upload className="h-3.5 w-3.5" />}
169
185
  label={isReplacing ? 'Replacing...' : 'Replace'}
170
186
  onClick={() => replaceInputRef.current?.click()}
171
- disabled={isReplacing}
172
187
  />
173
188
  <ActionButton
189
+ className="text-destructive hover:bg-destructive/10"
174
190
  icon={<Trash2 className="h-3.5 w-3.5" />}
175
191
  label="Delete"
176
192
  onClick={handleDeleteGuide}
177
- className="text-destructive hover:bg-destructive/10"
178
193
  />
179
194
  </ActionGroup>
180
195
 
@@ -232,16 +247,16 @@ export function ReferencePanel() {
232
247
 
233
248
  <ActionGroup>
234
249
  <ActionButton
235
- label={scaleReferenceVisible ? 'Hide Scale' : 'Show Scale'}
236
250
  disabled={!node.scaleReference}
251
+ label={scaleReferenceVisible ? 'Hide Scale' : 'Show Scale'}
237
252
  onClick={() => {
238
253
  if (!node.scaleReference) return
239
254
  setGuideScaleReferenceVisible(node.id, !scaleReferenceVisible)
240
255
  }}
241
256
  />
242
257
  <ActionButton
243
- label="Clear Scale"
244
258
  disabled={!node.scaleReference}
259
+ label="Clear Scale"
245
260
  onClick={() => handleUpdate({ scaleReference: null } as Partial<GuideNode>)}
246
261
  />
247
262
  </ActionGroup>
@@ -197,15 +197,19 @@ function DropdownMenuSubContent({
197
197
  className,
198
198
  ...props
199
199
  }: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
200
+ // Portalled so the side-opening submenu escapes the parent content's
201
+ // `overflow-x-hidden` clip (otherwise it renders but is invisible).
200
202
  return (
201
- <DropdownMenuPrimitive.SubContent
202
- className={cn(
203
- 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=closed]:animate-out data-[state=open]:animate-in',
204
- className,
205
- )}
206
- data-slot="dropdown-menu-sub-content"
207
- {...props}
208
- />
203
+ <DropdownMenuPrimitive.Portal>
204
+ <DropdownMenuPrimitive.SubContent
205
+ className={cn(
206
+ 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=closed]:animate-out data-[state=open]:animate-in',
207
+ className,
208
+ )}
209
+ data-slot="dropdown-menu-sub-content"
210
+ {...props}
211
+ />
212
+ </DropdownMenuPrimitive.Portal>
209
213
  )
210
214
  }
211
215
 
@@ -144,7 +144,7 @@ export function NumberInput({
144
144
  }}
145
145
  />
146
146
  <div
147
- className={`relative z-10 flex items-center overflow-hidden rounded-lg border shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] transition-all focus-within:border-primary focus-within:ring-1 focus-within:ring-primary ${isDragging ? 'border-neutral-300 bg-transparent ring-1 ring-neutral-200/60 dark:border-border dark:ring-border/50' : 'border-neutral-200/60 bg-white hover:border-neutral-300 dark:border-border/50 dark:bg-accent/30 dark:hover:border-border/80'}`}
147
+ className={`relative z-10 flex items-center overflow-hidden rounded-lg border shadow-elevation-0 transition-all focus-within:border-primary focus-within:ring-1 focus-within:ring-primary ${isDragging ? 'border-neutral-300 bg-transparent ring-1 ring-neutral-200/60 dark:border-border dark:ring-border/50' : 'border-neutral-200/60 bg-white hover:border-neutral-300 dark:border-border/50 dark:bg-accent/30 dark:hover:border-border/80'}`}
148
148
  >
149
149
  <div
150
150
  className={`z-10 select-none truncate py-1.5 pr-1 pl-2 font-barlow font-medium text-muted-foreground text-xs ${
File without changes
@@ -17,15 +17,13 @@ interface SceneLoaderProps {
17
17
  }
18
18
 
19
19
  export function SceneLoader({ className, fullScreen = false }: SceneLoaderProps) {
20
- const [loaderClass, setLoaderClass] = useState<string | null>(null)
20
+ const [loaderClass, setLoaderClass] = useState(LOADERS[0]!)
21
21
 
22
22
  useEffect(() => {
23
23
  // Pick a random loader on mount
24
24
  setLoaderClass(LOADERS[Math.floor(Math.random() * LOADERS.length)] ?? LOADERS[0]!)
25
25
  }, [])
26
26
 
27
- if (!loaderClass) return null
28
-
29
27
  return (
30
28
  <div
31
29
  className={cn(
File without changes
File without changes