Wassim SAMAD 8abfc94b99
feat(editor): preset-system polish — paint panel, slim action bar, icon rail (#354)
* feat(editor): preset-system polish — paint panel, slim action bar, icon rail

- Export `MaterialPaintPanel` so embedders host the paint material picker in
  their own panel (community docks it in the Build sidebar) instead of the
  bottom action bar.
- ActionMenu: drop the build / material-paint / furnish modes and the
  structure-tools palette row + paint tray (the host's Build sidebar owns
  building now). Reduce `structure-tools` to the shared `tools` lookup still
  used by cursor/floorplan indicators; remove the orphaned `useContextualTools`.
- MaterialPicker: swatches wrap into a fluid `auto-fill` grid that fills width.
- IconRail (tab-bar): bigger icons, grayscale-when-idle, Radix tooltip, w-14
  rail; sync `RAIL_WIDTH` to 56.
- Inspector footer: hand the host `footer` to kind-owned custom panels via
  `InspectorFooterContext` so the save button renders without per-kind wiring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: biome format pass

Whole-repo formatter normalization (line wrap/unwrap only, no logic changes)
surfaced by the format-on-edit hook against prior drift. Kept separate from the
feature commit so the preset-system diff stays reviewable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 13:13:23 -04:00

706 lines
24 KiB
TypeScript

import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
import { createPortal } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
BufferGeometry,
ExtrudeGeometry,
Float32BufferAttribute,
type Line,
type Object3D,
Shape,
} from 'three'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { snapToHalf } from '../item/placement-math'
const Y_OFFSET = 0.02
// Per-side resize arrows: indigo chevrons that match the registry arrow
// handles (wall / column / fence). Each arrow sits just outside an edge
// midpoint, pointing along the edge's outward normal — dragging an arrow
// translates that edge only (its two vertices), leaving the opposite side
// fixed. Reuses the existing 'edge' drag mode in PolygonEditor.
const EDGE_ARROW_COLOR = '#8381ed'
const EDGE_ARROW_HOVER_COLOR = '#a5b4fc'
const EDGE_ARROW_SCALE = 0.65
const EDGE_ARROW_OFFSET = 0.34
function createEdgeArrowGeometry() {
const shape = new Shape()
shape.moveTo(0.22, 0)
shape.lineTo(-0.04, 0.12)
shape.lineTo(-0.04, 0.035)
shape.lineTo(-0.2, 0.035)
shape.lineTo(-0.2, -0.035)
shape.lineTo(-0.04, -0.035)
shape.lineTo(-0.04, -0.12)
shape.lineTo(0.22, 0)
const geometry = new ExtrudeGeometry(shape, {
depth: 0.08,
bevelEnabled: true,
bevelThickness: 0.035,
bevelSize: 0.03,
bevelOffset: 0,
bevelSegments: 10,
curveSegments: 16,
steps: 1,
})
geometry.translate(0, 0, -0.04)
geometry.rotateX(-Math.PI / 2)
geometry.computeVertexNormals()
geometry.computeBoundingSphere()
return geometry
}
type DragState = {
isDragging: boolean
mode: 'vertex' | 'polygon' | 'edge'
vertexIndex: number | null
edgeIndex?: number
edgeNormal?: [number, number]
initialPosition: [number, number]
initialPolygon: Array<[number, number]>
pointerId: number
}
export interface PolygonEditorProps {
polygon: Array<[number, number]>
color?: string
onPolygonChange: (polygon: Array<[number, number]>) => void
/**
* Fires on every drag tick with the in-flight polygon, then once with
* `null` when the drag commits or is otherwise cleared. Hosts wire
* this to `useLiveNodeOverrides` so the underlying mesh rebuilds at
* pointer rate while `onPolygonChange` stays a single store commit
* on release.
*/
onPolygonPreview?: (polygon: ReadonlyArray<readonly [number, number]> | null) => void
minVertices?: number
/** Level ID to mount the editor to. If provided, uses createPortal for automatic level animation following. */
levelId?: string
/** Height of the surface being edited (e.g. slab elevation). Handles adapt to this. */
surfaceHeight?: number
/** Whether to show the center handle that moves the entire polygon. */
allowPolygonMove?: boolean
/** Whether polygon edges can be dragged along their perpendicular normal. */
allowEdgeMove?: boolean
}
/**
* Generic polygon editor component for editing polygon vertices
* Used by zone and site boundary editors
*/
const MIN_HANDLE_HEIGHT = 0.15
const EDGE_HANDLE_HEIGHT = 0.06
const EDGE_HANDLE_THICKNESS = 0.12
function getEdgeNormal(start: [number, number], end: [number, number]): [number, number] | null {
const dx = end[0] - start[0]
const dz = end[1] - start[1]
const length = Math.hypot(dx, dz)
if (length < 1e-6) return null
return [-dz / length, dx / length]
}
export const PolygonEditor: React.FC<PolygonEditorProps> = ({
polygon,
color = '#3b82f6',
onPolygonChange,
onPolygonPreview,
minVertices = 3,
levelId,
surfaceHeight = 0,
allowPolygonMove = false,
allowEdgeMove = false,
}) => {
const [levelNode, setLevelNode] = useState<Object3D | null>(() =>
levelId ? (sceneRegistry.nodes.get(levelId) ?? null) : null,
)
useEffect(() => {
if (!levelId) {
setLevelNode(null)
return
}
let frameId = 0
const resolveLevelNode = () => {
const nextLevelNode = sceneRegistry.nodes.get(levelId) ?? null
setLevelNode((currentLevelNode) => {
if (currentLevelNode === nextLevelNode) {
return currentLevelNode
}
return nextLevelNode
})
if (!nextLevelNode) {
frameId = window.requestAnimationFrame(resolveLevelNode)
}
}
resolveLevelNode()
return () => {
if (frameId) {
window.cancelAnimationFrame(frameId)
}
}
}, [levelId])
// When using portal, edit at Y_OFFSET (local to level)
// When not using portal, edit at world origin
const editY = levelNode ? Y_OFFSET : 0
// Local state for dragging
const [dragState, setDragState] = useState<DragState | null>(null)
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
const previewPolygonRef = useRef<Array<[number, number]> | null>(null)
const onPolygonPreviewRef = useRef(onPolygonPreview)
useEffect(() => {
onPolygonPreviewRef.current = onPolygonPreview
}, [onPolygonPreview])
const updatePreviewPolygon = useCallback((nextPolygon: Array<[number, number]> | null) => {
previewPolygonRef.current = nextPolygon
setPreviewPolygon(nextPolygon)
// Notify the host so it can mirror the in-flight polygon onto
// `useLiveNodeOverrides` (drag rebuilds the mesh at pointer rate)
// and clear that override when we hand `null` back at commit /
// cancel / external-undo time.
onPolygonPreviewRef.current?.(nextPolygon)
}, [])
// Keep ref in sync
useEffect(() => {
previewPolygonRef.current = previewPolygon
}, [previewPolygon])
const [hoveredVertex, setHoveredVertex] = useState<number | null>(null)
const [hoveredMidpoint, setHoveredMidpoint] = useState<number | null>(null)
const [hoveredEdge, setHoveredEdge] = useState<number | null>(null)
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
const lineRef = useRef<Line>(null!)
const previousPositionRef = useRef<[number, number] | null>(null)
// Track the last polygon prop to detect external changes (undo/redo)
const lastPolygonRef = useRef(polygon)
if (polygon !== lastPolygonRef.current) {
lastPolygonRef.current = polygon
// External change (e.g. undo/redo) — clear any stale preview/drag state
if (previewPolygon) updatePreviewPolygon(null)
if (dragState) setDragState(null)
}
// The polygon to display (preview during drag, or actual polygon)
const displayPolygon = previewPolygon ?? polygon
const polygonCenter = useMemo(() => {
if (displayPolygon.length === 0) return [0, 0] as [number, number]
let sumX = 0
let sumZ = 0
for (const [x, z] of displayPolygon) {
sumX += x
sumZ += z
}
return [sumX / displayPolygon.length, sumZ / displayPolygon.length] as [number, number]
}, [displayPolygon])
// Calculate midpoints for adding new vertices
const midpoints = useMemo(() => {
if (displayPolygon.length < 2) return []
return displayPolygon.map(([x1, z1], index) => {
const nextIndex = (index + 1) % displayPolygon.length
const [x2, z2] = displayPolygon[nextIndex]!
return [(x1! + x2) / 2, (z1! + z2) / 2] as [number, number]
})
}, [displayPolygon])
const edgeHandles = useMemo(() => {
if (displayPolygon.length < 2) return []
let cx = 0
let cz = 0
for (const [x, z] of displayPolygon) {
cx += x
cz += z
}
cx /= displayPolygon.length
cz /= displayPolygon.length
return displayPolygon.flatMap(([x1, z1], index) => {
const nextIndex = (index + 1) % displayPolygon.length
const [x2, z2] = displayPolygon[nextIndex]!
const dx = x2 - x1
const dz = z2 - z1
const length = Math.hypot(dx, dz)
if (length < 1e-6) return []
const midpoint: [number, number] = [(x1 + x2) / 2, (z1 + z2) / 2]
// Outward normal: edge perpendicular flipped to point away from the
// polygon centroid. Independent of winding order so arrows always
// face outward even on hand-traced (mixed-winding) polygons.
let nx = -dz / length
let nz = dx / length
if (nx * (midpoint[0] - cx) + nz * (midpoint[1] - cz) < 0) {
nx = -nx
nz = -nz
}
return [
{
index,
length,
midpoint,
rotationY: -Math.atan2(dz, dx),
outwardNormal: [nx, nz] as [number, number],
outwardAngle: -Math.atan2(nz, nx),
},
]
})
}, [displayPolygon])
const arrowGeometry = useMemo(() => createEdgeArrowGeometry(), [])
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
// Update vertex position using grid cursor position
const handleVertexDrag = useCallback(
(vertexIndex: number, position: [number, number]) => {
const basePolygon = previewPolygonRef.current ?? polygon
const newPolygon = [...basePolygon]
newPolygon[vertexIndex] = position
updatePreviewPolygon(newPolygon)
},
[polygon, updatePreviewPolygon],
)
// Commit polygon changes
const commitPolygonChange = useCallback(() => {
if (previewPolygonRef.current) {
onPolygonChange(previewPolygonRef.current)
}
updatePreviewPolygon(null)
setDragState(null)
}, [onPolygonChange, updatePreviewPolygon])
// Handle adding a new vertex at midpoint
const handleAddVertex = useCallback(
(afterIndex: number, position: [number, number]) => {
const basePolygon = previewPolygon ?? polygon
const newPolygon = [
...basePolygon.slice(0, afterIndex + 1),
position,
...basePolygon.slice(afterIndex + 1),
]
updatePreviewPolygon(newPolygon)
return {
polygon: newPolygon,
vertexIndex: afterIndex + 1,
}
},
[polygon, previewPolygon, updatePreviewPolygon],
)
// Handle deleting a vertex
const handleDeleteVertex = useCallback(
(index: number) => {
const basePolygon = previewPolygon ?? polygon
if (basePolygon.length <= minVertices) return // Need at least minVertices points
const newPolygon = basePolygon.filter((_, i) => i !== index)
onPolygonChange(newPolygon)
updatePreviewPolygon(null)
},
[polygon, previewPolygon, onPolygonChange, minVertices, updatePreviewPolygon],
)
// Listen to grid:move events to track cursor position
useEffect(() => {
const onGridMove = (event: GridEvent) => {
const gridX = snapToHalf(event.localPosition[0])
const gridZ = snapToHalf(event.localPosition[2])
const newPosition: [number, number] = [gridX, gridZ]
// Play snap sound when cursor moves to a new grid cell during drag
if (
dragState?.isDragging &&
previousPositionRef.current &&
(newPosition[0] !== previousPositionRef.current[0] ||
newPosition[1] !== previousPositionRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousPositionRef.current = newPosition
setCursorPosition(newPosition)
// Update vertex position during drag
if (dragState?.isDragging) {
if (dragState.mode === 'vertex' && dragState.vertexIndex !== null) {
handleVertexDrag(dragState.vertexIndex, newPosition)
} else if (dragState.mode === 'polygon') {
const deltaX = newPosition[0] - dragState.initialPosition[0]
const deltaZ = newPosition[1] - dragState.initialPosition[1]
updatePreviewPolygon(
dragState.initialPolygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number]),
)
} else if (
dragState.mode === 'edge' &&
dragState.edgeIndex !== undefined &&
dragState.edgeNormal
) {
const [normalX, normalZ] = dragState.edgeNormal
const pointerDeltaX = newPosition[0] - dragState.initialPosition[0]
const pointerDeltaZ = newPosition[1] - dragState.initialPosition[1]
const normalDistance = pointerDeltaX * normalX + pointerDeltaZ * normalZ
const edgeStartIndex = dragState.edgeIndex
const edgeEndIndex = (edgeStartIndex + 1) % dragState.initialPolygon.length
const nextPolygon = dragState.initialPolygon.map((point, index) => {
if (index !== edgeStartIndex && index !== edgeEndIndex) {
return point
}
return [point[0] + normalX * normalDistance, point[1] + normalZ * normalDistance] as [
number,
number,
]
})
updatePreviewPolygon(nextPolygon)
}
}
}
emitter.on('grid:move', onGridMove)
return () => {
emitter.off('grid:move', onGridMove)
}
}, [dragState, handleVertexDrag, updatePreviewPolygon])
// Set up pointer up listener for ending drag
useEffect(() => {
if (!dragState?.isDragging) return
const handlePointerUp = (e: PointerEvent | MouseEvent) => {
// Only handle the specific pointer that started the drag, if it's a PointerEvent
if (
'pointerId' in e &&
dragState.pointerId !== undefined &&
e.pointerId !== dragState.pointerId
)
return
// Stop the event from propagating to prevent grid click
e.stopImmediatePropagation()
e.preventDefault()
// Suppress the follow-up click event that browsers fire after pointerup
const suppressClick = (ce: MouseEvent) => {
ce.stopImmediatePropagation()
ce.preventDefault()
window.removeEventListener('click', suppressClick, true)
}
window.addEventListener('click', suppressClick, true)
// Safety cleanup in case no click fires
requestAnimationFrame(() => {
window.removeEventListener('click', suppressClick, true)
})
commitPolygonChange()
}
window.addEventListener('pointerup', handlePointerUp as EventListener, true)
window.addEventListener('pointercancel', handlePointerUp as EventListener, true)
return () => {
window.removeEventListener('pointerup', handlePointerUp as EventListener, true)
window.removeEventListener('pointercancel', handlePointerUp as EventListener, true)
}
}, [dragState, commitPolygonChange])
// Update line geometry when polygon changes
useEffect(() => {
if (!lineRef.current || displayPolygon.length < 2) return
const positions: number[] = []
for (const [x, z] of displayPolygon) {
positions.push(x!, editY + 0.01, z!)
}
// Close the loop
const first = displayPolygon[0]!
positions.push(first[0]!, editY + 0.01, first[1]!)
const geometry = new BufferGeometry()
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
lineRef.current.geometry.dispose()
lineRef.current.geometry = geometry
}, [displayPolygon, editY])
if (displayPolygon.length < minVertices) return null
const canDelete = displayPolygon.length > minVertices
const handleHeight = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02)
const edgeHandleY = editY + handleHeight - EDGE_HANDLE_HEIGHT / 2
const editorContent = (
<group>
{/* Border line */}
<line
frustumCulled={false}
layers={EDITOR_LAYER}
raycast={() => {}}
// @ts-expect-error R3F <line> element conflicts with SVG <line> type
ref={lineRef}
renderOrder={10}
>
<bufferGeometry />
<lineBasicNodeMaterial
color={color}
depthTest={false}
depthWrite={false}
linewidth={2}
opacity={0.8}
transparent
/>
</line>
{/* Vertex handles - blue cylinders that match surface height */}
{displayPolygon.map(([x, z], index) => {
const isHovered = hoveredVertex === index
const isDragging = dragState?.mode === 'vertex' && dragState.vertexIndex === index
const radius = 0.1
const height = handleHeight
return (
<mesh
castShadow
key={`vertex-${index}`}
layers={EDITOR_LAYER}
onClick={(e) => {
if (e.button !== 0) return
e.stopPropagation()
}}
onDoubleClick={(e) => {
if (e.button !== 0) return
e.stopPropagation()
if (canDelete) {
handleDeleteVertex(index)
}
}}
onPointerDown={(e) => {
if (e.button !== 0) return
e.stopPropagation()
setHoveredEdge(null)
setDragState({
isDragging: true,
mode: 'vertex',
vertexIndex: index,
initialPosition: [x!, z!],
initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]),
pointerId: e.pointerId,
})
}}
onPointerEnter={(e) => {
e.stopPropagation()
setHoveredVertex(index)
}}
onPointerLeave={(e) => {
e.stopPropagation()
setHoveredVertex(null)
}}
position={[x!, editY + height / 2, z!]}
>
<cylinderGeometry args={[radius, radius, height, 16]} />
<meshBasicMaterial color={isDragging ? '#22c55e' : isHovered ? '#60a5fa' : '#3b82f6'} />
</mesh>
)
})}
{allowPolygonMove && (
<mesh
castShadow
layers={EDITOR_LAYER}
onClick={(e) => {
if (e.button !== 0) return
e.stopPropagation()
}}
onPointerDown={(e) => {
if (e.button !== 0) return
e.stopPropagation()
setHoveredEdge(null)
setDragState({
isDragging: true,
mode: 'polygon',
vertexIndex: null,
initialPosition: polygonCenter,
initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]),
pointerId: e.pointerId,
})
}}
position={[polygonCenter[0], editY + handleHeight + 0.08, polygonCenter[1]]}
>
<sphereGeometry args={[0.09, 20, 20]} />
<meshBasicMaterial color={dragState?.mode === 'polygon' ? '#22c55e' : '#f59e0b'} />
</mesh>
)}
{allowEdgeMove &&
edgeHandles.map(({ index, length, midpoint, rotationY, outwardNormal, outwardAngle }) => {
const isHovered = hoveredEdge === index
const isDragging = dragState?.mode === 'edge' && dragState.edgeIndex === index
const arrowX = midpoint[0] + outwardNormal[0] * EDGE_ARROW_OFFSET
const arrowZ = midpoint[1] + outwardNormal[1] * EDGE_ARROW_OFFSET
const beginEdgeDrag = (e: { button: number; pointerId: number }) => {
const start = displayPolygon[index]
const end = displayPolygon[(index + 1) % displayPolygon.length]
if (!(start && end)) return
const edgeNormal = getEdgeNormal(start, end)
if (!edgeNormal) return
setHoveredEdge(null)
setDragState({
isDragging: true,
mode: 'edge',
vertexIndex: null,
edgeIndex: index,
edgeNormal,
initialPosition: cursorPosition,
initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]),
pointerId: e.pointerId,
})
}
return (
<group key={`edge-${index}`}>
<mesh
layers={EDITOR_LAYER}
onClick={(e) => {
if (e.button !== 0) return
e.stopPropagation()
}}
onPointerDown={(e) => {
if (e.button !== 0) return
e.stopPropagation()
beginEdgeDrag(e)
}}
onPointerEnter={(e) => {
e.stopPropagation()
setHoveredEdge(index)
}}
onPointerLeave={(e) => {
e.stopPropagation()
setHoveredEdge(null)
}}
position={[midpoint[0], edgeHandleY, midpoint[1]]}
rotation={[0, rotationY, 0]}
>
<boxGeometry args={[length, EDGE_HANDLE_HEIGHT, EDGE_HANDLE_THICKNESS]} />
<meshBasicMaterial
color={isDragging ? '#22c55e' : '#60a5fa'}
opacity={isDragging ? 0.5 : isHovered ? 0.38 : 0.14}
transparent
/>
</mesh>
{/* Per-side resize arrow — points outward from the edge.
Dragging it pulls (or pushes) only this edge's two
vertices along the outward normal; the opposite side
of the polygon stays put. */}
<mesh
geometry={arrowGeometry}
layers={EDITOR_LAYER}
onClick={(e) => {
if (e.button !== 0) return
e.stopPropagation()
}}
onPointerDown={(e) => {
if (e.button !== 0) return
e.stopPropagation()
beginEdgeDrag(e)
}}
onPointerEnter={(e) => {
e.stopPropagation()
setHoveredEdge(index)
}}
onPointerLeave={(e) => {
e.stopPropagation()
setHoveredEdge(null)
}}
position={[arrowX, edgeHandleY, arrowZ]}
rotation={[0, outwardAngle, 0]}
scale={EDGE_ARROW_SCALE}
>
<meshBasicMaterial
color={
isDragging ? '#22c55e' : isHovered ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR
}
depthTest={false}
depthWrite={false}
transparent
/>
</mesh>
</group>
)
})}
{/* Midpoint handles - smaller green cylinders for adding vertices (hidden while dragging) */}
{!dragState &&
midpoints.map(([x, z], index) => {
const isHovered = hoveredMidpoint === index
const radius = 0.06
const height = handleHeight
return (
<mesh
key={`midpoint-${index}`}
layers={EDITOR_LAYER}
onClick={(e) => {
if (e.button !== 0) return
e.stopPropagation()
}}
onPointerDown={(e) => {
if (e.button !== 0) return
e.stopPropagation()
const insertedVertex = handleAddVertex(index, [x!, z!])
if (insertedVertex.vertexIndex >= 0) {
setDragState({
isDragging: true,
mode: 'vertex',
vertexIndex: insertedVertex.vertexIndex,
initialPosition: [x!, z!],
initialPolygon: insertedVertex.polygon,
pointerId: e.pointerId,
})
setHoveredMidpoint(null)
}
}}
onPointerEnter={(e) => {
e.stopPropagation()
setHoveredMidpoint(index)
}}
onPointerLeave={(e) => {
e.stopPropagation()
setHoveredMidpoint(null)
}}
position={[x!, editY + height / 2, z!]}
>
<cylinderGeometry args={[radius, radius, height, 16]} />
<meshBasicMaterial
color={isHovered ? '#4ade80' : '#22c55e'}
opacity={isHovered ? 1 : 0.7}
transparent
/>
</mesh>
)
})}
</group>
)
// Mount to level node if available, otherwise render at world origin
return levelNode ? createPortal(editorContent, levelNode) : editorContent
}