@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,98 @@
1
+ import { type AnyNode, useScene } from '@pascal-app/core'
2
+ import { Pencil } from 'lucide-react'
3
+ import { useCallback, useEffect, useRef, useState } from 'react'
4
+ import { cn } from './../../../../../lib/utils'
5
+
6
+ interface InlineRenameInputProps {
7
+ node: AnyNode
8
+ isEditing: boolean
9
+ onStopEditing: () => void
10
+ defaultName: string
11
+ className?: string
12
+ onStartEditing?: () => void
13
+ }
14
+
15
+ export function InlineRenameInput({
16
+ node,
17
+ isEditing,
18
+ onStopEditing,
19
+ defaultName,
20
+ className,
21
+ onStartEditing,
22
+ }: InlineRenameInputProps) {
23
+ const updateNode = useScene((s) => s.updateNode)
24
+ const [value, setValue] = useState(node.name || '')
25
+ const inputRef = useRef<HTMLInputElement>(null)
26
+ const inputSize = Math.max((value || defaultName).length, 1)
27
+
28
+ useEffect(() => {
29
+ if (isEditing) {
30
+ setValue(node.name || '')
31
+ // Focus and select all text after a short delay
32
+ setTimeout(() => {
33
+ if (inputRef.current) {
34
+ inputRef.current.focus()
35
+ inputRef.current.select()
36
+ }
37
+ }, 0)
38
+ }
39
+ }, [isEditing, node.name])
40
+
41
+ const handleSave = useCallback(() => {
42
+ const trimmed = value.trim()
43
+ if (trimmed !== node.name) {
44
+ updateNode(node.id, { name: trimmed || undefined })
45
+ }
46
+ onStopEditing()
47
+ }, [value, node.id, node.name, updateNode, onStopEditing])
48
+
49
+ const handleKeyDown = (e: React.KeyboardEvent) => {
50
+ if (e.key === 'Enter') {
51
+ e.preventDefault()
52
+ handleSave()
53
+ } else if (e.key === 'Escape') {
54
+ e.preventDefault()
55
+ onStopEditing()
56
+ }
57
+ }
58
+
59
+ if (!isEditing) {
60
+ return (
61
+ <div className="group/rename flex h-5 min-w-0 items-center gap-1">
62
+ <span className={cn('truncate border-transparent border-b', className)}>
63
+ {node.name || defaultName}
64
+ </span>
65
+ {onStartEditing && (
66
+ <button
67
+ className="shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/rename:opacity-100"
68
+ onClick={(e) => {
69
+ e.stopPropagation()
70
+ onStartEditing()
71
+ }}
72
+ >
73
+ <Pencil className="h-3 w-3" />
74
+ </button>
75
+ )}
76
+ </div>
77
+ )
78
+ }
79
+
80
+ return (
81
+ <input
82
+ className={cn(
83
+ 'm-0 h-5 min-w-[1ch] max-w-full flex-none rounded-none border-primary/50 border-b bg-transparent px-0 py-0 text-foreground text-sm outline-none focus:border-primary',
84
+ className,
85
+ )}
86
+ onBlur={handleSave}
87
+ onChange={(e) => setValue(e.target.value)}
88
+ onClick={(e) => e.stopPropagation()}
89
+ onDoubleClick={(e) => e.stopPropagation()}
90
+ onKeyDown={handleKeyDown}
91
+ placeholder={defaultName}
92
+ ref={inputRef}
93
+ size={inputSize}
94
+ type="text"
95
+ value={value}
96
+ />
97
+ )
98
+ }
@@ -0,0 +1,117 @@
1
+ import { type AnyNodeId, type ItemNode, useScene } from '@pascal-app/core'
2
+ import { useViewer } from '@pascal-app/viewer'
3
+ import Image from 'next/image'
4
+ import { useEffect, useState } from 'react'
5
+ import useEditor from './../../../../../store/use-editor'
6
+ import { InlineRenameInput } from './inline-rename-input'
7
+ import { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'
8
+ import { TreeNodeActions } from './tree-node-actions'
9
+
10
+ const CATEGORY_ICONS: Record<string, string> = {
11
+ door: '/icons/door.png',
12
+ window: '/icons/window.png',
13
+ furniture: '/icons/couch.png',
14
+ appliance: '/icons/appliance.png',
15
+ kitchen: '/icons/kitchen.png',
16
+ bathroom: '/icons/bathroom.png',
17
+ outdoor: '/icons/tree.png',
18
+ }
19
+
20
+ interface ItemTreeNodeProps {
21
+ node: ItemNode
22
+ depth: number
23
+ isLast?: boolean
24
+ }
25
+
26
+ export function ItemTreeNode({ node, depth, isLast }: ItemTreeNodeProps) {
27
+ const [isEditing, setIsEditing] = useState(false)
28
+ const [expanded, setExpanded] = useState(true)
29
+ const iconSrc = CATEGORY_ICONS[node.asset.category] || '/icons/couch.png'
30
+ const selectedIds = useViewer((state) => state.selection.selectedIds)
31
+ const isSelected = selectedIds.includes(node.id)
32
+ const isHovered = useViewer((state) => state.hoveredId === node.id)
33
+ const setSelection = useViewer((state) => state.setSelection)
34
+ const setHoveredId = useViewer((state) => state.setHoveredId)
35
+
36
+ useEffect(() => {
37
+ if (selectedIds.length === 0) return
38
+ const nodes = useScene.getState().nodes
39
+ let isDescendant = false
40
+ for (const id of selectedIds) {
41
+ let current = nodes[id as AnyNodeId]
42
+ while (current?.parentId) {
43
+ if (current.parentId === node.id) {
44
+ isDescendant = true
45
+ break
46
+ }
47
+ current = nodes[current.parentId as AnyNodeId]
48
+ }
49
+ if (isDescendant) break
50
+ }
51
+ if (isDescendant) {
52
+ setExpanded(true)
53
+ }
54
+ }, [selectedIds, node.id])
55
+
56
+ const handleClick = (e: React.MouseEvent) => {
57
+ e.stopPropagation()
58
+ const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)
59
+ if (!handled && useEditor.getState().phase === 'structure') {
60
+ useEditor.getState().setPhase('furnish')
61
+ }
62
+ }
63
+
64
+ const handleDoubleClick = () => {
65
+ focusTreeNode(node.id)
66
+ }
67
+
68
+ const handleMouseEnter = () => {
69
+ setHoveredId(node.id)
70
+ }
71
+
72
+ const handleMouseLeave = () => {
73
+ setHoveredId(null)
74
+ }
75
+
76
+ const defaultName = node.asset.name || 'Item'
77
+ const hasChildren = node.children && node.children.length > 0
78
+
79
+ return (
80
+ <TreeNodeWrapper
81
+ actions={<TreeNodeActions node={node} />}
82
+ depth={depth}
83
+ expanded={expanded}
84
+ hasChildren={hasChildren}
85
+ icon={<Image alt="" className="object-contain" height={14} src={iconSrc} width={14} />}
86
+ isHovered={isHovered}
87
+ isLast={isLast}
88
+ isSelected={isSelected}
89
+ isVisible={node.visible !== false}
90
+ label={
91
+ <InlineRenameInput
92
+ defaultName={defaultName}
93
+ isEditing={isEditing}
94
+ node={node}
95
+ onStartEditing={() => setIsEditing(true)}
96
+ onStopEditing={() => setIsEditing(false)}
97
+ />
98
+ }
99
+ nodeId={node.id}
100
+ onClick={handleClick}
101
+ onDoubleClick={handleDoubleClick}
102
+ onMouseEnter={handleMouseEnter}
103
+ onMouseLeave={handleMouseLeave}
104
+ onToggle={() => setExpanded(!expanded)}
105
+ >
106
+ {hasChildren &&
107
+ node.children.map((childId, index) => (
108
+ <TreeNode
109
+ depth={depth + 1}
110
+ isLast={index === node.children.length - 1}
111
+ key={childId}
112
+ nodeId={childId}
113
+ />
114
+ ))}
115
+ </TreeNodeWrapper>
116
+ )
117
+ }
@@ -0,0 +1,65 @@
1
+ import type { LevelNode } from '@pascal-app/core'
2
+ import { useViewer } from '@pascal-app/viewer'
3
+ import { Layers } from 'lucide-react'
4
+ import { useState } from 'react'
5
+ import { InlineRenameInput } from './inline-rename-input'
6
+ import { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node'
7
+ import { TreeNodeActions } from './tree-node-actions'
8
+
9
+ interface LevelTreeNodeProps {
10
+ node: LevelNode
11
+ depth: number
12
+ isLast?: boolean
13
+ }
14
+
15
+ export function LevelTreeNode({ node, depth, isLast }: LevelTreeNodeProps) {
16
+ const [expanded, setExpanded] = useState(true)
17
+ const [isEditing, setIsEditing] = useState(false)
18
+ const isSelected = useViewer((state) => state.selection.levelId === node.id)
19
+ const isHovered = useViewer((state) => state.hoveredId === node.id)
20
+ const setSelection = useViewer((state) => state.setSelection)
21
+
22
+ const handleClick = () => {
23
+ setSelection({ levelId: node.id })
24
+ }
25
+
26
+ const handleDoubleClick = () => {
27
+ focusTreeNode(node.id)
28
+ }
29
+
30
+ const defaultName = `Level ${node.level}`
31
+
32
+ return (
33
+ <TreeNodeWrapper
34
+ actions={<TreeNodeActions node={node} />}
35
+ depth={depth}
36
+ expanded={expanded}
37
+ hasChildren={node.children.length > 0}
38
+ icon={<Layers className="h-3.5 w-3.5" />}
39
+ isHovered={isHovered}
40
+ isLast={isLast}
41
+ isSelected={isSelected}
42
+ label={
43
+ <InlineRenameInput
44
+ defaultName={defaultName}
45
+ isEditing={isEditing}
46
+ node={node}
47
+ onStartEditing={() => setIsEditing(true)}
48
+ onStopEditing={() => setIsEditing(false)}
49
+ />
50
+ }
51
+ onClick={handleClick}
52
+ onDoubleClick={handleDoubleClick}
53
+ onToggle={() => setExpanded(!expanded)}
54
+ >
55
+ {node.children.map((childId, index) => (
56
+ <TreeNode
57
+ depth={depth + 1}
58
+ isLast={index === node.children.length - 1}
59
+ key={childId}
60
+ nodeId={childId}
61
+ />
62
+ ))}
63
+ </TreeNodeWrapper>
64
+ )
65
+ }
@@ -0,0 +1,214 @@
1
+ import { type AnyNodeId, type RoofNode, type RoofSegmentNode, 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 RoofTreeNodeProps {
13
+ node: RoofNode
14
+ depth: number
15
+ isLast?: boolean
16
+ }
17
+
18
+ export function RoofTreeNode({ node, depth, isLast }: RoofTreeNodeProps) {
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 RoofSegmentNode | undefined)
51
+ .filter((n): n is RoofSegmentNode => n?.type === 'roof-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 roof
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 = `Roof (${segmentCount} segment${segmentCount !== 1 ? 's' : ''})`
71
+
72
+ // Hide the dragged segment from every roof 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/roof.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
+ <RoofSegmentTreeNode
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 RoofSegmentTreeNode({
141
+ node,
142
+ depth,
143
+ isLast,
144
+ }: {
145
+ node: RoofSegmentNode
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 label = `${node.roofType.charAt(0).toUpperCase() + node.roofType.slice(1)} (${node.width.toFixed(1)}×${node.depth.toFixed(1)}m)`
167
+ startDrag(node.id, node.type, node.parentId as string, label, e.clientX, e.clientY)
168
+ },
169
+ [node.id, node.type, node.parentId, node.roofType, node.width, node.depth, startDrag],
170
+ )
171
+
172
+ const defaultName = `${node.roofType.charAt(0).toUpperCase() + node.roofType.slice(1)} (${node.width.toFixed(1)}x${node.depth.toFixed(1)}m)`
173
+
174
+ return (
175
+ <div data-drop-child={node.id}>
176
+ <TreeNodeWrapper
177
+ actions={<TreeNodeActions node={node} />}
178
+ depth={depth}
179
+ expanded={false}
180
+ hasChildren={false}
181
+ icon={
182
+ <Image
183
+ alt=""
184
+ className="object-contain opacity-60"
185
+ height={14}
186
+ src="/icons/roof.png"
187
+ width={14}
188
+ />
189
+ }
190
+ isDraggable
191
+ isHovered={isHovered}
192
+ isLast={isLast}
193
+ isSelected={isSelected}
194
+ isVisible={node.visible !== false}
195
+ label={
196
+ <InlineRenameInput
197
+ defaultName={defaultName}
198
+ isEditing={isEditing}
199
+ node={node}
200
+ onStartEditing={() => setIsEditing(true)}
201
+ onStopEditing={() => setIsEditing(false)}
202
+ />
203
+ }
204
+ nodeId={node.id}
205
+ onClick={handleClick}
206
+ onDoubleClick={() => focusTreeNode(node.id)}
207
+ onMouseEnter={() => setHoveredId(node.id)}
208
+ onMouseLeave={() => setHoveredId(null)}
209
+ onPointerDown={handlePointerDown}
210
+ onToggle={() => {}}
211
+ />
212
+ </div>
213
+ )
214
+ }
@@ -0,0 +1,96 @@
1
+ import type { SlabNode } from '@pascal-app/core'
2
+ import { useViewer } from '@pascal-app/viewer'
3
+ import Image from 'next/image'
4
+ import { useState } from 'react'
5
+ import useEditor from './../../../../../store/use-editor'
6
+ import { InlineRenameInput } from './inline-rename-input'
7
+ import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
8
+ import { TreeNodeActions } from './tree-node-actions'
9
+
10
+ interface SlabTreeNodeProps {
11
+ node: SlabNode
12
+ depth: number
13
+ isLast?: boolean
14
+ }
15
+
16
+ export function SlabTreeNode({ node, depth, isLast }: SlabTreeNodeProps) {
17
+ const [isEditing, setIsEditing] = useState(false)
18
+ const selectedIds = useViewer((state) => state.selection.selectedIds)
19
+ const isSelected = selectedIds.includes(node.id)
20
+ const isHovered = useViewer((state) => state.hoveredId === node.id)
21
+ const setSelection = useViewer((state) => state.setSelection)
22
+ const setHoveredId = useViewer((state) => state.setHoveredId)
23
+
24
+ const handleClick = (e: React.MouseEvent) => {
25
+ e.stopPropagation()
26
+ const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)
27
+ if (!handled && useEditor.getState().phase === 'furnish') {
28
+ useEditor.getState().setPhase('structure')
29
+ }
30
+ }
31
+
32
+ const handleDoubleClick = () => {
33
+ focusTreeNode(node.id)
34
+ }
35
+
36
+ const handleMouseEnter = () => {
37
+ setHoveredId(node.id)
38
+ }
39
+
40
+ const handleMouseLeave = () => {
41
+ setHoveredId(null)
42
+ }
43
+
44
+ // Calculate approximate area from polygon
45
+ const area = calculatePolygonArea(node.polygon).toFixed(1)
46
+ const defaultName = `Slab (${area}m²)`
47
+
48
+ return (
49
+ <TreeNodeWrapper
50
+ actions={<TreeNodeActions node={node} />}
51
+ depth={depth}
52
+ expanded={false}
53
+ hasChildren={false}
54
+ icon={
55
+ <Image alt="" className="object-contain" height={14} src="/icons/floor.png" width={14} />
56
+ }
57
+ isHovered={isHovered}
58
+ isLast={isLast}
59
+ isSelected={isSelected}
60
+ isVisible={node.visible !== false}
61
+ label={
62
+ <InlineRenameInput
63
+ defaultName={defaultName}
64
+ isEditing={isEditing}
65
+ node={node}
66
+ onStartEditing={() => setIsEditing(true)}
67
+ onStopEditing={() => setIsEditing(false)}
68
+ />
69
+ }
70
+ nodeId={node.id}
71
+ onClick={handleClick}
72
+ onDoubleClick={handleDoubleClick}
73
+ onMouseEnter={handleMouseEnter}
74
+ onMouseLeave={handleMouseLeave}
75
+ onToggle={() => {}}
76
+ />
77
+ )
78
+ }
79
+
80
+ /**
81
+ * Calculate the area of a polygon using the shoelace formula
82
+ */
83
+ function calculatePolygonArea(polygon: Array<[number, number]>): number {
84
+ if (polygon.length < 3) return 0
85
+
86
+ let area = 0
87
+ const n = polygon.length
88
+
89
+ for (let i = 0; i < n; i++) {
90
+ const j = (i + 1) % n
91
+ area += polygon[i]?.[0] * polygon[j]?.[1]
92
+ area -= polygon[j]?.[0] * polygon[i]?.[1]
93
+ }
94
+
95
+ return Math.abs(area) / 2
96
+ }