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