@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
@@ -4,54 +4,165 @@ import {
4
4
  type AnyNode,
5
5
  type AnyNodeId,
6
6
  emitter,
7
- type MaterialSchema,
7
+ useInteractive,
8
8
  useScene,
9
9
  WindowNode,
10
10
  } from '@pascal-app/core'
11
11
  import { useViewer } from '@pascal-app/viewer'
12
12
  import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
13
- import { useCallback } from '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
+ import { SegmentedControl } from '../controls/segmented-control'
21
22
  import { SliderControl } from '../controls/slider-control'
22
23
  import { ToggleControl } from '../controls/toggle-control'
23
24
  import { PanelWrapper } from './panel-wrapper'
24
25
  import { PresetsPopover } from './presets/presets-popover'
25
26
 
27
+ function isSameWindowValue(current: unknown, next: unknown): boolean {
28
+ if (typeof current === 'number' && typeof next === 'number') {
29
+ return Math.abs(current - next) < 1e-6
30
+ }
31
+
32
+ if (Array.isArray(current) && Array.isArray(next)) {
33
+ return (
34
+ current.length === next.length &&
35
+ current.every((value, index) => isSameWindowValue(value, next[index]))
36
+ )
37
+ }
38
+
39
+ return Object.is(current, next)
40
+ }
41
+
42
+ function getMaxSharedWindowRadius(width: number, height: number) {
43
+ return Math.max(0, Math.min(width / 2, height / 2))
44
+ }
45
+
46
+ function normalizeWindowCornerRadii(
47
+ radii: [number, number, number, number],
48
+ width: number,
49
+ height: number,
50
+ ): [number, number, number, number] {
51
+ const next = radii.map((radius) => Math.max(radius, 0)) as [number, number, number, number]
52
+ const scale = Math.min(
53
+ 1,
54
+ Math.max(width, 0) / Math.max(next[0] + next[1], 1e-6),
55
+ Math.max(width, 0) / Math.max(next[3] + next[2], 1e-6),
56
+ Math.max(height, 0) / Math.max(next[0] + next[3], 1e-6),
57
+ Math.max(height, 0) / Math.max(next[1] + next[2], 1e-6),
58
+ )
59
+
60
+ if (scale >= 1) return next
61
+
62
+ return next.map((radius) => radius * scale) as [number, number, number, number]
63
+ }
64
+
65
+ function isSameRadiusTuple(
66
+ current: [number, number, number, number],
67
+ next: [number, number, number, number],
68
+ ) {
69
+ return current.every((value, index) => Math.abs(value - (next[index] ?? 0)) < 1e-6)
70
+ }
71
+
72
+ const windowTypeOptions: Array<{ label: string; value: WindowNode['windowType'] }> = [
73
+ { label: 'Fixed', value: 'fixed' },
74
+ { label: 'Sliding', value: 'sliding' },
75
+ { label: 'Casement', value: 'casement' },
76
+ { label: 'Awning', value: 'awning' },
77
+ { label: 'Single Hung', value: 'single-hung' },
78
+ { label: 'Double Hung', value: 'double-hung' },
79
+ { label: 'Bay', value: 'bay' },
80
+ { label: 'Bow', value: 'bow' },
81
+ { label: 'Louvered', value: 'louvered' },
82
+ ]
83
+
84
+ const rectangleOnlyWindowTypes = new Set<WindowNode['windowType']>([
85
+ 'sliding',
86
+ 'single-hung',
87
+ 'double-hung',
88
+ 'bay',
89
+ 'bow',
90
+ ])
91
+
26
92
  export function WindowPanel() {
27
- const selectedIds = useViewer((s) => s.selection.selectedIds)
93
+ const selectedId = useViewer((s) => s.selection.selectedIds[0])
28
94
  const setSelection = useViewer((s) => s.setSelection)
29
- const nodes = useScene((s) => s.nodes)
30
95
  const updateNode = useScene((s) => s.updateNode)
31
96
  const deleteNode = useScene((s) => s.deleteNode)
32
97
  const setMovingNode = useEditor((s) => s.setMovingNode)
98
+ const previewRef = useRef<{
99
+ id: AnyNodeId
100
+ key: keyof WindowNode
101
+ value: unknown
102
+ } | null>(null)
33
103
 
34
104
  const adapter = usePresetsAdapter()
35
105
 
36
- const selectedId = selectedIds[0]
37
- const node = selectedId
38
- ? (nodes[selectedId as AnyNode['id']] as WindowNode | undefined)
39
- : undefined
106
+ const node = useScene((s) =>
107
+ selectedId ? (s.nodes[selectedId as AnyNode['id']] as WindowNode | undefined) : undefined,
108
+ )
40
109
 
41
110
  const handleUpdate = useCallback(
42
111
  (updates: Partial<WindowNode>) => {
43
- if (!selectedId) return
112
+ if (!(selectedId && node)) return
113
+ const hasChange = Object.entries(updates).some(([key, value]) => {
114
+ const currentValue = node[key as keyof WindowNode]
115
+ return !isSameWindowValue(currentValue, value)
116
+ })
117
+ if (!hasChange) return
118
+
44
119
  updateNode(selectedId as AnyNode['id'], updates)
45
120
  useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
46
121
  },
47
- [selectedId, updateNode],
122
+ [selectedId, node, updateNode],
48
123
  )
49
124
 
50
- const handleMaterialChange = useCallback(
51
- (material: MaterialSchema) => {
52
- handleUpdate({ material })
125
+ const previewWindowUpdate = useCallback(
126
+ <K extends keyof WindowNode>(key: K, value: WindowNode[K]) => {
127
+ if (!selectedId) return
128
+ const liveNode = useScene.getState().nodes[selectedId as AnyNodeId]
129
+ if (liveNode?.type !== 'window') return
130
+
131
+ if (
132
+ !(previewRef.current && previewRef.current.id === selectedId && previewRef.current.key === key)
133
+ ) {
134
+ previewRef.current = {
135
+ id: selectedId as AnyNodeId,
136
+ key,
137
+ value: liveNode[key],
138
+ }
139
+ }
140
+
141
+ if (isSameWindowValue(liveNode[key], value)) return
142
+
143
+ ;(liveNode as WindowNode)[key] = value
144
+ useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
53
145
  },
54
- [handleUpdate],
146
+ [selectedId],
147
+ )
148
+
149
+ const commitWindowPreview = useCallback(
150
+ <K extends keyof WindowNode>(key: K, value: WindowNode[K]) => {
151
+ if (!selectedId) return
152
+
153
+ const scene = useScene.getState()
154
+ const liveNode = scene.nodes[selectedId as AnyNodeId]
155
+ const preview = previewRef.current
156
+ if (liveNode?.type === 'window' && preview?.id === selectedId && preview.key === key) {
157
+ ;(liveNode as WindowNode)[key] = preview.value as WindowNode[K]
158
+ scene.dirtyNodes.add(selectedId as AnyNodeId)
159
+ }
160
+ previewRef.current = null
161
+
162
+ updateNode(selectedId as AnyNode['id'], { [key]: value } as Partial<WindowNode>)
163
+ scene.dirtyNodes.add(selectedId as AnyNodeId)
164
+ },
165
+ [selectedId, updateNode],
55
166
  )
56
167
 
57
168
  const handleClose = useCallback(() => {
@@ -93,8 +204,20 @@ export function WindowPanel() {
93
204
  parentId: node.parentId,
94
205
  width: node.width,
95
206
  height: node.height,
207
+ windowType: node.windowType,
208
+ operationState: node.operationState,
209
+ awningDirection: node.awningDirection,
210
+ casementStyle: node.casementStyle,
211
+ hingesSide: node.hingesSide,
96
212
  frameThickness: node.frameThickness,
97
213
  frameDepth: node.frameDepth,
214
+ openingKind: node.openingKind,
215
+ openingShape: node.openingShape,
216
+ openingRadiusMode: node.openingRadiusMode ?? 'all',
217
+ openingCornerRadii: [...(node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15])],
218
+ cornerRadius: node.cornerRadius,
219
+ archHeight: node.archHeight,
220
+ openingRevealRadius: node.openingRevealRadius,
98
221
  columnRatios: [...node.columnRatios],
99
222
  rowRatios: [...node.rowRatios],
100
223
  columnDividerThickness: node.columnDividerThickness,
@@ -114,8 +237,20 @@ export function WindowPanel() {
114
237
  return {
115
238
  width: node.width,
116
239
  height: node.height,
240
+ windowType: node.windowType,
241
+ operationState: node.operationState,
242
+ awningDirection: node.awningDirection,
243
+ casementStyle: node.casementStyle,
244
+ hingesSide: node.hingesSide,
117
245
  frameThickness: node.frameThickness,
118
246
  frameDepth: node.frameDepth,
247
+ openingKind: node.openingKind,
248
+ openingShape: node.openingShape,
249
+ openingRadiusMode: node.openingRadiusMode ?? 'all',
250
+ openingCornerRadii: node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15],
251
+ cornerRadius: node.cornerRadius,
252
+ archHeight: node.archHeight,
253
+ openingRevealRadius: node.openingRevealRadius,
119
254
  columnRatios: node.columnRatios,
120
255
  rowRatios: node.rowRatios,
121
256
  columnDividerThickness: node.columnDividerThickness,
@@ -153,7 +288,7 @@ export function WindowPanel() {
153
288
  [handleUpdate],
154
289
  )
155
290
 
156
- if (!node || node.type !== 'window' || selectedIds.length !== 1) return null
291
+ if (!(node && node.type === 'window' && selectedId)) return null
157
292
 
158
293
  const numCols = node.columnRatios.length
159
294
  const numRows = node.rowRatios.length
@@ -162,6 +297,75 @@ export function WindowPanel() {
162
297
  const rowSum = node.rowRatios.reduce((a, b) => a + b, 0)
163
298
  const normCols = node.columnRatios.map((r) => r / colSum)
164
299
  const normRows = node.rowRatios.map((r) => r / rowSum)
300
+ const isOpening = node.openingKind === 'opening'
301
+ const openingShape = node.openingShape ?? 'rectangle'
302
+ const windowShape = openingShape === 'arch' || openingShape === 'rounded' ? openingShape : 'rectangle'
303
+ const openingRadiusMode = node.openingRadiusMode ?? 'all'
304
+ const openingCornerRadii = node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15]
305
+ const cornerRadius = node.cornerRadius ?? 0.15
306
+ const archHeight = node.archHeight ?? 0.35
307
+ const openingRevealRadius = node.openingRevealRadius ?? 0.025
308
+ const maxRoundedRadius = Math.max(0.01, getMaxSharedWindowRadius(node.width, node.height))
309
+ const displayedWindowType = node.windowType === 'hopper' ? 'awning' : (node.windowType ?? 'fixed')
310
+ const awningDirection = node.windowType === 'hopper' ? 'down' : (node.awningDirection ?? 'up')
311
+ const isOperableWindow =
312
+ node.windowType === 'sliding' ||
313
+ node.windowType === 'casement' ||
314
+ node.windowType === 'awning' ||
315
+ node.windowType === 'hopper' ||
316
+ node.windowType === 'single-hung' ||
317
+ node.windowType === 'double-hung' ||
318
+ node.windowType === 'louvered'
319
+
320
+ const setOperationState = (value: number) => {
321
+ useInteractive.getState().cancelWindowAnimation(node.id)
322
+ useInteractive.getState().removeWindowOpenState(node.id)
323
+ handleUpdate({ operationState: Math.max(0, Math.min(1, value)) })
324
+ }
325
+
326
+ const getDimensionUpdates = (updates: Partial<Pick<WindowNode, 'width' | 'height'>>) => {
327
+ const nextWidth = updates.width ?? node.width
328
+ const nextHeight = updates.height ?? node.height
329
+ const nextUpdates: Partial<WindowNode> = { ...updates }
330
+
331
+ if (openingShape === 'rounded') {
332
+ if (openingRadiusMode === 'individual') {
333
+ const currentRadii = openingCornerRadii as [number, number, number, number]
334
+ const nextRadii = normalizeWindowCornerRadii(
335
+ openingCornerRadii as [number, number, number, number],
336
+ nextWidth,
337
+ nextHeight,
338
+ )
339
+ if (!isSameRadiusTuple(currentRadii, nextRadii)) {
340
+ nextUpdates.openingCornerRadii = nextRadii
341
+ }
342
+ } else {
343
+ const nextRadius = Math.min(Math.max(cornerRadius, 0), getMaxSharedWindowRadius(nextWidth, nextHeight))
344
+ if (Math.abs(nextRadius - cornerRadius) > 1e-6) {
345
+ nextUpdates.cornerRadius = nextRadius
346
+ }
347
+ }
348
+ }
349
+
350
+ if (openingShape === 'arch') {
351
+ const nextArchHeight = Math.min(Math.max(archHeight, 0.05), Math.max(nextHeight, 0.05))
352
+ if (Math.abs(nextArchHeight - archHeight) > 1e-6) {
353
+ nextUpdates.archHeight = nextArchHeight
354
+ }
355
+ }
356
+
357
+ return nextUpdates
358
+ }
359
+
360
+ const setOpeningCornerRadius = (index: number, value: number, commit = false) => {
361
+ const next = [...openingCornerRadii] as [number, number, number, number]
362
+ next[index] = value
363
+ if (commit) {
364
+ commitWindowPreview('openingCornerRadii', next)
365
+ } else {
366
+ previewWindowUpdate('openingCornerRadii', next)
367
+ }
368
+ }
165
369
 
166
370
  const setColumnRatio = (index: number, newVal: number) => {
167
371
  const clamped = Math.max(0.05, Math.min(0.95, newVal))
@@ -217,6 +421,122 @@ export function WindowPanel() {
217
421
  </PresetsPopover>
218
422
  </div>
219
423
 
424
+ <PanelSection title="Type">
425
+ <SegmentedControl
426
+ onChange={(value) =>
427
+ handleUpdate({
428
+ openingKind: value as WindowNode['openingKind'],
429
+ ...(value === 'opening'
430
+ ? {
431
+ openingShape,
432
+ openingRadiusMode,
433
+ openingCornerRadii,
434
+ cornerRadius,
435
+ archHeight,
436
+ openingRevealRadius,
437
+ }
438
+ : {}),
439
+ })
440
+ }
441
+ options={[
442
+ { value: 'window', label: 'Window' },
443
+ { value: 'opening', label: 'Opening' },
444
+ ]}
445
+ value={node.openingKind ?? 'window'}
446
+ />
447
+ </PanelSection>
448
+
449
+ {!isOpening && (
450
+ <PanelSection title="Window Type">
451
+ <div className="grid grid-cols-2 gap-1.5 px-1 pt-1">
452
+ {windowTypeOptions.map((option) => {
453
+ const isSelected = displayedWindowType === option.value
454
+ return (
455
+ <button
456
+ className={cn(
457
+ 'flex min-h-12 items-center rounded-lg border px-2.5 text-left text-xs transition-colors',
458
+ isSelected
459
+ ? 'border-orange-400/60 bg-orange-400/10 text-foreground'
460
+ : 'border-border/50 bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground',
461
+ )}
462
+ key={option.value}
463
+ onClick={() =>
464
+ handleUpdate({
465
+ windowType: option.value,
466
+ ...(option.value === 'awning' ? { awningDirection } : {}),
467
+ ...(rectangleOnlyWindowTypes.has(option.value)
468
+ ? { openingShape: 'rectangle' }
469
+ : {}),
470
+ ...(option.value === 'bay' || option.value === 'bow' ? { sill: false } : {}),
471
+ })
472
+ }
473
+ type="button"
474
+ >
475
+ <span className="truncate font-medium">{option.label}</span>
476
+ </button>
477
+ )
478
+ })}
479
+ </div>
480
+ {displayedWindowType === 'awning' && (
481
+ <div className="mt-2">
482
+ <SegmentedControl
483
+ onChange={(value) =>
484
+ handleUpdate({
485
+ windowType: 'awning',
486
+ awningDirection: value as WindowNode['awningDirection'],
487
+ })
488
+ }
489
+ options={[
490
+ { value: 'up', label: 'Up' },
491
+ { value: 'down', label: 'Down' },
492
+ ]}
493
+ value={awningDirection}
494
+ />
495
+ </div>
496
+ )}
497
+ {node.windowType === 'casement' && (
498
+ <div className="mt-2 space-y-2">
499
+ <SegmentedControl
500
+ onChange={(value) =>
501
+ handleUpdate({ casementStyle: value as WindowNode['casementStyle'] })
502
+ }
503
+ options={[
504
+ { value: 'single', label: 'Single' },
505
+ { value: 'french', label: 'French' },
506
+ ]}
507
+ value={node.casementStyle ?? 'single'}
508
+ />
509
+ {(node.casementStyle ?? 'single') === 'single' && (
510
+ <SegmentedControl
511
+ onChange={(value) =>
512
+ handleUpdate({ hingesSide: value as WindowNode['hingesSide'] })
513
+ }
514
+ options={[
515
+ { value: 'left', label: 'Left' },
516
+ { value: 'right', label: 'Right' },
517
+ ]}
518
+ value={node.hingesSide ?? 'left'}
519
+ />
520
+ )}
521
+ </div>
522
+ )}
523
+ {isOperableWindow && (
524
+ <div className="mt-2">
525
+ <SliderControl
526
+ label="Open"
527
+ max={1}
528
+ min={0}
529
+ onChange={setOperationState}
530
+ precision={2}
531
+ restoreOnCommit={false}
532
+ step={0.05}
533
+ value={Math.round((node.operationState ?? 0) * 100) / 100}
534
+ />
535
+ </div>
536
+ )}
537
+ </PanelSection>
538
+ )}
539
+
220
540
  <PanelSection title="Position">
221
541
  <SliderControl
222
542
  label={
@@ -242,22 +562,25 @@ export function WindowPanel() {
242
562
  unit="m"
243
563
  value={Math.round(node.position[1] * 100) / 100}
244
564
  />
245
- <div className="px-1 pt-2 pb-1">
246
- <ActionButton
247
- className="w-full"
248
- icon={<FlipHorizontal2 className="h-4 w-4" />}
249
- label="Flip Side"
250
- onClick={handleFlip}
251
- />
252
- </div>
565
+ {!isOpening && (
566
+ <div className="px-1 pt-2 pb-1">
567
+ <ActionButton
568
+ className="w-full"
569
+ icon={<FlipHorizontal2 className="h-4 w-4" />}
570
+ label="Flip Side"
571
+ onClick={handleFlip}
572
+ />
573
+ </div>
574
+ )}
253
575
  </PanelSection>
254
576
 
255
577
  <PanelSection title="Dimensions">
256
578
  <SliderControl
257
579
  label="Width"
258
580
  min={0}
259
- onChange={(v) => handleUpdate({ width: v })}
581
+ onChange={(v) => handleUpdate(getDimensionUpdates({ width: v }))}
260
582
  precision={2}
583
+ restoreOnCommit={false}
261
584
  step={0.1}
262
585
  unit="m"
263
586
  value={Math.round(node.width * 100) / 100}
@@ -265,157 +588,356 @@ export function WindowPanel() {
265
588
  <SliderControl
266
589
  label="Height"
267
590
  min={0}
268
- onChange={(v) => handleUpdate({ height: v })}
591
+ onChange={(v) => handleUpdate(getDimensionUpdates({ height: v }))}
269
592
  precision={2}
593
+ restoreOnCommit={false}
270
594
  step={0.1}
271
595
  unit="m"
272
596
  value={Math.round(node.height * 100) / 100}
273
597
  />
274
598
  </PanelSection>
275
599
 
276
- <PanelSection title="Frame">
277
- <SliderControl
278
- label="Thickness"
279
- min={0}
280
- onChange={(v) => handleUpdate({ frameThickness: v })}
281
- precision={3}
282
- step={0.01}
283
- unit="m"
284
- value={Math.round(node.frameThickness * 1000) / 1000}
285
- />
286
- <SliderControl
287
- label="Depth"
288
- min={0}
289
- onChange={(v) => handleUpdate({ frameDepth: v })}
290
- precision={3}
291
- step={0.01}
292
- unit="m"
293
- value={Math.round(node.frameDepth * 1000) / 1000}
294
- />
295
- </PanelSection>
296
-
297
- <PanelSection title="Grid">
298
- <SliderControl
299
- label="Columns"
300
- max={8}
301
- min={1}
302
- onChange={(v) => {
303
- const n = Math.max(1, Math.min(8, Math.round(v)))
304
- handleUpdate({ columnRatios: Array(n).fill(1 / n) })
305
- }}
306
- precision={0}
307
- step={1}
308
- value={numCols}
309
- />
310
- <SliderControl
311
- label="Rows"
312
- max={8}
313
- min={1}
314
- onChange={(v) => {
315
- const n = Math.max(1, Math.min(8, Math.round(v)))
316
- handleUpdate({ rowRatios: Array(n).fill(1 / n) })
317
- }}
318
- precision={0}
319
- step={1}
320
- value={numRows}
321
- />
322
-
323
- {numCols > 1 && (
324
- <div className="mt-2 flex flex-col gap-1">
325
- <div className="mb-1 px-1 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
326
- Col Widths
327
- </div>
328
- {normCols.map((ratio, i) => (
329
- <SliderControl
330
- key={`c-${i}`}
331
- label={`C${i + 1}`}
332
- max={95}
333
- min={5}
334
- onChange={(v) => setColumnRatio(i, v / 100)}
335
- precision={1}
336
- step={1}
337
- unit="%"
338
- value={Math.round(ratio * 100 * 10) / 10}
600
+ {!isOpening && !rectangleOnlyWindowTypes.has(node.windowType) && (
601
+ <PanelSection title="Corner Shape">
602
+ <SegmentedControl
603
+ onChange={(value) =>
604
+ handleUpdate({
605
+ openingShape: value as WindowNode['openingShape'],
606
+ ...(value === 'rounded'
607
+ ? {
608
+ openingRadiusMode,
609
+ openingCornerRadii,
610
+ cornerRadius: Math.min(cornerRadius, maxRoundedRadius),
611
+ openingRevealRadius,
612
+ sill: false,
613
+ }
614
+ : {}),
615
+ ...(value === 'arch' ? { archHeight } : {}),
616
+ })
617
+ }
618
+ options={[
619
+ { value: 'rectangle', label: 'Rect' },
620
+ { value: 'rounded', label: 'Rounded' },
621
+ { value: 'arch', label: 'Arch' },
622
+ ]}
623
+ value={windowShape}
624
+ />
625
+ {windowShape === 'rounded' && (
626
+ <div className="mt-2 flex flex-col gap-1">
627
+ <SegmentedControl
628
+ onChange={(value) =>
629
+ handleUpdate({ openingRadiusMode: value as WindowNode['openingRadiusMode'] })
630
+ }
631
+ options={[
632
+ { value: 'all', label: 'All' },
633
+ { value: 'individual', label: 'Individual' },
634
+ ]}
635
+ value={openingRadiusMode}
339
636
  />
340
- ))}
341
- <div className="mt-1 border-border/50 border-t pt-1">
637
+ {openingRadiusMode === 'all' ? (
638
+ <SliderControl
639
+ label="Corner Radius"
640
+ max={maxRoundedRadius}
641
+ min={0}
642
+ onChange={(value) => previewWindowUpdate('cornerRadius', value)}
643
+ onCommit={(value) => commitWindowPreview('cornerRadius', value)}
644
+ precision={2}
645
+ step={0.05}
646
+ unit="m"
647
+ value={Math.round(cornerRadius * 100) / 100}
648
+ />
649
+ ) : (
650
+ <>
651
+ {[
652
+ ['Top Left', 0],
653
+ ['Top Right', 1],
654
+ ['Bottom Right', 2],
655
+ ['Bottom Left', 3],
656
+ ].map(([label, index]) => (
657
+ <SliderControl
658
+ key={label}
659
+ label={label}
660
+ max={maxRoundedRadius}
661
+ min={0}
662
+ onChange={(value) => setOpeningCornerRadius(index as number, value)}
663
+ onCommit={(value) => setOpeningCornerRadius(index as number, value, true)}
664
+ precision={2}
665
+ step={0.05}
666
+ unit="m"
667
+ value={Math.round((openingCornerRadii[index as number] ?? 0) * 100) / 100}
668
+ />
669
+ ))}
670
+ </>
671
+ )}
342
672
  <SliderControl
343
- label="Divider"
344
- max={0.1}
345
- min={0.005}
346
- onChange={(v) => handleUpdate({ columnDividerThickness: v })}
673
+ label="Reveal Radius"
674
+ max={0.08}
675
+ min={0}
676
+ onChange={(value) => previewWindowUpdate('openingRevealRadius', value)}
677
+ onCommit={(value) => commitWindowPreview('openingRevealRadius', value)}
347
678
  precision={3}
348
- step={0.01}
679
+ step={0.005}
349
680
  unit="m"
350
- value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000}
681
+ value={Math.round(openingRevealRadius * 1000) / 1000}
351
682
  />
352
683
  </div>
353
- </div>
354
- )}
355
-
356
- {numRows > 1 && (
357
- <div className="mt-2 flex flex-col gap-1">
358
- <div className="mb-1 px-1 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
359
- Row Heights
360
- </div>
361
- {normRows.map((ratio, i) => (
684
+ )}
685
+ {windowShape === 'arch' && (
686
+ <div className="mt-2 flex flex-col gap-1">
362
687
  <SliderControl
363
- key={`r-${i}`}
364
- label={`R${i + 1}`}
365
- max={95}
366
- min={5}
367
- onChange={(v) => setRowRatio(i, v / 100)}
368
- precision={1}
369
- step={1}
370
- unit="%"
371
- value={Math.round(ratio * 100 * 10) / 10}
688
+ label="Arch Height"
689
+ max={Math.max(0.05, node.height)}
690
+ min={0.05}
691
+ onChange={(value) => handleUpdate({ archHeight: value })}
692
+ precision={2}
693
+ restoreOnCommit={false}
694
+ step={0.05}
695
+ unit="m"
696
+ value={Math.round(archHeight * 100) / 100}
372
697
  />
373
- ))}
374
- <div className="mt-1 border-border/50 border-t pt-1">
698
+ </div>
699
+ )}
700
+ </PanelSection>
701
+ )}
702
+
703
+ {isOpening && (
704
+ <PanelSection title="Opening Shape">
705
+ <SegmentedControl
706
+ onChange={(value) =>
707
+ handleUpdate({ openingShape: value as WindowNode['openingShape'] })
708
+ }
709
+ options={[
710
+ { value: 'rectangle', label: 'Rect' },
711
+ { value: 'rounded', label: 'Rounded' },
712
+ { value: 'arch', label: 'Arch' },
713
+ ]}
714
+ value={openingShape}
715
+ />
716
+ {openingShape === 'rounded' && (
717
+ <div className="mt-2 flex flex-col gap-1">
718
+ <SegmentedControl
719
+ onChange={(value) =>
720
+ handleUpdate({ openingRadiusMode: value as WindowNode['openingRadiusMode'] })
721
+ }
722
+ options={[
723
+ { value: 'all', label: 'All' },
724
+ { value: 'individual', label: 'Individual' },
725
+ ]}
726
+ value={openingRadiusMode}
727
+ />
728
+ {openingRadiusMode === 'all' ? (
729
+ <SliderControl
730
+ label="Corner Radius"
731
+ max={maxRoundedRadius}
732
+ min={0}
733
+ onChange={(value) => previewWindowUpdate('cornerRadius', value)}
734
+ onCommit={(value) => commitWindowPreview('cornerRadius', value)}
735
+ precision={2}
736
+ step={0.05}
737
+ unit="m"
738
+ value={Math.round(cornerRadius * 100) / 100}
739
+ />
740
+ ) : (
741
+ <>
742
+ {[
743
+ ['Top Left', 0],
744
+ ['Top Right', 1],
745
+ ['Bottom Right', 2],
746
+ ['Bottom Left', 3],
747
+ ].map(([label, index]) => (
748
+ <SliderControl
749
+ key={label}
750
+ label={label}
751
+ max={maxRoundedRadius}
752
+ min={0}
753
+ onChange={(value) => setOpeningCornerRadius(index as number, value)}
754
+ onCommit={(value) => setOpeningCornerRadius(index as number, value, true)}
755
+ precision={2}
756
+ step={0.05}
757
+ unit="m"
758
+ value={Math.round((openingCornerRadii[index as number] ?? 0) * 100) / 100}
759
+ />
760
+ ))}
761
+ </>
762
+ )}
375
763
  <SliderControl
376
- label="Divider"
377
- max={0.1}
378
- min={0.005}
379
- onChange={(v) => handleUpdate({ rowDividerThickness: v })}
764
+ label="Reveal Radius"
765
+ max={0.08}
766
+ min={0}
767
+ onChange={(value) => previewWindowUpdate('openingRevealRadius', value)}
768
+ onCommit={(value) => commitWindowPreview('openingRevealRadius', value)}
380
769
  precision={3}
381
- step={0.01}
770
+ step={0.005}
382
771
  unit="m"
383
- value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000}
772
+ value={Math.round(openingRevealRadius * 1000) / 1000}
384
773
  />
385
774
  </div>
386
- </div>
387
- )}
388
- </PanelSection>
775
+ )}
776
+ {openingShape === 'arch' && (
777
+ <div className="mt-2 flex flex-col gap-1">
778
+ <SliderControl
779
+ label="Arch Height"
780
+ max={Math.max(0.05, node.height)}
781
+ min={0.05}
782
+ onChange={(value) => handleUpdate({ archHeight: value })}
783
+ precision={2}
784
+ restoreOnCommit={false}
785
+ step={0.05}
786
+ unit="m"
787
+ value={Math.round(archHeight * 100) / 100}
788
+ />
789
+ </div>
790
+ )}
791
+ </PanelSection>
792
+ )}
389
793
 
390
- <PanelSection title="Sill">
391
- <ToggleControl
392
- checked={node.sill}
393
- label="Enable Sill"
394
- onChange={(checked) => handleUpdate({ sill: checked })}
395
- />
396
- {node.sill && (
397
- <div className="mt-1 flex flex-col gap-1">
794
+ {!isOpening && (
795
+ <>
796
+ <PanelSection title="Frame">
398
797
  <SliderControl
399
- label="Depth"
798
+ label="Thickness"
400
799
  min={0}
401
- onChange={(v) => handleUpdate({ sillDepth: v })}
800
+ onChange={(v) => handleUpdate({ frameThickness: v })}
402
801
  precision={3}
403
802
  step={0.01}
404
803
  unit="m"
405
- value={Math.round(node.sillDepth * 1000) / 1000}
804
+ value={Math.round(node.frameThickness * 1000) / 1000}
406
805
  />
407
806
  <SliderControl
408
- label="Thickness"
807
+ label="Depth"
409
808
  min={0}
410
- onChange={(v) => handleUpdate({ sillThickness: v })}
809
+ onChange={(v) => handleUpdate({ frameDepth: v })}
411
810
  precision={3}
412
811
  step={0.01}
413
812
  unit="m"
414
- value={Math.round(node.sillThickness * 1000) / 1000}
813
+ value={Math.round(node.frameDepth * 1000) / 1000}
415
814
  />
416
- </div>
417
- )}
418
- </PanelSection>
815
+ </PanelSection>
816
+
817
+ <PanelSection title="Grid">
818
+ <SliderControl
819
+ label="Columns"
820
+ max={8}
821
+ min={1}
822
+ onChange={(v) => {
823
+ const n = Math.max(1, Math.min(8, Math.round(v)))
824
+ handleUpdate({ columnRatios: Array(n).fill(1 / n) })
825
+ }}
826
+ precision={0}
827
+ step={1}
828
+ value={numCols}
829
+ />
830
+ <SliderControl
831
+ label="Rows"
832
+ max={8}
833
+ min={1}
834
+ onChange={(v) => {
835
+ const n = Math.max(1, Math.min(8, Math.round(v)))
836
+ handleUpdate({ rowRatios: Array(n).fill(1 / n) })
837
+ }}
838
+ precision={0}
839
+ step={1}
840
+ value={numRows}
841
+ />
842
+
843
+ {numCols > 1 && (
844
+ <div className="mt-2 flex flex-col gap-1">
845
+ <div className="mb-1 px-1 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
846
+ Col Widths
847
+ </div>
848
+ {normCols.map((ratio, i) => (
849
+ <SliderControl
850
+ key={`c-${i}`}
851
+ label={`C${i + 1}`}
852
+ max={95}
853
+ min={5}
854
+ onChange={(v) => setColumnRatio(i, v / 100)}
855
+ precision={1}
856
+ step={1}
857
+ unit="%"
858
+ value={Math.round(ratio * 100 * 10) / 10}
859
+ />
860
+ ))}
861
+ <div className="mt-1 border-border/50 border-t pt-1">
862
+ <SliderControl
863
+ label="Divider"
864
+ max={0.1}
865
+ min={0.005}
866
+ onChange={(v) => handleUpdate({ columnDividerThickness: v })}
867
+ precision={3}
868
+ step={0.01}
869
+ unit="m"
870
+ value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000}
871
+ />
872
+ </div>
873
+ </div>
874
+ )}
875
+
876
+ {numRows > 1 && (
877
+ <div className="mt-2 flex flex-col gap-1">
878
+ <div className="mb-1 px-1 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
879
+ Row Heights
880
+ </div>
881
+ {normRows.map((ratio, i) => (
882
+ <SliderControl
883
+ key={`r-${i}`}
884
+ label={`R${i + 1}`}
885
+ max={95}
886
+ min={5}
887
+ onChange={(v) => setRowRatio(i, v / 100)}
888
+ precision={1}
889
+ step={1}
890
+ unit="%"
891
+ value={Math.round(ratio * 100 * 10) / 10}
892
+ />
893
+ ))}
894
+ <div className="mt-1 border-border/50 border-t pt-1">
895
+ <SliderControl
896
+ label="Divider"
897
+ max={0.1}
898
+ min={0.005}
899
+ onChange={(v) => handleUpdate({ rowDividerThickness: v })}
900
+ precision={3}
901
+ step={0.01}
902
+ unit="m"
903
+ value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000}
904
+ />
905
+ </div>
906
+ </div>
907
+ )}
908
+ </PanelSection>
909
+
910
+ <PanelSection title="Sill">
911
+ <ToggleControl
912
+ checked={node.sill}
913
+ label="Enable Sill"
914
+ onChange={(checked) => handleUpdate({ sill: checked })}
915
+ />
916
+ {node.sill && (
917
+ <div className="mt-1 flex flex-col gap-1">
918
+ <SliderControl
919
+ label="Depth"
920
+ min={0}
921
+ onChange={(v) => handleUpdate({ sillDepth: v })}
922
+ precision={3}
923
+ step={0.01}
924
+ unit="m"
925
+ value={Math.round(node.sillDepth * 1000) / 1000}
926
+ />
927
+ <SliderControl
928
+ label="Thickness"
929
+ min={0}
930
+ onChange={(v) => handleUpdate({ sillThickness: v })}
931
+ precision={3}
932
+ step={0.01}
933
+ unit="m"
934
+ value={Math.round(node.sillThickness * 1000) / 1000}
935
+ />
936
+ </div>
937
+ )}
938
+ </PanelSection>
939
+ </>
940
+ )}
419
941
 
420
942
  <PanelSection title="Actions">
421
943
  <ActionGroup>
@@ -433,9 +955,6 @@ export function WindowPanel() {
433
955
  />
434
956
  </ActionGroup>
435
957
  </PanelSection>
436
- <PanelSection title="Material">
437
- <MaterialPicker onChange={handleMaterialChange} value={node.material} />
438
- </PanelSection>
439
958
  </PanelWrapper>
440
959
  )
441
960
  }