@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.
- package/package.json +62 -0
- package/src/components/editor/custom-camera-controls.tsx +387 -0
- package/src/components/editor/editor-layout-v2.tsx +220 -0
- package/src/components/editor/export-manager.tsx +78 -0
- package/src/components/editor/first-person-controls.tsx +249 -0
- package/src/components/editor/floating-action-menu.tsx +231 -0
- package/src/components/editor/floorplan-panel.tsx +9609 -0
- package/src/components/editor/grid.tsx +161 -0
- package/src/components/editor/index.tsx +928 -0
- package/src/components/editor/node-action-menu.tsx +66 -0
- package/src/components/editor/preset-thumbnail-generator.tsx +125 -0
- package/src/components/editor/selection-manager.tsx +897 -0
- package/src/components/editor/site-edge-labels.tsx +90 -0
- package/src/components/editor/thumbnail-generator.tsx +166 -0
- package/src/components/editor/wall-measurement-label.tsx +258 -0
- package/src/components/feedback-dialog.tsx +265 -0
- package/src/components/pascal-radio.tsx +280 -0
- package/src/components/preview-button.tsx +16 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +77 -0
- package/src/components/systems/roof/roof-edit-system.tsx +69 -0
- package/src/components/systems/stair/stair-edit-system.tsx +69 -0
- package/src/components/systems/zone/zone-label-editor-system.tsx +320 -0
- package/src/components/systems/zone/zone-system.tsx +87 -0
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +42 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +47 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +465 -0
- package/src/components/tools/door/door-math.ts +110 -0
- package/src/components/tools/door/door-tool.tsx +293 -0
- package/src/components/tools/door/move-door-tool.tsx +373 -0
- package/src/components/tools/item/item-tool.tsx +26 -0
- package/src/components/tools/item/move-tool.tsx +90 -0
- package/src/components/tools/item/placement-math.ts +85 -0
- package/src/components/tools/item/placement-strategies.ts +556 -0
- package/src/components/tools/item/placement-types.ts +117 -0
- package/src/components/tools/item/use-draft-node.ts +227 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +877 -0
- package/src/components/tools/roof/move-roof-tool.tsx +288 -0
- package/src/components/tools/roof/roof-tool.tsx +318 -0
- package/src/components/tools/select/box-select-tool.tsx +626 -0
- package/src/components/tools/shared/cursor-sphere.tsx +119 -0
- package/src/components/tools/shared/polygon-editor.tsx +361 -0
- package/src/components/tools/site/site-boundary-editor.tsx +42 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +42 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +47 -0
- package/src/components/tools/slab/slab-tool.tsx +322 -0
- package/src/components/tools/stair/stair-defaults.ts +7 -0
- package/src/components/tools/stair/stair-tool.tsx +194 -0
- package/src/components/tools/tool-manager.tsx +120 -0
- package/src/components/tools/wall/wall-drafting.ts +140 -0
- package/src/components/tools/wall/wall-tool.tsx +210 -0
- package/src/components/tools/window/move-window-tool.tsx +410 -0
- package/src/components/tools/window/window-math.ts +117 -0
- package/src/components/tools/window/window-tool.tsx +303 -0
- package/src/components/tools/zone/zone-boundary-editor.tsx +39 -0
- package/src/components/tools/zone/zone-tool.tsx +364 -0
- package/src/components/ui/action-menu/action-button.tsx +59 -0
- package/src/components/ui/action-menu/camera-actions.tsx +74 -0
- package/src/components/ui/action-menu/control-modes.tsx +240 -0
- package/src/components/ui/action-menu/furnish-tools.tsx +102 -0
- package/src/components/ui/action-menu/index.tsx +152 -0
- package/src/components/ui/action-menu/structure-tools.tsx +100 -0
- package/src/components/ui/action-menu/view-toggles.tsx +397 -0
- package/src/components/ui/command-palette/editor-commands.tsx +396 -0
- package/src/components/ui/command-palette/index.tsx +730 -0
- package/src/components/ui/controls/action-button.tsx +33 -0
- package/src/components/ui/controls/material-picker.tsx +194 -0
- package/src/components/ui/controls/metric-control.tsx +262 -0
- package/src/components/ui/controls/panel-section.tsx +65 -0
- package/src/components/ui/controls/segmented-control.tsx +45 -0
- package/src/components/ui/controls/slider-control.tsx +245 -0
- package/src/components/ui/controls/toggle-control.tsx +38 -0
- package/src/components/ui/floating-level-selector.tsx +355 -0
- package/src/components/ui/helpers/ceiling-helper.tsx +20 -0
- package/src/components/ui/helpers/helper-manager.tsx +33 -0
- package/src/components/ui/helpers/item-helper.tsx +40 -0
- package/src/components/ui/helpers/roof-helper.tsx +16 -0
- package/src/components/ui/helpers/slab-helper.tsx +20 -0
- package/src/components/ui/helpers/wall-helper.tsx +20 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1580 -0
- package/src/components/ui/item-catalog/item-catalog.tsx +219 -0
- package/src/components/ui/panels/ceiling-panel.tsx +230 -0
- package/src/components/ui/panels/collections/collections-popover.tsx +356 -0
- package/src/components/ui/panels/door-panel.tsx +600 -0
- package/src/components/ui/panels/item-panel.tsx +306 -0
- package/src/components/ui/panels/panel-manager.tsx +59 -0
- package/src/components/ui/panels/panel-wrapper.tsx +80 -0
- package/src/components/ui/panels/presets/presets-popover.tsx +511 -0
- package/src/components/ui/panels/reference-panel.tsx +177 -0
- package/src/components/ui/panels/roof-panel.tsx +262 -0
- package/src/components/ui/panels/roof-segment-panel.tsx +326 -0
- package/src/components/ui/panels/slab-panel.tsx +228 -0
- package/src/components/ui/panels/stair-panel.tsx +304 -0
- package/src/components/ui/panels/stair-segment-panel.tsx +339 -0
- package/src/components/ui/panels/wall-panel.tsx +123 -0
- package/src/components/ui/panels/window-panel.tsx +441 -0
- package/src/components/ui/primitives/button.tsx +69 -0
- package/src/components/ui/primitives/card.tsx +75 -0
- package/src/components/ui/primitives/color-dot.tsx +61 -0
- package/src/components/ui/primitives/context-menu.tsx +227 -0
- package/src/components/ui/primitives/dialog.tsx +129 -0
- package/src/components/ui/primitives/dropdown-menu.tsx +228 -0
- package/src/components/ui/primitives/error-boundary.tsx +52 -0
- package/src/components/ui/primitives/input.tsx +21 -0
- package/src/components/ui/primitives/number-input.tsx +187 -0
- package/src/components/ui/primitives/opacity-control.tsx +79 -0
- package/src/components/ui/primitives/popover.tsx +42 -0
- package/src/components/ui/primitives/separator.tsx +28 -0
- package/src/components/ui/primitives/sheet.tsx +130 -0
- package/src/components/ui/primitives/shortcut-token.tsx +64 -0
- package/src/components/ui/primitives/sidebar.tsx +855 -0
- package/src/components/ui/primitives/skeleton.tsx +13 -0
- package/src/components/ui/primitives/slider.tsx +58 -0
- package/src/components/ui/primitives/switch.tsx +29 -0
- package/src/components/ui/primitives/tooltip.tsx +57 -0
- package/src/components/ui/scene-loader.tsx +40 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +103 -0
- package/src/components/ui/sidebar/icon-rail.tsx +147 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +100 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +438 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +188 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +80 -0
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +126 -0
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +64 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +1543 -0
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +98 -0
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +117 -0
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +65 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +214 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +96 -0
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +216 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +115 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +342 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +271 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +106 -0
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +64 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +87 -0
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +167 -0
- package/src/components/ui/sidebar/tab-bar.tsx +39 -0
- package/src/components/ui/slider-demo.tsx +36 -0
- package/src/components/ui/slider.tsx +81 -0
- package/src/components/ui/viewer-toolbar.tsx +342 -0
- package/src/components/viewer-overlay.tsx +499 -0
- package/src/components/viewer-zone-system.tsx +48 -0
- package/src/contexts/presets-context.tsx +121 -0
- package/src/hooks/use-auto-save.ts +194 -0
- package/src/hooks/use-contextual-tools.ts +52 -0
- package/src/hooks/use-grid-events.ts +106 -0
- package/src/hooks/use-keyboard.ts +214 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/hooks/use-reduced-motion.ts +20 -0
- package/src/index.tsx +33 -0
- package/src/lib/constants.ts +3 -0
- package/src/lib/level-selection.ts +31 -0
- package/src/lib/scene.ts +394 -0
- package/src/lib/sfx/index.ts +2 -0
- package/src/lib/sfx-bus.ts +49 -0
- package/src/lib/sfx-player.ts +60 -0
- package/src/lib/utils.ts +43 -0
- package/src/store/use-audio.tsx +45 -0
- package/src/store/use-command-registry.ts +36 -0
- package/src/store/use-editor.tsx +522 -0
- package/src/store/use-palette-view-registry.ts +45 -0
- package/src/store/use-upload.ts +90 -0
- package/src/three-types.ts +3 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { Html } from '@react-three/drei'
|
|
2
|
+
import type { ThreeElements } from '@react-three/fiber'
|
|
3
|
+
import { forwardRef } from 'react'
|
|
4
|
+
import type { Group } from 'three'
|
|
5
|
+
import { furnishTools } from '../../../components/ui/action-menu/furnish-tools'
|
|
6
|
+
import { tools } from '../../../components/ui/action-menu/structure-tools'
|
|
7
|
+
import { EDITOR_LAYER } from '../../../lib/constants'
|
|
8
|
+
import useEditor from '../../../store/use-editor'
|
|
9
|
+
|
|
10
|
+
interface CursorSphereProps extends Omit<ThreeElements['group'], 'ref'> {
|
|
11
|
+
color?: string
|
|
12
|
+
depthWrite?: boolean
|
|
13
|
+
showTooltip?: boolean
|
|
14
|
+
height?: number
|
|
15
|
+
/** Custom tooltip content — overrides the auto-detected build tool icon */
|
|
16
|
+
tooltipContent?: React.ReactNode
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const CursorSphere = forwardRef<Group, CursorSphereProps>(function CursorSphere(
|
|
20
|
+
{ color = '#818cf8', showTooltip = true, height = 2.5, visible = true, tooltipContent, ...props },
|
|
21
|
+
ref,
|
|
22
|
+
) {
|
|
23
|
+
const tool = useEditor((s) => s.tool)
|
|
24
|
+
const mode = useEditor((s) => s.mode)
|
|
25
|
+
const catalogCategory = useEditor((s) => s.catalogCategory)
|
|
26
|
+
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
|
|
27
|
+
|
|
28
|
+
// Find the icon for the current tool
|
|
29
|
+
let activeToolConfig = null
|
|
30
|
+
if (mode === 'build' && tool) {
|
|
31
|
+
if (tool === 'item' && catalogCategory) {
|
|
32
|
+
activeToolConfig = furnishTools.find((t) => t.catalogCategory === catalogCategory)
|
|
33
|
+
} else {
|
|
34
|
+
activeToolConfig = tools.find((t) => t.id === tool)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const isVisible = visible && !isFloorplanHovered
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<group ref={ref} {...props} visible={isVisible}>
|
|
42
|
+
{/* Flat marker on the ground */}
|
|
43
|
+
<group rotation={[-Math.PI / 2, 0, 0]}>
|
|
44
|
+
{/* Center dot */}
|
|
45
|
+
<mesh layers={EDITOR_LAYER} renderOrder={2}>
|
|
46
|
+
<circleGeometry args={[0.06, 32]} />
|
|
47
|
+
<meshBasicMaterial
|
|
48
|
+
color={color}
|
|
49
|
+
depthTest={false}
|
|
50
|
+
depthWrite={false}
|
|
51
|
+
opacity={0.9}
|
|
52
|
+
transparent
|
|
53
|
+
/>
|
|
54
|
+
</mesh>
|
|
55
|
+
|
|
56
|
+
{/* Outer ring / glow */}
|
|
57
|
+
<mesh layers={EDITOR_LAYER} renderOrder={2}>
|
|
58
|
+
<circleGeometry args={[0.2, 32]} />
|
|
59
|
+
<meshBasicMaterial
|
|
60
|
+
color={color}
|
|
61
|
+
depthTest={false}
|
|
62
|
+
depthWrite={false}
|
|
63
|
+
opacity={0.25}
|
|
64
|
+
transparent
|
|
65
|
+
/>
|
|
66
|
+
</mesh>
|
|
67
|
+
</group>
|
|
68
|
+
|
|
69
|
+
{/* Vertical line */}
|
|
70
|
+
{height > 0 && (
|
|
71
|
+
<mesh layers={EDITOR_LAYER} position={[0, height / 2, 0]} renderOrder={2}>
|
|
72
|
+
<cylinderGeometry args={[0.01, 0.01, height, 8]} />
|
|
73
|
+
<meshBasicMaterial
|
|
74
|
+
color={color}
|
|
75
|
+
depthTest={false}
|
|
76
|
+
depthWrite={false}
|
|
77
|
+
opacity={0.7}
|
|
78
|
+
transparent
|
|
79
|
+
/>
|
|
80
|
+
</mesh>
|
|
81
|
+
)}
|
|
82
|
+
|
|
83
|
+
{/* Tool Icon Tooltip at the top of the line */}
|
|
84
|
+
{isVisible && showTooltip && (activeToolConfig || tooltipContent) && (
|
|
85
|
+
<Html
|
|
86
|
+
center
|
|
87
|
+
position={[0, height > 0 ? height + 0.2 : 0.6, 0]}
|
|
88
|
+
style={{
|
|
89
|
+
pointerEvents: 'none',
|
|
90
|
+
background: '#18181b', // zinc-900
|
|
91
|
+
padding: '6px',
|
|
92
|
+
borderRadius: '12px',
|
|
93
|
+
border: '1px solid rgba(255,255,255,0.05)',
|
|
94
|
+
boxShadow: '0 8px 16px -4px rgba(0, 0, 0, 0.3), 0 4px 8px -4px rgba(0, 0, 0, 0.2)',
|
|
95
|
+
display: 'flex',
|
|
96
|
+
alignItems: 'center',
|
|
97
|
+
justifyContent: 'center',
|
|
98
|
+
width: '36px',
|
|
99
|
+
height: '36px',
|
|
100
|
+
}}
|
|
101
|
+
>
|
|
102
|
+
{tooltipContent || (
|
|
103
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
104
|
+
<img
|
|
105
|
+
alt={activeToolConfig!.label}
|
|
106
|
+
src={activeToolConfig!.iconSrc}
|
|
107
|
+
style={{
|
|
108
|
+
width: '100%',
|
|
109
|
+
height: '100%',
|
|
110
|
+
objectFit: 'contain',
|
|
111
|
+
filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.5))',
|
|
112
|
+
}}
|
|
113
|
+
/>
|
|
114
|
+
)}
|
|
115
|
+
</Html>
|
|
116
|
+
)}
|
|
117
|
+
</group>
|
|
118
|
+
)
|
|
119
|
+
})
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
|
|
2
|
+
import { createPortal } from '@react-three/fiber'
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
4
|
+
import { BufferGeometry, Float32BufferAttribute, type Line } from 'three'
|
|
5
|
+
import { EDITOR_LAYER } from '../../../lib/constants'
|
|
6
|
+
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
7
|
+
|
|
8
|
+
const Y_OFFSET = 0.02
|
|
9
|
+
|
|
10
|
+
type DragState = {
|
|
11
|
+
isDragging: boolean
|
|
12
|
+
vertexIndex: number
|
|
13
|
+
initialPosition: [number, number]
|
|
14
|
+
pointerId: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PolygonEditorProps {
|
|
18
|
+
polygon: Array<[number, number]>
|
|
19
|
+
color?: string
|
|
20
|
+
onPolygonChange: (polygon: Array<[number, number]>) => void
|
|
21
|
+
minVertices?: number
|
|
22
|
+
/** Level ID to mount the editor to. If provided, uses createPortal for automatic level animation following. */
|
|
23
|
+
levelId?: string
|
|
24
|
+
/** Height of the surface being edited (e.g. slab elevation). Handles adapt to this. */
|
|
25
|
+
surfaceHeight?: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Generic polygon editor component for editing polygon vertices
|
|
30
|
+
* Used by zone and site boundary editors
|
|
31
|
+
*/
|
|
32
|
+
const MIN_HANDLE_HEIGHT = 0.15
|
|
33
|
+
|
|
34
|
+
export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
35
|
+
polygon,
|
|
36
|
+
color = '#3b82f6',
|
|
37
|
+
onPolygonChange,
|
|
38
|
+
minVertices = 3,
|
|
39
|
+
levelId,
|
|
40
|
+
surfaceHeight = 0,
|
|
41
|
+
}) => {
|
|
42
|
+
// Get level node from registry if levelId is provided
|
|
43
|
+
const levelNode = levelId ? sceneRegistry.nodes.get(levelId) : null
|
|
44
|
+
|
|
45
|
+
// When using portal, edit at Y_OFFSET (local to level)
|
|
46
|
+
// When not using portal, edit at world origin
|
|
47
|
+
const editY = levelNode ? Y_OFFSET : 0
|
|
48
|
+
|
|
49
|
+
// Local state for dragging
|
|
50
|
+
const [dragState, setDragState] = useState<DragState | null>(null)
|
|
51
|
+
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
|
|
52
|
+
const previewPolygonRef = useRef<Array<[number, number]> | null>(null)
|
|
53
|
+
|
|
54
|
+
// Keep ref in sync
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
previewPolygonRef.current = previewPolygon
|
|
57
|
+
}, [previewPolygon])
|
|
58
|
+
|
|
59
|
+
const [hoveredVertex, setHoveredVertex] = useState<number | null>(null)
|
|
60
|
+
const [hoveredMidpoint, setHoveredMidpoint] = useState<number | null>(null)
|
|
61
|
+
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
|
|
62
|
+
|
|
63
|
+
const lineRef = useRef<Line>(null!)
|
|
64
|
+
const previousPositionRef = useRef<[number, number] | null>(null)
|
|
65
|
+
|
|
66
|
+
// Track the last polygon prop to detect external changes (undo/redo)
|
|
67
|
+
const lastPolygonRef = useRef(polygon)
|
|
68
|
+
if (polygon !== lastPolygonRef.current) {
|
|
69
|
+
lastPolygonRef.current = polygon
|
|
70
|
+
// External change (e.g. undo/redo) — clear any stale preview/drag state
|
|
71
|
+
if (previewPolygon) setPreviewPolygon(null)
|
|
72
|
+
if (dragState) setDragState(null)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The polygon to display (preview during drag, or actual polygon)
|
|
76
|
+
const displayPolygon = previewPolygon ?? polygon
|
|
77
|
+
|
|
78
|
+
// Calculate midpoints for adding new vertices
|
|
79
|
+
const midpoints = useMemo(() => {
|
|
80
|
+
if (displayPolygon.length < 2) return []
|
|
81
|
+
return displayPolygon.map(([x1, z1], index) => {
|
|
82
|
+
const nextIndex = (index + 1) % displayPolygon.length
|
|
83
|
+
const [x2, z2] = displayPolygon[nextIndex]!
|
|
84
|
+
return [(x1! + x2) / 2, (z1! + z2) / 2] as [number, number]
|
|
85
|
+
})
|
|
86
|
+
}, [displayPolygon])
|
|
87
|
+
|
|
88
|
+
// Update vertex position using grid cursor position
|
|
89
|
+
const handleVertexDrag = useCallback(
|
|
90
|
+
(vertexIndex: number, position: [number, number]) => {
|
|
91
|
+
setPreviewPolygon((prev) => {
|
|
92
|
+
const basePolygon = prev ?? polygon
|
|
93
|
+
const newPolygon = [...basePolygon]
|
|
94
|
+
newPolygon[vertexIndex] = position
|
|
95
|
+
return newPolygon
|
|
96
|
+
})
|
|
97
|
+
},
|
|
98
|
+
[polygon],
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
// Commit polygon changes
|
|
102
|
+
const commitPolygonChange = useCallback(() => {
|
|
103
|
+
if (previewPolygonRef.current) {
|
|
104
|
+
onPolygonChange(previewPolygonRef.current)
|
|
105
|
+
}
|
|
106
|
+
setPreviewPolygon(null)
|
|
107
|
+
setDragState(null)
|
|
108
|
+
}, [onPolygonChange])
|
|
109
|
+
|
|
110
|
+
// Handle adding a new vertex at midpoint
|
|
111
|
+
const handleAddVertex = useCallback(
|
|
112
|
+
(afterIndex: number, position: [number, number]) => {
|
|
113
|
+
const basePolygon = previewPolygon ?? polygon
|
|
114
|
+
const newPolygon = [
|
|
115
|
+
...basePolygon.slice(0, afterIndex + 1),
|
|
116
|
+
position,
|
|
117
|
+
...basePolygon.slice(afterIndex + 1),
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
setPreviewPolygon(newPolygon)
|
|
121
|
+
return afterIndex + 1 // Return new vertex index
|
|
122
|
+
},
|
|
123
|
+
[polygon, previewPolygon],
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
// Handle deleting a vertex
|
|
127
|
+
const handleDeleteVertex = useCallback(
|
|
128
|
+
(index: number) => {
|
|
129
|
+
const basePolygon = previewPolygon ?? polygon
|
|
130
|
+
if (basePolygon.length <= minVertices) return // Need at least minVertices points
|
|
131
|
+
|
|
132
|
+
const newPolygon = basePolygon.filter((_, i) => i !== index)
|
|
133
|
+
onPolygonChange(newPolygon)
|
|
134
|
+
setPreviewPolygon(null)
|
|
135
|
+
},
|
|
136
|
+
[polygon, previewPolygon, onPolygonChange, minVertices],
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
// Listen to grid:move events to track cursor position
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
const onGridMove = (event: GridEvent) => {
|
|
142
|
+
const gridX = Math.round(event.position[0] * 2) / 2
|
|
143
|
+
const gridZ = Math.round(event.position[2] * 2) / 2
|
|
144
|
+
const newPosition: [number, number] = [gridX, gridZ]
|
|
145
|
+
|
|
146
|
+
// Play snap sound when cursor moves to a new grid cell during drag
|
|
147
|
+
if (
|
|
148
|
+
dragState?.isDragging &&
|
|
149
|
+
previousPositionRef.current &&
|
|
150
|
+
(newPosition[0] !== previousPositionRef.current[0] ||
|
|
151
|
+
newPosition[1] !== previousPositionRef.current[1])
|
|
152
|
+
) {
|
|
153
|
+
sfxEmitter.emit('sfx:grid-snap')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
previousPositionRef.current = newPosition
|
|
157
|
+
setCursorPosition(newPosition)
|
|
158
|
+
|
|
159
|
+
// Update vertex position during drag
|
|
160
|
+
if (dragState?.isDragging) {
|
|
161
|
+
handleVertexDrag(dragState.vertexIndex, newPosition)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
emitter.on('grid:move', onGridMove)
|
|
166
|
+
return () => {
|
|
167
|
+
emitter.off('grid:move', onGridMove)
|
|
168
|
+
}
|
|
169
|
+
}, [dragState, handleVertexDrag])
|
|
170
|
+
|
|
171
|
+
// Set up pointer up listener for ending drag
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
if (!dragState?.isDragging) return
|
|
174
|
+
|
|
175
|
+
const handlePointerUp = (e: PointerEvent | MouseEvent) => {
|
|
176
|
+
// Only handle the specific pointer that started the drag, if it's a PointerEvent
|
|
177
|
+
if (
|
|
178
|
+
'pointerId' in e &&
|
|
179
|
+
dragState.pointerId !== undefined &&
|
|
180
|
+
e.pointerId !== dragState.pointerId
|
|
181
|
+
)
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
// Stop the event from propagating to prevent grid click
|
|
185
|
+
e.stopImmediatePropagation()
|
|
186
|
+
e.preventDefault()
|
|
187
|
+
|
|
188
|
+
// Suppress the follow-up click event that browsers fire after pointerup
|
|
189
|
+
const suppressClick = (ce: MouseEvent) => {
|
|
190
|
+
ce.stopImmediatePropagation()
|
|
191
|
+
ce.preventDefault()
|
|
192
|
+
window.removeEventListener('click', suppressClick, true)
|
|
193
|
+
}
|
|
194
|
+
window.addEventListener('click', suppressClick, true)
|
|
195
|
+
|
|
196
|
+
// Safety cleanup in case no click fires
|
|
197
|
+
requestAnimationFrame(() => {
|
|
198
|
+
window.removeEventListener('click', suppressClick, true)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
commitPolygonChange()
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
window.addEventListener('pointerup', handlePointerUp as EventListener, true)
|
|
205
|
+
window.addEventListener('pointercancel', handlePointerUp as EventListener, true)
|
|
206
|
+
return () => {
|
|
207
|
+
window.removeEventListener('pointerup', handlePointerUp as EventListener, true)
|
|
208
|
+
window.removeEventListener('pointercancel', handlePointerUp as EventListener, true)
|
|
209
|
+
}
|
|
210
|
+
}, [dragState, commitPolygonChange])
|
|
211
|
+
|
|
212
|
+
// Update line geometry when polygon changes
|
|
213
|
+
useEffect(() => {
|
|
214
|
+
if (!lineRef.current || displayPolygon.length < 2) return
|
|
215
|
+
|
|
216
|
+
const positions: number[] = []
|
|
217
|
+
for (const [x, z] of displayPolygon) {
|
|
218
|
+
positions.push(x!, editY + 0.01, z!)
|
|
219
|
+
}
|
|
220
|
+
// Close the loop
|
|
221
|
+
const first = displayPolygon[0]!
|
|
222
|
+
positions.push(first[0]!, editY + 0.01, first[1]!)
|
|
223
|
+
|
|
224
|
+
const geometry = new BufferGeometry()
|
|
225
|
+
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
|
|
226
|
+
|
|
227
|
+
lineRef.current.geometry.dispose()
|
|
228
|
+
lineRef.current.geometry = geometry
|
|
229
|
+
}, [displayPolygon, editY])
|
|
230
|
+
|
|
231
|
+
if (displayPolygon.length < minVertices) return null
|
|
232
|
+
|
|
233
|
+
const canDelete = displayPolygon.length > minVertices
|
|
234
|
+
|
|
235
|
+
const editorContent = (
|
|
236
|
+
<group>
|
|
237
|
+
{/* Border line */}
|
|
238
|
+
<line
|
|
239
|
+
frustumCulled={false}
|
|
240
|
+
layers={EDITOR_LAYER}
|
|
241
|
+
raycast={() => {}}
|
|
242
|
+
// @ts-expect-error R3F <line> element conflicts with SVG <line> type
|
|
243
|
+
ref={lineRef}
|
|
244
|
+
renderOrder={10}
|
|
245
|
+
>
|
|
246
|
+
<bufferGeometry />
|
|
247
|
+
<lineBasicNodeMaterial
|
|
248
|
+
color={color}
|
|
249
|
+
depthTest={false}
|
|
250
|
+
depthWrite={false}
|
|
251
|
+
linewidth={2}
|
|
252
|
+
opacity={0.8}
|
|
253
|
+
transparent
|
|
254
|
+
/>
|
|
255
|
+
</line>
|
|
256
|
+
|
|
257
|
+
{/* Vertex handles - blue cylinders that match surface height */}
|
|
258
|
+
{displayPolygon.map(([x, z], index) => {
|
|
259
|
+
const isHovered = hoveredVertex === index
|
|
260
|
+
const isDragging = dragState?.vertexIndex === index
|
|
261
|
+
const radius = 0.1
|
|
262
|
+
const height = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02)
|
|
263
|
+
|
|
264
|
+
return (
|
|
265
|
+
<mesh
|
|
266
|
+
castShadow
|
|
267
|
+
key={`vertex-${index}`}
|
|
268
|
+
layers={EDITOR_LAYER}
|
|
269
|
+
onClick={(e) => {
|
|
270
|
+
if (e.button !== 0) return
|
|
271
|
+
e.stopPropagation()
|
|
272
|
+
}}
|
|
273
|
+
onDoubleClick={(e) => {
|
|
274
|
+
if (e.button !== 0) return
|
|
275
|
+
e.stopPropagation()
|
|
276
|
+
if (canDelete) {
|
|
277
|
+
handleDeleteVertex(index)
|
|
278
|
+
}
|
|
279
|
+
}}
|
|
280
|
+
onPointerDown={(e) => {
|
|
281
|
+
if (e.button !== 0) return
|
|
282
|
+
e.stopPropagation()
|
|
283
|
+
setDragState({
|
|
284
|
+
isDragging: true,
|
|
285
|
+
vertexIndex: index,
|
|
286
|
+
initialPosition: [x!, z!],
|
|
287
|
+
pointerId: e.pointerId,
|
|
288
|
+
})
|
|
289
|
+
}}
|
|
290
|
+
onPointerEnter={(e) => {
|
|
291
|
+
e.stopPropagation()
|
|
292
|
+
setHoveredVertex(index)
|
|
293
|
+
}}
|
|
294
|
+
onPointerLeave={(e) => {
|
|
295
|
+
e.stopPropagation()
|
|
296
|
+
setHoveredVertex(null)
|
|
297
|
+
}}
|
|
298
|
+
position={[x!, editY + height / 2, z!]}
|
|
299
|
+
>
|
|
300
|
+
<cylinderGeometry args={[radius, radius, height, 16]} />
|
|
301
|
+
<meshStandardMaterial
|
|
302
|
+
color={isDragging ? '#22c55e' : isHovered ? '#60a5fa' : '#3b82f6'}
|
|
303
|
+
/>
|
|
304
|
+
</mesh>
|
|
305
|
+
)
|
|
306
|
+
})}
|
|
307
|
+
|
|
308
|
+
{/* Midpoint handles - smaller green cylinders for adding vertices (hidden while dragging) */}
|
|
309
|
+
{!dragState &&
|
|
310
|
+
midpoints.map(([x, z], index) => {
|
|
311
|
+
const isHovered = hoveredMidpoint === index
|
|
312
|
+
const radius = 0.06
|
|
313
|
+
const height = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02)
|
|
314
|
+
|
|
315
|
+
return (
|
|
316
|
+
<mesh
|
|
317
|
+
key={`midpoint-${index}`}
|
|
318
|
+
layers={EDITOR_LAYER}
|
|
319
|
+
onClick={(e) => {
|
|
320
|
+
if (e.button !== 0) return
|
|
321
|
+
e.stopPropagation()
|
|
322
|
+
}}
|
|
323
|
+
onPointerDown={(e) => {
|
|
324
|
+
if (e.button !== 0) return
|
|
325
|
+
e.stopPropagation()
|
|
326
|
+
const newVertexIndex = handleAddVertex(index, [x!, z!])
|
|
327
|
+
if (newVertexIndex >= 0) {
|
|
328
|
+
setDragState({
|
|
329
|
+
isDragging: true,
|
|
330
|
+
vertexIndex: newVertexIndex,
|
|
331
|
+
initialPosition: [x!, z!],
|
|
332
|
+
pointerId: e.pointerId,
|
|
333
|
+
})
|
|
334
|
+
setHoveredMidpoint(null)
|
|
335
|
+
}
|
|
336
|
+
}}
|
|
337
|
+
onPointerEnter={(e) => {
|
|
338
|
+
e.stopPropagation()
|
|
339
|
+
setHoveredMidpoint(index)
|
|
340
|
+
}}
|
|
341
|
+
onPointerLeave={(e) => {
|
|
342
|
+
e.stopPropagation()
|
|
343
|
+
setHoveredMidpoint(null)
|
|
344
|
+
}}
|
|
345
|
+
position={[x!, editY + height / 2, z!]}
|
|
346
|
+
>
|
|
347
|
+
<cylinderGeometry args={[radius, radius, height, 16]} />
|
|
348
|
+
<meshStandardMaterial
|
|
349
|
+
color={isHovered ? '#4ade80' : '#22c55e'}
|
|
350
|
+
opacity={isHovered ? 1 : 0.7}
|
|
351
|
+
transparent
|
|
352
|
+
/>
|
|
353
|
+
</mesh>
|
|
354
|
+
)
|
|
355
|
+
})}
|
|
356
|
+
</group>
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
// Mount to level node if available, otherwise render at world origin
|
|
360
|
+
return levelNode ? createPortal(editorContent, levelNode) : editorContent
|
|
361
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type SiteNode, useScene } from '@pascal-app/core'
|
|
2
|
+
import { useCallback } from 'react'
|
|
3
|
+
import { PolygonEditor } from '../shared/polygon-editor'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Site boundary editor - allows editing site polygon when in site phase
|
|
7
|
+
* Uses the generic PolygonEditor component
|
|
8
|
+
*/
|
|
9
|
+
export const SiteBoundaryEditor: React.FC = () => {
|
|
10
|
+
const nodes = useScene((state) => state.nodes)
|
|
11
|
+
const rootNodeIds = useScene((state) => state.rootNodeIds)
|
|
12
|
+
const updateNode = useScene((state) => state.updateNode)
|
|
13
|
+
|
|
14
|
+
// Get the site node (first root node)
|
|
15
|
+
const siteNode = rootNodeIds[0] ? nodes[rootNodeIds[0]] : null
|
|
16
|
+
const site = siteNode?.type === 'site' ? (siteNode as SiteNode) : null
|
|
17
|
+
|
|
18
|
+
const handlePolygonChange = useCallback(
|
|
19
|
+
(newPolygon: Array<[number, number]>) => {
|
|
20
|
+
if (site) {
|
|
21
|
+
updateNode(site.id, {
|
|
22
|
+
polygon: {
|
|
23
|
+
type: 'polygon',
|
|
24
|
+
points: newPolygon,
|
|
25
|
+
},
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
[site, updateNode],
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
if (!site?.polygon?.points || site.polygon.points.length < 3) return null
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<PolygonEditor
|
|
36
|
+
color="#10b981"
|
|
37
|
+
minVertices={3}
|
|
38
|
+
onPolygonChange={handlePolygonChange}
|
|
39
|
+
polygon={site.polygon.points}
|
|
40
|
+
/>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { resolveLevelId, type SlabNode, useScene } from '@pascal-app/core'
|
|
2
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
3
|
+
import { useCallback } from 'react'
|
|
4
|
+
import { PolygonEditor } from '../shared/polygon-editor'
|
|
5
|
+
|
|
6
|
+
interface SlabBoundaryEditorProps {
|
|
7
|
+
slabId: SlabNode['id']
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Slab boundary editor - allows editing slab polygon vertices for a specific slab
|
|
12
|
+
* Uses the generic PolygonEditor component
|
|
13
|
+
*/
|
|
14
|
+
export const SlabBoundaryEditor: React.FC<SlabBoundaryEditorProps> = ({ slabId }) => {
|
|
15
|
+
const slabNode = useScene((state) => state.nodes[slabId])
|
|
16
|
+
const updateNode = useScene((state) => state.updateNode)
|
|
17
|
+
const setSelection = useViewer((state) => state.setSelection)
|
|
18
|
+
|
|
19
|
+
const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null
|
|
20
|
+
|
|
21
|
+
const handlePolygonChange = useCallback(
|
|
22
|
+
(newPolygon: Array<[number, number]>) => {
|
|
23
|
+
updateNode(slabId, { polygon: newPolygon })
|
|
24
|
+
// Re-assert selection so the slab stays selected after the edit
|
|
25
|
+
setSelection({ selectedIds: [slabId] })
|
|
26
|
+
},
|
|
27
|
+
[slabId, updateNode, setSelection],
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
if (!slab?.polygon || slab.polygon.length < 3) return null
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<PolygonEditor
|
|
34
|
+
color="#a3a3a3"
|
|
35
|
+
levelId={resolveLevelId(slab, useScene.getState().nodes)}
|
|
36
|
+
minVertices={3}
|
|
37
|
+
onPolygonChange={handlePolygonChange}
|
|
38
|
+
polygon={slab.polygon}
|
|
39
|
+
surfaceHeight={slab.elevation ?? 0.05}
|
|
40
|
+
/>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { resolveLevelId, type SlabNode, useScene } from '@pascal-app/core'
|
|
2
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
3
|
+
import { useCallback } from 'react'
|
|
4
|
+
import { PolygonEditor } from '../shared/polygon-editor'
|
|
5
|
+
|
|
6
|
+
interface SlabHoleEditorProps {
|
|
7
|
+
slabId: SlabNode['id']
|
|
8
|
+
holeIndex: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Slab hole editor - allows editing a specific hole polygon within a slab
|
|
13
|
+
* Uses the generic PolygonEditor component
|
|
14
|
+
*/
|
|
15
|
+
export const SlabHoleEditor: React.FC<SlabHoleEditorProps> = ({ slabId, holeIndex }) => {
|
|
16
|
+
const slabNode = useScene((state) => state.nodes[slabId])
|
|
17
|
+
const updateNode = useScene((state) => state.updateNode)
|
|
18
|
+
const setSelection = useViewer((state) => state.setSelection)
|
|
19
|
+
|
|
20
|
+
const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null
|
|
21
|
+
const holes = slab?.holes || []
|
|
22
|
+
const hole = holes[holeIndex]
|
|
23
|
+
|
|
24
|
+
const handlePolygonChange = useCallback(
|
|
25
|
+
(newPolygon: Array<[number, number]>) => {
|
|
26
|
+
const updatedHoles = [...holes]
|
|
27
|
+
updatedHoles[holeIndex] = newPolygon
|
|
28
|
+
updateNode(slabId, { holes: updatedHoles })
|
|
29
|
+
// Re-assert selection so the slab stays selected after the edit
|
|
30
|
+
setSelection({ selectedIds: [slabId] })
|
|
31
|
+
},
|
|
32
|
+
[slabId, holeIndex, holes, updateNode, setSelection],
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if (!(slab && hole) || hole.length < 3) return null
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<PolygonEditor
|
|
39
|
+
color="#ef4444"
|
|
40
|
+
levelId={resolveLevelId(slab, useScene.getState().nodes)} // red for holes
|
|
41
|
+
minVertices={3}
|
|
42
|
+
onPolygonChange={handlePolygonChange}
|
|
43
|
+
polygon={hole}
|
|
44
|
+
surfaceHeight={slab.elevation ?? 0.05}
|
|
45
|
+
/>
|
|
46
|
+
)
|
|
47
|
+
}
|