@pascal-app/editor 0.6.0 → 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 (122) hide show
  1. package/package.json +9 -5
  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 +20 -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 +32 -55
  10. package/src/components/editor/floorplan-background-selection.ts +113 -0
  11. package/src/components/editor/floorplan-panel.tsx +9855 -3298
  12. package/src/components/editor/index.tsx +269 -21
  13. package/src/components/editor/selection-manager.tsx +575 -13
  14. package/src/components/editor/thumbnail-generator.tsx +38 -7
  15. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  16. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  17. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  18. package/src/components/editor/wall-measurement-label.tsx +267 -36
  19. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  20. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  21. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  22. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
  23. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  24. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
  25. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  26. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  27. package/src/components/editor-2d/svg-paths.ts +119 -0
  28. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  29. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  30. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  31. package/src/components/tools/column/column-tool.tsx +97 -0
  32. package/src/components/tools/column/move-column-tool.tsx +105 -0
  33. package/src/components/tools/door/door-tool.tsx +7 -0
  34. package/src/components/tools/door/move-door-tool.tsx +28 -8
  35. package/src/components/tools/fence/fence-drafting.ts +10 -3
  36. package/src/components/tools/fence/fence-tool.tsx +159 -3
  37. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +129 -18
  38. package/src/components/tools/fence/move-fence-tool.tsx +101 -34
  39. package/src/components/tools/item/move-tool.tsx +10 -1
  40. package/src/components/tools/item/placement-math.ts +30 -1
  41. package/src/components/tools/item/placement-strategies.ts +109 -31
  42. package/src/components/tools/item/placement-types.ts +7 -0
  43. package/src/components/tools/item/use-draft-node.ts +2 -0
  44. package/src/components/tools/item/use-placement-coordinator.tsx +660 -52
  45. package/src/components/tools/roof/move-roof-tool.tsx +22 -15
  46. package/src/components/tools/shared/polygon-editor.tsx +153 -28
  47. package/src/components/tools/shared/segment-angle.ts +156 -0
  48. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  49. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  50. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  51. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  52. package/src/components/tools/tool-manager.tsx +18 -3
  53. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +121 -20
  54. package/src/components/tools/wall/wall-drafting.ts +18 -9
  55. package/src/components/tools/wall/wall-tool.tsx +134 -2
  56. package/src/components/tools/window/move-window-tool.tsx +18 -0
  57. package/src/components/tools/window/window-tool.tsx +5 -0
  58. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  59. package/src/components/ui/action-menu/control-modes.tsx +28 -1
  60. package/src/components/ui/action-menu/index.tsx +91 -1
  61. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  62. package/src/components/ui/action-menu/view-toggles.tsx +424 -35
  63. package/src/components/ui/command-palette/editor-commands.tsx +18 -1
  64. package/src/components/ui/controls/material-picker.tsx +152 -165
  65. package/src/components/ui/controls/slider-control.tsx +66 -18
  66. package/src/components/ui/floating-level-selector.tsx +286 -55
  67. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  68. package/src/components/ui/item-catalog/catalog-items.tsx +1116 -1219
  69. package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
  70. package/src/components/ui/level-duplicate-dialog.tsx +115 -0
  71. package/src/components/ui/panels/ceiling-panel.tsx +1 -25
  72. package/src/components/ui/panels/column-panel.tsx +715 -0
  73. package/src/components/ui/panels/door-panel.tsx +981 -289
  74. package/src/components/ui/panels/fence-panel.tsx +3 -45
  75. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  76. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  77. package/src/components/ui/panels/node-display.ts +39 -0
  78. package/src/components/ui/panels/paint-panel.tsx +138 -0
  79. package/src/components/ui/panels/panel-manager.tsx +210 -1
  80. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  81. package/src/components/ui/panels/reference-panel.tsx +238 -5
  82. package/src/components/ui/panels/roof-panel.tsx +4 -105
  83. package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
  84. package/src/components/ui/panels/slab-panel.tsx +4 -30
  85. package/src/components/ui/panels/spawn-panel.tsx +155 -0
  86. package/src/components/ui/panels/stair-panel.tsx +11 -117
  87. package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
  88. package/src/components/ui/panels/wall-panel.tsx +1 -95
  89. package/src/components/ui/panels/window-panel.tsx +660 -139
  90. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  91. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  92. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -2
  93. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  94. package/src/components/ui/sidebar/panels/site-panel/index.tsx +109 -24
  95. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
  96. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
  97. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +9 -3
  98. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +8 -5
  99. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  100. package/src/components/ui/viewer-toolbar.tsx +42 -1
  101. package/src/hooks/use-auto-frame.ts +45 -0
  102. package/src/hooks/use-keyboard.ts +64 -7
  103. package/src/hooks/use-mobile.ts +12 -12
  104. package/src/lib/door-interaction.ts +88 -0
  105. package/src/lib/floorplan/geometry.ts +263 -0
  106. package/src/lib/floorplan/index.ts +38 -0
  107. package/src/lib/floorplan/items.ts +179 -0
  108. package/src/lib/floorplan/selection-tool.ts +231 -0
  109. package/src/lib/floorplan/stairs.ts +478 -0
  110. package/src/lib/floorplan/types.ts +57 -0
  111. package/src/lib/floorplan/walls.ts +23 -0
  112. package/src/lib/guide-events.ts +10 -0
  113. package/src/lib/level-duplication.test.ts +72 -0
  114. package/src/lib/level-duplication.ts +153 -0
  115. package/src/lib/local-guide-image.ts +42 -0
  116. package/src/lib/material-paint.ts +284 -0
  117. package/src/lib/roof-duplication.ts +214 -0
  118. package/src/lib/scene-bounds.test.ts +183 -0
  119. package/src/lib/scene-bounds.ts +169 -0
  120. package/src/lib/stair-duplication.ts +126 -0
  121. package/src/lib/window-interaction.ts +86 -0
  122. package/src/store/use-editor.tsx +164 -8
@@ -4,31 +4,102 @@ 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
93
  const selectedId = useViewer((s) => s.selection.selectedIds[0])
28
94
  const setSelection = useViewer((s) => s.setSelection)
29
95
  const updateNode = useScene((s) => s.updateNode)
30
96
  const deleteNode = useScene((s) => s.deleteNode)
31
97
  const setMovingNode = useEditor((s) => s.setMovingNode)
98
+ const previewRef = useRef<{
99
+ id: AnyNodeId
100
+ key: keyof WindowNode
101
+ value: unknown
102
+ } | null>(null)
32
103
 
33
104
  const adapter = usePresetsAdapter()
34
105
 
@@ -38,18 +109,60 @@ export function WindowPanel() {
38
109
 
39
110
  const handleUpdate = useCallback(
40
111
  (updates: Partial<WindowNode>) => {
41
- 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
+
42
119
  updateNode(selectedId as AnyNode['id'], updates)
43
120
  useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
44
121
  },
45
- [selectedId, updateNode],
122
+ [selectedId, node, updateNode],
46
123
  )
47
124
 
48
- const handleMaterialChange = useCallback(
49
- (material: MaterialSchema) => {
50
- 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)
51
145
  },
52
- [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],
53
166
  )
54
167
 
55
168
  const handleClose = useCallback(() => {
@@ -91,8 +204,20 @@ export function WindowPanel() {
91
204
  parentId: node.parentId,
92
205
  width: node.width,
93
206
  height: node.height,
207
+ windowType: node.windowType,
208
+ operationState: node.operationState,
209
+ awningDirection: node.awningDirection,
210
+ casementStyle: node.casementStyle,
211
+ hingesSide: node.hingesSide,
94
212
  frameThickness: node.frameThickness,
95
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,
96
221
  columnRatios: [...node.columnRatios],
97
222
  rowRatios: [...node.rowRatios],
98
223
  columnDividerThickness: node.columnDividerThickness,
@@ -112,8 +237,20 @@ export function WindowPanel() {
112
237
  return {
113
238
  width: node.width,
114
239
  height: node.height,
240
+ windowType: node.windowType,
241
+ operationState: node.operationState,
242
+ awningDirection: node.awningDirection,
243
+ casementStyle: node.casementStyle,
244
+ hingesSide: node.hingesSide,
115
245
  frameThickness: node.frameThickness,
116
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,
117
254
  columnRatios: node.columnRatios,
118
255
  rowRatios: node.rowRatios,
119
256
  columnDividerThickness: node.columnDividerThickness,
@@ -160,6 +297,75 @@ export function WindowPanel() {
160
297
  const rowSum = node.rowRatios.reduce((a, b) => a + b, 0)
161
298
  const normCols = node.columnRatios.map((r) => r / colSum)
162
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
+ }
163
369
 
164
370
  const setColumnRatio = (index: number, newVal: number) => {
165
371
  const clamped = Math.max(0.05, Math.min(0.95, newVal))
@@ -215,6 +421,122 @@ export function WindowPanel() {
215
421
  </PresetsPopover>
216
422
  </div>
217
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
+
218
540
  <PanelSection title="Position">
219
541
  <SliderControl
220
542
  label={
@@ -240,22 +562,25 @@ export function WindowPanel() {
240
562
  unit="m"
241
563
  value={Math.round(node.position[1] * 100) / 100}
242
564
  />
243
- <div className="px-1 pt-2 pb-1">
244
- <ActionButton
245
- className="w-full"
246
- icon={<FlipHorizontal2 className="h-4 w-4" />}
247
- label="Flip Side"
248
- onClick={handleFlip}
249
- />
250
- </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
+ )}
251
575
  </PanelSection>
252
576
 
253
577
  <PanelSection title="Dimensions">
254
578
  <SliderControl
255
579
  label="Width"
256
580
  min={0}
257
- onChange={(v) => handleUpdate({ width: v })}
581
+ onChange={(v) => handleUpdate(getDimensionUpdates({ width: v }))}
258
582
  precision={2}
583
+ restoreOnCommit={false}
259
584
  step={0.1}
260
585
  unit="m"
261
586
  value={Math.round(node.width * 100) / 100}
@@ -263,157 +588,356 @@ export function WindowPanel() {
263
588
  <SliderControl
264
589
  label="Height"
265
590
  min={0}
266
- onChange={(v) => handleUpdate({ height: v })}
591
+ onChange={(v) => handleUpdate(getDimensionUpdates({ height: v }))}
267
592
  precision={2}
593
+ restoreOnCommit={false}
268
594
  step={0.1}
269
595
  unit="m"
270
596
  value={Math.round(node.height * 100) / 100}
271
597
  />
272
598
  </PanelSection>
273
599
 
274
- <PanelSection title="Frame">
275
- <SliderControl
276
- label="Thickness"
277
- min={0}
278
- onChange={(v) => handleUpdate({ frameThickness: v })}
279
- precision={3}
280
- step={0.01}
281
- unit="m"
282
- value={Math.round(node.frameThickness * 1000) / 1000}
283
- />
284
- <SliderControl
285
- label="Depth"
286
- min={0}
287
- onChange={(v) => handleUpdate({ frameDepth: v })}
288
- precision={3}
289
- step={0.01}
290
- unit="m"
291
- value={Math.round(node.frameDepth * 1000) / 1000}
292
- />
293
- </PanelSection>
294
-
295
- <PanelSection title="Grid">
296
- <SliderControl
297
- label="Columns"
298
- max={8}
299
- min={1}
300
- onChange={(v) => {
301
- const n = Math.max(1, Math.min(8, Math.round(v)))
302
- handleUpdate({ columnRatios: Array(n).fill(1 / n) })
303
- }}
304
- precision={0}
305
- step={1}
306
- value={numCols}
307
- />
308
- <SliderControl
309
- label="Rows"
310
- max={8}
311
- min={1}
312
- onChange={(v) => {
313
- const n = Math.max(1, Math.min(8, Math.round(v)))
314
- handleUpdate({ rowRatios: Array(n).fill(1 / n) })
315
- }}
316
- precision={0}
317
- step={1}
318
- value={numRows}
319
- />
320
-
321
- {numCols > 1 && (
322
- <div className="mt-2 flex flex-col gap-1">
323
- <div className="mb-1 px-1 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
324
- Col Widths
325
- </div>
326
- {normCols.map((ratio, i) => (
327
- <SliderControl
328
- key={`c-${i}`}
329
- label={`C${i + 1}`}
330
- max={95}
331
- min={5}
332
- onChange={(v) => setColumnRatio(i, v / 100)}
333
- precision={1}
334
- step={1}
335
- unit="%"
336
- 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}
337
636
  />
338
- ))}
339
- <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
+ )}
340
672
  <SliderControl
341
- label="Divider"
342
- max={0.1}
343
- min={0.005}
344
- 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)}
345
678
  precision={3}
346
- step={0.01}
679
+ step={0.005}
347
680
  unit="m"
348
- value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000}
681
+ value={Math.round(openingRevealRadius * 1000) / 1000}
349
682
  />
350
683
  </div>
351
- </div>
352
- )}
353
-
354
- {numRows > 1 && (
355
- <div className="mt-2 flex flex-col gap-1">
356
- <div className="mb-1 px-1 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
357
- Row Heights
358
- </div>
359
- {normRows.map((ratio, i) => (
684
+ )}
685
+ {windowShape === 'arch' && (
686
+ <div className="mt-2 flex flex-col gap-1">
360
687
  <SliderControl
361
- key={`r-${i}`}
362
- label={`R${i + 1}`}
363
- max={95}
364
- min={5}
365
- onChange={(v) => setRowRatio(i, v / 100)}
366
- precision={1}
367
- step={1}
368
- unit="%"
369
- 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}
370
697
  />
371
- ))}
372
- <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
+ )}
373
763
  <SliderControl
374
- label="Divider"
375
- max={0.1}
376
- min={0.005}
377
- 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)}
378
769
  precision={3}
379
- step={0.01}
770
+ step={0.005}
380
771
  unit="m"
381
- value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000}
772
+ value={Math.round(openingRevealRadius * 1000) / 1000}
382
773
  />
383
774
  </div>
384
- </div>
385
- )}
386
- </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
+ )}
387
793
 
388
- <PanelSection title="Sill">
389
- <ToggleControl
390
- checked={node.sill}
391
- label="Enable Sill"
392
- onChange={(checked) => handleUpdate({ sill: checked })}
393
- />
394
- {node.sill && (
395
- <div className="mt-1 flex flex-col gap-1">
794
+ {!isOpening && (
795
+ <>
796
+ <PanelSection title="Frame">
396
797
  <SliderControl
397
- label="Depth"
798
+ label="Thickness"
398
799
  min={0}
399
- onChange={(v) => handleUpdate({ sillDepth: v })}
800
+ onChange={(v) => handleUpdate({ frameThickness: v })}
400
801
  precision={3}
401
802
  step={0.01}
402
803
  unit="m"
403
- value={Math.round(node.sillDepth * 1000) / 1000}
804
+ value={Math.round(node.frameThickness * 1000) / 1000}
404
805
  />
405
806
  <SliderControl
406
- label="Thickness"
807
+ label="Depth"
407
808
  min={0}
408
- onChange={(v) => handleUpdate({ sillThickness: v })}
809
+ onChange={(v) => handleUpdate({ frameDepth: v })}
409
810
  precision={3}
410
811
  step={0.01}
411
812
  unit="m"
412
- value={Math.round(node.sillThickness * 1000) / 1000}
813
+ value={Math.round(node.frameDepth * 1000) / 1000}
413
814
  />
414
- </div>
415
- )}
416
- </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
+ )}
417
941
 
418
942
  <PanelSection title="Actions">
419
943
  <ActionGroup>
@@ -431,9 +955,6 @@ export function WindowPanel() {
431
955
  />
432
956
  </ActionGroup>
433
957
  </PanelSection>
434
- <PanelSection title="Material">
435
- <MaterialPicker onChange={handleMaterialChange} value={node.material} />
436
- </PanelSection>
437
958
  </PanelWrapper>
438
959
  )
439
960
  }