diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index 13c9d378..5e011722 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -204,12 +204,31 @@ export const ToolManager: React.FC = () => { ) })()} - {showCeilingBoundaryEditor && selectedCeilingId && ( - - )} - {showCeilingHoleEditor && selectedCeilingId && editingHole && ( - - )} + {showCeilingBoundaryEditor && + selectedCeilingId && + (() => { + const Registry = getRegistryAffordanceTool('ceiling', 'boundary-edit') + return Registry ? ( + + + + ) : ( + + ) + })()} + {showCeilingHoleEditor && + selectedCeilingId && + editingHole && + (() => { + const Registry = getRegistryAffordanceTool('ceiling', 'hole-edit') + return Registry ? ( + + + + ) : ( + + ) + })()} {movingWallEndpoint && } {movingFenceEndpoint && (() => { diff --git a/packages/nodes/src/ceiling/actions/move.ts b/packages/nodes/src/ceiling/actions/move.ts new file mode 100644 index 00000000..14baedfb --- /dev/null +++ b/packages/nodes/src/ceiling/actions/move.ts @@ -0,0 +1,92 @@ +import type { AnyNode, AnyNodeId, CeilingNode, DragAction } from '@pascal-app/core' + +/** + * Phase 5 Stage D — whole-ceiling move drag affordance. + * + * Mirrors `slab/actions/move.ts` shape but ceiling snaps purely to a + * 0.5m grid (no wall/fence corner snap — ceilings are typically + * placed independent of the floor layout). Drag anchor is latched on + * the first preview tick so the ceiling doesn't jump. + * + * Single-undo dance on commit, same recipe as slab/fence. + */ + +const GRID_STEP = 0.5 + +function snap(value: number): number { + return Math.round(value / GRID_STEP) * GRID_STEP +} + +function translatePolygon( + polygon: Array<[number, number]>, + deltaX: number, + deltaZ: number, +): Array<[number, number]> { + return polygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number]) +} + +export type MoveCeilingCtx = { + ceilingId: AnyNodeId + originalPolygon: Array<[number, number]> + originalHoles: Array> + dragAnchor: [number, number] | null +} + +export type MoveCeilingDraft = { + polygon: Array<[number, number]> + holes: Array> + deltaX: number + deltaZ: number +} + +export const moveCeilingDragAction: DragAction = { + begin: (input) => { + const ceiling = input.node as CeilingNode | undefined + if (!ceiling) throw new Error('[moveCeilingDragAction] begin requires a ceiling node') + return { + ceilingId: ceiling.id as AnyNodeId, + originalPolygon: ceiling.polygon.map(([x, z]) => [x, z] as [number, number]), + originalHoles: (ceiling.holes ?? []).map((h) => + h.map(([x, z]) => [x, z] as [number, number]), + ), + dragAnchor: null, + } + }, + + preview: (ctx, point, _modifiers) => { + const sx = snap(point[0]) + const sz = snap(point[1]) + if (!ctx.dragAnchor) ctx.dragAnchor = [sx, sz] + const deltaX = sx - ctx.dragAnchor[0] + const deltaZ = sz - ctx.dragAnchor[1] + return { + polygon: translatePolygon(ctx.originalPolygon, deltaX, deltaZ), + holes: ctx.originalHoles.map((h) => translatePolygon(h, deltaX, deltaZ)), + deltaX, + deltaZ, + } + }, + + apply: (draft, ctx, scene) => { + scene.update(ctx.ceilingId, { + polygon: draft.polygon, + holes: draft.holes, + } as Partial) + return [ctx.ceilingId] + }, + + commit: (draft, ctx, scene) => { + if (draft.deltaX === 0 && draft.deltaZ === 0) return false + scene.restoreAll() + scene.resumeHistory() + scene.update(ctx.ceilingId, { + polygon: draft.polygon, + holes: draft.holes, + } as Partial) + return true + }, + + cancel: (_ctx, _scene) => { + // No-op — orchestrator's scene.restoreAll() restores via snapshot. + }, +} diff --git a/packages/nodes/src/ceiling/boundary-editor.tsx b/packages/nodes/src/ceiling/boundary-editor.tsx new file mode 100644 index 00000000..b807a6a8 --- /dev/null +++ b/packages/nodes/src/ceiling/boundary-editor.tsx @@ -0,0 +1,47 @@ +'use client' + +import { type CeilingNode, resolveLevelId, useScene } from '@pascal-app/core' +import { PolygonEditor } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { useCallback } from 'react' + +/** + * Phase 5 Stage D — ceiling boundary editor (registry-driven). + * + * Thin wrapper around the shared `` (same shape as + * slab's boundary-editor). Activates when a ceiling is selected in + * structure/select mode and no hole edit is in progress. + */ +export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> = ({ + ceilingId, +}) => { + const ceilingNode = useScene((s) => s.nodes[ceilingId]) + const updateNode = useScene((s) => s.updateNode) + const setSelection = useViewer((s) => s.setSelection) + + const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null + + const handlePolygonChange = useCallback( + (newPolygon: Array<[number, number]>) => { + updateNode(ceilingId, { polygon: newPolygon }) + setSelection({ selectedIds: [ceilingId] }) + }, + [ceilingId, updateNode, setSelection], + ) + + if (!ceiling?.polygon || ceiling.polygon.length < 3) return null + + return ( + + ) +} + +export default CeilingBoundaryEditor diff --git a/packages/nodes/src/ceiling/definition.ts b/packages/nodes/src/ceiling/definition.ts index ab3b8749..33318bfe 100644 --- a/packages/nodes/src/ceiling/definition.ts +++ b/packages/nodes/src/ceiling/definition.ts @@ -54,6 +54,19 @@ export const ceilingDefinition: NodeDefinition = { parametrics: ceilingParametrics, + // Stage D: kind-owned placement tool. Multi-click polygon drawing + // with a vertical TSL-gradient connector + ground-shadow lines. + tool: () => import('./tool'), + + // Stage D: drag/edit affordances. Boundary editor + hole editor + // delegate to the shared ``; move uses the single- + // undo dance. + affordanceTools: { + 'boundary-edit': () => import('./boundary-editor'), + 'hole-edit': () => import('./hole-editor'), + move: () => import('./move-tool'), + }, + renderer: { kind: 'parametric', module: () => import('./renderer'), diff --git a/packages/nodes/src/ceiling/hole-editor.tsx b/packages/nodes/src/ceiling/hole-editor.tsx new file mode 100644 index 00000000..5923a5ea --- /dev/null +++ b/packages/nodes/src/ceiling/hole-editor.tsx @@ -0,0 +1,49 @@ +'use client' + +import { type CeilingNode, resolveLevelId, useScene } from '@pascal-app/core' +import { PolygonEditor } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { useCallback } from 'react' + +/** + * Phase 5 Stage D — ceiling hole editor (registry-driven). + */ +export const CeilingHoleEditor: React.FC<{ + ceilingId: CeilingNode['id'] + holeIndex: number +}> = ({ ceilingId, holeIndex }) => { + const ceilingNode = useScene((s) => s.nodes[ceilingId]) + const updateNode = useScene((s) => s.updateNode) + const setSelection = useViewer((s) => s.setSelection) + + const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null + const holes = ceiling?.holes || [] + const hole = holes[holeIndex] + + const handlePolygonChange = useCallback( + (newPolygon: Array<[number, number]>) => { + const updatedHoles = [...holes] + updatedHoles[holeIndex] = newPolygon + updateNode(ceilingId, { holes: updatedHoles }) + setSelection({ selectedIds: [ceilingId] }) + }, + [ceilingId, holeIndex, holes, updateNode, setSelection], + ) + + if (!(ceiling && hole) || hole.length < 3) return null + + return ( + + ) +} + +export default CeilingHoleEditor diff --git a/packages/nodes/src/ceiling/move-tool.tsx b/packages/nodes/src/ceiling/move-tool.tsx new file mode 100644 index 00000000..61a5b90a --- /dev/null +++ b/packages/nodes/src/ceiling/move-tool.tsx @@ -0,0 +1,119 @@ +'use client' + +import { type CeilingNode, useScene } from '@pascal-app/core' +import { CursorSphere, triggerSFX, useDragAction, useEditor } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { useMemo } from 'react' +import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three' +import { moveCeilingDragAction } from './actions/move' + +/** + * Phase 5 Stage D — thin React wrapper around `moveCeilingDragAction`. + * + * Renders the cursor sphere at the ceiling polygon's live center plus + * a translucent preview fill + outline so the user sees where the + * ceiling lands before clicking. Polygon + holes are pulled from + * `useScene` so the wrapper mirrors the action's per-tick writes. + */ +export const CeilingMoveTool: React.FC<{ node: CeilingNode }> = ({ node }) => { + const ceilingId = node.id + const height = node.height ?? 2.5 + + const live = useScene((s) => s.nodes[ceilingId]) + const liveCeiling = live?.type === 'ceiling' ? (live as CeilingNode) : node + const polygon = liveCeiling.polygon + const holes = liveCeiling.holes ?? [] + + const center: [number, number] = useMemo(() => { + if (polygon.length === 0) return [0, 0] + let sx = 0 + let sz = 0 + for (const [x, z] of polygon) { + sx += x + sz += z + } + return [sx / polygon.length, sz / polygon.length] + }, [polygon]) + + const previewFillGeometry = useMemo(() => createPreviewFill(polygon, holes), [polygon, holes]) + const previewOutlineGeometry = useMemo(() => createOutline(polygon), [polygon]) + + const exitMoveMode = (committed: boolean) => { + if (committed) triggerSFX('sfx:item-place') + useViewer.getState().setSelection({ selectedIds: [ceilingId] }) + useEditor.getState().setMovingNode(null) + } + + useDragAction({ + active: true, + action: moveCeilingDragAction, + initial: { + node, + point: center, + }, + onCommit: () => exitMoveMode(true), + onCancel: () => exitMoveMode(false), + }) + + return ( + + + + + {/* @ts-ignore */} + + + + + + ) +} + +function createPreviewFill( + polygon: Array<[number, number]>, + holes: Array>, +): BufferGeometry { + if (polygon.length < 3) return new BufferGeometry() + const shape = new Shape() + const [firstX, firstZ] = polygon[0]! + shape.moveTo(firstX, -firstZ) + for (let i = 1; i < polygon.length; i++) { + const [x, z] = polygon[i]! + shape.lineTo(x, -z) + } + shape.closePath() + for (const holePolygon of holes) { + if (holePolygon.length < 3) continue + const hole = new Path() + const [hx, hz] = holePolygon[0]! + hole.moveTo(hx, -hz) + for (let i = 1; i < holePolygon.length; i++) { + const [x, z] = holePolygon[i]! + hole.lineTo(x, -z) + } + hole.closePath() + shape.holes.push(hole) + } + const geometry = new ShapeGeometry(shape) + geometry.rotateX(-Math.PI / 2) + geometry.computeVertexNormals() + return geometry +} + +function createOutline(polygon: Array<[number, number]>): BufferGeometry { + const geometry = new BufferGeometry() + if (polygon.length < 2) return geometry + const points = polygon.map(([x, z]) => new Vector3(x, 0, z)) + const [firstX, firstZ] = polygon[0]! + points.push(new Vector3(firstX, 0, firstZ)) + geometry.setFromPoints(points) + return geometry +} + +export default CeilingMoveTool diff --git a/packages/nodes/src/ceiling/tool.tsx b/packages/nodes/src/ceiling/tool.tsx new file mode 100644 index 00000000..84226f1f --- /dev/null +++ b/packages/nodes/src/ceiling/tool.tsx @@ -0,0 +1,388 @@ +'use client' + +import { emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core' +import { CursorSphere, EDITOR_LAYER, markToolCancelConsumed, triggerSFX } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useMemo, useRef, useState } from 'react' +import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three' +import { mix, positionLocal } from 'three/tsl' +import { CeilingNode } from './schema' + +/** + * Phase 5 Stage D — ceiling placement tool (kind-owned via `def.tool`). + * + * Multi-click polygon drawing at the ceiling height (2.52m default) + * with a vertical TSL-gradient connector + ground-shadow lines so the + * draft is visible against both the ceiling plane and the floor. + * Shift defeats the axis/45° snap during drag. + */ + +const CEILING_HEIGHT = 2.52 +const GRID_OFFSET = 0.02 + +function calculateSnapPoint( + lastPoint: [number, number], + currentPoint: [number, number], +): [number, number] { + const [x1, y1] = lastPoint + const [x, y] = currentPoint + const dx = x - x1 + const dy = y - y1 + const absDx = Math.abs(dx) + const absDy = Math.abs(dy) + const horizontalDist = absDy + const verticalDist = absDx + const diagonalDist = Math.abs(absDx - absDy) + const minDist = Math.min(horizontalDist, verticalDist, diagonalDist) + if (minDist === diagonalDist) { + const diagonalLength = Math.min(absDx, absDy) + return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength] + } + if (minDist === horizontalDist) return [x, y1] + return [x1, y] +} + +function commitCeilingDrawing(levelId: LevelNode['id'], points: Array<[number, number]>): string { + const { createNode, nodes } = useScene.getState() + const ceilingCount = Object.values(nodes).filter((n) => n.type === 'ceiling').length + const name = `Ceiling ${ceilingCount + 1}` + const ceiling = CeilingNode.parse({ name, polygon: points }) + createNode(ceiling, levelId) + triggerSFX('sfx:structure-build') + return ceiling.id +} + +export const CeilingTool: React.FC = () => { + const cursorRef = useRef(null) + const gridCursorRef = useRef(null) + const mainLineRef = useRef(null!) + const closingLineRef = useRef(null!) + const groundMainLineRef = useRef(null!) + const groundClosingLineRef = useRef(null!) + const verticalLineRef = useRef(null!) + const currentLevelId = useViewer((s) => s.selection.levelId) + const setSelection = useViewer((s) => s.setSelection) + + const [points, setPoints] = useState>([]) + const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0]) + const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0]) + const [levelY, setLevelY] = useState(0) + const previousSnappedPointRef = useRef<[number, number] | null>(null) + const shiftPressed = useRef(false) + + const verticalGeo = useMemo( + () => + new BufferGeometry().setFromPoints([ + new Vector3(0, 0, 0), + new Vector3(0, CEILING_HEIGHT - GRID_OFFSET, 0), + ]), + [], + ) + + const gradientOpacityNode = useMemo( + () => mix(0.6, 0.0, positionLocal.y.div(CEILING_HEIGHT - GRID_OFFSET).clamp()), + [], + ) + + useEffect(() => { + if (!currentLevelId) return + + const onGridMove = (event: GridEvent) => { + if (!(cursorRef.current && gridCursorRef.current)) return + const gridX = Math.round(event.localPosition[0] * 2) / 2 + const gridZ = Math.round(event.localPosition[2] * 2) / 2 + const gridPosition: [number, number] = [gridX, gridZ] + setCursorPosition(gridPosition) + setLevelY(event.localPosition[1]) + const ceilingY = event.localPosition[1] + CEILING_HEIGHT + const gridY = event.localPosition[1] + GRID_OFFSET + const lastPoint = points[points.length - 1] + const displayPoint = + shiftPressed.current || !lastPoint + ? gridPosition + : calculateSnapPoint(lastPoint, gridPosition) + setSnappedCursorPosition(displayPoint) + if ( + points.length > 0 && + previousSnappedPointRef.current && + (displayPoint[0] !== previousSnappedPointRef.current[0] || + displayPoint[1] !== previousSnappedPointRef.current[1]) + ) { + triggerSFX('sfx:grid-snap') + } + previousSnappedPointRef.current = displayPoint + cursorRef.current.position.set(displayPoint[0], ceilingY, displayPoint[1]) + gridCursorRef.current.position.set(displayPoint[0], gridY, displayPoint[1]) + if (verticalLineRef.current) { + verticalLineRef.current.position.set(displayPoint[0], gridY, displayPoint[1]) + } + } + + const onGridClick = (_event: GridEvent) => { + if (!currentLevelId) return + const clickPoint = previousSnappedPointRef.current ?? cursorPosition + const firstPoint = points[0] + if ( + points.length >= 3 && + firstPoint && + Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 && + Math.abs(clickPoint[1] - firstPoint[1]) < 0.25 + ) { + const ceilingId = commitCeilingDrawing(currentLevelId, points) + setSelection({ selectedIds: [ceilingId] }) + setPoints([]) + } else { + setPoints([...points, clickPoint]) + } + } + + const onGridDoubleClick = (_event: GridEvent) => { + if (!currentLevelId) return + if (points.length >= 3) { + const ceilingId = commitCeilingDrawing(currentLevelId, points) + setSelection({ selectedIds: [ceilingId] }) + setPoints([]) + } + } + + const onCancel = () => { + if (points.length > 0) markToolCancelConsumed() + setPoints([]) + } + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Shift') shiftPressed.current = true + } + const onKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Shift') shiftPressed.current = false + } + document.addEventListener('keydown', onKeyDown) + document.addEventListener('keyup', onKeyUp) + + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) + emitter.on('grid:double-click', onGridDoubleClick) + emitter.on('tool:cancel', onCancel) + + return () => { + document.removeEventListener('keydown', onKeyDown) + document.removeEventListener('keyup', onKeyUp) + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + emitter.off('grid:double-click', onGridDoubleClick) + emitter.off('tool:cancel', onCancel) + } + }, [currentLevelId, points, cursorPosition, setSelection]) + + useEffect(() => { + if (!(mainLineRef.current && closingLineRef.current)) return + if (points.length === 0) { + mainLineRef.current.visible = false + closingLineRef.current.visible = false + groundMainLineRef.current && (groundMainLineRef.current.visible = false) + groundClosingLineRef.current && (groundClosingLineRef.current.visible = false) + return + } + const ceilingY = levelY + CEILING_HEIGHT + const snappedCursor = snappedCursorPosition + const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, ceilingY, z)) + linePoints.push(new Vector3(snappedCursor[0], ceilingY, snappedCursor[1])) + const gridY = levelY + GRID_OFFSET + const groundLinePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, gridY, z)) + groundLinePoints.push(new Vector3(snappedCursor[0], gridY, snappedCursor[1])) + if (linePoints.length >= 2) { + mainLineRef.current.geometry.dispose() + mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints) + mainLineRef.current.visible = true + groundMainLineRef.current.geometry.dispose() + groundMainLineRef.current.geometry = new BufferGeometry().setFromPoints(groundLinePoints) + groundMainLineRef.current.visible = true + } else { + mainLineRef.current.visible = false + groundMainLineRef.current.visible = false + } + const firstPoint = points[0] + if (points.length >= 2 && firstPoint) { + const closingPoints = [ + new Vector3(snappedCursor[0], ceilingY, snappedCursor[1]), + new Vector3(firstPoint[0], ceilingY, firstPoint[1]), + ] + closingLineRef.current.geometry.dispose() + closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints) + closingLineRef.current.visible = true + const groundClosingPoints = [ + new Vector3(snappedCursor[0], gridY, snappedCursor[1]), + new Vector3(firstPoint[0], gridY, firstPoint[1]), + ] + groundClosingLineRef.current.geometry.dispose() + groundClosingLineRef.current.geometry = new BufferGeometry().setFromPoints( + groundClosingPoints, + ) + groundClosingLineRef.current.visible = true + } else { + closingLineRef.current.visible = false + groundClosingLineRef.current.visible = false + } + }, [points, snappedCursorPosition, levelY]) + + const previewShape = useMemo(() => { + if (points.length < 3) return null + const snappedCursor = snappedCursorPosition + const allPoints = [...points, snappedCursor] + const firstPt = allPoints[0] + if (!firstPt) return null + const shape = new Shape() + shape.moveTo(firstPt[0], -firstPt[1]) + for (let i = 1; i < allPoints.length; i++) { + const pt = allPoints[i] + if (pt) shape.lineTo(pt[0], -pt[1]) + } + shape.closePath() + return shape + }, [points, snappedCursorPosition]) + + return ( + + + + + + + {/* @ts-ignore */} + + + + {previewShape && ( + + + + + )} + {previewShape && ( + + + + + )} + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {points.map(([x, z], index) => ( + + ))} + + ) +} + +export default CeilingTool