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