@pascal-app/editor 0.5.1 → 0.7.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 (150) hide show
  1. package/package.json +12 -7
  2. package/src/components/editor/bottom-sheet.tsx +149 -0
  3. package/src/components/editor/custom-camera-controls.tsx +75 -7
  4. package/src/components/editor/editor-layout-mobile.tsx +264 -0
  5. package/src/components/editor/editor-layout-v2.tsx +29 -0
  6. package/src/components/editor/first-person/build-collider-world.ts +365 -0
  7. package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -0
  8. package/src/components/editor/first-person-controls.tsx +496 -143
  9. package/src/components/editor/floating-action-menu.tsx +281 -83
  10. package/src/components/editor/floating-building-action-menu.tsx +4 -3
  11. package/src/components/editor/floorplan-background-selection.ts +113 -0
  12. package/src/components/editor/floorplan-panel.tsx +10442 -3275
  13. package/src/components/editor/index.tsx +270 -20
  14. package/src/components/editor/node-action-menu.tsx +14 -1
  15. package/src/components/editor/selection-manager.tsx +766 -12
  16. package/src/components/editor/site-edge-labels.tsx +9 -3
  17. package/src/components/editor/thumbnail-generator.tsx +350 -157
  18. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  19. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  20. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  21. package/src/components/editor/wall-measurement-label.tsx +377 -58
  22. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  23. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  24. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  25. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
  26. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  27. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
  28. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  29. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  30. package/src/components/editor-2d/svg-paths.ts +119 -0
  31. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
  32. package/src/components/systems/roof/roof-edit-system.tsx +5 -5
  33. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  34. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +2 -0
  35. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  36. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  37. package/src/components/tools/column/column-tool.tsx +97 -0
  38. package/src/components/tools/column/move-column-tool.tsx +105 -0
  39. package/src/components/tools/door/door-tool.tsx +19 -0
  40. package/src/components/tools/door/move-door-tool.tsx +38 -8
  41. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  42. package/src/components/tools/fence/fence-drafting.ts +27 -8
  43. package/src/components/tools/fence/fence-tool.tsx +159 -3
  44. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +438 -0
  45. package/src/components/tools/fence/move-fence-tool.tsx +102 -27
  46. package/src/components/tools/item/move-tool.tsx +19 -1
  47. package/src/components/tools/item/placement-math.ts +44 -7
  48. package/src/components/tools/item/placement-strategies.ts +111 -33
  49. package/src/components/tools/item/placement-types.ts +7 -0
  50. package/src/components/tools/item/use-draft-node.ts +2 -0
  51. package/src/components/tools/item/use-placement-coordinator.tsx +701 -61
  52. package/src/components/tools/roof/move-roof-tool.tsx +111 -43
  53. package/src/components/tools/shared/polygon-editor.tsx +244 -29
  54. package/src/components/tools/shared/segment-angle.ts +156 -0
  55. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  56. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  57. package/src/components/tools/slab/slab-hole-editor.tsx +2 -0
  58. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  59. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  60. package/src/components/tools/stair/stair-tool.tsx +11 -3
  61. package/src/components/tools/tool-manager.tsx +30 -3
  62. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  63. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +423 -0
  64. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  65. package/src/components/tools/wall/wall-drafting.ts +348 -17
  66. package/src/components/tools/wall/wall-tool.tsx +134 -2
  67. package/src/components/tools/window/move-window-tool.tsx +28 -0
  68. package/src/components/tools/window/window-tool.tsx +17 -0
  69. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  70. package/src/components/ui/action-menu/control-modes.tsx +37 -5
  71. package/src/components/ui/action-menu/index.tsx +91 -1
  72. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  73. package/src/components/ui/action-menu/view-toggles.tsx +424 -35
  74. package/src/components/ui/command-palette/editor-commands.tsx +27 -5
  75. package/src/components/ui/command-palette/index.tsx +0 -1
  76. package/src/components/ui/controls/material-picker.tsx +189 -169
  77. package/src/components/ui/controls/slider-control.tsx +88 -26
  78. package/src/components/ui/floating-level-selector.tsx +286 -55
  79. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  80. package/src/components/ui/item-catalog/catalog-items.tsx +1121 -1219
  81. package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
  82. package/src/components/ui/level-duplicate-dialog.tsx +115 -0
  83. package/src/components/ui/panels/ceiling-panel.tsx +47 -27
  84. package/src/components/ui/panels/column-panel.tsx +715 -0
  85. package/src/components/ui/panels/door-panel.tsx +986 -294
  86. package/src/components/ui/panels/fence-panel.tsx +55 -12
  87. package/src/components/ui/panels/item-panel.tsx +5 -5
  88. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  89. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  90. package/src/components/ui/panels/node-display.ts +39 -0
  91. package/src/components/ui/panels/paint-panel.tsx +138 -0
  92. package/src/components/ui/panels/panel-manager.tsx +241 -30
  93. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  94. package/src/components/ui/panels/reference-panel.tsx +243 -9
  95. package/src/components/ui/panels/roof-panel.tsx +30 -62
  96. package/src/components/ui/panels/roof-segment-panel.tsx +8 -23
  97. package/src/components/ui/panels/slab-panel.tsx +46 -24
  98. package/src/components/ui/panels/spawn-panel.tsx +155 -0
  99. package/src/components/ui/panels/stair-panel.tsx +117 -69
  100. package/src/components/ui/panels/stair-segment-panel.tsx +13 -27
  101. package/src/components/ui/panels/wall-panel.tsx +71 -17
  102. package/src/components/ui/panels/window-panel.tsx +665 -146
  103. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  104. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  105. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +9 -5
  106. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
  107. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  108. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
  109. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
  110. package/src/components/ui/sidebar/panels/site-panel/index.tsx +138 -56
  111. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
  112. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +9 -5
  113. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  114. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
  115. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
  116. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
  117. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
  118. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +12 -6
  119. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
  120. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
  121. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +15 -8
  122. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  123. package/src/components/ui/viewer-toolbar.tsx +96 -2
  124. package/src/components/viewer-overlay.tsx +25 -19
  125. package/src/hooks/use-auto-frame.ts +45 -0
  126. package/src/hooks/use-contextual-tools.ts +14 -13
  127. package/src/hooks/use-keyboard.ts +67 -9
  128. package/src/hooks/use-mobile.ts +12 -12
  129. package/src/index.tsx +2 -1
  130. package/src/lib/door-interaction.ts +88 -0
  131. package/src/lib/floorplan/geometry.ts +263 -0
  132. package/src/lib/floorplan/index.ts +38 -0
  133. package/src/lib/floorplan/items.ts +179 -0
  134. package/src/lib/floorplan/selection-tool.ts +231 -0
  135. package/src/lib/floorplan/stairs.ts +478 -0
  136. package/src/lib/floorplan/types.ts +57 -0
  137. package/src/lib/floorplan/walls.ts +23 -0
  138. package/src/lib/guide-events.ts +10 -0
  139. package/src/lib/history.ts +20 -0
  140. package/src/lib/level-duplication.test.ts +72 -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/sfx-player.ts +96 -13
  148. package/src/lib/stair-duplication.ts +126 -0
  149. package/src/lib/window-interaction.ts +86 -0
  150. package/src/store/use-editor.tsx +279 -15
@@ -1,32 +1,56 @@
1
1
  'use client'
2
2
 
3
- import { type AnyNode, type AnyNodeId, type FenceBaseStyle, type FenceNode, type FenceStyle, useScene } from '@pascal-app/core'
3
+
4
+ import {
5
+ type AnyNode,
6
+ type AnyNodeId,
7
+ type FenceNode,
8
+ getClampedWallCurveOffset,
9
+ getMaxWallCurveOffset,
10
+ getWallCurveLength,
11
+ type MaterialSchema,
12
+ normalizeWallCurveOffset,
13
+ useScene,
14
+ } from '@pascal-app/core'
15
+
4
16
  import { useViewer } from '@pascal-app/viewer'
17
+ import { Move, Spline } from 'lucide-react'
5
18
  import { useCallback } from 'react'
19
+
20
+ import { sfxEmitter } from '../../../lib/sfx-bus'
21
+ import useEditor from '../../../store/use-editor'
22
+ import { ActionButton, ActionGroup } from '../controls/action-button'
23
+ import { MaterialPicker } from '../controls/material-picker'
6
24
  import { PanelSection } from '../controls/panel-section'
7
25
  import { SegmentedControl } from '../controls/segmented-control'
8
26
  import { SliderControl } from '../controls/slider-control'
9
27
  import { PanelWrapper } from './panel-wrapper'
10
28
 
11
- const FENCE_STYLE_OPTIONS: { label: string; value: FenceStyle }[] = [
29
+ type FenceStyleValue = 'slat' | 'rail' | 'privacy'
30
+ type FenceBaseStyleValue = 'grounded' | 'floating'
31
+
32
+ const FENCE_STYLE_OPTIONS: { label: string; value: FenceStyleValue }[] = [
12
33
  { label: 'Slat', value: 'slat' },
13
34
  { label: 'Rail', value: 'rail' },
14
35
  { label: 'Privacy', value: 'privacy' },
15
36
  ]
16
37
 
17
- const FENCE_BASE_STYLE_OPTIONS: { label: string; value: FenceBaseStyle }[] = [
38
+ const FENCE_BASE_STYLE_OPTIONS: { label: string; value: FenceBaseStyleValue }[] = [
18
39
  { label: 'Grounded', value: 'grounded' },
19
40
  { label: 'Floating', value: 'floating' },
20
41
  ]
21
42
 
22
43
  export function FencePanel() {
23
- const selectedIds = useViewer((s) => s.selection.selectedIds)
44
+ const selectedId = useViewer((s) => s.selection.selectedIds[0])
45
+ const selectedCount = useViewer((s) => s.selection.selectedIds.length)
24
46
  const setSelection = useViewer((s) => s.setSelection)
25
- const nodes = useScene((s) => s.nodes)
26
47
  const updateNode = useScene((s) => s.updateNode)
48
+ const setMovingNode = useEditor((s) => s.setMovingNode)
49
+ const setCurvingFence = useEditor((s) => s.setCurvingFence)
27
50
 
28
- const selectedId = selectedIds[0]
29
- const node = selectedId ? (nodes[selectedId as AnyNode['id']] as FenceNode | undefined) : undefined
51
+ const node = useScene((s) =>
52
+ selectedId ? (s.nodes[selectedId as AnyNode['id']] as FenceNode | undefined) : undefined,
53
+ )
30
54
 
31
55
  const handleUpdate = useCallback(
32
56
  (updates: Partial<FenceNode>) => {
@@ -62,14 +86,23 @@ export function FencePanel() {
62
86
  setSelection({ selectedIds: [] })
63
87
  }, [setSelection])
64
88
 
65
- if (!node || node.type !== 'fence' || selectedIds.length !== 1) return null
66
89
 
67
- const dx = node.end[0] - node.start[0]
68
- const dz = node.end[1] - node.start[1]
69
- const length = Math.sqrt(dx * dx + dz * dz)
90
+
91
+
92
+
93
+ if (!(node && node.type === 'fence' && selectedId && selectedCount === 1)) return null
94
+
95
+ const length = getWallCurveLength(node)
96
+ const curveOffset = getClampedWallCurveOffset(node)
97
+ const maxCurveOffset = getMaxWallCurveOffset(node)
70
98
 
71
99
  return (
72
- <PanelWrapper icon="/icons/build.png" onClose={handleClose} title={node.name || 'Fence'} width={300}>
100
+ <PanelWrapper
101
+ icon="/icons/build.png"
102
+ onClose={handleClose}
103
+ title={node.name || 'Fence'}
104
+ width={300}
105
+ >
73
106
  <PanelSection title="Style">
74
107
  <SegmentedControl
75
108
  onChange={(value) => handleUpdate({ style: value })}
@@ -95,6 +128,16 @@ export function FencePanel() {
95
128
  unit="m"
96
129
  value={length}
97
130
  />
131
+ <SliderControl
132
+ label="Curve"
133
+ max={Math.max(0.01, maxCurveOffset)}
134
+ min={-Math.max(0.01, maxCurveOffset)}
135
+ onChange={(value) => handleUpdate({ curveOffset: normalizeWallCurveOffset(node, value) })}
136
+ precision={2}
137
+ step={0.1}
138
+ unit="m"
139
+ value={Math.round(curveOffset * 100) / 100}
140
+ />
98
141
  <SliderControl
99
142
  label="Height"
100
143
  max={4}
@@ -14,15 +14,15 @@ import { CollectionsPopover } from './collections/collections-popover'
14
14
  import { PanelWrapper } from './panel-wrapper'
15
15
 
16
16
  export function ItemPanel() {
17
- const selectedIds = useViewer((s) => s.selection.selectedIds)
17
+ const selectedId = useViewer((s) => s.selection.selectedIds[0])
18
18
  const setSelection = useViewer((s) => s.setSelection)
19
- const nodes = useScene((s) => s.nodes)
20
19
  const updateNode = useScene((s) => s.updateNode)
21
20
  const deleteNode = useScene((s) => s.deleteNode)
22
21
  const setMovingNode = useEditor((s) => s.setMovingNode)
23
22
 
24
- const selectedId = selectedIds[0]
25
- const node = selectedId ? (nodes[selectedId as AnyNode['id']] as ItemNode | undefined) : undefined
23
+ const node = useScene((s) =>
24
+ selectedId ? (s.nodes[selectedId as AnyNode['id']] as ItemNode | undefined) : undefined,
25
+ )
26
26
 
27
27
  const [uniformScale, setUniformScale] = useState(true)
28
28
 
@@ -75,7 +75,7 @@ export function ItemPanel() {
75
75
  setSelection({ selectedIds: [] })
76
76
  }, [selectedId, deleteNode, setSelection])
77
77
 
78
- if (!node || node.type !== 'item' || selectedIds.length !== 1) return null
78
+ if (!(node && node.type === 'item' && selectedId)) return null
79
79
 
80
80
  return (
81
81
  <PanelWrapper
@@ -0,0 +1,108 @@
1
+ 'use client'
2
+
3
+ import { X } from 'lucide-react'
4
+ import { AnimatePresence, motion } from 'motion/react'
5
+ import Image from 'next/image'
6
+ import { type ReactNode, useEffect, useState } from 'react'
7
+ import { createPortal } from 'react-dom'
8
+ import useEditor from '../../../store/use-editor'
9
+
10
+ interface MobilePanelSheetProps {
11
+ open: boolean
12
+ onClose: () => void
13
+ icon?: string
14
+ title: string
15
+ children: ReactNode
16
+ }
17
+
18
+ const HEIGHT_VH = 50
19
+ const DRAG_CLOSE_THRESHOLD_PX = 120
20
+
21
+ export function MobilePanelSheet({ open, onClose, icon, title, children }: MobilePanelSheetProps) {
22
+ const [mounted, setMounted] = useState(false)
23
+ const setMobilePanelSheetHeight = useEditor((s) => s.setMobilePanelSheetHeight)
24
+
25
+ useEffect(() => {
26
+ setMounted(true)
27
+ }, [])
28
+
29
+ // Publish the sheet's pixel height to the shared store so the mobile layout
30
+ // can shrink the viewer container and preview edits live. 0 means closed.
31
+ // Tracks visualViewport so the value follows the on-screen keyboard on iOS.
32
+ useEffect(() => {
33
+ if (!open) {
34
+ setMobilePanelSheetHeight(0)
35
+ return
36
+ }
37
+ const compute = () => {
38
+ const vh = window.visualViewport?.height ?? window.innerHeight
39
+ setMobilePanelSheetHeight(Math.round((vh * HEIGHT_VH) / 100))
40
+ }
41
+ compute()
42
+ const vv = window.visualViewport
43
+ vv?.addEventListener('resize', compute)
44
+ window.addEventListener('resize', compute)
45
+ return () => {
46
+ vv?.removeEventListener('resize', compute)
47
+ window.removeEventListener('resize', compute)
48
+ setMobilePanelSheetHeight(0)
49
+ }
50
+ }, [open, setMobilePanelSheetHeight])
51
+
52
+ if (!mounted) return null
53
+
54
+ return createPortal(
55
+ <AnimatePresence>
56
+ {open && (
57
+ <motion.div
58
+ animate={{ y: 0 }}
59
+ className="dark fixed right-0 bottom-0 left-0 z-[60] flex flex-col overflow-hidden rounded-t-2xl bg-sidebar text-sidebar-foreground shadow-[0_-8px_24px_rgba(0,0,0,0.24)]"
60
+ drag="y"
61
+ dragConstraints={{ top: 0, bottom: 0 }}
62
+ dragElastic={{ top: 0, bottom: 0.4 }}
63
+ exit={{ y: '100%' }}
64
+ initial={{ y: '100%' }}
65
+ onDragEnd={(_, info) => {
66
+ if (info.offset.y > DRAG_CLOSE_THRESHOLD_PX) onClose()
67
+ }}
68
+ style={{ height: `${HEIGHT_VH}dvh` }}
69
+ transition={{ type: 'spring', stiffness: 320, damping: 32, mass: 0.8 }}
70
+ >
71
+ <div className="flex h-6 shrink-0 cursor-grab touch-none items-center justify-center active:cursor-grabbing">
72
+ <div className="h-1 w-10 rounded-full bg-muted-foreground/40" />
73
+ </div>
74
+
75
+ <div className="flex shrink-0 items-center justify-between border-border/50 border-b px-3 pt-1 pb-3">
76
+ <div className="flex min-w-0 items-center gap-2">
77
+ {icon && (
78
+ <Image
79
+ alt=""
80
+ className="shrink-0 object-contain"
81
+ height={18}
82
+ src={icon}
83
+ width={18}
84
+ />
85
+ )}
86
+ <h2 className="truncate font-semibold text-foreground text-sm tracking-tight">
87
+ {title}
88
+ </h2>
89
+ </div>
90
+ <button
91
+ aria-label="Close"
92
+ className="flex h-8 w-8 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground"
93
+ onClick={onClose}
94
+ type="button"
95
+ >
96
+ <X className="h-4 w-4" />
97
+ </button>
98
+ </div>
99
+
100
+ <div className="no-scrollbar flex min-h-0 flex-1 flex-col overflow-y-auto">
101
+ {children}
102
+ </div>
103
+ </motion.div>
104
+ )}
105
+ </AnimatePresence>,
106
+ document.body,
107
+ )
108
+ }
@@ -0,0 +1,100 @@
1
+ 'use client'
2
+
3
+ import type { AnyNode } from '@pascal-app/core'
4
+ import { Copy, Move, SlidersHorizontal, Trash2 } from 'lucide-react'
5
+ import Image from 'next/image'
6
+ import type { MouseEventHandler } from 'react'
7
+ import { cn } from '../../../lib/utils'
8
+ import { getNodeDisplay } from './node-display'
9
+
10
+ interface MobileSelectionBarProps {
11
+ node: AnyNode
12
+ onMove: () => void
13
+ onDuplicate: () => void
14
+ onDelete: () => void
15
+ onEdit: () => void
16
+ }
17
+
18
+ const ACTION_BTN =
19
+ 'flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-white/8 hover:text-foreground'
20
+
21
+ export function MobileSelectionBar({
22
+ node,
23
+ onMove,
24
+ onDuplicate,
25
+ onDelete,
26
+ onEdit,
27
+ }: MobileSelectionBarProps) {
28
+ const { icon, label } = getNodeDisplay(node)
29
+
30
+ const stop: MouseEventHandler<HTMLButtonElement> = (e) => e.stopPropagation()
31
+
32
+ return (
33
+ <div className="pointer-events-auto absolute right-3 bottom-6 left-3 z-50 flex h-12 items-stretch gap-1 rounded-2xl border border-border/50 bg-background/95 px-2 shadow-2xl backdrop-blur-xl">
34
+ <button
35
+ aria-label={`Edit ${label}`}
36
+ className={cn(
37
+ 'flex min-w-0 flex-1 items-center gap-2 rounded-lg px-2 text-left transition-colors hover:bg-white/8',
38
+ )}
39
+ onClick={onEdit}
40
+ type="button"
41
+ >
42
+ <Image
43
+ alt=""
44
+ className="shrink-0 rounded object-contain"
45
+ height={20}
46
+ src={icon}
47
+ width={20}
48
+ />
49
+ <span className="truncate font-medium text-foreground text-sm">{label}</span>
50
+ </button>
51
+
52
+ <div className="flex items-center gap-0.5 border-border/40 border-l pl-1">
53
+ <button
54
+ aria-label="Move"
55
+ className={ACTION_BTN}
56
+ onClick={(e) => {
57
+ stop(e)
58
+ onMove()
59
+ }}
60
+ type="button"
61
+ >
62
+ <Move className="h-4 w-4" />
63
+ </button>
64
+ <button
65
+ aria-label="Duplicate"
66
+ className={ACTION_BTN}
67
+ onClick={(e) => {
68
+ stop(e)
69
+ onDuplicate()
70
+ }}
71
+ type="button"
72
+ >
73
+ <Copy className="h-4 w-4" />
74
+ </button>
75
+ <button
76
+ aria-label="Delete"
77
+ className={cn(ACTION_BTN, 'hover:bg-red-500/15 hover:text-red-400')}
78
+ onClick={(e) => {
79
+ stop(e)
80
+ onDelete()
81
+ }}
82
+ type="button"
83
+ >
84
+ <Trash2 className="h-4 w-4" />
85
+ </button>
86
+ <button
87
+ aria-label="Edit properties"
88
+ className={ACTION_BTN}
89
+ onClick={(e) => {
90
+ stop(e)
91
+ onEdit()
92
+ }}
93
+ type="button"
94
+ >
95
+ <SlidersHorizontal className="h-4 w-4" />
96
+ </button>
97
+ </div>
98
+ </div>
99
+ )
100
+ }
@@ -0,0 +1,39 @@
1
+ import type { AnyNode } from '@pascal-app/core'
2
+
3
+ export type NodeDisplay = {
4
+ icon: string
5
+ label: string
6
+ }
7
+
8
+ const TYPE_DEFAULTS: Record<string, NodeDisplay> = {
9
+ item: { icon: '/icons/furniture.png', label: 'Item' },
10
+ wall: { icon: '/icons/wall.png', label: 'Wall' },
11
+ door: { icon: '/icons/door.png', label: 'Door' },
12
+ window: { icon: '/icons/window.png', label: 'Window' },
13
+ slab: { icon: '/icons/floor.png', label: 'Slab' },
14
+ ceiling: { icon: '/icons/ceiling.png', label: 'Ceiling' },
15
+ column: { icon: '/icons/column.png', label: 'Column' },
16
+ fence: { icon: '/icons/fence.png', label: 'Fence' },
17
+ roof: { icon: '/icons/roof.png', label: 'Roof' },
18
+ 'roof-segment': { icon: '/icons/roof.png', label: 'Roof segment' },
19
+ stair: { icon: '/icons/stair.png', label: 'Stair' },
20
+ 'stair-segment': { icon: '/icons/stair.png', label: 'Stair segment' },
21
+ scan: { icon: '/icons/mesh.png', label: '3D Scan' },
22
+ guide: { icon: '/icons/floorplan.png', label: 'Guide image' },
23
+ }
24
+
25
+ export function getNodeDisplay(node: AnyNode | null | undefined): NodeDisplay {
26
+ if (!node) return { icon: '/icons/select.png', label: 'Selection' }
27
+ const fallback = TYPE_DEFAULTS[node.type] ?? { icon: '/icons/select.png', label: node.type }
28
+ // Item nodes carry an asset with its own thumbnail/name
29
+ if (node.type === 'item') {
30
+ return {
31
+ icon: node.asset?.thumbnail || fallback.icon,
32
+ label: node.name || node.asset?.name || fallback.label,
33
+ }
34
+ }
35
+ return {
36
+ icon: fallback.icon,
37
+ label: node.name || fallback.label,
38
+ }
39
+ }
@@ -0,0 +1,138 @@
1
+ 'use client'
2
+
3
+ import useEditor from '../../../store/use-editor'
4
+ import { SliderControl } from '../controls/slider-control'
5
+ import { Input } from '../primitives/input'
6
+ import { PanelSection } from '../controls/panel-section'
7
+ import { PanelWrapper } from './panel-wrapper'
8
+
9
+ function buildDefaultCustomMaterial() {
10
+ return {
11
+ preset: 'custom' as const,
12
+ properties: {
13
+ color: '#ffffff',
14
+ roughness: 0.5,
15
+ metalness: 0,
16
+ opacity: 1,
17
+ transparent: false,
18
+ side: 'front' as const,
19
+ },
20
+ }
21
+ }
22
+
23
+ export function PaintPanel() {
24
+ const activePaintMaterial = useEditor((state) => state.activePaintMaterial)
25
+ const activePaintTarget = useEditor((state) => state.activePaintTarget)
26
+ const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial)
27
+ const setPaintPanelOpen = useEditor((state) => state.setPaintPanelOpen)
28
+
29
+ const customMaterial =
30
+ activePaintMaterial?.material?.properties && !activePaintMaterial.materialPreset
31
+ ? activePaintMaterial.material
32
+ : null
33
+
34
+ if (!customMaterial) return null
35
+
36
+ const currentProps = customMaterial.properties ?? buildDefaultCustomMaterial().properties
37
+
38
+ const updateCustomMaterial = (
39
+ updates: Partial<typeof currentProps>,
40
+ nextTransparent = currentProps.transparent,
41
+ ) => {
42
+ setActivePaintMaterial({
43
+ material: {
44
+ preset: 'custom',
45
+ properties: {
46
+ ...currentProps,
47
+ ...updates,
48
+ transparent: nextTransparent,
49
+ },
50
+ },
51
+ sourceTarget: activePaintMaterial?.sourceTarget ?? activePaintTarget,
52
+ })
53
+ }
54
+
55
+ return (
56
+ <PanelWrapper
57
+ onClose={() => setPaintPanelOpen(false)}
58
+ title="Material"
59
+ width={320}
60
+ >
61
+ <PanelSection title="Custom Material">
62
+ <div className="space-y-3">
63
+ <div className="space-y-2">
64
+ <label className="block font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
65
+ Color
66
+ </label>
67
+ <div className="flex items-center gap-2">
68
+ <input
69
+ className="h-10 w-14 cursor-pointer rounded-md border border-input bg-transparent"
70
+ onChange={(e) => updateCustomMaterial({ color: e.target.value })}
71
+ type="color"
72
+ value={currentProps.color}
73
+ />
74
+ <Input
75
+ onChange={(e) => updateCustomMaterial({ color: e.target.value })}
76
+ value={currentProps.color}
77
+ />
78
+ </div>
79
+ </div>
80
+
81
+ <div className="space-y-1">
82
+ <label className="block font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
83
+ Surface
84
+ </label>
85
+ <div className="space-y-1 rounded-lg border border-border/50 bg-background/40 p-2">
86
+ <SliderControl
87
+ label="Roughness"
88
+ max={1}
89
+ min={0}
90
+ onChange={(roughness) => updateCustomMaterial({ roughness })}
91
+ precision={2}
92
+ step={0.01}
93
+ value={currentProps.roughness}
94
+ />
95
+ <SliderControl
96
+ label="Metalness"
97
+ max={1}
98
+ min={0}
99
+ onChange={(metalness) => updateCustomMaterial({ metalness })}
100
+ precision={2}
101
+ step={0.01}
102
+ value={currentProps.metalness}
103
+ />
104
+ <SliderControl
105
+ label="Opacity"
106
+ max={1}
107
+ min={0}
108
+ onChange={(opacity) =>
109
+ updateCustomMaterial({ opacity }, opacity < 1 || currentProps.transparent)
110
+ }
111
+ precision={2}
112
+ step={0.01}
113
+ value={currentProps.opacity}
114
+ />
115
+ </div>
116
+ </div>
117
+
118
+ <div className="space-y-2">
119
+ <label className="block font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
120
+ Side
121
+ </label>
122
+ <select
123
+ className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-input/30"
124
+ onChange={(e) =>
125
+ updateCustomMaterial({ side: e.target.value as 'front' | 'back' | 'double' })
126
+ }
127
+ value={currentProps.side}
128
+ >
129
+ <option value="front">Front</option>
130
+ <option value="back">Back</option>
131
+ <option value="double">Double</option>
132
+ </select>
133
+ </div>
134
+ </div>
135
+ </PanelSection>
136
+ </PanelWrapper>
137
+ )
138
+ }