@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,216 @@
1
+ import { type AnyNodeId, type StairNode, type StairSegmentNode, useScene } from '@pascal-app/core'
2
+ import { useViewer } from '@pascal-app/viewer'
3
+ import { AnimatePresence } from 'motion/react'
4
+ import Image from 'next/image'
5
+ import { useCallback, useEffect, useState } from 'react'
6
+ import useEditor from '../../../../../store/use-editor'
7
+ import { InlineRenameInput } from './inline-rename-input'
8
+ import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
9
+ import { TreeNodeActions } from './tree-node-actions'
10
+ import { DropIndicatorLine, useTreeNodeDrag } from './tree-node-drag'
11
+
12
+ interface StairTreeNodeProps {
13
+ node: StairNode
14
+ depth: number
15
+ isLast?: boolean
16
+ }
17
+
18
+ export function StairTreeNode({ node, depth, isLast }: StairTreeNodeProps) {
19
+ const [isEditing, setIsEditing] = useState(false)
20
+ const [expanded, setExpanded] = useState(false)
21
+ const selectedIds = useViewer((state) => state.selection.selectedIds)
22
+ const isSelected = selectedIds.includes(node.id)
23
+ const isHovered = useViewer((state) => state.hoveredId === node.id)
24
+ const setSelection = useViewer((state) => state.setSelection)
25
+ const setHoveredId = useViewer((state) => state.setHoveredId)
26
+ const nodes = useScene((state) => state.nodes)
27
+ const { drag, dropTarget } = useTreeNodeDrag()
28
+
29
+ const handleClick = (e: React.MouseEvent) => {
30
+ e.stopPropagation()
31
+ const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)
32
+ if (!handled && useEditor.getState().phase === 'furnish') {
33
+ useEditor.getState().setPhase('structure')
34
+ }
35
+ }
36
+
37
+ const handleDoubleClick = () => {
38
+ focusTreeNode(node.id)
39
+ }
40
+
41
+ const handleMouseEnter = () => {
42
+ setHoveredId(node.id)
43
+ }
44
+
45
+ const handleMouseLeave = () => {
46
+ setHoveredId(null)
47
+ }
48
+
49
+ const segments = (node.children ?? [])
50
+ .map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
51
+ .filter((n): n is StairSegmentNode => n?.type === 'stair-segment')
52
+
53
+ const hasSelectedChild = segments.some((seg) => selectedIds.includes(seg.id))
54
+
55
+ useEffect(() => {
56
+ if (isSelected || hasSelectedChild) {
57
+ setExpanded(true)
58
+ }
59
+ }, [isSelected, hasSelectedChild])
60
+
61
+ // Auto-expand when a segment is being dragged over this stair
62
+ const isDropTarget = drag !== null && dropTarget?.parentId === node.id
63
+ useEffect(() => {
64
+ if (isDropTarget && !expanded) {
65
+ setExpanded(true)
66
+ }
67
+ }, [isDropTarget, expanded])
68
+
69
+ const segmentCount = segments.length
70
+ const defaultName = `Staircase (${segmentCount} segment${segmentCount !== 1 ? 's' : ''})`
71
+
72
+ // Hide the dragged segment from every stair while dragging
73
+ const visibleSegments = drag ? segments.filter((seg) => seg.id !== drag.nodeId) : segments
74
+
75
+ const isValidDropTarget = drag !== null && drag.nodeId !== node.id
76
+
77
+ return (
78
+ <div data-drop-target={node.id}>
79
+ <TreeNodeWrapper
80
+ actions={<TreeNodeActions node={node} />}
81
+ depth={depth}
82
+ expanded={expanded}
83
+ hasChildren={segments.length > 0}
84
+ icon={
85
+ <Image alt="" className="object-contain" height={14} src="/icons/stairs.png" width={14} />
86
+ }
87
+ isDropTarget={isValidDropTarget && isDropTarget}
88
+ isHovered={isHovered || isDropTarget}
89
+ isLast={isLast && !expanded}
90
+ isSelected={isSelected}
91
+ isVisible={node.visible !== false}
92
+ label={
93
+ <InlineRenameInput
94
+ defaultName={defaultName}
95
+ isEditing={isEditing}
96
+ node={node}
97
+ onStartEditing={() => setIsEditing(true)}
98
+ onStopEditing={() => setIsEditing(false)}
99
+ />
100
+ }
101
+ nodeId={node.id}
102
+ onClick={handleClick}
103
+ onDoubleClick={handleDoubleClick}
104
+ onMouseEnter={handleMouseEnter}
105
+ onMouseLeave={handleMouseLeave}
106
+ onToggle={() => setExpanded(!expanded)}
107
+ >
108
+ {visibleSegments.map((seg, i) => {
109
+ const showIndicatorBefore = isDropTarget && dropTarget?.insertIndex === i
110
+ const showIndicatorAfter =
111
+ isDropTarget &&
112
+ i === visibleSegments.length - 1 &&
113
+ dropTarget?.insertIndex !== undefined &&
114
+ dropTarget.insertIndex > i
115
+
116
+ return (
117
+ <div key={seg.id}>
118
+ <AnimatePresence>
119
+ {showIndicatorBefore && <DropIndicatorLine key="indicator-before" />}
120
+ </AnimatePresence>
121
+ <StairSegmentTreeNode
122
+ depth={depth + 1}
123
+ isLast={isLast && i === visibleSegments.length - 1 && !showIndicatorAfter}
124
+ node={seg}
125
+ />
126
+ <AnimatePresence>
127
+ {showIndicatorAfter && <DropIndicatorLine key="indicator-after" />}
128
+ </AnimatePresence>
129
+ </div>
130
+ )
131
+ })}
132
+ <AnimatePresence>
133
+ {isDropTarget && visibleSegments.length === 0 && <DropIndicatorLine />}
134
+ </AnimatePresence>
135
+ </TreeNodeWrapper>
136
+ </div>
137
+ )
138
+ }
139
+
140
+ function StairSegmentTreeNode({
141
+ node,
142
+ depth,
143
+ isLast,
144
+ }: {
145
+ node: StairSegmentNode
146
+ depth: number
147
+ isLast?: boolean
148
+ }) {
149
+ const [isEditing, setIsEditing] = useState(false)
150
+ const selectedIds = useViewer((state) => state.selection.selectedIds)
151
+ const isSelected = selectedIds.includes(node.id)
152
+ const isHovered = useViewer((state) => state.hoveredId === node.id)
153
+ const setSelection = useViewer((state) => state.setSelection)
154
+ const setHoveredId = useViewer((state) => state.setHoveredId)
155
+ const { startDrag, isDragging } = useTreeNodeDrag()
156
+
157
+ const handleClick = (e: React.MouseEvent) => {
158
+ if (isDragging) return
159
+ e.stopPropagation()
160
+ handleTreeSelection(e, node.id, selectedIds, setSelection)
161
+ }
162
+
163
+ const handlePointerDown = useCallback(
164
+ (e: React.PointerEvent) => {
165
+ if (e.button !== 0) return
166
+ const typeLabel = node.segmentType === 'stair' ? 'Flight' : 'Landing'
167
+ const label = `${typeLabel} (${node.width.toFixed(1)}×${node.length.toFixed(1)}m)`
168
+ startDrag(node.id, node.type, node.parentId as string, label, e.clientX, e.clientY)
169
+ },
170
+ [node.id, node.type, node.parentId, node.segmentType, node.width, node.length, startDrag],
171
+ )
172
+
173
+ const typeLabel = node.segmentType === 'stair' ? 'Flight' : 'Landing'
174
+ const defaultName = `${typeLabel} (${node.width.toFixed(1)}×${node.length.toFixed(1)}m)`
175
+
176
+ return (
177
+ <div data-drop-child={node.id}>
178
+ <TreeNodeWrapper
179
+ actions={<TreeNodeActions node={node} />}
180
+ depth={depth}
181
+ expanded={false}
182
+ hasChildren={false}
183
+ icon={
184
+ <Image
185
+ alt=""
186
+ className="object-contain opacity-60"
187
+ height={14}
188
+ src="/icons/stairs.png"
189
+ width={14}
190
+ />
191
+ }
192
+ isDraggable
193
+ isHovered={isHovered}
194
+ isLast={isLast}
195
+ isSelected={isSelected}
196
+ isVisible={node.visible !== false}
197
+ label={
198
+ <InlineRenameInput
199
+ defaultName={defaultName}
200
+ isEditing={isEditing}
201
+ node={node}
202
+ onStartEditing={() => setIsEditing(true)}
203
+ onStopEditing={() => setIsEditing(false)}
204
+ />
205
+ }
206
+ nodeId={node.id}
207
+ onClick={handleClick}
208
+ onDoubleClick={() => focusTreeNode(node.id)}
209
+ onMouseEnter={() => setHoveredId(node.id)}
210
+ onMouseLeave={() => setHoveredId(null)}
211
+ onPointerDown={handlePointerDown}
212
+ onToggle={() => {}}
213
+ />
214
+ </div>
215
+ )
216
+ }
@@ -0,0 +1,115 @@
1
+ import { type AnyNode, type AnyNodeId, emitter, useScene } from '@pascal-app/core'
2
+ import { useViewer } from '@pascal-app/viewer'
3
+ import { Camera, Eye, EyeOff, Trash2 } from 'lucide-react'
4
+ import { useState } from 'react'
5
+ import {
6
+ Popover,
7
+ PopoverContent,
8
+ PopoverTrigger,
9
+ } from './../../../../../components/ui/primitives/popover'
10
+
11
+ interface TreeNodeActionsProps {
12
+ node: AnyNode
13
+ }
14
+
15
+ export function TreeNodeActions({ node }: TreeNodeActionsProps) {
16
+ const [open, setOpen] = useState(false)
17
+ const updateNode = useScene((state) => state.updateNode)
18
+ const updateNodes = useScene((state) => state.updateNodes)
19
+ const selectedIds = useViewer((state) => state.selection.selectedIds)
20
+ const hasCamera = !!node.camera
21
+ const isVisible = node.visible !== false
22
+
23
+ const toggleVisibility = (e: React.MouseEvent) => {
24
+ e.stopPropagation()
25
+ const newVisibility = !isVisible
26
+ if (selectedIds?.includes(node.id)) {
27
+ updateNodes(
28
+ selectedIds.map((id) => ({
29
+ id: id as AnyNodeId,
30
+ data: { visible: newVisibility },
31
+ })),
32
+ )
33
+ } else {
34
+ updateNode(node.id, { visible: newVisibility })
35
+ }
36
+ }
37
+
38
+ const handleCaptureCamera = (e: React.MouseEvent) => {
39
+ e.stopPropagation()
40
+ emitter.emit('camera-controls:capture', { nodeId: node.id })
41
+ setOpen(false)
42
+ }
43
+ const handleViewCamera = (e: React.MouseEvent) => {
44
+ e.stopPropagation()
45
+ emitter.emit('camera-controls:view', { nodeId: node.id })
46
+ setOpen(false)
47
+ }
48
+
49
+ const handleClearCamera = (e: React.MouseEvent) => {
50
+ e.stopPropagation()
51
+ updateNode(node.id, { camera: undefined })
52
+ setOpen(false)
53
+ }
54
+
55
+ return (
56
+ <div className="flex items-center gap-0.5">
57
+ <button
58
+ className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10"
59
+ onClick={toggleVisibility}
60
+ title={isVisible ? 'Hide' : 'Show'}
61
+ >
62
+ {isVisible ? <Eye className="h-3 w-3" /> : <EyeOff className="h-3 w-3 opacity-50" />}
63
+ </button>
64
+
65
+ <Popover onOpenChange={setOpen} open={open}>
66
+ <PopoverTrigger asChild>
67
+ <button
68
+ className="relative flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10"
69
+ onClick={(e) => e.stopPropagation()}
70
+ title="Camera snapshot"
71
+ >
72
+ <Camera className="h-3 w-3" />
73
+ {hasCamera && (
74
+ <span className="absolute top-0.5 right-0.5 h-1.5 w-1.5 rounded-full bg-primary" />
75
+ )}
76
+ </button>
77
+ </PopoverTrigger>
78
+ <PopoverContent
79
+ align="start"
80
+ className="w-auto p-1"
81
+ onClick={(e) => e.stopPropagation()}
82
+ side="right"
83
+ >
84
+ <div className="flex flex-col gap-0.5">
85
+ {hasCamera && (
86
+ <button
87
+ className="flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-accent"
88
+ onClick={handleViewCamera}
89
+ >
90
+ <Camera className="h-3.5 w-3.5" />
91
+ View snapshot
92
+ </button>
93
+ )}
94
+ <button
95
+ className="flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-accent"
96
+ onClick={handleCaptureCamera}
97
+ >
98
+ <Camera className="h-3.5 w-3.5" />
99
+ {hasCamera ? 'Update snapshot' : 'Take snapshot'}
100
+ </button>
101
+ {hasCamera && (
102
+ <button
103
+ className="flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-destructive hover:text-destructive-foreground"
104
+ onClick={handleClearCamera}
105
+ >
106
+ <Trash2 className="h-3.5 w-3.5" />
107
+ Clear snapshot
108
+ </button>
109
+ )}
110
+ </div>
111
+ </PopoverContent>
112
+ </Popover>
113
+ </div>
114
+ )
115
+ }
@@ -0,0 +1,342 @@
1
+ 'use client'
2
+
3
+ import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core'
4
+ import { motion } from 'motion/react'
5
+ import {
6
+ createContext,
7
+ type ReactNode,
8
+ useCallback,
9
+ useContext,
10
+ useEffect,
11
+ useRef,
12
+ useState,
13
+ } from 'react'
14
+ import { createPortal } from 'react-dom'
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Reparenting rules
18
+ // ---------------------------------------------------------------------------
19
+
20
+ // Maps a draggable node type to the parent types it can be dropped into.
21
+ const REPARENT_TARGETS: Record<string, string[]> = {
22
+ 'roof-segment': ['roof'],
23
+ }
24
+
25
+ // Container types that should be auto-removed when all children are moved out.
26
+ const REMOVE_WHEN_EMPTY = new Set(['roof'])
27
+
28
+ export function canDrag(node: AnyNode): boolean {
29
+ return node.type in REPARENT_TARGETS
30
+ }
31
+
32
+ export function canDrop(draggedType: string, targetType: string): boolean {
33
+ return REPARENT_TARGETS[draggedType]?.includes(targetType) ?? false
34
+ }
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Coordinate preservation
38
+ // ---------------------------------------------------------------------------
39
+
40
+ type Transform = {
41
+ position: [number, number, number]
42
+ rotation: number
43
+ }
44
+
45
+ function getTransform(node: AnyNode): Transform {
46
+ const pos =
47
+ 'position' in node && Array.isArray(node.position)
48
+ ? (node.position as [number, number, number])
49
+ : ([0, 0, 0] as [number, number, number])
50
+ const rot = 'rotation' in node && typeof node.rotation === 'number' ? node.rotation : 0
51
+ return { position: pos, rotation: rot }
52
+ }
53
+
54
+ /**
55
+ * Compute new local position + rotation so the child stays at the same
56
+ * absolute grid position when moved from oldParent to newParent.
57
+ */
58
+ function computeReparentTransform(
59
+ child: Transform,
60
+ oldParent: Transform,
61
+ newParent: Transform,
62
+ ): Transform {
63
+ // child → world: world = parentPos + rotateY(childPos, parentRot)
64
+ const cosOld = Math.cos(oldParent.rotation)
65
+ const sinOld = Math.sin(oldParent.rotation)
66
+ const absX = oldParent.position[0] + child.position[0] * cosOld + child.position[2] * sinOld
67
+ const absY = oldParent.position[1] + child.position[1]
68
+ const absZ = oldParent.position[2] - child.position[0] * sinOld + child.position[2] * cosOld
69
+
70
+ // world → newParent local: rotateY_inverse(world - newParentPos, newParentRot)
71
+ const dx = absX - newParent.position[0]
72
+ const dy = absY - newParent.position[1]
73
+ const dz = absZ - newParent.position[2]
74
+ const cosNew = Math.cos(-newParent.rotation)
75
+ const sinNew = Math.sin(-newParent.rotation)
76
+
77
+ return {
78
+ position: [dx * cosNew + dz * sinNew, dy, -dx * sinNew + dz * cosNew],
79
+ rotation: oldParent.rotation + child.rotation - newParent.rotation,
80
+ }
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Types
85
+ // ---------------------------------------------------------------------------
86
+
87
+ type DragState = {
88
+ nodeId: string
89
+ nodeType: string
90
+ sourceParentId: string
91
+ label: string
92
+ pointerX: number
93
+ pointerY: number
94
+ } | null
95
+
96
+ type DropTarget = {
97
+ parentId: string
98
+ insertIndex: number
99
+ } | null
100
+
101
+ type TreeNodeDragContextValue = {
102
+ drag: DragState
103
+ dropTarget: DropTarget
104
+ startDrag: (
105
+ nodeId: string,
106
+ nodeType: string,
107
+ sourceParentId: string,
108
+ label: string,
109
+ x: number,
110
+ y: number,
111
+ ) => void
112
+ isDragging: boolean
113
+ }
114
+
115
+ const TreeNodeDragContext = createContext<TreeNodeDragContextValue>({
116
+ drag: null,
117
+ dropTarget: null,
118
+ startDrag: () => {},
119
+ isDragging: false,
120
+ })
121
+
122
+ export const useTreeNodeDrag = () => useContext(TreeNodeDragContext)
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Provider
126
+ // ---------------------------------------------------------------------------
127
+
128
+ const DRAG_THRESHOLD = 4
129
+
130
+ export function TreeNodeDragProvider({ children }: { children: ReactNode }) {
131
+ const [drag, setDrag] = useState<DragState>(null)
132
+ const [dropTarget, setDropTarget] = useState<DropTarget>(null)
133
+ const pendingRef = useRef<{
134
+ nodeId: string
135
+ nodeType: string
136
+ sourceParentId: string
137
+ label: string
138
+ startX: number
139
+ startY: number
140
+ } | null>(null)
141
+
142
+ const commitDrop = useCallback(() => {
143
+ if (!(drag && dropTarget)) return
144
+
145
+ const state = useScene.getState()
146
+
147
+ if (dropTarget.parentId === drag.sourceParentId) {
148
+ // --- Reorder within same parent ---
149
+ const parent = state.nodes[dropTarget.parentId as AnyNodeId]
150
+ if (parent && 'children' in parent && Array.isArray(parent.children)) {
151
+ const currentChildren = [...parent.children] as string[]
152
+ const fromIndex = currentChildren.indexOf(drag.nodeId)
153
+ if (fromIndex === -1) return
154
+ currentChildren.splice(fromIndex, 1)
155
+ const toIndex = Math.min(dropTarget.insertIndex, currentChildren.length)
156
+ currentChildren.splice(toIndex, 0, drag.nodeId)
157
+ state.updateNode(dropTarget.parentId as AnyNodeId, { children: currentChildren } as any)
158
+ }
159
+ } else {
160
+ // --- Reparent to different parent, preserving world position ---
161
+ const node = state.nodes[drag.nodeId as AnyNodeId]
162
+ const oldParent = state.nodes[drag.sourceParentId as AnyNodeId]
163
+ const newParent = state.nodes[dropTarget.parentId as AnyNodeId]
164
+ if (!(node && oldParent && newParent)) return
165
+
166
+ const newLocal = computeReparentTransform(
167
+ getTransform(node),
168
+ getTransform(oldParent),
169
+ getTransform(newParent),
170
+ )
171
+
172
+ state.updateNode(
173
+ drag.nodeId as AnyNodeId,
174
+ {
175
+ parentId: dropTarget.parentId,
176
+ position: newLocal.position,
177
+ rotation: newLocal.rotation,
178
+ } as any,
179
+ )
180
+
181
+ // Place at the correct index within the new parent's children
182
+ const updatedParent = state.nodes[dropTarget.parentId as AnyNodeId]
183
+ if (updatedParent && 'children' in updatedParent && Array.isArray(updatedParent.children)) {
184
+ const children = [...updatedParent.children] as string[]
185
+ const idx = children.indexOf(drag.nodeId)
186
+ if (idx !== -1) {
187
+ children.splice(idx, 1)
188
+ const toIndex = Math.min(dropTarget.insertIndex, children.length)
189
+ children.splice(toIndex, 0, drag.nodeId)
190
+ state.updateNode(dropTarget.parentId as AnyNodeId, { children } as any)
191
+ }
192
+ }
193
+
194
+ // Lifecycle: remove old parent if it's now empty and in REMOVE_WHEN_EMPTY
195
+ const staleParent = state.nodes[drag.sourceParentId as AnyNodeId]
196
+ if (
197
+ staleParent &&
198
+ REMOVE_WHEN_EMPTY.has(staleParent.type) &&
199
+ 'children' in staleParent &&
200
+ Array.isArray(staleParent.children) &&
201
+ staleParent.children.length === 0
202
+ ) {
203
+ state.deleteNode(drag.sourceParentId as AnyNodeId)
204
+ }
205
+ }
206
+ }, [drag, dropTarget])
207
+
208
+ const startDrag = useCallback(
209
+ (
210
+ nodeId: string,
211
+ nodeType: string,
212
+ sourceParentId: string,
213
+ label: string,
214
+ x: number,
215
+ y: number,
216
+ ) => {
217
+ pendingRef.current = { nodeId, nodeType, sourceParentId, label, startX: x, startY: y }
218
+ },
219
+ [],
220
+ )
221
+
222
+ useEffect(() => {
223
+ const handlePointerMove = (e: PointerEvent) => {
224
+ if (pendingRef.current && !drag) {
225
+ const dx = e.clientX - pendingRef.current.startX
226
+ const dy = e.clientY - pendingRef.current.startY
227
+ if (Math.abs(dx) + Math.abs(dy) >= DRAG_THRESHOLD) {
228
+ const p = pendingRef.current
229
+ setDrag({
230
+ nodeId: p.nodeId,
231
+ nodeType: p.nodeType,
232
+ sourceParentId: p.sourceParentId,
233
+ label: p.label,
234
+ pointerX: e.clientX,
235
+ pointerY: e.clientY,
236
+ })
237
+ }
238
+ return
239
+ }
240
+
241
+ if (!drag) return
242
+
243
+ setDrag((prev) => (prev ? { ...prev, pointerX: e.clientX, pointerY: e.clientY } : null))
244
+
245
+ // Hit-test for drop targets
246
+ const els = document.elementsFromPoint(e.clientX, e.clientY)
247
+ let foundTarget: DropTarget = null
248
+
249
+ for (const el of els) {
250
+ const targetEl = (el as HTMLElement).closest?.('[data-drop-target]') as HTMLElement | null
251
+ if (!targetEl) continue
252
+
253
+ const parentId = targetEl.dataset.dropTarget!
254
+
255
+ // Validate this is a legal drop
256
+ const targetNode = useScene.getState().nodes[parentId as AnyNodeId]
257
+ if (!(targetNode && canDrop(drag.nodeType, targetNode.type))) continue
258
+
259
+ // Find child rows to determine insert index
260
+ const childRows = targetEl.querySelectorAll<HTMLElement>('[data-drop-child]')
261
+ let insertIndex = childRows.length
262
+
263
+ for (let i = 0; i < childRows.length; i++) {
264
+ const row = childRows[i]!
265
+ const rect = row.getBoundingClientRect()
266
+ const midY = rect.top + rect.height / 2
267
+ if (e.clientY < midY) {
268
+ insertIndex = i
269
+ break
270
+ }
271
+ }
272
+
273
+ foundTarget = { parentId, insertIndex }
274
+ break
275
+ }
276
+
277
+ setDropTarget(foundTarget)
278
+ }
279
+
280
+ const handlePointerUp = () => {
281
+ if (drag) {
282
+ commitDrop()
283
+ }
284
+ pendingRef.current = null
285
+ setDrag(null)
286
+ setDropTarget(null)
287
+ }
288
+
289
+ window.addEventListener('pointermove', handlePointerMove)
290
+ window.addEventListener('pointerup', handlePointerUp)
291
+ return () => {
292
+ window.removeEventListener('pointermove', handlePointerMove)
293
+ window.removeEventListener('pointerup', handlePointerUp)
294
+ }
295
+ }, [drag, commitDrop])
296
+
297
+ const isDragging = drag !== null
298
+
299
+ return (
300
+ <TreeNodeDragContext.Provider value={{ drag, dropTarget, startDrag, isDragging }}>
301
+ {isDragging && <style>{'* { cursor: grabbing !important; }'}</style>}
302
+ {children}
303
+ {drag && <FloatingPreview drag={drag} />}
304
+ </TreeNodeDragContext.Provider>
305
+ )
306
+ }
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // Floating preview (portal)
310
+ // ---------------------------------------------------------------------------
311
+
312
+ function FloatingPreview({ drag }: { drag: NonNullable<DragState> }) {
313
+ return createPortal(
314
+ <div
315
+ className="pointer-events-none fixed z-[200] flex items-center gap-1.5 rounded-lg border border-accent bg-background/95 px-2.5 py-1.5 font-medium text-foreground text-xs shadow-xl backdrop-blur-sm"
316
+ style={{
317
+ left: drag.pointerX + 12,
318
+ top: drag.pointerY - 14,
319
+ }}
320
+ >
321
+ <span className="opacity-60">↕</span>
322
+ {drag.label}
323
+ </div>,
324
+ document.body,
325
+ )
326
+ }
327
+
328
+ // ---------------------------------------------------------------------------
329
+ // Drop indicator line
330
+ // ---------------------------------------------------------------------------
331
+
332
+ export function DropIndicatorLine() {
333
+ return (
334
+ <motion.div
335
+ animate={{ height: 2, opacity: 1 }}
336
+ className="pointer-events-none mx-3 rounded-full bg-blue-500"
337
+ exit={{ height: 0, opacity: 0 }}
338
+ initial={{ height: 0, opacity: 0 }}
339
+ transition={{ type: 'spring', bounce: 0.3, duration: 0.25 }}
340
+ />
341
+ )
342
+ }