From 0514363136b0797a431ed56fd953eef13bb23c1f Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 8 May 2026 14:31:58 +0530 Subject: [PATCH] Add column hit testing and align column defaults --- packages/core/src/schema/nodes/column.ts | 32 +-- .../src/components/editor/floorplan-panel.tsx | 29 ++ .../editor/use-floorplan-hit-testing.ts | 12 + .../components/tools/column/column-tool.tsx | 5 +- .../components/tools/fence/fence-drafting.ts | 59 ++-- .../tools/fence/move-fence-endpoint-tool.tsx | 60 ++-- .../tools/select/box-select-tool.tsx | 15 + .../components/ui/floating-level-selector.tsx | 42 ++- packages/editor/src/hooks/use-keyboard.ts | 15 + .../src/lib/floorplan/selection-tool.ts | 20 ++ packages/editor/src/lib/scene-clipboard.ts | 267 ++++++++++++++++++ .../renderers/column/column-renderer.tsx | 44 ++- .../viewer/src/systems/slab/slab-system.tsx | 15 +- 13 files changed, 532 insertions(+), 83 deletions(-) create mode 100644 packages/editor/src/lib/scene-clipboard.ts diff --git a/packages/core/src/schema/nodes/column.ts b/packages/core/src/schema/nodes/column.ts index 0e19a8d6..78a52126 100644 --- a/packages/core/src/schema/nodes/column.ts +++ b/packages/core/src/schema/nodes/column.ts @@ -88,7 +88,7 @@ export const ColumnNode = BaseNode.extend({ rotation: z.number().default(0), style: ColumnStyle.default('plain'), crossSection: ColumnCrossSection.default('round'), - height: z.number().positive().default(2.8), + height: z.number().positive().default(2.5), radius: z.number().positive().default(0.22), width: z.number().positive().default(0.44), depth: z.number().positive().default(0.44), @@ -179,7 +179,7 @@ export const COLUMN_PRESETS = { label: 'Straight Round', style: 'plain', crossSection: 'round', - height: 2.9, + height: 2.5, radius: 0.22, width: 0.44, depth: 0.44, @@ -229,7 +229,7 @@ export const COLUMN_PRESETS = { label: 'Square Block', style: 'faceted', crossSection: 'square', - height: 2.9, + height: 2.5, radius: 0.24, width: 0.48, depth: 0.48, @@ -279,7 +279,7 @@ export const COLUMN_PRESETS = { label: 'Tapered Round', style: 'plain', crossSection: 'round', - height: 3, + height: 2.5, radius: 0.23, width: 0.46, depth: 0.46, @@ -329,7 +329,7 @@ export const COLUMN_PRESETS = { label: 'Soft Bulged', style: 'plain', crossSection: 'round', - height: 2.9, + height: 2.5, radius: 0.22, width: 0.44, depth: 0.44, @@ -379,7 +379,7 @@ export const COLUMN_PRESETS = { label: 'Hourglass', style: 'plain', crossSection: 'round', - height: 2.9, + height: 2.5, radius: 0.22, width: 0.44, depth: 0.44, @@ -430,7 +430,7 @@ export const COLUMN_PRESETS = { supportStyle: 'a-frame', style: 'faceted', crossSection: 'rectangular', - height: 2.6, + height: 2.5, radius: 0.08, width: 0.16, depth: 0.16, @@ -486,7 +486,7 @@ export const COLUMN_PRESETS = { supportStyle: 'y-frame', style: 'faceted', crossSection: 'rectangular', - height: 2.7, + height: 2.5, radius: 0.08, width: 0.16, depth: 0.16, @@ -542,7 +542,7 @@ export const COLUMN_PRESETS = { supportStyle: 'v-frame', style: 'faceted', crossSection: 'rectangular', - height: 2.6, + height: 2.5, radius: 0.08, width: 0.16, depth: 0.16, @@ -598,7 +598,7 @@ export const COLUMN_PRESETS = { supportStyle: 'x-brace', style: 'faceted', crossSection: 'rectangular', - height: 2.6, + height: 2.5, radius: 0.07, width: 0.14, depth: 0.14, @@ -654,7 +654,7 @@ export const COLUMN_PRESETS = { supportStyle: 'k-brace', style: 'faceted', crossSection: 'rectangular', - height: 2.6, + height: 2.5, radius: 0.07, width: 0.14, depth: 0.14, @@ -710,7 +710,7 @@ export const COLUMN_PRESETS = { supportStyle: 'single-strut', style: 'faceted', crossSection: 'rectangular', - height: 2.6, + height: 2.5, radius: 0.07, width: 0.14, depth: 0.14, @@ -766,7 +766,7 @@ export const COLUMN_PRESETS = { supportStyle: 'tripod', style: 'faceted', crossSection: 'rectangular', - height: 2.6, + height: 2.5, radius: 0.07, width: 0.14, depth: 0.14, @@ -822,7 +822,7 @@ export const COLUMN_PRESETS = { supportStyle: 'trestle', style: 'faceted', crossSection: 'rectangular', - height: 2.6, + height: 2.5, radius: 0.07, width: 0.14, depth: 0.14, @@ -878,7 +878,7 @@ export const COLUMN_PRESETS = { supportStyle: 'portal-frame', style: 'faceted', crossSection: 'rectangular', - height: 2.6, + height: 2.5, radius: 0.08, width: 0.16, depth: 0.16, @@ -934,7 +934,7 @@ export const COLUMN_PRESETS = { supportStyle: 'box-frame', style: 'faceted', crossSection: 'rectangular', - height: 2.6, + height: 2.5, radius: 0.07, width: 0.14, depth: 0.14, diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index b74e4515..aa44ad6a 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -625,6 +625,12 @@ type FloorplanSpawnEntry = { rotation: number } +type FloorplanColumnEntry = { + column: ColumnNode + points: string + polygon: Point2D[] +} + type ReferenceFloorData = { ceilingPolygons: CeilingPolygonEntry[] columnEntries: ReferenceFloorColumnEntry[] @@ -8189,6 +8195,28 @@ export function FloorplanPanel() { : entry, ) }, [zoneBoundaryDraft, zonePolygons]) + const floorplanColumnEntries = useMemo( + () => + levelDescendantNodes.flatMap((node) => { + if (!(node.type === 'column' && node.visible !== false)) { + return [] + } + + const polygon = getColumnPlanFootprint(node) + if (polygon.length < 3) { + return [] + } + + return [ + { + column: node, + points: formatPolygonPoints(polygon), + polygon, + }, + ] + }), + [levelDescendantNodes], + ) const levelDescendantNodeById = useMemo( () => new Map(levelDescendantNodes.map((node) => [node.id, node] as const)), [levelDescendantNodes], @@ -13016,6 +13044,7 @@ export function FloorplanPanel() { ) const { getFloorplanHitIdAtPoint, getFloorplanSelectionIdsInBounds } = useFloorplanHitTesting({ ceilingPolygons: displayCeilingPolygons, + columnPolygons: floorplanColumnEntries, displaySlabPolygons, displayWallPolygons, floorplanItemEntries, diff --git a/packages/editor/src/components/editor/use-floorplan-hit-testing.ts b/packages/editor/src/components/editor/use-floorplan-hit-testing.ts index 39722305..09bc830c 100644 --- a/packages/editor/src/components/editor/use-floorplan-hit-testing.ts +++ b/packages/editor/src/components/editor/use-floorplan-hit-testing.ts @@ -3,6 +3,7 @@ import type { AnyNode, CeilingNode, + ColumnNode, DoorNode, ItemNode, Point2D, @@ -46,6 +47,11 @@ type CeilingPolygonEntry = { holes: Point2D[][] } +type ColumnPolygonEntry = { + column: ColumnNode + polygon: Point2D[] +} + type FloorplanRoofEntry = { roof: RoofNode segments: Array<{ @@ -72,6 +78,7 @@ type FloorplanStairEntry = { type UseFloorplanHitTestingArgs = { ceilingPolygons: CeilingPolygonEntry[] + columnPolygons: ColumnPolygonEntry[] displaySlabPolygons: SlabPolygonEntry[] displayWallPolygons: WallPolygonEntry[] floorplanItemEntries: FloorplanItemEntry[] @@ -88,6 +95,7 @@ type UseFloorplanHitTestingArgs = { export function useFloorplanHitTesting({ ceilingPolygons, + columnPolygons, displaySlabPolygons, displayWallPolygons, floorplanItemEntries, @@ -117,11 +125,13 @@ export function useFloorplanHitTesting({ slabs: displaySlabPolygons, openingHitTolerance: floorplanOpeningHitTolerance, wallHitTolerance: floorplanWallHitTolerance, + columns: columnPolygons, getOpeningCenterLine, }) }, [ ceilingPolygons, + columnPolygons, displaySlabPolygons, displayWallPolygons, floorplanItemEntries, @@ -149,10 +159,12 @@ export function useFloorplanHitTesting({ openings: openingsPolygons, roofs: floorplanRoofEntries, slabs: displaySlabPolygons, + columns: columnPolygons, stairs: floorplanStairEntries, }), [ ceilingPolygons, + columnPolygons, displaySlabPolygons, displayWallPolygons, floorplanItemEntries, diff --git a/packages/editor/src/components/tools/column/column-tool.tsx b/packages/editor/src/components/tools/column/column-tool.tsx index e9593d1a..837b1729 100644 --- a/packages/editor/src/components/tools/column/column-tool.tsx +++ b/packages/editor/src/components/tools/column/column-tool.tsx @@ -13,7 +13,6 @@ import { import { useEffect, useRef, useState } from 'react' import type { Group } from 'three' import { sfxEmitter } from '../../../lib/sfx-bus' -import useEditor from '../../../store/use-editor' import { CursorSphere } from '../shared/cursor-sphere' const COLUMN_ICON = ( @@ -70,8 +69,6 @@ export const ColumnTool: React.FC = ({ currentLevelId, onPlaced useScene.getState().createNode(column, currentLevelId) onPlaced?.(column.id) sfxEmitter.emit('sfx:structure-build') - useEditor.getState().setTool(null) - useEditor.getState().setMode('select') } emitter.on('grid:move', onGridMove) @@ -88,7 +85,7 @@ export const ColumnTool: React.FC = ({ currentLevelId, onPlaced return ( = [fence.start, fence.end] - if (isCurvedWall(fence)) { - const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(fence) / 0.3)) - for (let index = 0; index <= sampleCount; index += 1) { - const frame = getWallCurveFrameAt(fence, index / sampleCount) - candidates.push([frame.point.x, frame.point.y]) + for (const candidate of [fence.start, fence.end]) { + const candidateDistanceSquared = distanceSquared(point, candidate) + if ( + candidateDistanceSquared > cornerRadiusSquared || + candidateDistanceSquared >= bestCornerDistanceSquared + ) { + continue } - } else { - candidates.push(projectPointOntoSegment(point, fence)) + + bestCornerTarget = candidate + bestCornerDistanceSquared = candidateDistanceSquared } - for (const candidate of candidates) { + if (isCurvedWall(fence)) { + const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(fence) / 0.3)) + for (let index = 1; index < sampleCount; index += 1) { + const frame = getWallCurveFrameAt(fence, index / sampleCount) + const candidate: FencePlanPoint = [frame.point.x, frame.point.y] + const candidateDistanceSquared = distanceSquared(point, candidate) + if ( + candidateDistanceSquared > spanRadiusSquared || + candidateDistanceSquared >= bestSpanDistanceSquared + ) { + continue + } + + bestSpanTarget = candidate + bestSpanDistanceSquared = candidateDistanceSquared + } + } else { + const candidate = projectPointOntoSegment(point, fence) if (!candidate) { continue } const candidateDistanceSquared = distanceSquared(point, candidate) if ( - candidateDistanceSquared > radiusSquared || - candidateDistanceSquared >= bestDistanceSquared + candidateDistanceSquared > spanRadiusSquared || + candidateDistanceSquared >= bestSpanDistanceSquared ) { continue } - bestTarget = candidate - bestDistanceSquared = candidateDistanceSquared + bestSpanTarget = candidate + bestSpanDistanceSquared = candidateDistanceSquared } } - return bestTarget + return bestCornerTarget ?? bestSpanTarget } export function snapFenceDraftPoint(args: { diff --git a/packages/editor/src/components/tools/fence/move-fence-endpoint-tool.tsx b/packages/editor/src/components/tools/fence/move-fence-endpoint-tool.tsx index 8ebb774e..5bbadedd 100644 --- a/packages/editor/src/components/tools/fence/move-fence-endpoint-tool.tsx +++ b/packages/editor/src/components/tools/fence/move-fence-endpoint-tool.tsx @@ -25,8 +25,13 @@ import { import { isWallLongEnough } from '../wall/wall-drafting' import { type FencePlanPoint, snapFenceDraftPoint } from './fence-drafting' +const LINKED_FENCE_ENDPOINT_EPSILON = 0.025 + function samePoint(a: FencePlanPoint, b: FencePlanPoint) { - return a[0] === b[0] && a[1] === b[1] + return ( + Math.abs(a[0] - b[0]) <= LINKED_FENCE_ENDPOINT_EPSILON && + Math.abs(a[1] - b[1]) <= LINKED_FENCE_ENDPOINT_EPSILON + ) } type SegmentLike = { @@ -114,10 +119,9 @@ type LinkedFenceSnapshot = { function getLinkedFenceSnapshots(args: { fenceId: FenceNode['id'] fenceParentId: string | null - originalStart: FencePlanPoint - originalEnd: FencePlanPoint + linkedPoint: FencePlanPoint }) { - const { fenceId, fenceParentId, originalStart, originalEnd } = args + const { fenceId, fenceParentId, linkedPoint } = args const { nodes } = useScene.getState() const snapshots: LinkedFenceSnapshot[] = [] @@ -130,12 +134,7 @@ function getLinkedFenceSnapshots(args: { continue } - if ( - !samePoint(node.start, originalStart) && - !samePoint(node.start, originalEnd) && - !samePoint(node.end, originalStart) && - !samePoint(node.end, originalEnd) - ) { + if (!samePoint(node.start, linkedPoint) && !samePoint(node.end, linkedPoint)) { continue } @@ -152,24 +151,14 @@ function getLinkedFenceSnapshots(args: { function getLinkedFenceUpdates( linkedFences: LinkedFenceSnapshot[], - originalStart: FencePlanPoint, - originalEnd: FencePlanPoint, - nextStart: FencePlanPoint, - nextEnd: FencePlanPoint, + linkedPoint: FencePlanPoint, + nextLinkedPoint: FencePlanPoint, ) { return linkedFences.map((fence) => ({ id: fence.id, curveOffset: fence.curveOffset, - start: samePoint(fence.start, originalStart) - ? nextStart - : samePoint(fence.start, originalEnd) - ? nextEnd - : fence.start, - end: samePoint(fence.end, originalStart) - ? nextStart - : samePoint(fence.end, originalEnd) - ? nextEnd - : fence.end, + start: samePoint(fence.start, linkedPoint) ? nextLinkedPoint : fence.start, + end: samePoint(fence.end, linkedPoint) ? nextLinkedPoint : fence.end, })) } @@ -181,6 +170,11 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = const nodeIdRef = useRef(target.fence.id) const originalStartRef = useRef([...target.fence.start] as FencePlanPoint) const originalEndRef = useRef([...target.fence.end] as FencePlanPoint) + const originalMovingPointRef = useRef( + target.endpoint === 'start' + ? ([...target.fence.start] as FencePlanPoint) + : ([...target.fence.end] as FencePlanPoint), + ) const fixedPointRef = useRef( target.endpoint === 'start' ? ([...target.fence.end] as FencePlanPoint) @@ -190,8 +184,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = getLinkedFenceSnapshots({ fenceId: target.fence.id, fenceParentId: target.fence.parentId ?? null, - originalStart: target.fence.start, - originalEnd: target.fence.end, + linkedPoint: target.endpoint === 'start' ? target.fence.start : target.fence.end, }), ) const previewRef = useRef<{ start: FencePlanPoint; end: FencePlanPoint } | null>(null) @@ -211,6 +204,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = const nodeId = nodeIdRef.current const originalStart = originalStartRef.current const originalEnd = originalEndRef.current + const originalMovingPoint = originalMovingPointRef.current const fixedPoint = fixedPointRef.current const siblings = Object.values(useScene.getState().nodes) const levelWalls = siblings.filter( @@ -244,13 +238,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint const linkedUpdates = detachLinkedFences ? [] - : getLinkedFenceUpdates( - linkedOriginalsRef.current, - originalStart, - originalEnd, - nextStart, - nextEnd, - ) + : getLinkedFenceUpdates(linkedOriginalsRef.current, originalMovingPoint, movingPoint) previewRef.current = { start: nextStart, end: nextEnd } setCursorLocalPos([movingPoint[0], 0, movingPoint[1]]) setAngleLabel( @@ -321,10 +309,8 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = ? [] : getLinkedFenceUpdates( linkedOriginalsRef.current, - originalStart, - originalEnd, - preview.start, - preview.end, + originalMovingPoint, + target.endpoint === 'start' ? preview.start : preview.end, )), ]) pauseSceneHistory(useScene) diff --git a/packages/editor/src/components/tools/select/box-select-tool.tsx b/packages/editor/src/components/tools/select/box-select-tool.tsx index 8dd700a6..6a86db7d 100644 --- a/packages/editor/src/components/tools/select/box-select-tool.tsx +++ b/packages/editor/src/components/tools/select/box-select-tool.tsx @@ -1,7 +1,10 @@ +import '../../../three-types' + import { Icon } from '@iconify/react' import { type AnyNodeId, type CeilingNode, + type ColumnNode, emitter, type GridEvent, type ItemNode, @@ -14,6 +17,7 @@ import { } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useThree } from '@react-three/fiber' +import type { ThreeElements } from '@react-three/fiber' import { useEffect, useRef } from 'react' import { Box3, @@ -34,6 +38,12 @@ import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { CursorSphere } from '../shared/cursor-sphere' +declare module 'react/jsx-runtime' { + namespace JSX { + interface IntrinsicElements extends ThreeElements {} + } +} + /** * Module-level flag to prevent the SelectionManager from deselecting * on the grid:click that fires right after a box-select drag completes. @@ -240,6 +250,11 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] { if (objectBoundsIntersectsBounds(node.id, bounds)) { result.push(node.id) } + } else if (node.type === 'column') { + const column = node as ColumnNode + if (objectBoundsIntersectsBounds(column.id, bounds)) { + result.push(column.id) + } } } } else if (phase === 'structure' && structureLayer === 'zones') { diff --git a/packages/editor/src/components/ui/floating-level-selector.tsx b/packages/editor/src/components/ui/floating-level-selector.tsx index c348a303..f251a001 100755 --- a/packages/editor/src/components/ui/floating-level-selector.tsx +++ b/packages/editor/src/components/ui/floating-level-selector.tsx @@ -26,7 +26,7 @@ import { useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { Copy, GripVertical, MoreVertical, Plus, Trash2 } from 'lucide-react' +import { ClipboardPaste, Copy, GripVertical, MoreVertical, Plus, Trash2 } from 'lucide-react' import { type ButtonHTMLAttributes, type CSSProperties, @@ -34,6 +34,7 @@ import { useEffect, useRef, useState, + useSyncExternalStore, } from 'react' import { useShallow } from 'zustand/react/shallow' import { @@ -41,6 +42,12 @@ import { type LevelDuplicatePreset, } from '../../lib/level-duplication' import { deleteLevelWithFallbackSelection } from '../../lib/level-selection' +import { + getEditorClipboardSnapshot, + pasteEditorClipboardToLevel, + subscribeEditorClipboard, +} from '../../lib/scene-clipboard' +import { sfxEmitter } from '../../lib/sfx-bus' import { cn } from '../../lib/utils' import { LevelDuplicateDialog } from './level-duplicate-dialog' import { @@ -126,6 +133,7 @@ function LevelRow({ dragHandleRef, onSelect, onDuplicate, + onPaste, onRequestDelete, }: { level: LevelNode @@ -135,6 +143,7 @@ function LevelRow({ dragHandleRef?: (element: HTMLButtonElement | null) => void onSelect: () => void onDuplicate: (preset?: LevelDuplicatePreset) => void + onPaste?: () => void onRequestDelete: () => void }) { const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false) @@ -223,6 +232,19 @@ function LevelRow({ Duplicate with options... + {onPaste && ( + + )}