@pascal-app/editor 0.4.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 (165) hide show
  1. package/package.json +62 -0
  2. package/src/components/editor/custom-camera-controls.tsx +387 -0
  3. package/src/components/editor/editor-layout-v2.tsx +220 -0
  4. package/src/components/editor/export-manager.tsx +78 -0
  5. package/src/components/editor/first-person-controls.tsx +249 -0
  6. package/src/components/editor/floating-action-menu.tsx +231 -0
  7. package/src/components/editor/floorplan-panel.tsx +9609 -0
  8. package/src/components/editor/grid.tsx +161 -0
  9. package/src/components/editor/index.tsx +928 -0
  10. package/src/components/editor/node-action-menu.tsx +66 -0
  11. package/src/components/editor/preset-thumbnail-generator.tsx +125 -0
  12. package/src/components/editor/selection-manager.tsx +897 -0
  13. package/src/components/editor/site-edge-labels.tsx +90 -0
  14. package/src/components/editor/thumbnail-generator.tsx +166 -0
  15. package/src/components/editor/wall-measurement-label.tsx +258 -0
  16. package/src/components/feedback-dialog.tsx +265 -0
  17. package/src/components/pascal-radio.tsx +280 -0
  18. package/src/components/preview-button.tsx +16 -0
  19. package/src/components/systems/ceiling/ceiling-system.tsx +77 -0
  20. package/src/components/systems/roof/roof-edit-system.tsx +69 -0
  21. package/src/components/systems/stair/stair-edit-system.tsx +69 -0
  22. package/src/components/systems/zone/zone-label-editor-system.tsx +320 -0
  23. package/src/components/systems/zone/zone-system.tsx +87 -0
  24. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +42 -0
  25. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +47 -0
  26. package/src/components/tools/ceiling/ceiling-tool.tsx +465 -0
  27. package/src/components/tools/door/door-math.ts +110 -0
  28. package/src/components/tools/door/door-tool.tsx +293 -0
  29. package/src/components/tools/door/move-door-tool.tsx +373 -0
  30. package/src/components/tools/item/item-tool.tsx +26 -0
  31. package/src/components/tools/item/move-tool.tsx +90 -0
  32. package/src/components/tools/item/placement-math.ts +85 -0
  33. package/src/components/tools/item/placement-strategies.ts +556 -0
  34. package/src/components/tools/item/placement-types.ts +117 -0
  35. package/src/components/tools/item/use-draft-node.ts +227 -0
  36. package/src/components/tools/item/use-placement-coordinator.tsx +877 -0
  37. package/src/components/tools/roof/move-roof-tool.tsx +288 -0
  38. package/src/components/tools/roof/roof-tool.tsx +318 -0
  39. package/src/components/tools/select/box-select-tool.tsx +626 -0
  40. package/src/components/tools/shared/cursor-sphere.tsx +119 -0
  41. package/src/components/tools/shared/polygon-editor.tsx +361 -0
  42. package/src/components/tools/site/site-boundary-editor.tsx +42 -0
  43. package/src/components/tools/slab/slab-boundary-editor.tsx +42 -0
  44. package/src/components/tools/slab/slab-hole-editor.tsx +47 -0
  45. package/src/components/tools/slab/slab-tool.tsx +322 -0
  46. package/src/components/tools/stair/stair-defaults.ts +7 -0
  47. package/src/components/tools/stair/stair-tool.tsx +194 -0
  48. package/src/components/tools/tool-manager.tsx +120 -0
  49. package/src/components/tools/wall/wall-drafting.ts +140 -0
  50. package/src/components/tools/wall/wall-tool.tsx +210 -0
  51. package/src/components/tools/window/move-window-tool.tsx +410 -0
  52. package/src/components/tools/window/window-math.ts +117 -0
  53. package/src/components/tools/window/window-tool.tsx +303 -0
  54. package/src/components/tools/zone/zone-boundary-editor.tsx +39 -0
  55. package/src/components/tools/zone/zone-tool.tsx +364 -0
  56. package/src/components/ui/action-menu/action-button.tsx +59 -0
  57. package/src/components/ui/action-menu/camera-actions.tsx +74 -0
  58. package/src/components/ui/action-menu/control-modes.tsx +240 -0
  59. package/src/components/ui/action-menu/furnish-tools.tsx +102 -0
  60. package/src/components/ui/action-menu/index.tsx +152 -0
  61. package/src/components/ui/action-menu/structure-tools.tsx +100 -0
  62. package/src/components/ui/action-menu/view-toggles.tsx +397 -0
  63. package/src/components/ui/command-palette/editor-commands.tsx +396 -0
  64. package/src/components/ui/command-palette/index.tsx +730 -0
  65. package/src/components/ui/controls/action-button.tsx +33 -0
  66. package/src/components/ui/controls/material-picker.tsx +194 -0
  67. package/src/components/ui/controls/metric-control.tsx +262 -0
  68. package/src/components/ui/controls/panel-section.tsx +65 -0
  69. package/src/components/ui/controls/segmented-control.tsx +45 -0
  70. package/src/components/ui/controls/slider-control.tsx +245 -0
  71. package/src/components/ui/controls/toggle-control.tsx +38 -0
  72. package/src/components/ui/floating-level-selector.tsx +355 -0
  73. package/src/components/ui/helpers/ceiling-helper.tsx +20 -0
  74. package/src/components/ui/helpers/helper-manager.tsx +33 -0
  75. package/src/components/ui/helpers/item-helper.tsx +40 -0
  76. package/src/components/ui/helpers/roof-helper.tsx +16 -0
  77. package/src/components/ui/helpers/slab-helper.tsx +20 -0
  78. package/src/components/ui/helpers/wall-helper.tsx +20 -0
  79. package/src/components/ui/item-catalog/catalog-items.tsx +1580 -0
  80. package/src/components/ui/item-catalog/item-catalog.tsx +219 -0
  81. package/src/components/ui/panels/ceiling-panel.tsx +230 -0
  82. package/src/components/ui/panels/collections/collections-popover.tsx +356 -0
  83. package/src/components/ui/panels/door-panel.tsx +600 -0
  84. package/src/components/ui/panels/item-panel.tsx +306 -0
  85. package/src/components/ui/panels/panel-manager.tsx +59 -0
  86. package/src/components/ui/panels/panel-wrapper.tsx +80 -0
  87. package/src/components/ui/panels/presets/presets-popover.tsx +511 -0
  88. package/src/components/ui/panels/reference-panel.tsx +177 -0
  89. package/src/components/ui/panels/roof-panel.tsx +262 -0
  90. package/src/components/ui/panels/roof-segment-panel.tsx +326 -0
  91. package/src/components/ui/panels/slab-panel.tsx +228 -0
  92. package/src/components/ui/panels/stair-panel.tsx +304 -0
  93. package/src/components/ui/panels/stair-segment-panel.tsx +339 -0
  94. package/src/components/ui/panels/wall-panel.tsx +123 -0
  95. package/src/components/ui/panels/window-panel.tsx +441 -0
  96. package/src/components/ui/primitives/button.tsx +69 -0
  97. package/src/components/ui/primitives/card.tsx +75 -0
  98. package/src/components/ui/primitives/color-dot.tsx +61 -0
  99. package/src/components/ui/primitives/context-menu.tsx +227 -0
  100. package/src/components/ui/primitives/dialog.tsx +129 -0
  101. package/src/components/ui/primitives/dropdown-menu.tsx +228 -0
  102. package/src/components/ui/primitives/error-boundary.tsx +52 -0
  103. package/src/components/ui/primitives/input.tsx +21 -0
  104. package/src/components/ui/primitives/number-input.tsx +187 -0
  105. package/src/components/ui/primitives/opacity-control.tsx +79 -0
  106. package/src/components/ui/primitives/popover.tsx +42 -0
  107. package/src/components/ui/primitives/separator.tsx +28 -0
  108. package/src/components/ui/primitives/sheet.tsx +130 -0
  109. package/src/components/ui/primitives/shortcut-token.tsx +64 -0
  110. package/src/components/ui/primitives/sidebar.tsx +855 -0
  111. package/src/components/ui/primitives/skeleton.tsx +13 -0
  112. package/src/components/ui/primitives/slider.tsx +58 -0
  113. package/src/components/ui/primitives/switch.tsx +29 -0
  114. package/src/components/ui/primitives/tooltip.tsx +57 -0
  115. package/src/components/ui/scene-loader.tsx +40 -0
  116. package/src/components/ui/sidebar/app-sidebar.tsx +103 -0
  117. package/src/components/ui/sidebar/icon-rail.tsx +147 -0
  118. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +100 -0
  119. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +438 -0
  120. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +188 -0
  121. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +80 -0
  122. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +126 -0
  123. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +64 -0
  124. package/src/components/ui/sidebar/panels/site-panel/index.tsx +1543 -0
  125. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +98 -0
  126. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +117 -0
  127. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +65 -0
  128. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +214 -0
  129. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +96 -0
  130. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +216 -0
  131. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +115 -0
  132. package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +342 -0
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +271 -0
  134. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +106 -0
  135. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +64 -0
  136. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +87 -0
  137. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +167 -0
  138. package/src/components/ui/sidebar/tab-bar.tsx +39 -0
  139. package/src/components/ui/slider-demo.tsx +36 -0
  140. package/src/components/ui/slider.tsx +81 -0
  141. package/src/components/ui/viewer-toolbar.tsx +342 -0
  142. package/src/components/viewer-overlay.tsx +499 -0
  143. package/src/components/viewer-zone-system.tsx +48 -0
  144. package/src/contexts/presets-context.tsx +121 -0
  145. package/src/hooks/use-auto-save.ts +194 -0
  146. package/src/hooks/use-contextual-tools.ts +52 -0
  147. package/src/hooks/use-grid-events.ts +106 -0
  148. package/src/hooks/use-keyboard.ts +214 -0
  149. package/src/hooks/use-mobile.ts +19 -0
  150. package/src/hooks/use-reduced-motion.ts +20 -0
  151. package/src/index.tsx +33 -0
  152. package/src/lib/constants.ts +3 -0
  153. package/src/lib/level-selection.ts +31 -0
  154. package/src/lib/scene.ts +394 -0
  155. package/src/lib/sfx/index.ts +2 -0
  156. package/src/lib/sfx-bus.ts +49 -0
  157. package/src/lib/sfx-player.ts +60 -0
  158. package/src/lib/utils.ts +43 -0
  159. package/src/store/use-audio.tsx +45 -0
  160. package/src/store/use-command-registry.ts +36 -0
  161. package/src/store/use-editor.tsx +522 -0
  162. package/src/store/use-palette-view-registry.ts +45 -0
  163. package/src/store/use-upload.ts +90 -0
  164. package/src/three-types.ts +3 -0
  165. package/tsconfig.json +9 -0
@@ -0,0 +1,441 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeId,
6
+ emitter,
7
+ type MaterialSchema,
8
+ useScene,
9
+ WindowNode,
10
+ } from '@pascal-app/core'
11
+ import { useViewer } from '@pascal-app/viewer'
12
+ import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
13
+ import { useCallback } from 'react'
14
+ import { usePresetsAdapter } from '../../../contexts/presets-context'
15
+ import { sfxEmitter } from '../../../lib/sfx-bus'
16
+ import useEditor from '../../../store/use-editor'
17
+ import { ActionButton, ActionGroup } from '../controls/action-button'
18
+ import { MaterialPicker } from '../controls/material-picker'
19
+ import { MetricControl } from '../controls/metric-control'
20
+ import { PanelSection } from '../controls/panel-section'
21
+ import { SliderControl } from '../controls/slider-control'
22
+ import { ToggleControl } from '../controls/toggle-control'
23
+ import { PanelWrapper } from './panel-wrapper'
24
+ import { PresetsPopover } from './presets/presets-popover'
25
+
26
+ export function WindowPanel() {
27
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
28
+ const setSelection = useViewer((s) => s.setSelection)
29
+ const nodes = useScene((s) => s.nodes)
30
+ const updateNode = useScene((s) => s.updateNode)
31
+ const deleteNode = useScene((s) => s.deleteNode)
32
+ const setMovingNode = useEditor((s) => s.setMovingNode)
33
+
34
+ const adapter = usePresetsAdapter()
35
+
36
+ const selectedId = selectedIds[0]
37
+ const node = selectedId
38
+ ? (nodes[selectedId as AnyNode['id']] as WindowNode | undefined)
39
+ : undefined
40
+
41
+ const handleUpdate = useCallback(
42
+ (updates: Partial<WindowNode>) => {
43
+ if (!selectedId) return
44
+ updateNode(selectedId as AnyNode['id'], updates)
45
+ useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
46
+ },
47
+ [selectedId, updateNode],
48
+ )
49
+
50
+ const handleMaterialChange = useCallback(
51
+ (material: MaterialSchema) => {
52
+ handleUpdate({ material })
53
+ },
54
+ [handleUpdate],
55
+ )
56
+
57
+ const handleClose = useCallback(() => {
58
+ setSelection({ selectedIds: [] })
59
+ }, [setSelection])
60
+
61
+ const handleFlip = useCallback(() => {
62
+ if (!node) return
63
+ handleUpdate({
64
+ side: node.side === 'front' ? 'back' : 'front',
65
+ rotation: [node.rotation[0], node.rotation[1] + Math.PI, node.rotation[2]],
66
+ })
67
+ }, [node, handleUpdate])
68
+
69
+ const handleMove = useCallback(() => {
70
+ if (!node) return
71
+ sfxEmitter.emit('sfx:item-pick')
72
+ setMovingNode(node)
73
+ setSelection({ selectedIds: [] })
74
+ }, [node, setMovingNode, setSelection])
75
+
76
+ const handleDelete = useCallback(() => {
77
+ if (!(selectedId && node)) return
78
+ sfxEmitter.emit('sfx:item-delete')
79
+ deleteNode(selectedId as AnyNode['id'])
80
+ if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
81
+ setSelection({ selectedIds: [] })
82
+ }, [selectedId, node, deleteNode, setSelection])
83
+
84
+ const handleDuplicate = useCallback(() => {
85
+ if (!node?.parentId) return
86
+ sfxEmitter.emit('sfx:item-pick')
87
+ useScene.temporal.getState().pause()
88
+ const duplicate = WindowNode.parse({
89
+ position: [...node.position] as [number, number, number],
90
+ rotation: [...node.rotation] as [number, number, number],
91
+ side: node.side,
92
+ wallId: node.wallId,
93
+ parentId: node.parentId,
94
+ width: node.width,
95
+ height: node.height,
96
+ frameThickness: node.frameThickness,
97
+ frameDepth: node.frameDepth,
98
+ columnRatios: [...node.columnRatios],
99
+ rowRatios: [...node.rowRatios],
100
+ columnDividerThickness: node.columnDividerThickness,
101
+ rowDividerThickness: node.rowDividerThickness,
102
+ sill: node.sill,
103
+ sillDepth: node.sillDepth,
104
+ sillThickness: node.sillThickness,
105
+ metadata: { isNew: true },
106
+ })
107
+ useScene.getState().createNode(duplicate, node.parentId as AnyNodeId)
108
+ setMovingNode(duplicate)
109
+ setSelection({ selectedIds: [] })
110
+ }, [node, setMovingNode, setSelection])
111
+
112
+ const getWindowPresetData = useCallback(() => {
113
+ if (!node) return null
114
+ return {
115
+ width: node.width,
116
+ height: node.height,
117
+ frameThickness: node.frameThickness,
118
+ frameDepth: node.frameDepth,
119
+ columnRatios: node.columnRatios,
120
+ rowRatios: node.rowRatios,
121
+ columnDividerThickness: node.columnDividerThickness,
122
+ rowDividerThickness: node.rowDividerThickness,
123
+ sill: node.sill,
124
+ sillDepth: node.sillDepth,
125
+ sillThickness: node.sillThickness,
126
+ }
127
+ }, [node])
128
+
129
+ const handleSavePreset = useCallback(
130
+ async (name: string) => {
131
+ const data = getWindowPresetData()
132
+ if (!(data && selectedId)) return
133
+ const presetId = await adapter.savePreset('window', name, data)
134
+ if (presetId) emitter.emit('preset:generate-thumbnail', { presetId, nodeId: selectedId })
135
+ },
136
+ [getWindowPresetData, selectedId, adapter],
137
+ )
138
+
139
+ const handleOverwritePreset = useCallback(
140
+ async (id: string) => {
141
+ const data = getWindowPresetData()
142
+ if (!(data && selectedId)) return
143
+ await adapter.overwritePreset('window', id, data)
144
+ emitter.emit('preset:generate-thumbnail', { presetId: id, nodeId: selectedId })
145
+ },
146
+ [getWindowPresetData, selectedId, adapter],
147
+ )
148
+
149
+ const handleApplyPreset = useCallback(
150
+ (data: Record<string, unknown>) => {
151
+ handleUpdate(data as Partial<WindowNode>)
152
+ },
153
+ [handleUpdate],
154
+ )
155
+
156
+ if (!node || node.type !== 'window' || selectedIds.length !== 1) return null
157
+
158
+ const numCols = node.columnRatios.length
159
+ const numRows = node.rowRatios.length
160
+
161
+ const colSum = node.columnRatios.reduce((a, b) => a + b, 0)
162
+ const rowSum = node.rowRatios.reduce((a, b) => a + b, 0)
163
+ const normCols = node.columnRatios.map((r) => r / colSum)
164
+ const normRows = node.rowRatios.map((r) => r / rowSum)
165
+
166
+ const setColumnRatio = (index: number, newVal: number) => {
167
+ const clamped = Math.max(0.05, Math.min(0.95, newVal))
168
+ const neighborIdx = index < numCols - 1 ? index + 1 : index - 1
169
+ const delta = clamped - normCols[index]!
170
+ const neighborVal = Math.max(0.05, normCols[neighborIdx]! - delta)
171
+ const newRatios = normCols.map((v, i) => {
172
+ if (i === index) return clamped
173
+ if (i === neighborIdx) return neighborVal
174
+ return v
175
+ })
176
+ handleUpdate({ columnRatios: newRatios })
177
+ }
178
+
179
+ const setRowRatio = (index: number, newVal: number) => {
180
+ const clamped = Math.max(0.05, Math.min(0.95, newVal))
181
+ const neighborIdx = index < numRows - 1 ? index + 1 : index - 1
182
+ const delta = clamped - normRows[index]!
183
+ const neighborVal = Math.max(0.05, normRows[neighborIdx]! - delta)
184
+ const newRatios = normRows.map((v, i) => {
185
+ if (i === index) return clamped
186
+ if (i === neighborIdx) return neighborVal
187
+ return v
188
+ })
189
+ handleUpdate({ rowRatios: newRatios })
190
+ }
191
+
192
+ return (
193
+ <PanelWrapper
194
+ icon="/icons/window.png"
195
+ onClose={handleClose}
196
+ title={node.name || 'Window'}
197
+ width={320}
198
+ >
199
+ {/* Presets strip */}
200
+ <div className="border-border/30 border-b px-3 pt-2.5 pb-1.5">
201
+ <PresetsPopover
202
+ isAuthenticated={adapter.isAuthenticated}
203
+ onApply={handleApplyPreset}
204
+ onDelete={(id) => adapter.deletePreset(id)}
205
+ onFetchPresets={(tab) => adapter.fetchPresets('window', tab)}
206
+ onOverwrite={handleOverwritePreset}
207
+ onRename={(id, name) => adapter.renamePreset(id, name)}
208
+ onSave={handleSavePreset}
209
+ onToggleCommunity={adapter.togglePresetCommunity}
210
+ tabs={adapter.tabs}
211
+ type="window"
212
+ >
213
+ <button className="flex w-full items-center gap-2 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 font-medium text-muted-foreground text-xs transition-colors hover:bg-[#3e3e3e] hover:text-foreground">
214
+ <BookMarked className="h-3.5 w-3.5 shrink-0" />
215
+ <span>Presets</span>
216
+ </button>
217
+ </PresetsPopover>
218
+ </div>
219
+
220
+ <PanelSection title="Position">
221
+ <SliderControl
222
+ label={
223
+ <>
224
+ X<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
225
+ </>
226
+ }
227
+ onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}
228
+ precision={2}
229
+ step={0.1}
230
+ unit="m"
231
+ value={Math.round(node.position[0] * 100) / 100}
232
+ />
233
+ <SliderControl
234
+ label={
235
+ <>
236
+ Y<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
237
+ </>
238
+ }
239
+ onChange={(v) => handleUpdate({ position: [node.position[0], v, node.position[2]] })}
240
+ precision={2}
241
+ step={0.1}
242
+ unit="m"
243
+ value={Math.round(node.position[1] * 100) / 100}
244
+ />
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>
253
+ </PanelSection>
254
+
255
+ <PanelSection title="Dimensions">
256
+ <SliderControl
257
+ label="Width"
258
+ min={0}
259
+ onChange={(v) => handleUpdate({ width: v })}
260
+ precision={2}
261
+ step={0.1}
262
+ unit="m"
263
+ value={Math.round(node.width * 100) / 100}
264
+ />
265
+ <SliderControl
266
+ label="Height"
267
+ min={0}
268
+ onChange={(v) => handleUpdate({ height: v })}
269
+ precision={2}
270
+ step={0.1}
271
+ unit="m"
272
+ value={Math.round(node.height * 100) / 100}
273
+ />
274
+ </PanelSection>
275
+
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}
339
+ />
340
+ ))}
341
+ <div className="mt-1 border-border/50 border-t pt-1">
342
+ <SliderControl
343
+ label="Divider"
344
+ max={0.1}
345
+ min={0.005}
346
+ onChange={(v) => handleUpdate({ columnDividerThickness: v })}
347
+ precision={3}
348
+ step={0.01}
349
+ unit="m"
350
+ value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000}
351
+ />
352
+ </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) => (
362
+ <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}
372
+ />
373
+ ))}
374
+ <div className="mt-1 border-border/50 border-t pt-1">
375
+ <SliderControl
376
+ label="Divider"
377
+ max={0.1}
378
+ min={0.005}
379
+ onChange={(v) => handleUpdate({ rowDividerThickness: v })}
380
+ precision={3}
381
+ step={0.01}
382
+ unit="m"
383
+ value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000}
384
+ />
385
+ </div>
386
+ </div>
387
+ )}
388
+ </PanelSection>
389
+
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">
398
+ <SliderControl
399
+ label="Depth"
400
+ min={0}
401
+ onChange={(v) => handleUpdate({ sillDepth: v })}
402
+ precision={3}
403
+ step={0.01}
404
+ unit="m"
405
+ value={Math.round(node.sillDepth * 1000) / 1000}
406
+ />
407
+ <SliderControl
408
+ label="Thickness"
409
+ min={0}
410
+ onChange={(v) => handleUpdate({ sillThickness: v })}
411
+ precision={3}
412
+ step={0.01}
413
+ unit="m"
414
+ value={Math.round(node.sillThickness * 1000) / 1000}
415
+ />
416
+ </div>
417
+ )}
418
+ </PanelSection>
419
+
420
+ <PanelSection title="Actions">
421
+ <ActionGroup>
422
+ <ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
423
+ <ActionButton
424
+ icon={<Copy className="h-3.5 w-3.5" />}
425
+ label="Duplicate"
426
+ onClick={handleDuplicate}
427
+ />
428
+ <ActionButton
429
+ className="hover:bg-red-500/20"
430
+ icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
431
+ label="Delete"
432
+ onClick={handleDelete}
433
+ />
434
+ </ActionGroup>
435
+ </PanelSection>
436
+ <PanelSection title="Material">
437
+ <MaterialPicker onChange={handleMaterialChange} value={node.material} />
438
+ </PanelSection>
439
+ </PanelWrapper>
440
+ )
441
+ }
@@ -0,0 +1,69 @@
1
+ import { Slot } from '@radix-ui/react-slot'
2
+ import { cva, type VariantProps } from 'class-variance-authority'
3
+ import type * as React from 'react'
4
+
5
+ import { cn } from '../../../lib/utils'
6
+
7
+ const buttonVariants = cva(
8
+ "inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md font-barlow font-medium text-sm outline-none transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default: 'bg-primary text-primary-foreground hover:bg-primary/90',
13
+ destructive:
14
+ 'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40',
15
+ outline:
16
+ 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
17
+ secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
18
+ ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
19
+ link: 'text-primary underline-offset-4 hover:underline',
20
+ },
21
+ size: {
22
+ default: 'h-9 px-4 py-2 has-[>svg]:px-3',
23
+ sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
24
+ lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
25
+ icon: 'size-9',
26
+ 'icon-sm': 'size-8',
27
+ 'icon-lg': 'size-10',
28
+ },
29
+ },
30
+ defaultVariants: {
31
+ variant: 'default',
32
+ size: 'default',
33
+ },
34
+ },
35
+ )
36
+
37
+ function Button({
38
+ className,
39
+ variant,
40
+ size,
41
+ asChild = false,
42
+ ref,
43
+ ...props
44
+ }: React.ComponentProps<'button'> &
45
+ VariantProps<typeof buttonVariants> & {
46
+ asChild?: boolean
47
+ }) {
48
+ if (asChild) {
49
+ return (
50
+ <Slot
51
+ className={cn(buttonVariants({ variant, size, className }))}
52
+ data-slot="button"
53
+ ref={ref as never}
54
+ {...props}
55
+ />
56
+ )
57
+ }
58
+
59
+ return (
60
+ <button
61
+ className={cn(buttonVariants({ variant, size, className }))}
62
+ data-slot="button"
63
+ ref={ref}
64
+ {...props}
65
+ />
66
+ )
67
+ }
68
+
69
+ export { Button, buttonVariants }
@@ -0,0 +1,75 @@
1
+ import type * as React from 'react'
2
+
3
+ import { cn } from '../../../lib/utils'
4
+
5
+ function Card({ className, ...props }: React.ComponentProps<'div'>) {
6
+ return (
7
+ <div
8
+ className={cn(
9
+ 'flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm',
10
+ className,
11
+ )}
12
+ data-slot="card"
13
+ {...props}
14
+ />
15
+ )
16
+ }
17
+
18
+ function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
19
+ return (
20
+ <div
21
+ className={cn(
22
+ '@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
23
+ className,
24
+ )}
25
+ data-slot="card-header"
26
+ {...props}
27
+ />
28
+ )
29
+ }
30
+
31
+ function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
32
+ return (
33
+ <div
34
+ className={cn('font-semibold leading-none', className)}
35
+ data-slot="card-title"
36
+ {...props}
37
+ />
38
+ )
39
+ }
40
+
41
+ function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
42
+ return (
43
+ <div
44
+ className={cn('text-muted-foreground text-sm', className)}
45
+ data-slot="card-description"
46
+ {...props}
47
+ />
48
+ )
49
+ }
50
+
51
+ function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
52
+ return (
53
+ <div
54
+ className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
55
+ data-slot="card-action"
56
+ {...props}
57
+ />
58
+ )
59
+ }
60
+
61
+ function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
62
+ return <div className={cn('px-6', className)} data-slot="card-content" {...props} />
63
+ }
64
+
65
+ function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
66
+ return (
67
+ <div
68
+ className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
69
+ data-slot="card-footer"
70
+ {...props}
71
+ />
72
+ )
73
+ }
74
+
75
+ export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }
@@ -0,0 +1,61 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { cn } from '../../../lib/utils'
5
+ import { Popover, PopoverContent, PopoverTrigger } from './popover'
6
+
7
+ export const PALETTE_COLORS = [
8
+ '#ef4444', // Red 0°
9
+ '#f97316', // Orange 30°
10
+ '#f59e0b', // Amber 45°
11
+ '#84cc16', // Lime 85°
12
+ '#22c55e', // Green 142°
13
+ '#10b981', // Emerald 160°
14
+ '#06b6d4', // Cyan 190°
15
+ '#3b82f6', // Blue 217°
16
+ '#6366f1', // Indigo 239°
17
+ '#a855f7', // Violet 270°
18
+ '#64748b', // Dark gray
19
+ '#cccccc', // Light gray
20
+ ]
21
+
22
+ interface ColorDotProps {
23
+ color: string
24
+ onChange: (color: string) => void
25
+ }
26
+
27
+ export function ColorDot({ color, onChange }: ColorDotProps) {
28
+ const [open, setOpen] = useState(false)
29
+
30
+ return (
31
+ <Popover onOpenChange={setOpen} open={open}>
32
+ <PopoverTrigger asChild>
33
+ <button
34
+ className="relative h-3 w-3 shrink-0 cursor-pointer rounded-sm border border-border/50 transition-all hover:ring-1 hover:ring-ring/50"
35
+ onClick={(e) => e.stopPropagation()}
36
+ style={{ backgroundColor: color }}
37
+ type="button"
38
+ />
39
+ </PopoverTrigger>
40
+ <PopoverContent align="center" className="w-auto p-1.5" side="left" sideOffset={6}>
41
+ <div className="grid grid-cols-4 gap-1">
42
+ {PALETTE_COLORS.map((c) => (
43
+ <button
44
+ className={cn(
45
+ 'h-5 w-5 rounded-sm border transition-transform hover:scale-110',
46
+ c === color ? 'border-foreground/50 ring-1 ring-ring/50' : 'border-border/30',
47
+ )}
48
+ key={c}
49
+ onClick={() => {
50
+ onChange(c)
51
+ setOpen(false)
52
+ }}
53
+ style={{ backgroundColor: c }}
54
+ type="button"
55
+ />
56
+ ))}
57
+ </div>
58
+ </PopoverContent>
59
+ </Popover>
60
+ )
61
+ }