@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
@@ -1,24 +1,204 @@
1
1
  'use client'
2
2
 
3
- import { type AnyNodeId, useScene } from '@pascal-app/core'
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeId,
6
+ type BuildingNode,
7
+ type CeilingNode,
8
+ type ColumnNode,
9
+ type DoorNode,
10
+ type FenceNode,
11
+ type ItemNode,
12
+ type RoofNode,
13
+ type RoofSegmentNode,
14
+ type SlabNode,
15
+ type StairNode,
16
+ type StairSegmentNode,
17
+ useScene,
18
+ type WallNode,
19
+ type WindowNode,
20
+ } from '@pascal-app/core'
4
21
  import { useViewer } from '@pascal-app/viewer'
22
+ import { useCallback, useEffect, useState } from 'react'
23
+ import { useIsMobile } from '../../../hooks/use-mobile'
24
+ import { sfxEmitter } from '../../../lib/sfx-bus'
5
25
  import useEditor from '../../../store/use-editor'
6
26
  import { CeilingPanel } from './ceiling-panel'
27
+ import { ColumnPanel } from './column-panel'
7
28
  import { DoorPanel } from './door-panel'
8
29
  import { FencePanel } from './fence-panel'
9
30
  import { ItemPanel } from './item-panel'
31
+ import { MobilePanelSheet } from './mobile-panel-sheet'
32
+ import { MobileSelectionBar } from './mobile-selection-bar'
33
+ import { getNodeDisplay } from './node-display'
34
+ import { PaintPanel } from './paint-panel'
10
35
  import { ReferencePanel } from './reference-panel'
11
36
  import { RoofPanel } from './roof-panel'
12
37
  import { RoofSegmentPanel } from './roof-segment-panel'
13
38
  import { SlabPanel } from './slab-panel'
39
+ import { SpawnPanel } from './spawn-panel'
14
40
  import { StairPanel } from './stair-panel'
15
41
  import { StairSegmentPanel } from './stair-segment-panel'
16
42
  import { WallPanel } from './wall-panel'
17
43
  import { WindowPanel } from './window-panel'
18
44
 
45
+ type MovableNode =
46
+ | ItemNode
47
+ | WindowNode
48
+ | DoorNode
49
+ | CeilingNode
50
+ | ColumnNode
51
+ | SlabNode
52
+ | WallNode
53
+ | FenceNode
54
+ | RoofNode
55
+ | RoofSegmentNode
56
+ | StairNode
57
+ | StairSegmentNode
58
+ | BuildingNode
59
+
60
+ const MOVABLE_TYPES = new Set<string>([
61
+ 'item',
62
+ 'window',
63
+ 'door',
64
+ 'ceiling',
65
+ 'column',
66
+ 'slab',
67
+ 'wall',
68
+ 'fence',
69
+ 'roof',
70
+ 'roof-segment',
71
+ 'stair',
72
+ 'stair-segment',
73
+ 'building',
74
+ ])
75
+
76
+ function isMovableNode(node: AnyNode | null): node is MovableNode {
77
+ return !!node && MOVABLE_TYPES.has(node.type)
78
+ }
79
+
80
+ function panelForType(type: string | null) {
81
+ if (!type) return null
82
+ switch (type) {
83
+ case 'item':
84
+ return <ItemPanel />
85
+ case 'roof':
86
+ return <RoofPanel />
87
+ case 'roof-segment':
88
+ return <RoofSegmentPanel />
89
+ case 'stair':
90
+ return <StairPanel />
91
+ case 'stair-segment':
92
+ return <StairSegmentPanel />
93
+ case 'slab':
94
+ return <SlabPanel />
95
+ case 'spawn':
96
+ return <SpawnPanel />
97
+ case 'ceiling':
98
+ return <CeilingPanel />
99
+ case 'column':
100
+ return <ColumnPanel />
101
+ case 'wall':
102
+ return <WallPanel />
103
+ case 'fence':
104
+ return <FencePanel />
105
+ case 'door':
106
+ return <DoorPanel />
107
+ case 'window':
108
+ return <WindowPanel />
109
+ default:
110
+ return null
111
+ }
112
+ }
113
+
114
+ function MobilePanelLayer({
115
+ node,
116
+ panel,
117
+ isReference,
118
+ }: {
119
+ node: AnyNode | null
120
+ panel: React.ReactNode
121
+ isReference: boolean
122
+ }) {
123
+ const setSelection = useViewer((s) => s.setSelection)
124
+ const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
125
+ const setMovingNode = useEditor((s) => s.setMovingNode)
126
+ const deleteNode = useScene((s) => s.deleteNode)
127
+ const [isSheetOpen, setIsSheetOpen] = useState(false)
128
+
129
+ // Reset sheet open state when the selection changes / clears
130
+ const selectionKey = node?.id ?? (isReference ? 'reference' : null)
131
+ useEffect(() => {
132
+ setIsSheetOpen(false)
133
+ }, [selectionKey])
134
+
135
+ const clearSelection = useCallback(() => {
136
+ setSelection({ selectedIds: [] })
137
+ setSelectedReferenceId(null)
138
+ }, [setSelection, setSelectedReferenceId])
139
+
140
+ const handleMove = useCallback(() => {
141
+ if (!isMovableNode(node)) return
142
+ sfxEmitter.emit('sfx:item-pick')
143
+ setMovingNode(node)
144
+ clearSelection()
145
+ }, [node, setMovingNode, clearSelection])
146
+
147
+ const handleDuplicate = useCallback(() => {
148
+ if (!isMovableNode(node)) return
149
+ sfxEmitter.emit('sfx:item-pick')
150
+ const cloned = structuredClone(node) as MovableNode & { id?: AnyNodeId }
151
+ delete (cloned as { id?: AnyNodeId }).id
152
+ const prevMeta =
153
+ cloned.metadata && typeof cloned.metadata === 'object' && !Array.isArray(cloned.metadata)
154
+ ? (cloned.metadata as Record<string, unknown>)
155
+ : {}
156
+ cloned.metadata = { ...prevMeta, isNew: true }
157
+ setMovingNode(cloned as MovableNode)
158
+ clearSelection()
159
+ }, [node, setMovingNode, clearSelection])
160
+
161
+ const handleDelete = useCallback(() => {
162
+ if (!node) return
163
+ sfxEmitter.emit('sfx:item-delete')
164
+ deleteNode(node.id)
165
+ clearSelection()
166
+ }, [node, deleteNode, clearSelection])
167
+
168
+ if (!(node || isReference)) return null
169
+
170
+ const display = getNodeDisplay(node)
171
+
172
+ return (
173
+ <>
174
+ {node && (
175
+ <MobileSelectionBar
176
+ node={node}
177
+ onDelete={handleDelete}
178
+ onDuplicate={handleDuplicate}
179
+ onEdit={() => setIsSheetOpen((v) => !v)}
180
+ onMove={handleMove}
181
+ />
182
+ )}
183
+ <MobilePanelSheet
184
+ icon={display.icon}
185
+ onClose={() => setIsSheetOpen(false)}
186
+ open={isSheetOpen}
187
+ title={display.label}
188
+ >
189
+ {panel}
190
+ </MobilePanelSheet>
191
+ </>
192
+ )
193
+ }
194
+
19
195
  export function PanelManager() {
196
+ const isMobile = useIsMobile()
20
197
  const selectedIds = useViewer((s) => s.selection.selectedIds)
21
198
  const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
199
+ const isPaintPanelOpen = useEditor((s) => s.isPaintPanelOpen)
200
+ const mode = useEditor((s) => s.mode)
201
+ const activePaintMaterial = useEditor((s) => s.activePaintMaterial)
22
202
  // Only subscribe to the *type* of the single-selected node — string primitive
23
203
  // so we don't re-render on unrelated scene mutations.
24
204
  const selectedNodeType = useScene((s) => {
@@ -26,39 +206,39 @@ export function PanelManager() {
26
206
  const id = selectedIds[0]
27
207
  return id ? (s.nodes[id as AnyNodeId]?.type ?? null) : null
28
208
  })
209
+ const selectedNode = useScene((s) => {
210
+ if (selectedIds.length !== 1) return null
211
+ const id = selectedIds[0]
212
+ return id ? (s.nodes[id as AnyNodeId] ?? null) : null
213
+ })
214
+
215
+ if (isMobile) {
216
+ if (selectedReferenceId) {
217
+ return <MobilePanelLayer isReference={true} node={null} panel={<ReferencePanel />} />
218
+ }
219
+ return (
220
+ <MobilePanelLayer
221
+ isReference={false}
222
+ node={selectedNode}
223
+ panel={panelForType(selectedNodeType)}
224
+ />
225
+ )
226
+ }
29
227
 
30
228
  // Show reference panel if a reference is selected
31
229
  if (selectedReferenceId) {
32
230
  return <ReferencePanel />
33
231
  }
34
232
 
35
- // Show appropriate panel based on selected node type
36
- if (selectedNodeType) {
37
- switch (selectedNodeType) {
38
- case 'item':
39
- return <ItemPanel />
40
- case 'roof':
41
- return <RoofPanel />
42
- case 'roof-segment':
43
- return <RoofSegmentPanel />
44
- case 'stair':
45
- return <StairPanel />
46
- case 'stair-segment':
47
- return <StairSegmentPanel />
48
- case 'slab':
49
- return <SlabPanel />
50
- case 'ceiling':
51
- return <CeilingPanel />
52
- case 'wall':
53
- return <WallPanel />
54
- case 'fence':
55
- return <FencePanel />
56
- case 'door':
57
- return <DoorPanel />
58
- case 'window':
59
- return <WindowPanel />
60
- }
233
+ if (
234
+ isPaintPanelOpen &&
235
+ mode === 'material-paint' &&
236
+ activePaintMaterial?.material?.properties &&
237
+ !activePaintMaterial.materialPreset
238
+ ) {
239
+ return <PaintPanel />
61
240
  }
62
241
 
63
- return null
242
+ // Show appropriate panel based on selected node type
243
+ return panelForType(selectedNodeType)
64
244
  }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { ChevronLeft, RotateCcw, X } from 'lucide-react'
4
4
  import Image from 'next/image'
5
+ import { useIsMobile } from '../../../hooks/use-mobile'
5
6
  import { cn } from '../../../lib/utils'
6
7
 
7
8
  interface PanelWrapperProps {
@@ -25,53 +26,61 @@ export function PanelWrapper({
25
26
  className,
26
27
  width = 320, // default width
27
28
  }: PanelWrapperProps) {
29
+ const isMobile = useIsMobile()
30
+
28
31
  return (
29
32
  <div
30
33
  className={cn(
31
- 'pointer-events-auto fixed top-20 right-4 z-50 flex max-h-[calc(100dvh-100px)] flex-col overflow-hidden rounded-xl border border-border/50 bg-sidebar/95 shadow-2xl backdrop-blur-xl dark:text-foreground',
34
+ isMobile
35
+ ? 'flex h-full w-full flex-col overflow-hidden bg-transparent dark:text-foreground'
36
+ : 'pointer-events-auto fixed top-20 right-4 z-50 flex max-h-[calc(100dvh-100px)] flex-col overflow-hidden rounded-xl border border-border/50 bg-sidebar/95 shadow-2xl backdrop-blur-xl dark:text-foreground',
32
37
  className,
33
38
  )}
34
- style={{ width }}
39
+ style={isMobile ? undefined : { width }}
35
40
  >
36
- {/* Header */}
37
- <div className="flex items-center justify-between border-border/50 border-b px-3 py-3">
38
- <div className="flex items-center gap-2">
39
- {onBack && (
40
- <button
41
- className="mr-1 flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground"
42
- onClick={onBack}
43
- type="button"
44
- >
45
- <ChevronLeft className="h-4 w-4" />
46
- </button>
47
- )}
48
- {icon && (
49
- <Image alt="" className="shrink-0 object-contain" height={16} src={icon} width={16} />
50
- )}
51
- <h2 className="truncate font-semibold text-foreground text-sm tracking-tight">{title}</h2>
52
- </div>
41
+ {/* Header — desktop only; mobile sheet provides its own header */}
42
+ {!isMobile && (
43
+ <div className="flex items-center justify-between border-border/50 border-b px-3 py-3">
44
+ <div className="flex items-center gap-2">
45
+ {onBack && (
46
+ <button
47
+ className="mr-1 flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground"
48
+ onClick={onBack}
49
+ type="button"
50
+ >
51
+ <ChevronLeft className="h-4 w-4" />
52
+ </button>
53
+ )}
54
+ {icon && (
55
+ <Image alt="" className="shrink-0 object-contain" height={16} src={icon} width={16} />
56
+ )}
57
+ <h2 className="truncate font-semibold text-foreground text-sm tracking-tight">
58
+ {title}
59
+ </h2>
60
+ </div>
53
61
 
54
- <div className="flex items-center gap-1">
55
- {onReset && (
56
- <button
57
- className="flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground"
58
- onClick={onReset}
59
- type="button"
60
- >
61
- <RotateCcw className="h-4 w-4" />
62
- </button>
63
- )}
64
- {onClose && (
65
- <button
66
- className="flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground"
67
- onClick={onClose}
68
- type="button"
69
- >
70
- <X className="h-4 w-4" />
71
- </button>
72
- )}
62
+ <div className="flex items-center gap-1">
63
+ {onReset && (
64
+ <button
65
+ className="flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground"
66
+ onClick={onReset}
67
+ type="button"
68
+ >
69
+ <RotateCcw className="h-4 w-4" />
70
+ </button>
71
+ )}
72
+ {onClose && (
73
+ <button
74
+ className="flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground"
75
+ onClick={onClose}
76
+ type="button"
77
+ >
78
+ <X className="h-4 w-4" />
79
+ </button>
80
+ )}
81
+ </div>
73
82
  </div>
74
- </div>
83
+ )}
75
84
 
76
85
  {/* Content */}
77
86
  <div className="no-scrollbar flex min-h-0 flex-1 flex-col overflow-y-auto">{children}</div>
@@ -1,21 +1,59 @@
1
1
  'use client'
2
2
 
3
- import { type AnyNode, type GuideNode, type ScanNode, useScene } from '@pascal-app/core'
4
- import { Box, Image as ImageIcon } from 'lucide-react'
5
- import { useCallback } from 'react'
3
+ import {
4
+ type AnyNode,
5
+ type GuideNode,
6
+ loadAssetUrl,
7
+ type ScanNode,
8
+ saveAsset,
9
+ useScene,
10
+ } from '@pascal-app/core'
11
+ import {
12
+ Eye,
13
+ EyeOff,
14
+ LocateFixed,
15
+ Lock,
16
+ RotateCcw,
17
+ Ruler,
18
+ Trash2,
19
+ Unlock,
20
+ Upload,
21
+ } from 'lucide-react'
22
+ import { useCallback, useEffect, useRef, useState } from 'react'
23
+ import { guideEmitter } from '../../../lib/guide-events'
24
+ import { getGuideImageName } from '../../../lib/local-guide-image'
6
25
  import useEditor from '../../../store/use-editor'
7
26
  import { ActionButton, ActionGroup } from '../controls/action-button'
8
- import { MetricControl } from '../controls/metric-control'
9
27
  import { PanelSection } from '../controls/panel-section'
10
28
  import { SliderControl } from '../controls/slider-control'
11
29
  import { PanelWrapper } from './panel-wrapper'
12
30
 
13
31
  type ReferenceNode = ScanNode | GuideNode
14
32
 
33
+ function getScaleStatus(guide: GuideNode, scaleReferenceVisible: boolean) {
34
+ const reference = guide.scaleReference
35
+ if (!reference) {
36
+ return 'Uncalibrated'
37
+ }
38
+
39
+ return `${scaleReferenceVisible ? 'Scaled' : 'Scaled (hidden)'} · ${reference.label}`
40
+ }
41
+
15
42
  export function ReferencePanel() {
16
43
  const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
17
44
  const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
45
+ const guideUi = useEditor((s) =>
46
+ selectedReferenceId ? s.guideUi[selectedReferenceId] : undefined,
47
+ )
48
+ const setGuideLocked = useEditor((s) => s.setGuideLocked)
49
+ const setGuideScaleReferenceVisible = useEditor((s) => s.setGuideScaleReferenceVisible)
50
+ const clearGuideUi = useEditor((s) => s.clearGuideUi)
18
51
  const updateNode = useScene((s) => s.updateNode)
52
+ const deleteNode = useScene((s) => s.deleteNode)
53
+ const replaceInputRef = useRef<HTMLInputElement>(null)
54
+ const [isReplacing, setIsReplacing] = useState(false)
55
+ const [replaceError, setReplaceError] = useState<string | null>(null)
56
+ const [isAssetMissing, setIsAssetMissing] = useState(false)
19
57
 
20
58
  const node = useScene((s) =>
21
59
  selectedReferenceId
@@ -35,17 +73,227 @@ export function ReferencePanel() {
35
73
  setSelectedReferenceId(null)
36
74
  }, [setSelectedReferenceId])
37
75
 
76
+ const handleReplaceFile = useCallback(
77
+ async (file: File) => {
78
+ if (!(selectedReferenceId && node?.type === 'guide')) {
79
+ return
80
+ }
81
+
82
+ if (!file.type.startsWith('image/')) {
83
+ setReplaceError('Choose a PNG, JPEG, or WebP image.')
84
+ return
85
+ }
86
+
87
+ setIsReplacing(true)
88
+ setReplaceError(null)
89
+
90
+ try {
91
+ const assetUrl = await saveAsset(file)
92
+ updateNode(
93
+ selectedReferenceId as AnyNode['id'],
94
+ {
95
+ name: getGuideImageName(file.name),
96
+ url: assetUrl,
97
+ scaleReference: null,
98
+ } as Partial<GuideNode>,
99
+ )
100
+ setGuideScaleReferenceVisible(selectedReferenceId, true)
101
+ } catch {
102
+ setReplaceError('Could not replace that image.')
103
+ } finally {
104
+ setIsReplacing(false)
105
+ }
106
+ },
107
+ [node?.type, selectedReferenceId, setGuideScaleReferenceVisible, updateNode],
108
+ )
109
+
110
+ const handleDeleteGuide = useCallback(() => {
111
+ if (!(selectedReferenceId && node?.type === 'guide')) {
112
+ return
113
+ }
114
+
115
+ deleteNode(selectedReferenceId as AnyNode['id'])
116
+ guideEmitter.emit('guide:deleted', { guideId: selectedReferenceId as GuideNode['id'] })
117
+ clearGuideUi(selectedReferenceId)
118
+ setSelectedReferenceId(null)
119
+ }, [clearGuideUi, deleteNode, node?.type, selectedReferenceId, setSelectedReferenceId])
120
+
121
+ const handleStartScale = useCallback(() => {
122
+ if (node?.type !== 'guide') {
123
+ return
124
+ }
125
+
126
+ guideEmitter.emit('guide:set-reference-scale', { guideId: node.id })
127
+ }, [node])
128
+
129
+ const handleCancelScale = useCallback(() => {
130
+ guideEmitter.emit('guide:cancel-reference-scale')
131
+ }, [])
132
+
133
+ useEffect(() => {
134
+ if (node?.type !== 'guide' || !node.url.startsWith('asset://')) {
135
+ setIsAssetMissing(false)
136
+ return
137
+ }
138
+
139
+ let cancelled = false
140
+ loadAssetUrl(node.url).then((resolvedUrl) => {
141
+ if (!cancelled) {
142
+ setIsAssetMissing(!resolvedUrl)
143
+ }
144
+ })
145
+
146
+ return () => {
147
+ cancelled = true
148
+ }
149
+ }, [node])
150
+
38
151
  if (!node || (node.type !== 'scan' && node.type !== 'guide')) return null
39
152
 
40
153
  const isScan = node.type === 'scan'
154
+ const guideLocked = !isScan && guideUi?.locked === true
155
+ const scaleReferenceVisible = !isScan && guideUi?.scaleReferenceVisible !== false
156
+ const scaleStatus = isScan ? null : getScaleStatus(node, scaleReferenceVisible)
41
157
 
42
158
  return (
43
159
  <PanelWrapper
44
- icon={isScan ? undefined : undefined}
45
160
  onClose={handleClose}
46
161
  title={node.name || (isScan ? '3D Scan' : 'Guide Image')}
47
162
  width={300}
48
163
  >
164
+ {!isScan && (
165
+ <>
166
+ <PanelSection title="Image">
167
+ <input
168
+ accept="image/*"
169
+ className="hidden"
170
+ onChange={(event) => {
171
+ const file = event.currentTarget.files?.[0]
172
+ event.currentTarget.value = ''
173
+ if (file) {
174
+ void handleReplaceFile(file)
175
+ }
176
+ }}
177
+ ref={replaceInputRef}
178
+ type="file"
179
+ />
180
+
181
+ <ActionGroup>
182
+ <ActionButton
183
+ disabled={isReplacing}
184
+ icon={<Upload className="h-3.5 w-3.5" />}
185
+ label={isReplacing ? 'Replacing...' : 'Replace'}
186
+ onClick={() => replaceInputRef.current?.click()}
187
+ />
188
+ <ActionButton
189
+ className="text-destructive hover:bg-destructive/10"
190
+ icon={<Trash2 className="h-3.5 w-3.5" />}
191
+ label="Delete"
192
+ onClick={handleDeleteGuide}
193
+ />
194
+ </ActionGroup>
195
+
196
+ <ActionGroup>
197
+ <ActionButton
198
+ icon={
199
+ node.visible === false ? (
200
+ <EyeOff className="h-3.5 w-3.5" />
201
+ ) : (
202
+ <Eye className="h-3.5 w-3.5" />
203
+ )
204
+ }
205
+ label={node.visible === false ? 'Show' : 'Hide'}
206
+ onClick={() => handleUpdate({ visible: node.visible === false })}
207
+ />
208
+ <ActionButton
209
+ icon={
210
+ guideLocked ? (
211
+ <Lock className="h-3.5 w-3.5" />
212
+ ) : (
213
+ <Unlock className="h-3.5 w-3.5" />
214
+ )
215
+ }
216
+ label={guideLocked ? 'Unlock' : 'Lock'}
217
+ onClick={() => setGuideLocked(node.id, !guideLocked)}
218
+ />
219
+ </ActionGroup>
220
+
221
+ {replaceError && (
222
+ <div className="rounded-md border border-destructive/30 bg-destructive/10 px-2 py-1.5 text-destructive text-xs">
223
+ {replaceError}
224
+ </div>
225
+ )}
226
+
227
+ {isAssetMissing && (
228
+ <div className="rounded-md border border-amber-500/35 bg-amber-500/10 px-2 py-1.5 text-amber-700 text-xs dark:text-amber-300">
229
+ Overlay image unavailable. Replace the image to restore it.
230
+ </div>
231
+ )}
232
+ </PanelSection>
233
+
234
+ <PanelSection title="Reference Scale">
235
+ <div className="flex items-center gap-2 rounded-md border border-border/50 bg-background/40 px-2.5 py-2 text-sm">
236
+ <Ruler className="h-4 w-4 shrink-0 text-primary" />
237
+ <span className="truncate text-muted-foreground">{scaleStatus}</span>
238
+ </div>
239
+
240
+ <ActionGroup>
241
+ <ActionButton
242
+ label={node.scaleReference ? 'Edit Scale' : 'Set Scale'}
243
+ onClick={handleStartScale}
244
+ />
245
+ <ActionButton label="Cancel" onClick={handleCancelScale} />
246
+ </ActionGroup>
247
+
248
+ <ActionGroup>
249
+ <ActionButton
250
+ disabled={!node.scaleReference}
251
+ label={scaleReferenceVisible ? 'Hide Scale' : 'Show Scale'}
252
+ onClick={() => {
253
+ if (!node.scaleReference) return
254
+ setGuideScaleReferenceVisible(node.id, !scaleReferenceVisible)
255
+ }}
256
+ />
257
+ <ActionButton
258
+ disabled={!node.scaleReference}
259
+ label="Clear Scale"
260
+ onClick={() => handleUpdate({ scaleReference: null } as Partial<GuideNode>)}
261
+ />
262
+ </ActionGroup>
263
+ </PanelSection>
264
+
265
+ <PanelSection title="Quick Actions">
266
+ <ActionGroup>
267
+ <ActionButton
268
+ icon={<LocateFixed className="h-3.5 w-3.5" />}
269
+ label="Center"
270
+ onClick={() =>
271
+ handleUpdate({
272
+ position: [0, node.position[1], 0],
273
+ } as Partial<GuideNode>)
274
+ }
275
+ />
276
+ <ActionButton
277
+ icon={<RotateCcw className="h-3.5 w-3.5" />}
278
+ label="Reset Rotation"
279
+ onClick={() =>
280
+ handleUpdate({
281
+ rotation: [node.rotation[0], 0, node.rotation[2]],
282
+ } as Partial<GuideNode>)
283
+ }
284
+ />
285
+ </ActionGroup>
286
+ <ActionGroup>
287
+ <ActionButton
288
+ icon={<Ruler className="h-3.5 w-3.5" />}
289
+ label="Reset Image Scale"
290
+ onClick={() => handleUpdate({ scale: 1 } as Partial<GuideNode>)}
291
+ />
292
+ </ActionGroup>
293
+ </PanelSection>
294
+ </>
295
+ )}
296
+
49
297
  <PanelSection title="Position">
50
298
  <SliderControl
51
299
  label={