@pascal-app/editor 0.5.1 → 0.7.0

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 (150) hide show
  1. package/package.json +12 -7
  2. package/src/components/editor/bottom-sheet.tsx +149 -0
  3. package/src/components/editor/custom-camera-controls.tsx +75 -7
  4. package/src/components/editor/editor-layout-mobile.tsx +264 -0
  5. package/src/components/editor/editor-layout-v2.tsx +29 -0
  6. package/src/components/editor/first-person/build-collider-world.ts +365 -0
  7. package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -0
  8. package/src/components/editor/first-person-controls.tsx +496 -143
  9. package/src/components/editor/floating-action-menu.tsx +281 -83
  10. package/src/components/editor/floating-building-action-menu.tsx +4 -3
  11. package/src/components/editor/floorplan-background-selection.ts +113 -0
  12. package/src/components/editor/floorplan-panel.tsx +10442 -3275
  13. package/src/components/editor/index.tsx +270 -20
  14. package/src/components/editor/node-action-menu.tsx +14 -1
  15. package/src/components/editor/selection-manager.tsx +766 -12
  16. package/src/components/editor/site-edge-labels.tsx +9 -3
  17. package/src/components/editor/thumbnail-generator.tsx +350 -157
  18. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  19. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  20. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  21. package/src/components/editor/wall-measurement-label.tsx +377 -58
  22. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  23. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  24. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  25. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
  26. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  27. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
  28. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  29. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  30. package/src/components/editor-2d/svg-paths.ts +119 -0
  31. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
  32. package/src/components/systems/roof/roof-edit-system.tsx +5 -5
  33. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  34. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +2 -0
  35. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  36. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  37. package/src/components/tools/column/column-tool.tsx +97 -0
  38. package/src/components/tools/column/move-column-tool.tsx +105 -0
  39. package/src/components/tools/door/door-tool.tsx +19 -0
  40. package/src/components/tools/door/move-door-tool.tsx +38 -8
  41. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  42. package/src/components/tools/fence/fence-drafting.ts +27 -8
  43. package/src/components/tools/fence/fence-tool.tsx +159 -3
  44. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +438 -0
  45. package/src/components/tools/fence/move-fence-tool.tsx +102 -27
  46. package/src/components/tools/item/move-tool.tsx +19 -1
  47. package/src/components/tools/item/placement-math.ts +44 -7
  48. package/src/components/tools/item/placement-strategies.ts +111 -33
  49. package/src/components/tools/item/placement-types.ts +7 -0
  50. package/src/components/tools/item/use-draft-node.ts +2 -0
  51. package/src/components/tools/item/use-placement-coordinator.tsx +701 -61
  52. package/src/components/tools/roof/move-roof-tool.tsx +111 -43
  53. package/src/components/tools/shared/polygon-editor.tsx +244 -29
  54. package/src/components/tools/shared/segment-angle.ts +156 -0
  55. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  56. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  57. package/src/components/tools/slab/slab-hole-editor.tsx +2 -0
  58. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  59. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  60. package/src/components/tools/stair/stair-tool.tsx +11 -3
  61. package/src/components/tools/tool-manager.tsx +30 -3
  62. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  63. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +423 -0
  64. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  65. package/src/components/tools/wall/wall-drafting.ts +348 -17
  66. package/src/components/tools/wall/wall-tool.tsx +134 -2
  67. package/src/components/tools/window/move-window-tool.tsx +28 -0
  68. package/src/components/tools/window/window-tool.tsx +17 -0
  69. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  70. package/src/components/ui/action-menu/control-modes.tsx +37 -5
  71. package/src/components/ui/action-menu/index.tsx +91 -1
  72. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  73. package/src/components/ui/action-menu/view-toggles.tsx +424 -35
  74. package/src/components/ui/command-palette/editor-commands.tsx +27 -5
  75. package/src/components/ui/command-palette/index.tsx +0 -1
  76. package/src/components/ui/controls/material-picker.tsx +189 -169
  77. package/src/components/ui/controls/slider-control.tsx +88 -26
  78. package/src/components/ui/floating-level-selector.tsx +286 -55
  79. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  80. package/src/components/ui/item-catalog/catalog-items.tsx +1121 -1219
  81. package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
  82. package/src/components/ui/level-duplicate-dialog.tsx +115 -0
  83. package/src/components/ui/panels/ceiling-panel.tsx +47 -27
  84. package/src/components/ui/panels/column-panel.tsx +715 -0
  85. package/src/components/ui/panels/door-panel.tsx +986 -294
  86. package/src/components/ui/panels/fence-panel.tsx +55 -12
  87. package/src/components/ui/panels/item-panel.tsx +5 -5
  88. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  89. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  90. package/src/components/ui/panels/node-display.ts +39 -0
  91. package/src/components/ui/panels/paint-panel.tsx +138 -0
  92. package/src/components/ui/panels/panel-manager.tsx +241 -30
  93. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  94. package/src/components/ui/panels/reference-panel.tsx +243 -9
  95. package/src/components/ui/panels/roof-panel.tsx +30 -62
  96. package/src/components/ui/panels/roof-segment-panel.tsx +8 -23
  97. package/src/components/ui/panels/slab-panel.tsx +46 -24
  98. package/src/components/ui/panels/spawn-panel.tsx +155 -0
  99. package/src/components/ui/panels/stair-panel.tsx +117 -69
  100. package/src/components/ui/panels/stair-segment-panel.tsx +13 -27
  101. package/src/components/ui/panels/wall-panel.tsx +71 -17
  102. package/src/components/ui/panels/window-panel.tsx +665 -146
  103. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  104. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  105. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +9 -5
  106. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
  107. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  108. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
  109. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
  110. package/src/components/ui/sidebar/panels/site-panel/index.tsx +138 -56
  111. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
  112. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +9 -5
  113. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  114. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
  115. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
  116. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
  117. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
  118. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +12 -6
  119. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
  120. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
  121. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +15 -8
  122. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  123. package/src/components/ui/viewer-toolbar.tsx +96 -2
  124. package/src/components/viewer-overlay.tsx +25 -19
  125. package/src/hooks/use-auto-frame.ts +45 -0
  126. package/src/hooks/use-contextual-tools.ts +14 -13
  127. package/src/hooks/use-keyboard.ts +67 -9
  128. package/src/hooks/use-mobile.ts +12 -12
  129. package/src/index.tsx +2 -1
  130. package/src/lib/door-interaction.ts +88 -0
  131. package/src/lib/floorplan/geometry.ts +263 -0
  132. package/src/lib/floorplan/index.ts +38 -0
  133. package/src/lib/floorplan/items.ts +179 -0
  134. package/src/lib/floorplan/selection-tool.ts +231 -0
  135. package/src/lib/floorplan/stairs.ts +478 -0
  136. package/src/lib/floorplan/types.ts +57 -0
  137. package/src/lib/floorplan/walls.ts +23 -0
  138. package/src/lib/guide-events.ts +10 -0
  139. package/src/lib/history.ts +20 -0
  140. package/src/lib/level-duplication.test.ts +72 -0
  141. package/src/lib/level-duplication.ts +153 -0
  142. package/src/lib/local-guide-image.ts +42 -0
  143. package/src/lib/material-paint.ts +284 -0
  144. package/src/lib/roof-duplication.ts +214 -0
  145. package/src/lib/scene-bounds.test.ts +183 -0
  146. package/src/lib/scene-bounds.ts +169 -0
  147. package/src/lib/sfx-player.ts +96 -13
  148. package/src/lib/stair-duplication.ts +126 -0
  149. package/src/lib/window-interaction.ts +86 -0
  150. package/src/store/use-editor.tsx +279 -15
@@ -5,17 +5,17 @@ import {
5
5
  type AnyNodeId,
6
6
  DoorNode,
7
7
  emitter,
8
- type MaterialSchema,
8
+ useInteractive,
9
9
  useScene,
10
10
  } from '@pascal-app/core'
11
11
  import { useViewer } from '@pascal-app/viewer'
12
- import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
13
- import { useCallback } from 'react'
12
+ import { BookMarked, Copy, DoorOpen, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
13
+ import { useCallback, useRef } from 'react'
14
14
  import { usePresetsAdapter } from '../../../contexts/presets-context'
15
+ import { cn } from '../../../lib/utils'
15
16
  import { sfxEmitter } from '../../../lib/sfx-bus'
16
17
  import useEditor from '../../../store/use-editor'
17
18
  import { ActionButton, ActionGroup } from '../controls/action-button'
18
- import { MaterialPicker } from '../controls/material-picker'
19
19
  import { MetricControl } from '../controls/metric-control'
20
20
  import { PanelSection } from '../controls/panel-section'
21
21
  import { SegmentedControl } from '../controls/segmented-control'
@@ -24,33 +24,163 @@ import { ToggleControl } from '../controls/toggle-control'
24
24
  import { PanelWrapper } from './panel-wrapper'
25
25
  import { PresetsPopover } from './presets/presets-popover'
26
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
+
27
109
  export function DoorPanel() {
28
- const selectedIds = useViewer((s) => s.selection.selectedIds)
110
+ const selectedId = useViewer((s) => s.selection.selectedIds[0])
29
111
  const setSelection = useViewer((s) => s.setSelection)
30
- const nodes = useScene((s) => s.nodes)
31
112
  const updateNode = useScene((s) => s.updateNode)
32
113
  const deleteNode = useScene((s) => s.deleteNode)
33
114
  const setMovingNode = useEditor((s) => s.setMovingNode)
115
+ const previewRef = useRef<{
116
+ id: AnyNodeId
117
+ key: keyof DoorNode
118
+ value: unknown
119
+ } | null>(null)
34
120
 
35
121
  const adapter = usePresetsAdapter()
36
122
 
37
- const selectedId = selectedIds[0]
38
- const node = selectedId ? (nodes[selectedId as AnyNode['id']] as DoorNode | undefined) : undefined
123
+ const node = useScene((s) =>
124
+ selectedId ? (s.nodes[selectedId as AnyNode['id']] as DoorNode | undefined) : undefined,
125
+ )
39
126
 
40
127
  const handleUpdate = useCallback(
41
128
  (updates: Partial<DoorNode>) => {
42
- if (!selectedId) return
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
+ }
43
139
  updateNode(selectedId as AnyNode['id'], updates)
44
140
  useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
45
141
  },
46
- [selectedId, updateNode],
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],
47
165
  )
48
166
 
49
- const handleMaterialChange = useCallback(
50
- (material: MaterialSchema) => {
51
- handleUpdate({ material })
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)
52
182
  },
53
- [handleUpdate],
183
+ [selectedId, updateNode],
54
184
  )
55
185
 
56
186
  const handleClose = useCallback(() => {
@@ -94,9 +224,10 @@ export function DoorPanel() {
94
224
  }, [node, setMovingNode, setSelection])
95
225
 
96
226
  const setSegmentHeightRatio = (segIdx: number, newVal: number) => {
97
- const numSegs = node?.segments.length
98
- const totalH = node?.segments.reduce((sum, s) => sum + s.heightRatio, 0)
99
- const normH = node?.segments.map((s) => s.heightRatio / totalH)
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)
100
231
  const clamped = Math.max(0.05, Math.min(0.95, newVal))
101
232
  const neighborIdx = segIdx < numSegs - 1 ? segIdx + 1 : segIdx - 1
102
233
  const delta = clamped - normH[segIdx]!
@@ -106,7 +237,7 @@ export function DoorPanel() {
106
237
  if (i === neighborIdx) return neighborVal
107
238
  return v
108
239
  })
109
- const updated = node?.segments.map((s, idx) => ({ ...s, heightRatio: newRatios[idx]! }))
240
+ const updated = node.segments.map((s, idx) => ({ ...s, heightRatio: newRatios[idx]! }))
110
241
  handleUpdate({ segments: updated })
111
242
  }
112
243
 
@@ -136,10 +267,24 @@ export function DoorPanel() {
136
267
  const getDoorPresetData = useCallback(() => {
137
268
  if (!node) return null
138
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,
139
277
  width: node.width,
140
278
  height: node.height,
141
279
  frameThickness: node.frameThickness,
142
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,
143
288
  contentPadding: node.contentPadding,
144
289
  hingesSide: node.hingesSide,
145
290
  swingDirection: node.swingDirection,
@@ -182,10 +327,183 @@ export function DoorPanel() {
182
327
  [handleUpdate],
183
328
  )
184
329
 
185
- if (!node || node.type !== 'door' || selectedIds.length !== 1) return null
330
+ if (!(node && node.type === 'door' && selectedId)) return null
186
331
 
187
332
  const hSum = node.segments.reduce((s, seg) => s + seg.heightRatio, 0)
188
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
+ }
189
507
 
190
508
  return (
191
509
  <PanelWrapper
@@ -215,6 +533,67 @@ export function DoorPanel() {
215
533
  </PresetsPopover>
216
534
  </div>
217
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
+
218
597
  <PanelSection title="Position">
219
598
  <SliderControl
220
599
  label={
@@ -230,23 +609,116 @@ export function DoorPanel() {
230
609
  unit="m"
231
610
  value={Math.round(node.position[0] * 100) / 100}
232
611
  />
233
- <div className="px-1 pt-2 pb-1">
234
- <ActionButton
235
- className="w-full"
236
- icon={<FlipHorizontal2 className="h-4 w-4" />}
237
- label="Flip Side"
238
- onClick={handleFlip}
239
- />
240
- </div>
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
+ )}
241
622
  </PanelSection>
242
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
+
243
714
  <PanelSection title="Dimensions">
244
715
  <SliderControl
245
716
  label="Width"
246
- max={3}
717
+ max={maxDoorWidth}
247
718
  min={0.5}
248
719
  onChange={(v) => handleUpdate({ width: v })}
249
720
  precision={2}
721
+ restoreOnCommit={false}
250
722
  step={0.05}
251
723
  unit="m"
252
724
  value={Math.round(node.width * 100) / 100}
@@ -259,322 +731,545 @@ export function DoorPanel() {
259
731
  handleUpdate({ height: v, position: [node.position[0], v / 2, node.position[2]] })
260
732
  }
261
733
  precision={2}
734
+ restoreOnCommit={false}
262
735
  step={0.05}
263
736
  unit="m"
264
737
  value={Math.round(node.height * 100) / 100}
265
738
  />
266
739
  </PanelSection>
267
740
 
268
- <PanelSection title="Frame">
269
- <SliderControl
270
- label="Thickness"
271
- max={0.2}
272
- min={0.01}
273
- onChange={(v) => handleUpdate({ frameThickness: v })}
274
- precision={3}
275
- step={0.01}
276
- unit="m"
277
- value={Math.round(node.frameThickness * 1000) / 1000}
278
- />
279
- <SliderControl
280
- label="Depth"
281
- max={0.3}
282
- min={0.01}
283
- onChange={(v) => handleUpdate({ frameDepth: v })}
284
- precision={3}
285
- step={0.01}
286
- unit="m"
287
- value={Math.round(node.frameDepth * 1000) / 1000}
288
- />
289
- </PanelSection>
290
-
291
- <PanelSection title="Content Padding">
292
- <SliderControl
293
- label="Horizontal"
294
- max={0.2}
295
- min={0}
296
- onChange={(v) => handleUpdate({ contentPadding: [v, node.contentPadding[1]] })}
297
- precision={3}
298
- step={0.005}
299
- unit="m"
300
- value={Math.round(node.contentPadding[0] * 1000) / 1000}
301
- />
302
- <SliderControl
303
- label="Vertical"
304
- max={0.2}
305
- min={0}
306
- onChange={(v) => handleUpdate({ contentPadding: [node.contentPadding[0], v] })}
307
- precision={3}
308
- step={0.005}
309
- unit="m"
310
- value={Math.round(node.contentPadding[1] * 1000) / 1000}
311
- />
312
- </PanelSection>
313
-
314
- <PanelSection title="Swing">
315
- <div className="flex flex-col gap-2 px-1 pb-1">
316
- <div className="space-y-1">
317
- <span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
318
- Hinges Side
319
- </span>
741
+ {!isOpening && (
742
+ <PanelSection title="Top Shape">
743
+ <div className="flex flex-col gap-2 px-1 pb-1">
320
744
  <SegmentedControl
321
- onChange={(v) => handleUpdate({ hingesSide: v })}
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
+ }
322
759
  options={[
323
- { label: 'Left', value: 'left' },
324
- { label: 'Right', value: 'right' },
760
+ { label: 'Rect', value: 'rectangle' },
761
+ { label: 'Rounded', value: 'rounded' },
762
+ { label: 'Arch', value: 'arch' },
325
763
  ]}
326
- value={node.hingesSide}
764
+ value={doorShape}
327
765
  />
328
766
  </div>
329
- <div className="space-y-1">
330
- <span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
331
- Direction
332
- </span>
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">
333
846
  <SegmentedControl
334
- onChange={(v) => handleUpdate({ swingDirection: v })}
847
+ onChange={(v) =>
848
+ handleUpdate({
849
+ openingShape: v,
850
+ ...(v === 'rounded'
851
+ ? { openingRadiusMode, openingTopRadii, cornerRadius, openingRevealRadius }
852
+ : {}),
853
+ ...(v === 'arch' ? { archHeight } : {}),
854
+ })
855
+ }
335
856
  options={[
336
- { label: 'Inward', value: 'inward' },
337
- { label: 'Outward', value: 'outward' },
857
+ { label: 'Rect', value: 'rectangle' },
858
+ { label: 'Rounded', value: 'rounded' },
859
+ { label: 'Arch', value: 'arch' },
338
860
  ]}
339
- value={node.swingDirection}
861
+ value={openingShape}
340
862
  />
341
863
  </div>
342
- </div>
343
- </PanelSection>
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
+ )}
344
939
 
345
- <PanelSection title="Threshold">
346
- <ToggleControl
347
- checked={node.threshold}
348
- label="Enable Threshold"
349
- onChange={(checked) => handleUpdate({ threshold: checked })}
350
- />
351
- {node.threshold && (
352
- <div className="mt-1 flex flex-col gap-1">
940
+ {!isOpening && (
941
+ <>
942
+ <PanelSection title="Frame">
353
943
  <SliderControl
354
- label="Height"
355
- max={0.1}
356
- min={0.005}
357
- onChange={(v) => handleUpdate({ thresholdHeight: v })}
944
+ label="Thickness"
945
+ max={0.2}
946
+ min={0.01}
947
+ onChange={(v) => handleUpdate({ frameThickness: v })}
358
948
  precision={3}
359
- step={0.005}
949
+ step={0.01}
360
950
  unit="m"
361
- value={Math.round(node.thresholdHeight * 1000) / 1000}
951
+ value={Math.round(node.frameThickness * 1000) / 1000}
362
952
  />
363
- </div>
364
- )}
365
- </PanelSection>
366
-
367
- <PanelSection title="Handle">
368
- <ToggleControl
369
- checked={node.handle}
370
- label="Enable Handle"
371
- onChange={(checked) => handleUpdate({ handle: checked })}
372
- />
373
- {node.handle && (
374
- <div className="mt-1 flex flex-col gap-1">
375
953
  <SliderControl
376
- label="Height"
377
- max={node.height - 0.1}
378
- min={0.5}
379
- onChange={(v) => handleUpdate({ handleHeight: v })}
380
- precision={2}
381
- step={0.05}
954
+ label="Depth"
955
+ max={0.3}
956
+ min={0.01}
957
+ onChange={(v) => handleUpdate({ frameDepth: v })}
958
+ precision={3}
959
+ step={0.01}
382
960
  unit="m"
383
- value={Math.round(node.handleHeight * 100) / 100}
961
+ value={Math.round(node.frameDepth * 1000) / 1000}
384
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">
385
993
  <div className="space-y-1">
386
994
  <span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
387
- Handle Side
995
+ Hinges Side
388
996
  </span>
389
997
  <SegmentedControl
390
- onChange={(v) => handleUpdate({ handleSide: v })}
998
+ onChange={(v) => handleUpdate({ hingesSide: v })}
391
999
  options={[
392
1000
  { label: 'Left', value: 'left' },
393
1001
  { label: 'Right', value: 'right' },
394
1002
  ]}
395
- value={node.handleSide}
1003
+ value={node.hingesSide}
396
1004
  />
397
1005
  </div>
398
- </div>
399
- )}
400
- </PanelSection>
401
-
402
- <PanelSection title="Hardware">
403
- <ToggleControl
404
- checked={node.doorCloser}
405
- label="Door Closer"
406
- onChange={(checked) => handleUpdate({ doorCloser: checked })}
407
- />
408
- <ToggleControl
409
- checked={node.panicBar}
410
- label="Panic Bar"
411
- onChange={(checked) => handleUpdate({ panicBar: checked })}
412
- />
413
- {node.panicBar && (
414
- <div className="mt-1 flex flex-col gap-1">
415
- <SliderControl
416
- label="Bar Height"
417
- max={node.height - 0.1}
418
- min={0.5}
419
- onChange={(v) => handleUpdate({ panicBarHeight: v })}
420
- precision={2}
421
- step={0.05}
422
- unit="m"
423
- value={Math.round(node.panicBarHeight * 100) / 100}
424
- />
425
- </div>
426
- )}
427
- </PanelSection>
428
-
429
- <PanelSection title="Segments">
430
- {node.segments.map((seg, i) => {
431
- const numCols = seg.columnRatios.length
432
- const colSum = seg.columnRatios.reduce((a, b) => a + b, 0)
433
- const normCols = seg.columnRatios.map((r) => r / colSum)
434
- return (
435
- <div className="mb-2 flex flex-col gap-1" key={i}>
436
- <div className="flex items-center justify-between pb-1">
437
- <span className="font-medium text-white/80 text-xs">Segment {i + 1}</span>
438
- </div>
439
-
1006
+ <div className="space-y-1">
1007
+ <span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
1008
+ Direction
1009
+ </span>
440
1010
  <SegmentedControl
441
- onChange={(t) => {
442
- const updated = node.segments.map((s, idx) => (idx === i ? { ...s, type: t } : s))
443
- handleUpdate({ segments: updated })
444
- }}
1011
+ onChange={(v) => handleUpdate({ swingDirection: v })}
445
1012
  options={[
446
- { label: 'Panel', value: 'panel' },
447
- { label: 'Glass', value: 'glass' },
448
- { label: 'Empty', value: 'empty' },
1013
+ { label: 'Inward', value: 'inward' },
1014
+ { label: 'Outward', value: 'outward' },
449
1015
  ]}
450
- value={seg.type}
1016
+ value={node.swingDirection}
451
1017
  />
1018
+ </div>
1019
+ </div>
1020
+ </PanelSection>
1021
+ )}
452
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">
453
1032
  <SliderControl
454
1033
  label="Height"
455
- max={95}
456
- min={5}
457
- onChange={(v) => setSegmentHeightRatio(i, v / 100)}
458
- precision={1}
459
- step={1}
460
- unit="%"
461
- value={Math.round(normHeights[i]! * 100 * 10) / 10}
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}
462
1041
  />
1042
+ </div>
1043
+ )}
1044
+ </PanelSection>
1045
+ )}
463
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">
464
1058
  <SliderControl
465
- label="Columns"
466
- max={8}
467
- min={1}
468
- onChange={(v) => {
469
- const n = Math.max(1, Math.min(8, Math.round(v)))
470
- const updated = node.segments.map((s, idx) =>
471
- idx === i ? { ...s, columnRatios: Array(n).fill(1 / n) } : s,
472
- )
473
- handleUpdate({ segments: updated })
474
- }}
475
- precision={0}
476
- step={1}
477
- value={numCols}
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}
478
1067
  />
479
-
480
- {numCols > 1 && (
481
- <div className="mt-1 border-border/50 border-t pt-1">
482
- {normCols.map((ratio, ci) => (
483
- <SliderControl
484
- key={`c-${ci}`}
485
- label={`C${ci + 1}`}
486
- max={95}
487
- min={5}
488
- onChange={(v) => setSegmentColumnRatio(i, ci, v / 100)}
489
- precision={1}
490
- step={1}
491
- unit="%"
492
- value={Math.round(ratio * 100 * 10) / 10}
493
- />
494
- ))}
495
- <SliderControl
496
- label="Divider"
497
- max={0.1}
498
- min={0.005}
499
- onChange={(v) => {
500
- const updated = node.segments.map((s, idx) =>
501
- idx === i ? { ...s, dividerThickness: v } : s,
502
- )
503
- handleUpdate({ segments: updated })
504
- }}
505
- precision={3}
506
- step={0.005}
507
- unit="m"
508
- value={Math.round(seg.dividerThickness * 1000) / 1000}
509
- />
510
- </div>
511
- )}
512
-
513
- {seg.type === 'panel' && (
514
- <div className="mt-1 border-border/50 border-t pt-1">
515
- <SliderControl
516
- label="Inset"
517
- max={0.1}
518
- min={0}
519
- onChange={(v) => {
520
- const updated = node.segments.map((s, idx) =>
521
- idx === i ? { ...s, panelInset: v } : s,
522
- )
523
- handleUpdate({ segments: updated })
524
- }}
525
- precision={3}
526
- step={0.005}
527
- unit="m"
528
- value={Math.round(seg.panelInset * 1000) / 1000}
529
- />
530
- <SliderControl
531
- label="Depth"
532
- max={0.1}
533
- min={0}
534
- onChange={(v) => {
535
- const updated = node.segments.map((s, idx) =>
536
- idx === i ? { ...s, panelDepth: v } : s,
537
- )
538
- handleUpdate({ segments: updated })
539
- }}
540
- precision={3}
541
- step={0.005}
542
- unit="m"
543
- value={Math.round(seg.panelDepth * 1000) / 1000}
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}
544
1080
  />
545
1081
  </div>
546
1082
  )}
547
1083
  </div>
548
- )
549
- })}
1084
+ )}
1085
+ </PanelSection>
1086
+ )}
550
1087
 
551
- <div className="flex gap-1.5 px-1 pt-1">
552
- <ActionButton
553
- label="+ Add Segment"
554
- onClick={() => {
555
- const updated = [
556
- ...node.segments,
557
- {
558
- type: 'panel' as const,
559
- heightRatio: 1,
560
- columnRatios: [1],
561
- dividerThickness: 0.03,
562
- panelDepth: 0.01,
563
- panelInset: 0.04,
564
- },
565
- ]
566
- handleUpdate({ segments: updated })
567
- }}
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 })}
568
1099
  />
569
- {node.segments.length > 1 && (
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">
570
1243
  <ActionButton
571
- className="text-white/60 hover:text-white"
572
- label="- Remove"
573
- onClick={() => handleUpdate({ segments: node.segments.slice(0, -1) })}
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
+ }}
574
1259
  />
575
- )}
576
- </div>
577
- </PanelSection>
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
+ )}
578
1273
 
579
1274
  <PanelSection title="Actions">
580
1275
  <ActionGroup>
@@ -592,9 +1287,6 @@ export function DoorPanel() {
592
1287
  />
593
1288
  </ActionGroup>
594
1289
  </PanelSection>
595
- <PanelSection title="Material">
596
- <MaterialPicker onChange={handleMaterialChange} value={node.material} />
597
- </PanelSection>
598
1290
  </PanelWrapper>
599
1291
  )
600
1292
  }