@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,1292 +0,0 @@
1
- 'use client'
2
-
3
- import {
4
- type AnyNode,
5
- type AnyNodeId,
6
- DoorNode,
7
- emitter,
8
- useInteractive,
9
- useScene,
10
- } from '@pascal-app/core'
11
- import { useViewer } from '@pascal-app/viewer'
12
- import { BookMarked, Copy, DoorOpen, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
13
- import { useCallback, useRef } from 'react'
14
- import { usePresetsAdapter } from '../../../contexts/presets-context'
15
- import { cn } from '../../../lib/utils'
16
- import { sfxEmitter } from '../../../lib/sfx-bus'
17
- import useEditor from '../../../store/use-editor'
18
- import { ActionButton, ActionGroup } from '../controls/action-button'
19
- import { MetricControl } from '../controls/metric-control'
20
- import { PanelSection } from '../controls/panel-section'
21
- import { SegmentedControl } from '../controls/segmented-control'
22
- import { SliderControl } from '../controls/slider-control'
23
- import { ToggleControl } from '../controls/toggle-control'
24
- import { PanelWrapper } from './panel-wrapper'
25
- import { PresetsPopover } from './presets/presets-popover'
26
-
27
- const doorTypeOptions = [
28
- { label: 'Hinged', value: 'hinged', available: true },
29
- { label: 'Double', value: 'double', available: true },
30
- { label: 'French', value: 'french', available: true },
31
- { label: 'Folding', value: 'folding', available: true },
32
- { label: 'Pocket', value: 'pocket', available: true },
33
- { label: 'Barn', value: 'barn', available: true },
34
- { label: 'Sliding', value: 'sliding', available: true },
35
- ] satisfies {
36
- label: string
37
- value: DoorNode['doorType']
38
- available: boolean
39
- }[]
40
-
41
- const garageDoorTypeOptions = [
42
- { label: 'Sectional', value: 'garage-sectional', available: true },
43
- { label: 'Roll-up', value: 'garage-rollup', available: true },
44
- { label: 'Tilt-up', value: 'garage-tiltup', available: true },
45
- ] satisfies {
46
- label: string
47
- value: DoorNode['doorType']
48
- available: boolean
49
- }[]
50
-
51
- const frenchDoorSegments: DoorNode['segments'] = [
52
- {
53
- type: 'glass',
54
- heightRatio: 0.76,
55
- columnRatios: [1, 1],
56
- dividerThickness: 0.025,
57
- panelDepth: 0.01,
58
- panelInset: 0.04,
59
- },
60
- {
61
- type: 'panel',
62
- heightRatio: 0.24,
63
- columnRatios: [1],
64
- dividerThickness: 0.03,
65
- panelDepth: 0.012,
66
- panelInset: 0.035,
67
- },
68
- ]
69
-
70
- const foldingDoorSegments: DoorNode['segments'] = [
71
- {
72
- type: 'panel',
73
- heightRatio: 1,
74
- columnRatios: [1],
75
- dividerThickness: 0.02,
76
- panelDepth: 0.008,
77
- panelInset: 0.025,
78
- },
79
- ]
80
-
81
- const defaultDoorDimensions: Record<DoorNode['doorType'], { width: number; height: number }> = {
82
- hinged: { width: 0.9, height: 2.1 },
83
- double: { width: 1.5, height: 2.1 },
84
- french: { width: 1.5, height: 2.1 },
85
- folding: { width: 1.8, height: 2.1 },
86
- pocket: { width: 0.9, height: 2.1 },
87
- barn: { width: 1, height: 2.1 },
88
- sliding: { width: 1.5, height: 2.1 },
89
- 'garage-sectional': { width: 2.7, height: 2.4 },
90
- 'garage-rollup': { width: 2.7, height: 2.4 },
91
- 'garage-tiltup': { width: 2.7, height: 2.4 },
92
- }
93
-
94
- function isSameDoorValue(current: unknown, next: unknown): boolean {
95
- if (typeof current === 'number' && typeof next === 'number') {
96
- return Math.abs(current - next) < 1e-6
97
- }
98
-
99
- if (Array.isArray(current) && Array.isArray(next)) {
100
- return (
101
- current.length === next.length &&
102
- current.every((value, index) => isSameDoorValue(value, next[index]))
103
- )
104
- }
105
-
106
- return Object.is(current, next)
107
- }
108
-
109
- export function DoorPanel() {
110
- const selectedId = useViewer((s) => s.selection.selectedIds[0])
111
- const setSelection = useViewer((s) => s.setSelection)
112
- const updateNode = useScene((s) => s.updateNode)
113
- const deleteNode = useScene((s) => s.deleteNode)
114
- const setMovingNode = useEditor((s) => s.setMovingNode)
115
- const previewRef = useRef<{
116
- id: AnyNodeId
117
- key: keyof DoorNode
118
- value: unknown
119
- } | null>(null)
120
-
121
- const adapter = usePresetsAdapter()
122
-
123
- const node = useScene((s) =>
124
- selectedId ? (s.nodes[selectedId as AnyNode['id']] as DoorNode | undefined) : undefined,
125
- )
126
-
127
- const handleUpdate = useCallback(
128
- (updates: Partial<DoorNode>) => {
129
- if (!(selectedId && node)) return
130
- const hasChange = Object.entries(updates).some(([key, value]) => {
131
- const currentValue = node[key as keyof DoorNode]
132
- return !isSameDoorValue(currentValue, value)
133
- })
134
- if (!hasChange) return
135
-
136
- if ('operationState' in updates || 'swingAngle' in updates || 'doorType' in updates) {
137
- useInteractive.getState().removeDoorOpenState(selectedId as AnyNodeId)
138
- }
139
- updateNode(selectedId as AnyNode['id'], updates)
140
- useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
141
- },
142
- [selectedId, node, updateNode],
143
- )
144
-
145
- const previewDoorUpdate = useCallback(
146
- <K extends keyof DoorNode>(key: K, value: DoorNode[K]) => {
147
- if (!selectedId) return
148
- const liveNode = useScene.getState().nodes[selectedId as AnyNodeId]
149
- if (liveNode?.type !== 'door') return
150
-
151
- if (!(previewRef.current && previewRef.current.id === selectedId && previewRef.current.key === key)) {
152
- previewRef.current = {
153
- id: selectedId as AnyNodeId,
154
- key,
155
- value: liveNode[key],
156
- }
157
- }
158
-
159
- if (isSameDoorValue(liveNode[key], value)) return
160
-
161
- ;(liveNode as DoorNode)[key] = value
162
- useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
163
- },
164
- [selectedId],
165
- )
166
-
167
- const commitDoorPreview = useCallback(
168
- <K extends keyof DoorNode>(key: K, value: DoorNode[K]) => {
169
- if (!selectedId) return
170
-
171
- const scene = useScene.getState()
172
- const liveNode = scene.nodes[selectedId as AnyNodeId]
173
- const preview = previewRef.current
174
- if (liveNode?.type === 'door' && preview?.id === selectedId && preview.key === key) {
175
- ;(liveNode as DoorNode)[key] = preview.value as DoorNode[K]
176
- scene.dirtyNodes.add(selectedId as AnyNodeId)
177
- }
178
- previewRef.current = null
179
-
180
- updateNode(selectedId as AnyNode['id'], { [key]: value } as Partial<DoorNode>)
181
- scene.dirtyNodes.add(selectedId as AnyNodeId)
182
- },
183
- [selectedId, updateNode],
184
- )
185
-
186
- const handleClose = useCallback(() => {
187
- setSelection({ selectedIds: [] })
188
- }, [setSelection])
189
-
190
- const handleFlip = useCallback(() => {
191
- if (!node) return
192
- handleUpdate({
193
- side: node.side === 'front' ? 'back' : 'front',
194
- rotation: [node.rotation[0], node.rotation[1] + Math.PI, node.rotation[2]],
195
- })
196
- }, [node, handleUpdate])
197
-
198
- const handleMove = useCallback(() => {
199
- if (!node) return
200
- sfxEmitter.emit('sfx:item-pick')
201
- setMovingNode(node)
202
- setSelection({ selectedIds: [] })
203
- }, [node, setMovingNode, setSelection])
204
-
205
- const handleDelete = useCallback(() => {
206
- if (!(selectedId && node)) return
207
- sfxEmitter.emit('sfx:item-delete')
208
- deleteNode(selectedId as AnyNode['id'])
209
- if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
210
- setSelection({ selectedIds: [] })
211
- }, [selectedId, node, deleteNode, setSelection])
212
-
213
- const handleDuplicate = useCallback(() => {
214
- if (!node?.parentId) return
215
- sfxEmitter.emit('sfx:item-pick')
216
- useScene.temporal.getState().pause()
217
- const cloned = structuredClone(node) as any
218
- delete cloned.id
219
- cloned.metadata = { ...cloned.metadata, isNew: true }
220
- const duplicate = DoorNode.parse(cloned)
221
- useScene.getState().createNode(duplicate, node.parentId as AnyNodeId)
222
- setMovingNode(duplicate)
223
- setSelection({ selectedIds: [] })
224
- }, [node, setMovingNode, setSelection])
225
-
226
- const setSegmentHeightRatio = (segIdx: number, newVal: number) => {
227
- if (!node) return
228
- const numSegs = node.segments.length
229
- const totalH = node.segments.reduce((sum, s) => sum + s.heightRatio, 0)
230
- const normH = node.segments.map((s) => s.heightRatio / totalH)
231
- const clamped = Math.max(0.05, Math.min(0.95, newVal))
232
- const neighborIdx = segIdx < numSegs - 1 ? segIdx + 1 : segIdx - 1
233
- const delta = clamped - normH[segIdx]!
234
- const neighborVal = Math.max(0.05, normH[neighborIdx]! - delta)
235
- const newRatios = normH.map((v, i) => {
236
- if (i === segIdx) return clamped
237
- if (i === neighborIdx) return neighborVal
238
- return v
239
- })
240
- const updated = node.segments.map((s, idx) => ({ ...s, heightRatio: newRatios[idx]! }))
241
- handleUpdate({ segments: updated })
242
- }
243
-
244
- const setSegmentColumnRatio = (segIdx: number, colIdx: number, newVal: number) => {
245
- const seg = node?.segments[segIdx]
246
- if (!seg) return
247
- const normRatios = (() => {
248
- const sum = seg.columnRatios.reduce((a, b) => a + b, 0)
249
- return seg.columnRatios.map((r) => r / sum)
250
- })()
251
- const numCols = normRatios.length
252
- const clamped = Math.max(0.05, Math.min(0.95, newVal))
253
- const neighborIdx = colIdx < numCols - 1 ? colIdx + 1 : colIdx - 1
254
- const delta = clamped - normRatios[colIdx]!
255
- const neighborVal = Math.max(0.05, normRatios[neighborIdx]! - delta)
256
- const newRatios = normRatios.map((v, i) => {
257
- if (i === colIdx) return clamped
258
- if (i === neighborIdx) return neighborVal
259
- return v
260
- })
261
- const updated = node?.segments.map((s, idx) =>
262
- idx === segIdx ? { ...s, columnRatios: newRatios } : s,
263
- )
264
- handleUpdate({ segments: updated })
265
- }
266
-
267
- const getDoorPresetData = useCallback(() => {
268
- if (!node) return null
269
- return {
270
- doorCategory: node.doorCategory,
271
- doorType: node.doorType,
272
- leafCount: node.leafCount,
273
- operationState: node.operationState,
274
- slideDirection: node.slideDirection,
275
- trackStyle: node.trackStyle,
276
- garagePanelCount: node.garagePanelCount,
277
- width: node.width,
278
- height: node.height,
279
- frameThickness: node.frameThickness,
280
- frameDepth: node.frameDepth,
281
- openingKind: node.openingKind,
282
- openingShape: node.openingShape,
283
- openingRadiusMode: node.openingRadiusMode ?? 'all',
284
- openingTopRadii: node.openingTopRadii ?? [0.15, 0.15],
285
- cornerRadius: node.cornerRadius,
286
- archHeight: node.archHeight,
287
- openingRevealRadius: node.openingRevealRadius,
288
- contentPadding: node.contentPadding,
289
- hingesSide: node.hingesSide,
290
- swingDirection: node.swingDirection,
291
- threshold: node.threshold,
292
- thresholdHeight: node.thresholdHeight,
293
- handle: node.handle,
294
- handleHeight: node.handleHeight,
295
- handleSide: node.handleSide,
296
- doorCloser: node.doorCloser,
297
- panicBar: node.panicBar,
298
- panicBarHeight: node.panicBarHeight,
299
- segments: node.segments,
300
- }
301
- }, [node])
302
-
303
- const handleSavePreset = useCallback(
304
- async (name: string) => {
305
- const data = getDoorPresetData()
306
- if (!(data && selectedId)) return
307
- const presetId = await adapter.savePreset('door', name, data)
308
- if (presetId) emitter.emit('preset:generate-thumbnail', { presetId, nodeId: selectedId })
309
- },
310
- [getDoorPresetData, selectedId, adapter],
311
- )
312
-
313
- const handleOverwritePreset = useCallback(
314
- async (id: string) => {
315
- const data = getDoorPresetData()
316
- if (!(data && selectedId)) return
317
- await adapter.overwritePreset('door', id, data)
318
- emitter.emit('preset:generate-thumbnail', { presetId: id, nodeId: selectedId })
319
- },
320
- [getDoorPresetData, selectedId, adapter],
321
- )
322
-
323
- const handleApplyPreset = useCallback(
324
- (data: Record<string, unknown>) => {
325
- handleUpdate(data as Partial<DoorNode>)
326
- },
327
- [handleUpdate],
328
- )
329
-
330
- if (!(node && node.type === 'door' && selectedId)) return null
331
-
332
- const hSum = node.segments.reduce((s, seg) => s + seg.heightRatio, 0)
333
- const normHeights = node.segments.map((seg) => seg.heightRatio / hSum)
334
- const isOpening = node.openingKind === 'opening'
335
- const openingShape = node.openingShape ?? 'rectangle'
336
- const doorShape = openingShape === 'arch' || openingShape === 'rounded' ? openingShape : 'rectangle'
337
- const openingRadiusMode = node.openingRadiusMode ?? 'all'
338
- const openingTopRadii = node.openingTopRadii ?? [0.15, 0.15]
339
- const cornerRadius = node.cornerRadius ?? 0.15
340
- const archHeight = node.archHeight ?? 0.45
341
- const openingRevealRadius = node.openingRevealRadius ?? 0.025
342
- const maxRoundedRadius = Math.max(0.01, Math.min(node.width / 2, node.height))
343
- const doorType = node.doorType ?? 'hinged'
344
- const isSwingDoor = doorType === 'hinged' || doorType === 'double' || doorType === 'french'
345
- const isSlidingDoor = doorType === 'pocket' || doorType === 'barn' || doorType === 'sliding'
346
- const isGarageDoor = node.doorCategory === 'garage' || doorType.startsWith('garage-')
347
- const isSectionalGarageDoor = doorType === 'garage-sectional'
348
- const isRollupGarageDoor = doorType === 'garage-rollup'
349
- const isTiltupGarageDoor = doorType === 'garage-tiltup'
350
- const typeMode = isOpening ? 'opening' : isGarageDoor ? 'garage' : 'door'
351
- const supportsHandleSide = isSwingDoor
352
- const maxDoorWidth = isGarageDoor ? 6 : 3
353
-
354
- const setOpeningTopRadius = (index: number, value: number, commit = false) => {
355
- const next = [...openingTopRadii] as [number, number]
356
- next[index] = value
357
- if (commit) {
358
- commitDoorPreview('openingTopRadii', next)
359
- } else {
360
- previewDoorUpdate('openingTopRadii', next)
361
- }
362
- }
363
-
364
- const getDoorTypeUpdates = (nextDoorType: DoorNode['doorType']): Partial<DoorNode> => {
365
- const dimensions = defaultDoorDimensions[nextDoorType]
366
- const dimensionUpdates = {
367
- width: dimensions.width,
368
- height: dimensions.height,
369
- position: [node.position[0], dimensions.height / 2, node.position[2]] as DoorNode['position'],
370
- }
371
-
372
- if (nextDoorType === 'double' || nextDoorType === 'french') {
373
- return {
374
- doorCategory: 'interior',
375
- doorType: nextDoorType,
376
- leafCount: 2,
377
- ...dimensionUpdates,
378
- handleSide: 'right',
379
- ...(nextDoorType === 'french'
380
- ? {
381
- contentPadding: [0.045, 0.055],
382
- segments: frenchDoorSegments,
383
- }
384
- : {}),
385
- }
386
- }
387
-
388
- if (nextDoorType === 'folding') {
389
- return {
390
- doorCategory: 'interior',
391
- doorType: nextDoorType,
392
- leafCount: 4,
393
- ...dimensionUpdates,
394
- handle: true,
395
- handleSide: 'right',
396
- trackStyle: 'visible',
397
- operationState: Math.max(node.operationState ?? 0, 0.65),
398
- contentPadding: [0.03, 0.04],
399
- segments: foldingDoorSegments,
400
- }
401
- }
402
-
403
- if (nextDoorType === 'pocket') {
404
- return {
405
- doorCategory: 'interior',
406
- doorType: nextDoorType,
407
- leafCount: 1,
408
- ...dimensionUpdates,
409
- handle: true,
410
- handleSide: 'right',
411
- trackStyle: 'pocket',
412
- slideDirection: node.slideDirection ?? 'left',
413
- operationState: node.operationState ?? 0,
414
- contentPadding: [0.035, 0.045],
415
- segments: foldingDoorSegments,
416
- }
417
- }
418
-
419
- if (nextDoorType === 'barn') {
420
- return {
421
- doorCategory: 'interior',
422
- doorType: nextDoorType,
423
- leafCount: 1,
424
- ...dimensionUpdates,
425
- handle: true,
426
- handleSide: 'right',
427
- trackStyle: 'visible',
428
- slideDirection: node.slideDirection ?? 'left',
429
- operationState: node.operationState ?? 0,
430
- contentPadding: [0.035, 0.045],
431
- segments: foldingDoorSegments,
432
- }
433
- }
434
-
435
- if (nextDoorType === 'sliding') {
436
- return {
437
- doorCategory: 'interior',
438
- doorType: nextDoorType,
439
- leafCount: 2,
440
- ...dimensionUpdates,
441
- handle: true,
442
- handleSide: 'right',
443
- trackStyle: 'visible',
444
- slideDirection: node.slideDirection ?? 'left',
445
- operationState: node.operationState ?? 0,
446
- contentPadding: [0.03, 0.04],
447
- segments: frenchDoorSegments,
448
- }
449
- }
450
-
451
- if (nextDoorType === 'garage-sectional') {
452
- return {
453
- doorCategory: 'garage',
454
- doorType: nextDoorType,
455
- leafCount: 1,
456
- ...dimensionUpdates,
457
- handle: false,
458
- threshold: false,
459
- trackStyle: 'overhead',
460
- operationState: 0,
461
- garagePanelCount: Math.max(3, Math.min(8, node.garagePanelCount ?? 4)),
462
- contentPadding: [0.04, 0.04],
463
- segments: foldingDoorSegments,
464
- }
465
- }
466
-
467
- if (nextDoorType === 'garage-rollup') {
468
- return {
469
- doorCategory: 'garage',
470
- doorType: nextDoorType,
471
- leafCount: 1,
472
- ...dimensionUpdates,
473
- handle: false,
474
- threshold: false,
475
- trackStyle: 'overhead',
476
- operationState: 0,
477
- garagePanelCount: 4,
478
- contentPadding: [0.04, 0.04],
479
- segments: foldingDoorSegments,
480
- }
481
- }
482
-
483
- if (nextDoorType === 'garage-tiltup') {
484
- return {
485
- doorCategory: 'garage',
486
- doorType: nextDoorType,
487
- leafCount: 1,
488
- ...dimensionUpdates,
489
- handle: false,
490
- threshold: false,
491
- trackStyle: 'overhead',
492
- operationState: 0,
493
- garagePanelCount: 4,
494
- contentPadding: [0.04, 0.04],
495
- segments: foldingDoorSegments,
496
- }
497
- }
498
-
499
- return {
500
- doorCategory: 'interior',
501
- doorType: nextDoorType,
502
- leafCount: 1,
503
- ...dimensionUpdates,
504
- threshold: true,
505
- }
506
- }
507
-
508
- return (
509
- <PanelWrapper
510
- icon="/icons/door.png"
511
- onClose={handleClose}
512
- title={node.name || 'Door'}
513
- width={320}
514
- >
515
- {/* Presets strip */}
516
- <div className="border-border/30 border-b px-3 pt-2.5 pb-1.5">
517
- <PresetsPopover
518
- isAuthenticated={adapter.isAuthenticated}
519
- onApply={handleApplyPreset}
520
- onDelete={(id) => adapter.deletePreset(id)}
521
- onFetchPresets={(tab) => adapter.fetchPresets('door', tab)}
522
- onOverwrite={handleOverwritePreset}
523
- onRename={(id, name) => adapter.renamePreset(id, name)}
524
- onSave={handleSavePreset}
525
- onToggleCommunity={adapter.togglePresetCommunity}
526
- tabs={adapter.tabs}
527
- type="door"
528
- >
529
- <button className="flex w-full items-center gap-2 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 font-medium text-muted-foreground text-xs transition-colors hover:bg-[#3e3e3e] hover:text-foreground">
530
- <BookMarked className="h-3.5 w-3.5 shrink-0" />
531
- <span>Presets</span>
532
- </button>
533
- </PresetsPopover>
534
- </div>
535
-
536
- <PanelSection title="Type">
537
- <div className="flex flex-col gap-2 px-1 pb-1">
538
- <SegmentedControl
539
- onChange={(v) =>
540
- handleUpdate(
541
- v === 'opening'
542
- ? {
543
- openingKind: v,
544
- openingShape,
545
- openingRadiusMode,
546
- openingTopRadii,
547
- cornerRadius,
548
- archHeight,
549
- openingRevealRadius,
550
- }
551
- : v === 'garage'
552
- ? {
553
- openingKind: 'door',
554
- ...getDoorTypeUpdates(isGarageDoor ? doorType : 'garage-sectional'),
555
- }
556
- : {
557
- openingKind: 'door',
558
- ...(isGarageDoor ? getDoorTypeUpdates('hinged') : {}),
559
- },
560
- )
561
- }
562
- options={[
563
- { label: 'Door', value: 'door' },
564
- { label: 'Opening', value: 'opening' },
565
- { label: 'Garage', value: 'garage' },
566
- ]}
567
- value={typeMode}
568
- />
569
- </div>
570
- {!isOpening && (
571
- <div className="grid grid-cols-2 gap-1.5 px-1 pt-1">
572
- {(isGarageDoor ? garageDoorTypeOptions : doorTypeOptions).map((option) => {
573
- const isSelected = doorType === option.value
574
- return (
575
- <button
576
- className={cn(
577
- 'flex min-h-12 items-center gap-2 rounded-lg border px-2.5 text-left text-xs transition-colors',
578
- isSelected
579
- ? 'border-orange-400/60 bg-orange-400/10 text-foreground'
580
- : 'border-border/50 bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground',
581
- !option.available && 'cursor-not-allowed opacity-45 hover:bg-[#2C2C2E] hover:text-muted-foreground',
582
- )}
583
- disabled={!option.available}
584
- key={option.value}
585
- onClick={() => handleUpdate(getDoorTypeUpdates(option.value))}
586
- type="button"
587
- >
588
- <DoorOpen className="h-3.5 w-3.5 shrink-0" />
589
- <span className="truncate font-medium">{option.label}</span>
590
- </button>
591
- )
592
- })}
593
- </div>
594
- )}
595
- </PanelSection>
596
-
597
- <PanelSection title="Position">
598
- <SliderControl
599
- label={
600
- <>
601
- X<sub className="ml-[1px] text-[11px] opacity-70">wall</sub>
602
- </>
603
- }
604
- max={10}
605
- min={-10}
606
- onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}
607
- precision={2}
608
- step={0.1}
609
- unit="m"
610
- value={Math.round(node.position[0] * 100) / 100}
611
- />
612
- {!isOpening && (
613
- <div className="px-1 pt-2 pb-1">
614
- <ActionButton
615
- className="w-full"
616
- icon={<FlipHorizontal2 className="h-4 w-4" />}
617
- label="Flip Side"
618
- onClick={handleFlip}
619
- />
620
- </div>
621
- )}
622
- </PanelSection>
623
-
624
- {doorType === 'folding' && !isOpening && (
625
- <PanelSection title="Fold">
626
- <div className="flex flex-col gap-2 px-1 pb-1">
627
- <div className="space-y-1">
628
- <span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
629
- Panels
630
- </span>
631
- <SegmentedControl
632
- onChange={(v) => handleUpdate({ leafCount: v === '2' ? 2 : 4 })}
633
- options={[
634
- { label: '2', value: '2' },
635
- { label: '4', value: '4' },
636
- ]}
637
- value={node.leafCount === 2 ? '2' : '4'}
638
- />
639
- </div>
640
- </div>
641
- <SliderControl
642
- label="Open"
643
- max={100}
644
- min={0}
645
- onChange={(v) => handleUpdate({ operationState: v / 100 })}
646
- precision={0}
647
- restoreOnCommit={false}
648
- step={5}
649
- unit="%"
650
- value={Math.round((node.operationState ?? 0) * 100)}
651
- />
652
- </PanelSection>
653
- )}
654
-
655
- {isSlidingDoor && !isOpening && (
656
- <PanelSection title="Slide">
657
- <div className="flex flex-col gap-2 px-1 pb-1">
658
- <div className="space-y-1">
659
- <span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
660
- {doorType === 'pocket' ? 'Pocket' : doorType === 'barn' ? 'Rail' : 'Panel'}
661
- </span>
662
- <SegmentedControl
663
- onChange={(v) => handleUpdate({ slideDirection: v })}
664
- options={[
665
- { label: 'Left', value: 'left' },
666
- { label: 'Right', value: 'right' },
667
- ]}
668
- value={node.slideDirection ?? 'left'}
669
- />
670
- </div>
671
- </div>
672
- <SliderControl
673
- label="Open"
674
- max={100}
675
- min={0}
676
- onChange={(v) => handleUpdate({ operationState: v / 100 })}
677
- precision={0}
678
- restoreOnCommit={false}
679
- step={5}
680
- unit="%"
681
- value={Math.round((node.operationState ?? 0) * 100)}
682
- />
683
- </PanelSection>
684
- )}
685
-
686
- {(isSectionalGarageDoor || isRollupGarageDoor || isTiltupGarageDoor) && !isOpening && (
687
- <PanelSection title="Garage">
688
- <SliderControl
689
- label="Open"
690
- max={100}
691
- min={0}
692
- onChange={(v) => handleUpdate({ operationState: v / 100 })}
693
- precision={0}
694
- restoreOnCommit={false}
695
- step={5}
696
- unit="%"
697
- value={Math.round((node.operationState ?? 0) * 100)}
698
- />
699
- {isSectionalGarageDoor && (
700
- <SliderControl
701
- label="Panels"
702
- max={8}
703
- min={3}
704
- onChange={(v) => handleUpdate({ garagePanelCount: Math.round(v) })}
705
- precision={0}
706
- restoreOnCommit={false}
707
- step={1}
708
- value={node.garagePanelCount ?? 4}
709
- />
710
- )}
711
- </PanelSection>
712
- )}
713
-
714
- <PanelSection title="Dimensions">
715
- <SliderControl
716
- label="Width"
717
- max={maxDoorWidth}
718
- min={0.5}
719
- onChange={(v) => handleUpdate({ width: v })}
720
- precision={2}
721
- restoreOnCommit={false}
722
- step={0.05}
723
- unit="m"
724
- value={Math.round(node.width * 100) / 100}
725
- />
726
- <SliderControl
727
- label="Height"
728
- max={4}
729
- min={1.0}
730
- onChange={(v) =>
731
- handleUpdate({ height: v, position: [node.position[0], v / 2, node.position[2]] })
732
- }
733
- precision={2}
734
- restoreOnCommit={false}
735
- step={0.05}
736
- unit="m"
737
- value={Math.round(node.height * 100) / 100}
738
- />
739
- </PanelSection>
740
-
741
- {!isOpening && (
742
- <PanelSection title="Top Shape">
743
- <div className="flex flex-col gap-2 px-1 pb-1">
744
- <SegmentedControl
745
- onChange={(v) =>
746
- handleUpdate({
747
- openingShape: v as DoorNode['openingShape'],
748
- ...(v === 'rounded'
749
- ? {
750
- openingRadiusMode,
751
- openingTopRadii,
752
- cornerRadius: Math.min(cornerRadius, maxRoundedRadius),
753
- openingRevealRadius,
754
- }
755
- : {}),
756
- ...(v === 'arch' ? { archHeight } : {}),
757
- })
758
- }
759
- options={[
760
- { label: 'Rect', value: 'rectangle' },
761
- { label: 'Rounded', value: 'rounded' },
762
- { label: 'Arch', value: 'arch' },
763
- ]}
764
- value={doorShape}
765
- />
766
- </div>
767
- {doorShape === 'rounded' && (
768
- <>
769
- <div className="flex flex-col gap-2 px-1 pb-1">
770
- <SegmentedControl
771
- onChange={(v) =>
772
- handleUpdate({ openingRadiusMode: v as DoorNode['openingRadiusMode'] })
773
- }
774
- options={[
775
- { label: 'All', value: 'all' },
776
- { label: 'Individual', value: 'individual' },
777
- ]}
778
- value={openingRadiusMode}
779
- />
780
- </div>
781
- {openingRadiusMode === 'all' ? (
782
- <SliderControl
783
- label="Corner Radius"
784
- max={maxRoundedRadius}
785
- min={0}
786
- onChange={(v) => previewDoorUpdate('cornerRadius', v)}
787
- onCommit={(v) => commitDoorPreview('cornerRadius', v)}
788
- precision={2}
789
- step={0.05}
790
- unit="m"
791
- value={Math.round(cornerRadius * 100) / 100}
792
- />
793
- ) : (
794
- <>
795
- {[
796
- ['Top Left', 0],
797
- ['Top Right', 1],
798
- ].map(([label, index]) => (
799
- <SliderControl
800
- key={label}
801
- label={label}
802
- max={maxRoundedRadius}
803
- min={0}
804
- onChange={(v) => setOpeningTopRadius(index as number, v)}
805
- onCommit={(v) => setOpeningTopRadius(index as number, v, true)}
806
- precision={2}
807
- step={0.05}
808
- unit="m"
809
- value={Math.round((openingTopRadii[index as number] ?? 0) * 100) / 100}
810
- />
811
- ))}
812
- </>
813
- )}
814
- <SliderControl
815
- label="Reveal Radius"
816
- max={0.08}
817
- min={0}
818
- onChange={(v) => previewDoorUpdate('openingRevealRadius', v)}
819
- onCommit={(v) => commitDoorPreview('openingRevealRadius', v)}
820
- precision={3}
821
- step={0.005}
822
- unit="m"
823
- value={Math.round(openingRevealRadius * 1000) / 1000}
824
- />
825
- </>
826
- )}
827
- {doorShape === 'arch' && (
828
- <SliderControl
829
- label="Arch Height"
830
- max={node.height}
831
- min={0.05}
832
- onChange={(v) => handleUpdate({ archHeight: v })}
833
- precision={2}
834
- restoreOnCommit={false}
835
- step={0.05}
836
- unit="m"
837
- value={Math.round(archHeight * 100) / 100}
838
- />
839
- )}
840
- </PanelSection>
841
- )}
842
-
843
- {isOpening && (
844
- <PanelSection title="Opening Shape">
845
- <div className="flex flex-col gap-2 px-1 pb-1">
846
- <SegmentedControl
847
- onChange={(v) =>
848
- handleUpdate({
849
- openingShape: v,
850
- ...(v === 'rounded'
851
- ? { openingRadiusMode, openingTopRadii, cornerRadius, openingRevealRadius }
852
- : {}),
853
- ...(v === 'arch' ? { archHeight } : {}),
854
- })
855
- }
856
- options={[
857
- { label: 'Rect', value: 'rectangle' },
858
- { label: 'Rounded', value: 'rounded' },
859
- { label: 'Arch', value: 'arch' },
860
- ]}
861
- value={openingShape}
862
- />
863
- </div>
864
- {openingShape === 'rounded' && (
865
- <>
866
- <div className="flex flex-col gap-2 px-1 pb-1">
867
- <SegmentedControl
868
- onChange={(v) =>
869
- handleUpdate({ openingRadiusMode: v as DoorNode['openingRadiusMode'] })
870
- }
871
- options={[
872
- { label: 'All', value: 'all' },
873
- { label: 'Individual', value: 'individual' },
874
- ]}
875
- value={openingRadiusMode}
876
- />
877
- </div>
878
- {openingRadiusMode === 'all' ? (
879
- <SliderControl
880
- label="Corner Radius"
881
- max={maxRoundedRadius}
882
- min={0}
883
- onChange={(v) => previewDoorUpdate('cornerRadius', v)}
884
- onCommit={(v) => commitDoorPreview('cornerRadius', v)}
885
- precision={2}
886
- step={0.05}
887
- unit="m"
888
- value={Math.round(cornerRadius * 100) / 100}
889
- />
890
- ) : (
891
- <>
892
- {[
893
- ['Top Left', 0],
894
- ['Top Right', 1],
895
- ].map(([label, index]) => (
896
- <SliderControl
897
- key={label}
898
- label={label}
899
- max={maxRoundedRadius}
900
- min={0}
901
- onChange={(v) => setOpeningTopRadius(index as number, v)}
902
- onCommit={(v) => setOpeningTopRadius(index as number, v, true)}
903
- precision={2}
904
- step={0.05}
905
- unit="m"
906
- value={Math.round((openingTopRadii[index as number] ?? 0) * 100) / 100}
907
- />
908
- ))}
909
- </>
910
- )}
911
- <SliderControl
912
- label="Reveal Radius"
913
- max={0.08}
914
- min={0}
915
- onChange={(v) => previewDoorUpdate('openingRevealRadius', v)}
916
- onCommit={(v) => commitDoorPreview('openingRevealRadius', v)}
917
- precision={3}
918
- step={0.005}
919
- unit="m"
920
- value={Math.round(openingRevealRadius * 1000) / 1000}
921
- />
922
- </>
923
- )}
924
- {openingShape === 'arch' && (
925
- <SliderControl
926
- label="Arch Height"
927
- max={node.height}
928
- min={0.05}
929
- onChange={(v) => handleUpdate({ archHeight: v })}
930
- precision={2}
931
- restoreOnCommit={false}
932
- step={0.05}
933
- unit="m"
934
- value={Math.round(archHeight * 100) / 100}
935
- />
936
- )}
937
- </PanelSection>
938
- )}
939
-
940
- {!isOpening && (
941
- <>
942
- <PanelSection title="Frame">
943
- <SliderControl
944
- label="Thickness"
945
- max={0.2}
946
- min={0.01}
947
- onChange={(v) => handleUpdate({ frameThickness: v })}
948
- precision={3}
949
- step={0.01}
950
- unit="m"
951
- value={Math.round(node.frameThickness * 1000) / 1000}
952
- />
953
- <SliderControl
954
- label="Depth"
955
- max={0.3}
956
- min={0.01}
957
- onChange={(v) => handleUpdate({ frameDepth: v })}
958
- precision={3}
959
- step={0.01}
960
- unit="m"
961
- value={Math.round(node.frameDepth * 1000) / 1000}
962
- />
963
- </PanelSection>
964
-
965
- {!isGarageDoor && (
966
- <PanelSection title="Content Padding">
967
- <SliderControl
968
- label="Horizontal"
969
- max={0.2}
970
- min={0}
971
- onChange={(v) => handleUpdate({ contentPadding: [v, node.contentPadding[1]] })}
972
- precision={3}
973
- step={0.005}
974
- unit="m"
975
- value={Math.round(node.contentPadding[0] * 1000) / 1000}
976
- />
977
- <SliderControl
978
- label="Vertical"
979
- max={0.2}
980
- min={0}
981
- onChange={(v) => handleUpdate({ contentPadding: [node.contentPadding[0], v] })}
982
- precision={3}
983
- step={0.005}
984
- unit="m"
985
- value={Math.round(node.contentPadding[1] * 1000) / 1000}
986
- />
987
- </PanelSection>
988
- )}
989
-
990
- {isSwingDoor && (
991
- <PanelSection title="Swing">
992
- <div className="flex flex-col gap-2 px-1 pb-1">
993
- <div className="space-y-1">
994
- <span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
995
- Hinges Side
996
- </span>
997
- <SegmentedControl
998
- onChange={(v) => handleUpdate({ hingesSide: v })}
999
- options={[
1000
- { label: 'Left', value: 'left' },
1001
- { label: 'Right', value: 'right' },
1002
- ]}
1003
- value={node.hingesSide}
1004
- />
1005
- </div>
1006
- <div className="space-y-1">
1007
- <span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
1008
- Direction
1009
- </span>
1010
- <SegmentedControl
1011
- onChange={(v) => handleUpdate({ swingDirection: v })}
1012
- options={[
1013
- { label: 'Inward', value: 'inward' },
1014
- { label: 'Outward', value: 'outward' },
1015
- ]}
1016
- value={node.swingDirection}
1017
- />
1018
- </div>
1019
- </div>
1020
- </PanelSection>
1021
- )}
1022
-
1023
- {isSwingDoor && (
1024
- <PanelSection title="Threshold">
1025
- <ToggleControl
1026
- checked={node.threshold}
1027
- label="Enable Threshold"
1028
- onChange={(checked) => handleUpdate({ threshold: checked })}
1029
- />
1030
- {node.threshold && (
1031
- <div className="mt-1 flex flex-col gap-1">
1032
- <SliderControl
1033
- label="Height"
1034
- max={0.1}
1035
- min={0.005}
1036
- onChange={(v) => handleUpdate({ thresholdHeight: v })}
1037
- precision={3}
1038
- step={0.005}
1039
- unit="m"
1040
- value={Math.round(node.thresholdHeight * 1000) / 1000}
1041
- />
1042
- </div>
1043
- )}
1044
- </PanelSection>
1045
- )}
1046
-
1047
- {!isGarageDoor && (
1048
- <PanelSection title="Handle">
1049
- {isSwingDoor && (
1050
- <ToggleControl
1051
- checked={node.handle}
1052
- label="Enable Handle"
1053
- onChange={(checked) => handleUpdate({ handle: checked })}
1054
- />
1055
- )}
1056
- {(node.handle || !isSwingDoor) && (
1057
- <div className="mt-1 flex flex-col gap-1">
1058
- <SliderControl
1059
- label="Height"
1060
- max={node.height - 0.1}
1061
- min={0.5}
1062
- onChange={(v) => handleUpdate({ handleHeight: v })}
1063
- precision={2}
1064
- step={0.05}
1065
- unit="m"
1066
- value={Math.round(node.handleHeight * 100) / 100}
1067
- />
1068
- {supportsHandleSide && (
1069
- <div className="space-y-1">
1070
- <span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
1071
- Handle Side
1072
- </span>
1073
- <SegmentedControl
1074
- onChange={(v) => handleUpdate({ handleSide: v })}
1075
- options={[
1076
- { label: 'Left', value: 'left' },
1077
- { label: 'Right', value: 'right' },
1078
- ]}
1079
- value={node.handleSide}
1080
- />
1081
- </div>
1082
- )}
1083
- </div>
1084
- )}
1085
- </PanelSection>
1086
- )}
1087
-
1088
- {isSwingDoor && (
1089
- <PanelSection title="Hardware">
1090
- <ToggleControl
1091
- checked={node.doorCloser}
1092
- label="Door Closer"
1093
- onChange={(checked) => handleUpdate({ doorCloser: checked })}
1094
- />
1095
- <ToggleControl
1096
- checked={node.panicBar}
1097
- label="Panic Bar"
1098
- onChange={(checked) => handleUpdate({ panicBar: checked })}
1099
- />
1100
- {node.panicBar && (
1101
- <div className="mt-1 flex flex-col gap-1">
1102
- <SliderControl
1103
- label="Bar Height"
1104
- max={node.height - 0.1}
1105
- min={0.5}
1106
- onChange={(v) => handleUpdate({ panicBarHeight: v })}
1107
- precision={2}
1108
- step={0.05}
1109
- unit="m"
1110
- value={Math.round(node.panicBarHeight * 100) / 100}
1111
- />
1112
- </div>
1113
- )}
1114
- </PanelSection>
1115
- )}
1116
-
1117
- {!isGarageDoor && (
1118
- <PanelSection title="Segments">
1119
- {node.segments.map((seg, i) => {
1120
- const numCols = seg.columnRatios.length
1121
- const colSum = seg.columnRatios.reduce((a, b) => a + b, 0)
1122
- const normCols = seg.columnRatios.map((r) => r / colSum)
1123
- return (
1124
- <div className="mb-2 flex flex-col gap-1" key={i}>
1125
- <div className="flex items-center justify-between pb-1">
1126
- <span className="font-medium text-white/80 text-xs">Segment {i + 1}</span>
1127
- </div>
1128
-
1129
- <SegmentedControl
1130
- onChange={(t) => {
1131
- const updated = node.segments.map((s, idx) =>
1132
- idx === i ? { ...s, type: t } : s,
1133
- )
1134
- handleUpdate({ segments: updated })
1135
- }}
1136
- options={[
1137
- { label: 'Panel', value: 'panel' },
1138
- { label: 'Glass', value: 'glass' },
1139
- { label: 'Empty', value: 'empty' },
1140
- ]}
1141
- value={seg.type}
1142
- />
1143
-
1144
- <SliderControl
1145
- label="Height"
1146
- max={95}
1147
- min={5}
1148
- onChange={(v) => setSegmentHeightRatio(i, v / 100)}
1149
- precision={1}
1150
- step={1}
1151
- unit="%"
1152
- value={Math.round(normHeights[i]! * 100 * 10) / 10}
1153
- />
1154
-
1155
- <SliderControl
1156
- label="Columns"
1157
- max={8}
1158
- min={1}
1159
- onChange={(v) => {
1160
- const n = Math.max(1, Math.min(8, Math.round(v)))
1161
- const updated = node.segments.map((s, idx) =>
1162
- idx === i ? { ...s, columnRatios: Array(n).fill(1 / n) } : s,
1163
- )
1164
- handleUpdate({ segments: updated })
1165
- }}
1166
- precision={0}
1167
- step={1}
1168
- value={numCols}
1169
- />
1170
-
1171
- {numCols > 1 && (
1172
- <div className="mt-1 border-border/50 border-t pt-1">
1173
- {normCols.map((ratio, ci) => (
1174
- <SliderControl
1175
- key={`c-${ci}`}
1176
- label={`C${ci + 1}`}
1177
- max={95}
1178
- min={5}
1179
- onChange={(v) => setSegmentColumnRatio(i, ci, v / 100)}
1180
- precision={1}
1181
- step={1}
1182
- unit="%"
1183
- value={Math.round(ratio * 100 * 10) / 10}
1184
- />
1185
- ))}
1186
- <SliderControl
1187
- label="Divider"
1188
- max={0.1}
1189
- min={0.005}
1190
- onChange={(v) => {
1191
- const updated = node.segments.map((s, idx) =>
1192
- idx === i ? { ...s, dividerThickness: v } : s,
1193
- )
1194
- handleUpdate({ segments: updated })
1195
- }}
1196
- precision={3}
1197
- step={0.005}
1198
- unit="m"
1199
- value={Math.round(seg.dividerThickness * 1000) / 1000}
1200
- />
1201
- </div>
1202
- )}
1203
-
1204
- {seg.type === 'panel' && (
1205
- <div className="mt-1 border-border/50 border-t pt-1">
1206
- <SliderControl
1207
- label="Inset"
1208
- max={0.1}
1209
- min={0}
1210
- onChange={(v) => {
1211
- const updated = node.segments.map((s, idx) =>
1212
- idx === i ? { ...s, panelInset: v } : s,
1213
- )
1214
- handleUpdate({ segments: updated })
1215
- }}
1216
- precision={3}
1217
- step={0.005}
1218
- unit="m"
1219
- value={Math.round(seg.panelInset * 1000) / 1000}
1220
- />
1221
- <SliderControl
1222
- label="Depth"
1223
- max={0.1}
1224
- min={0}
1225
- onChange={(v) => {
1226
- const updated = node.segments.map((s, idx) =>
1227
- idx === i ? { ...s, panelDepth: v } : s,
1228
- )
1229
- handleUpdate({ segments: updated })
1230
- }}
1231
- precision={3}
1232
- step={0.005}
1233
- unit="m"
1234
- value={Math.round(seg.panelDepth * 1000) / 1000}
1235
- />
1236
- </div>
1237
- )}
1238
- </div>
1239
- )
1240
- })}
1241
-
1242
- <div className="flex gap-1.5 px-1 pt-1">
1243
- <ActionButton
1244
- label="+ Add Segment"
1245
- onClick={() => {
1246
- const updated = [
1247
- ...node.segments,
1248
- {
1249
- type: 'panel' as const,
1250
- heightRatio: 1,
1251
- columnRatios: [1],
1252
- dividerThickness: 0.03,
1253
- panelDepth: 0.01,
1254
- panelInset: 0.04,
1255
- },
1256
- ]
1257
- handleUpdate({ segments: updated })
1258
- }}
1259
- />
1260
- {node.segments.length > 1 && (
1261
- <ActionButton
1262
- className="text-white/60 hover:text-white"
1263
- label="- Remove"
1264
- onClick={() => handleUpdate({ segments: node.segments.slice(0, -1) })}
1265
- />
1266
- )}
1267
- </div>
1268
- </PanelSection>
1269
- )}
1270
-
1271
- </>
1272
- )}
1273
-
1274
- <PanelSection title="Actions">
1275
- <ActionGroup>
1276
- <ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
1277
- <ActionButton
1278
- icon={<Copy className="h-3.5 w-3.5" />}
1279
- label="Duplicate"
1280
- onClick={handleDuplicate}
1281
- />
1282
- <ActionButton
1283
- className="hover:bg-red-500/20"
1284
- icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
1285
- label="Delete"
1286
- onClick={handleDelete}
1287
- />
1288
- </ActionGroup>
1289
- </PanelSection>
1290
- </PanelWrapper>
1291
- )
1292
- }