From 70933adf9d2927741b91d5d15354b91466f1a636 Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 6 May 2026 13:54:05 +0530 Subject: [PATCH 1/5] Fix floorplan player move and action menu anchoring --- .../editor-2d/floorplan-action-menu-layer.tsx | 3 + .../src/components/editor-2d/svg-paths.ts | 8 +- .../src/components/editor/floorplan-panel.tsx | 467 +++++++++++++++++- .../editor/use-floorplan-scene-data.ts | 3 + .../tools/shared/polygon-editor.tsx | 169 ++++++- .../tools/slab/slab-boundary-editor.tsx | 1 + .../tools/slab/slab-hole-editor.tsx | 1 + 7 files changed, 600 insertions(+), 52 deletions(-) diff --git a/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx index d08f3e848..fefaf949a 100644 --- a/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx @@ -26,6 +26,7 @@ type FloorplanActionMenuLayerProps = { slab: FloorplanActionMenuEntry ceiling: FloorplanActionMenuEntry opening: FloorplanActionMenuEntry + spawn: FloorplanActionMenuEntry stair: FloorplanActionMenuEntry roof: FloorplanActionMenuEntry offsetY?: number @@ -38,6 +39,7 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({ slab, ceiling, opening, + spawn, stair, roof, offsetY = 10, @@ -59,6 +61,7 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({ slab, ceiling, opening, + spawn, stair, roof, ] diff --git a/packages/editor/src/components/editor-2d/svg-paths.ts b/packages/editor/src/components/editor-2d/svg-paths.ts index e9e30a179..8a3fc3d02 100644 --- a/packages/editor/src/components/editor-2d/svg-paths.ts +++ b/packages/editor/src/components/editor-2d/svg-paths.ts @@ -3,11 +3,11 @@ import type { Point2D } from '@pascal-app/core' function toSvgX(value: number) { - return -value + return value } function toSvgY(value: number) { - return -value + return value } function toSvgPoint(point: Point2D) { @@ -100,9 +100,7 @@ export function buildSvgAnnularSectorPath( } export function formatSvgPolygonPoints(points: Point2D[]) { - return points - .map((point) => `${toSvgX(point.x)},${toSvgY(point.y)}`) - .join(' ') + return points.map((point) => `${toSvgX(point.x)},${toSvgY(point.y)}`).join(' ') } export function buildSvgArrowHeadPoints(point: Point2D, angle: number, size: number) { diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index f51880129..3ac3362ba 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -29,6 +29,7 @@ import { type RoofSegmentNode, type SiteNode, SlabNode, + type SpawnNode, type StairNode, StairNode as StairNodeSchema, type StairSegmentNode, @@ -215,6 +216,13 @@ const FLOORPLAN_TRACE_STRUCTURE_SELECTED_FILL_OPACITY = 0.34 const FLOORPLAN_SITE_COLOR = '#10b981' const FLOORPLAN_NODE_FOOTPRINT_STROKE_WIDTH = FLOORPLAN_OPENING_STROKE_WIDTH / 2 const FLOORPLAN_NODE_FOOTPRINT_CROSS_STROKE_WIDTH = FLOORPLAN_NODE_FOOTPRINT_STROKE_WIDTH * 0.7 +const FLOORPLAN_SPAWN_RING_RADIUS = 0.34 +const FLOORPLAN_SPAWN_RING_STROKE_WIDTH = 0.08 +const FLOORPLAN_SPAWN_HIT_RADIUS = 0.62 +const FLOORPLAN_SPAWN_ARROW_POINTS = '0,-0.62 -0.19,-0.2 0.19,-0.2' +const FLOORPLAN_SPAWN_BODY_WIDTH = 0.3 +const FLOORPLAN_SPAWN_BODY_HEIGHT = 0.46 +const FLOORPLAN_VIEW_ROTATION_DEG = 90 type FloorplanViewport = { centerX: number centerY: number @@ -587,6 +595,12 @@ type FloorplanItemEntry = { depth: number } +type FloorplanSpawnEntry = { + spawn: SpawnNode + position: Point2D + rotation: number +} + type ReferenceFloorData = { ceilingPolygons: CeilingPolygonEntry[] fenceEntries: FloorplanFenceEntry[] @@ -772,11 +786,11 @@ function toWallPlanPoint(point: Point2D): WallPlanPoint { } function toSvgX(value: number): number { - return -value + return value } function toSvgY(value: number): number { - return -value + return value } function toSvgPoint(point: Point2D): SvgPoint { @@ -915,11 +929,11 @@ function getGuideRotateCursor(isDarkMode: boolean) { } function getGuideSvgRotation(rotationY: number) { - return normalizeAngle(Math.PI - rotationY) + return normalizeAngle(-rotationY) } function getGuideSceneRotationFromSvgRotation(rotationSvg: number) { - return normalizeAngle(Math.PI - rotationSvg) + return normalizeAngle(-rotationSvg) } function buildGuideTranslateDraft( @@ -2938,6 +2952,37 @@ function buildGridPath( return commands.join(' ') } +function getRotatedViewBoxBounds( + viewBox: { minX: number; minY: number; width: number; height: number }, + rotationDegrees: number, +) { + const radians = (-rotationDegrees * Math.PI) / 180 + const cos = Math.cos(radians) + const sin = Math.sin(radians) + const corners = [ + { x: viewBox.minX, y: viewBox.minY }, + { x: viewBox.minX + viewBox.width, y: viewBox.minY }, + { x: viewBox.minX + viewBox.width, y: viewBox.minY + viewBox.height }, + { x: viewBox.minX, y: viewBox.minY + viewBox.height }, + ] + + let minX = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + + for (const corner of corners) { + const x = corner.x * cos - corner.y * sin + const y = corner.x * sin + corner.y * cos + minX = Math.min(minX, x) + maxX = Math.max(maxX, x) + minY = Math.min(minY, y) + maxY = Math.max(maxY, y) + } + + return { minX, maxX, minY, maxY } +} + function findClosestWallPoint( point: WallPlanPoint, walls: WallNode[], @@ -3264,20 +3309,20 @@ const FloorplanGridLayer = memo(function FloorplanGridLayer({ @@ -5193,11 +5238,14 @@ function FloorplanItemImage({ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({ canFocusItems, + canFocusSpawns, canFocusStairs, canSelectItems, + canSelectSpawns, canSelectStairs, highlightedIdSet, hoveredItemId, + hoveredSpawnId, hoveredStairId, isDeleteMode, isFurnishContextActive, @@ -5207,6 +5255,11 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({ onItemHoverEnter, onItemPointerDown, onItemSelect, + onSpawnDoubleClick, + onSpawnHoverChange, + onSpawnHoverEnter, + onSpawnPointerDown, + onSpawnSelect, onStairDoubleClick, onStairHoverChange, onStairHoverEnter, @@ -5214,16 +5267,20 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({ onStairSelect, palette, selectedIdSet, + spawnEntries, stairEntries, unit, wallSelectionHatchId, }: { canFocusItems: boolean + canFocusSpawns: boolean canFocusStairs: boolean canSelectItems: boolean + canSelectSpawns: boolean canSelectStairs: boolean highlightedIdSet: ReadonlySet hoveredItemId: ItemNode['id'] | null + hoveredSpawnId: SpawnNode['id'] | null hoveredStairId: StairNode['id'] | null isDeleteMode: boolean isFurnishContextActive: boolean @@ -5233,6 +5290,11 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({ onItemHoverEnter: (itemId: ItemNode['id']) => void onItemPointerDown: (itemId: ItemNode['id'], event: ReactPointerEvent) => void onItemSelect: (itemId: ItemNode['id'], event: ReactMouseEvent) => void + onSpawnDoubleClick: (spawn: SpawnNode, event: ReactMouseEvent) => void + onSpawnHoverChange: (spawnId: SpawnNode['id'] | null) => void + onSpawnHoverEnter: (spawnId: SpawnNode['id']) => void + onSpawnPointerDown: (spawnId: SpawnNode['id'], event: ReactPointerEvent) => void + onSpawnSelect: (spawnId: SpawnNode['id'], event: ReactMouseEvent) => void onStairDoubleClick: (stair: StairNode, event: ReactMouseEvent) => void onStairHoverChange: (stairId: StairNode['id'] | null) => void onStairHoverEnter: (stairId: StairNode['id']) => void @@ -5240,11 +5302,12 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({ onStairSelect: (stairId: StairNode['id'], event: ReactMouseEvent) => void palette: FloorplanPalette selectedIdSet: ReadonlySet + spawnEntries: FloorplanSpawnEntry[] stairEntries: FloorplanStairEntry[] unit: 'metric' | 'imperial' wallSelectionHatchId: string }) { - if (itemEntries.length === 0 && stairEntries.length === 0) { + if (itemEntries.length === 0 && stairEntries.length === 0 && spawnEntries.length === 0) { return null } @@ -5424,6 +5487,120 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({ ) }) + const spawnNodes = spawnEntries.map(({ spawn, position, rotation }) => { + const isSelected = selectedIdSet.has(spawn.id) + const isHighlighted = highlightedIdSet.has(spawn.id) + const isHovered = hoveredSpawnId === spawn.id + const isDeleteHovered = isDeleteMode && isHovered + const isSelectionActive = isSelected || isHighlighted + const showHighlight = isDeleteHovered || (isHovered && !isSelectionActive) + const stroke = isDeleteHovered + ? palette.deleteStroke + : isSelectionActive + ? palette.selectedStroke + : '#16a34a' + const fill = isDeleteHovered ? palette.deleteFill : '#22c55e' + const rotationDeg = (-rotation * 180) / Math.PI + + return ( + { + event.stopPropagation() + onSpawnSelect(spawn.id, event) + } + : undefined + } + onDoubleClick={ + canFocusSpawns + ? (event) => { + event.stopPropagation() + onSpawnDoubleClick(spawn, event) + } + : undefined + } + onPointerDown={ + canFocusSpawns && isSelected + ? (event) => { + if (event.button === 0) { + onSpawnPointerDown(spawn.id, event) + } + } + : undefined + } + onPointerEnter={canSelectSpawns ? () => onSpawnHoverEnter(spawn.id) : undefined} + onPointerLeave={canSelectSpawns ? () => onSpawnHoverChange(null) : undefined} + pointerEvents={canSelectSpawns ? undefined : 'none'} + style={canSelectSpawns ? { cursor: EDITOR_CURSOR } : undefined} + transform={`translate(${toSvgX(position.x)} ${toSvgY(position.y)}) rotate(${rotationDeg})`} + > + {spawn.name || 'Spawn Point'} + + + + + + + + ) + }) + return ( <> {isFurnishContextActive ? ( @@ -5446,10 +5623,12 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({ stairEntries={stairEntries} /> {itemNodes} + {spawnNodes} ) : ( <> {itemNodes} + {spawnNodes} (null) const [draftEnd, setDraftEnd] = useState(null) @@ -6558,6 +6739,7 @@ export function FloorplanPanel() { const [hoveredSlabId, setHoveredSlabId] = useState(null) const [hoveredCeilingId, setHoveredCeilingId] = useState(null) const [hoveredItemId, setHoveredItemId] = useState(null) + const [hoveredSpawnId, setHoveredSpawnId] = useState(null) const [hoveredStairId, setHoveredStairId] = useState(null) const [hoveredZoneId, setHoveredZoneId] = useState(null) const [hoveredEndpointId, setHoveredEndpointId] = useState(null) @@ -7145,6 +7327,24 @@ export function FloorplanPanel() { ), [levelDescendantNodes], ) + const floorplanSpawnEntries = useMemo( + () => + spawns + .filter((spawn) => spawn.visible !== false) + .map((spawn) => { + const live = useLiveTransforms.getState().get(spawn.id) + + return { + spawn, + position: { + x: live?.position[0] ?? spawn.position[0], + y: live?.position[2] ?? spawn.position[2], + }, + rotation: live?.rotation ?? spawn.rotation, + } + }), + [movingFloorplanNodeRevision, spawns], + ) const floorplanItemEntries = useMemo(() => { const transformCache = new Map() @@ -7504,6 +7704,13 @@ export function FloorplanPanel() { return floorplanItemEntries.find(({ item }) => item.id === selectedIds[0]) ?? null }, [floorplanItemEntries, selectedIds]) + const selectedSpawnEntry = useMemo(() => { + if (selectedIds.length !== 1) { + return null + } + + return floorplanSpawnEntries.find(({ spawn }) => spawn.id === selectedIds[0]) ?? null + }, [floorplanSpawnEntries, selectedIds]) const selectedItemClearanceMeasurements = useMemo(() => { if (!selectedItemEntry) { return [] as LinearMeasurementOverlay[] @@ -7837,6 +8044,7 @@ export function FloorplanPanel() { const isCeilingMoveActive = movingNode?.type === 'ceiling' const isFenceMoveActive = movingNode?.type === 'fence' const isWallMoveActive = movingNode?.type === 'wall' + const isSpawnMoveActive = movingNode?.type === 'spawn' const isWallCurveActive = curvingWall?.type === 'wall' const isFenceCurveActive = curvingFence?.type === 'fence' const isFenceEndpointMoveActive = movingFenceEndpoint !== null @@ -7855,6 +8063,7 @@ export function FloorplanPanel() { isCeilingMoveActive || isFenceMoveActive || isWallMoveActive || + isSpawnMoveActive || isWallCurveActive || isFenceCurveActive || isFenceEndpointMoveActive || @@ -7976,6 +8185,7 @@ export function FloorplanPanel() { !movingFenceEndpoint && isFloorplanStructureContextActive) || isDeleteMode + const canSelectFloorplanSpawns = canSelectFloorplanStairs const canSelectFloorplanItems = (mode === 'select' && floorplanSelectionTool === 'click' && @@ -7989,6 +8199,7 @@ export function FloorplanPanel() { !movingNode && !movingFenceEndpoint && isFloorplanStructureContextActive + const canFocusFloorplanSpawns = canFocusFloorplanStairs const canFocusFloorplanItems = mode === 'select' && floorplanSelectionTool === 'click' && @@ -8744,6 +8955,47 @@ export function FloorplanPanel() { : null, [selectedItemEntry, surfaceSize, viewBox], ) + const selectedSpawnActionMenuPosition = useMemo(() => { + if (!selectedSpawnEntry) { + return null + } + + const { position } = selectedSpawnEntry + const svg = svgRef.current + const scene = floorplanSceneRef.current + const sceneCtm = scene?.getScreenCTM() + const hasResolvedSceneRotation = Number.isFinite(floorplanSceneRotationDeg) + + if (svg && scene && sceneCtm && hasResolvedSceneRotation) { + const svgRect = svg.getBoundingClientRect() + const svgPoint = svg.createSVGPoint() + svgPoint.x = toSvgX(position.x) + svgPoint.y = toSvgY(position.y) - FLOORPLAN_SPAWN_HIT_RADIUS + + const screenPoint = svgPoint.matrixTransform(sceneCtm) + const anchorX = screenPoint.x - svgRect.left + const anchorY = screenPoint.y - svgRect.top + + return { + x: Math.min( + Math.max(anchorX, FLOORPLAN_ACTION_MENU_HORIZONTAL_PADDING), + surfaceSize.width - FLOORPLAN_ACTION_MENU_HORIZONTAL_PADDING, + ), + y: Math.max(anchorY, FLOORPLAN_ACTION_MENU_MIN_ANCHOR_Y), + } + } + + return getFloorplanActionMenuPosition( + [ + { x: position.x - FLOORPLAN_SPAWN_HIT_RADIUS, y: position.y - FLOORPLAN_SPAWN_HIT_RADIUS }, + { x: position.x + FLOORPLAN_SPAWN_HIT_RADIUS, y: position.y - FLOORPLAN_SPAWN_HIT_RADIUS }, + { x: position.x + FLOORPLAN_SPAWN_HIT_RADIUS, y: position.y + FLOORPLAN_SPAWN_HIT_RADIUS }, + { x: position.x - FLOORPLAN_SPAWN_HIT_RADIUS, y: position.y + FLOORPLAN_SPAWN_HIT_RADIUS }, + ], + viewBox, + surfaceSize, + ) + }, [floorplanSceneRotationDeg, selectedSpawnEntry, surfaceSize, viewBox]) const selectedSlabActionMenuPosition = useMemo(() => { if (slabHoleMoveDraft) { return null @@ -9049,31 +9301,35 @@ export function FloorplanPanel() { () => getVisibleGridSteps(viewBox.width, surfaceSize.width), [surfaceSize.width, viewBox.width], ) + const gridBounds = useMemo( + () => getRotatedViewBoxBounds(viewBox, floorplanSceneRotationDeg), + [floorplanSceneRotationDeg, viewBox], + ) const minorGridPath = useMemo( () => buildGridPath( - viewBox.minX, - viewBox.minX + viewBox.width, - viewBox.minY, - viewBox.minY + viewBox.height, + gridBounds.minX, + gridBounds.maxX, + gridBounds.minY, + gridBounds.maxY, gridSteps.minorStep, { excludeStep: gridSteps.majorStep, }, ), - [gridSteps.majorStep, gridSteps.minorStep, viewBox], + [gridBounds, gridSteps.majorStep, gridSteps.minorStep], ) const majorGridPath = useMemo( () => buildGridPath( - viewBox.minX, - viewBox.minX + viewBox.width, - viewBox.minY, - viewBox.minY + viewBox.height, + gridBounds.minX, + gridBounds.maxX, + gridBounds.minY, + gridBounds.maxY, gridSteps.majorStep, ), - [gridSteps.majorStep, viewBox], + [gridBounds, gridSteps.majorStep], ) const floorplanUnitsPerPixel = viewBox.width / Math.max(surfaceSize.width, 1) @@ -9808,6 +10064,30 @@ export function FloorplanPanel() { return unsubscribe }, [movingNode, scheduleMovingFloorplanNodeRefresh]) + useEffect(() => { + if (movingNode?.type !== 'spawn') { + return + } + + const movingSpawnId = movingNode.id + const refreshSpawnPreview = () => { + scheduleMovingFloorplanNodeRefresh() + } + + refreshSpawnPreview() + + const unsubscribe = useLiveTransforms.subscribe((state, previousState) => { + const nextTransform = state.transforms.get(movingSpawnId) + const previousTransform = previousState.transforms.get(movingSpawnId) + + if (nextTransform !== previousTransform) { + refreshSpawnPreview() + } + }) + + return unsubscribe + }, [movingNode, scheduleMovingFloorplanNodeRefresh]) + useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { const target = event.target as HTMLElement | null @@ -9831,7 +10111,9 @@ export function FloorplanPanel() { } if ( - (movingNode?.type === 'stair' || movingNode?.type === 'item') && + (movingNode?.type === 'stair' || + movingNode?.type === 'item' || + movingNode?.type === 'spawn') && (event.key === 'r' || event.key === 'R' || event.key === 't' || event.key === 'T') ) { setMovingFloorplanNodeRevision((current) => current + 1) @@ -11070,7 +11352,7 @@ export function FloorplanPanel() { const hoveredWallIdRef = useRef(null) const floorplanGridLocalY = useMemo(() => { - if (movingNode?.type === 'item') { + if (movingNode?.type === 'item' || movingNode?.type === 'spawn') { return movingNode.position[1] } @@ -11107,8 +11389,8 @@ export function FloorplanPanel() { const snappedPoint = getSnappedFloorplanPoint(planPoint) const cos = Math.cos(buildingRotationY) const sin = Math.sin(buildingRotationY) - const worldX = buildingPosition[0] + snappedPoint[0] * cos - snappedPoint[1] * sin - const worldZ = buildingPosition[2] + snappedPoint[0] * sin + snappedPoint[1] * cos + const worldX = buildingPosition[0] + snappedPoint[0] * cos + snappedPoint[1] * sin + const worldZ = buildingPosition[2] - snappedPoint[0] * sin + snappedPoint[1] * cos emitter.emit(`grid:${eventType}` as any, { nativeEvent: nativeEvent.nativeEvent as any, @@ -11918,6 +12200,14 @@ export function FloorplanPanel() { [syncDeleteHoveredId], ) + const handleSpawnHoverChange = useCallback( + (spawnId: SpawnNode['id'] | null) => { + setHoveredSpawnId(spawnId) + syncDeleteHoveredId(spawnId) + }, + [syncDeleteHoveredId], + ) + const handleStairHoverChange = useCallback( (stairId: StairNode['id'] | null) => { setHoveredStairId(stairId) @@ -11941,6 +12231,7 @@ export function FloorplanPanel() { handleSlabHoverChange(null) handleCeilingHoverChange(null) handleStairHoverChange(null) + handleSpawnHoverChange(null) handleZoneHoverChange(null) handleItemHoverChange(itemId) }, @@ -11950,6 +12241,7 @@ export function FloorplanPanel() { handleOpeningHoverChange, handleCeilingHoverChange, handleSlabHoverChange, + handleSpawnHoverChange, handleStairHoverChange, handleWallHoverChange, handleZoneHoverChange, @@ -11963,6 +12255,7 @@ export function FloorplanPanel() { handleSlabHoverChange(null) handleCeilingHoverChange(null) handleStairHoverChange(null) + handleSpawnHoverChange(null) handleZoneHoverChange(null) handleFenceHoverChange(fenceId) }, @@ -11972,6 +12265,7 @@ export function FloorplanPanel() { handleOpeningHoverChange, handleCeilingHoverChange, handleSlabHoverChange, + handleSpawnHoverChange, handleStairHoverChange, handleWallHoverChange, handleZoneHoverChange, @@ -11985,6 +12279,7 @@ export function FloorplanPanel() { handleSlabHoverChange(null) handleCeilingHoverChange(null) handleWallHoverChange(null) + handleSpawnHoverChange(null) handleZoneHoverChange(null) handleStairHoverChange(stairId) }, @@ -11994,6 +12289,31 @@ export function FloorplanPanel() { handleOpeningHoverChange, handleCeilingHoverChange, handleSlabHoverChange, + handleSpawnHoverChange, + handleStairHoverChange, + handleWallHoverChange, + handleZoneHoverChange, + ], + ) + const handleFloorplanSpawnHoverEnter = useCallback( + (spawnId: SpawnNode['id']) => { + handleItemHoverChange(null) + handleFenceHoverChange(null) + handleOpeningHoverChange(null) + handleSlabHoverChange(null) + handleCeilingHoverChange(null) + handleWallHoverChange(null) + handleStairHoverChange(null) + handleZoneHoverChange(null) + handleSpawnHoverChange(spawnId) + }, + [ + handleCeilingHoverChange, + handleFenceHoverChange, + handleItemHoverChange, + handleOpeningHoverChange, + handleSlabHoverChange, + handleSpawnHoverChange, handleStairHoverChange, handleWallHoverChange, handleZoneHoverChange, @@ -12086,6 +12406,7 @@ export function FloorplanPanel() { | OpeningNode['id'] | SlabNode['id'] | CeilingNode['id'] + | SpawnNode['id'] | StairNode['id'] | ZoneNodeType['id'], eventType: 'click' | 'double-click', @@ -12100,6 +12421,7 @@ export function FloorplanPanel() { node.type === 'door' || node.type === 'window' || node.type === 'item' || + node.type === 'spawn' || node.type === 'stair' || node.type === 'zone') ) @@ -12309,6 +12631,12 @@ export function FloorplanPanel() { }, [emitFloorplanNodeClick], ) + const handleSpawnSelect = useCallback( + (spawnId: SpawnNode['id'], event: ReactMouseEvent) => { + emitFloorplanNodeClick(spawnId, 'click', event) + }, + [emitFloorplanNodeClick], + ) const handleStairSelect = useCallback( (stairId: StairNode['id'], event: ReactMouseEvent) => { emitFloorplanNodeClick(stairId, 'click', event) @@ -12347,6 +12675,73 @@ export function FloorplanPanel() { }, [emitFloorplanNodeClick], ) + const handleSpawnDoubleClick = useCallback( + (spawn: SpawnNode, event: ReactMouseEvent) => { + emitFloorplanNodeClick(spawn.id, 'double-click', event) + emitter.emit('camera-controls:focus', { nodeId: spawn.id }) + }, + [emitFloorplanNodeClick], + ) + const handleSpawnPointerDown = useCallback( + (spawnId: SpawnNode['id'], event: ReactPointerEvent) => { + if (event.button !== 0) { + return + } + + const spawn = selectedSpawnEntry?.spawn + if (!spawn || spawn.id !== spawnId) { + return + } + + event.preventDefault() + event.stopPropagation() + + const suppressClick = (clickEvent: MouseEvent) => { + clickEvent.stopImmediatePropagation() + clickEvent.preventDefault() + window.removeEventListener('click', suppressClick, true) + } + window.addEventListener('click', suppressClick, true) + requestAnimationFrame(() => { + window.removeEventListener('click', suppressClick, true) + }) + + sfxEmitter.emit('sfx:item-pick') + setMovingNode(spawn) + setSelection({ selectedIds: [] }) + }, + [selectedSpawnEntry, setMovingNode, setSelection], + ) + const handleSelectedSpawnMove = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + + const spawn = selectedSpawnEntry?.spawn + if (!spawn) { + return + } + + sfxEmitter.emit('sfx:item-pick') + setMovingNode(spawn) + setSelection({ selectedIds: [] }) + }, + [selectedSpawnEntry, setMovingNode, setSelection], + ) + const handleSelectedSpawnDelete = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + + const spawn = selectedSpawnEntry?.spawn + if (!spawn) { + return + } + + sfxEmitter.emit('sfx:item-delete') + deleteNode(spawn.id as AnyNodeId) + setSelection({ selectedIds: [] }) + }, + [deleteNode, selectedSpawnEntry, setSelection], + ) const handleItemPointerDown = useCallback( (itemId: ItemNode['id'], event: ReactPointerEvent) => { if (event.button !== 0) { @@ -13818,6 +14213,7 @@ export function FloorplanPanel() { handleWallHoverChange(null) handleSlabHoverChange(null) handleCeilingHoverChange(null) + handleSpawnHoverChange(null) handleStairHoverChange(null) handleZoneHoverChange(null) setHoveredEndpointId(null) @@ -13835,6 +14231,7 @@ export function FloorplanPanel() { handleItemHoverChange, handleOpeningHoverChange, handleSlabHoverChange, + handleSpawnHoverChange, handleStairHoverChange, handleWallHoverChange, handleZoneHoverChange, @@ -13940,6 +14337,7 @@ export function FloorplanPanel() { handleOpeningHoverChange(null) handleWallHoverChange(null) handleSlabHoverChange(null) + handleSpawnHoverChange(null) handleStairHoverChange(null) handleZoneHoverChange(null) setHoveredEndpointId(null) @@ -13960,6 +14358,7 @@ export function FloorplanPanel() { handleItemHoverChange, handleOpeningHoverChange, handleSlabHoverChange, + handleSpawnHoverChange, handleStairHoverChange, handleWallHoverChange, handleZoneHoverChange, @@ -14397,6 +14796,11 @@ export function FloorplanPanel() { onDuplicate: handleSelectedOpeningDuplicate, onMove: handleSelectedOpeningMove, }} + spawn={{ + position: selectedSpawnActionMenuPosition, + onDelete: handleSelectedSpawnDelete, + onMove: handleSelectedSpawnMove, + }} roof={{ position: selectedRoofActionMenuPosition, onDelete: handleSelectedRoofDelete, @@ -14598,7 +15002,9 @@ export function FloorplanPanel() { node?.type === 'guide') const zones = useLevelChildren(levelId, (node): node is ZoneNodeType => node?.type === 'zone') + const spawns = useLevelChildren(levelId, (node): node is SpawnNode => node?.type === 'spawn') const roofs = useScene( useShallow((state) => { if (!levelId) { @@ -180,6 +182,7 @@ export function useFloorplanSceneData({ roofs, site, slabs, + spawns, walls, zones, } diff --git a/packages/editor/src/components/tools/shared/polygon-editor.tsx b/packages/editor/src/components/tools/shared/polygon-editor.tsx index 6839a1bce..3c7420090 100644 --- a/packages/editor/src/components/tools/shared/polygon-editor.tsx +++ b/packages/editor/src/components/tools/shared/polygon-editor.tsx @@ -10,8 +10,10 @@ const Y_OFFSET = 0.02 type DragState = { isDragging: boolean - mode: 'vertex' | 'polygon' + mode: 'vertex' | 'polygon' | 'edge' vertexIndex: number | null + edgeIndex?: number + edgeNormal?: [number, number] initialPosition: [number, number] initialPolygon: Array<[number, number]> pointerId: number @@ -28,6 +30,8 @@ export interface PolygonEditorProps { 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 } /** @@ -35,6 +39,17 @@ export interface PolygonEditorProps { * 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 = ({ polygon, @@ -44,6 +59,7 @@ export const PolygonEditor: React.FC = ({ levelId, surfaceHeight = 0, allowPolygonMove = false, + allowEdgeMove = false, }) => { const [levelNode, setLevelNode] = useState(() => levelId ? (sceneRegistry.nodes.get(levelId) ?? null) : null, @@ -89,6 +105,11 @@ export const PolygonEditor: React.FC = ({ const [previewPolygon, setPreviewPolygon] = useState | null>(null) const previewPolygonRef = useRef | null>(null) + const updatePreviewPolygon = useCallback((nextPolygon: Array<[number, number]> | null) => { + previewPolygonRef.current = nextPolygon + setPreviewPolygon(nextPolygon) + }, []) + // Keep ref in sync useEffect(() => { previewPolygonRef.current = previewPolygon @@ -96,6 +117,7 @@ export const PolygonEditor: React.FC = ({ const [hoveredVertex, setHoveredVertex] = useState(null) const [hoveredMidpoint, setHoveredMidpoint] = useState(null) + const [hoveredEdge, setHoveredEdge] = useState(null) const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0]) const lineRef = useRef(null!) @@ -106,7 +128,7 @@ export const PolygonEditor: React.FC = ({ if (polygon !== lastPolygonRef.current) { lastPolygonRef.current = polygon // External change (e.g. undo/redo) — clear any stale preview/drag state - if (previewPolygon) setPreviewPolygon(null) + if (previewPolygon) updatePreviewPolygon(null) if (dragState) setDragState(null) } @@ -134,17 +156,37 @@ export const PolygonEditor: React.FC = ({ }) }, [displayPolygon]) + const edgeHandles = useMemo(() => { + if (displayPolygon.length < 2) return [] + + 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 [] + + return [ + { + index, + length, + midpoint: [(x1 + x2) / 2, (z1 + z2) / 2] as [number, number], + rotationY: -Math.atan2(dz, dx), + }, + ] + }) + }, [displayPolygon]) + // Update vertex position using grid cursor position const handleVertexDrag = useCallback( (vertexIndex: number, position: [number, number]) => { - setPreviewPolygon((prev) => { - const basePolygon = prev ?? polygon - const newPolygon = [...basePolygon] - newPolygon[vertexIndex] = position - return newPolygon - }) + const basePolygon = previewPolygonRef.current ?? polygon + const newPolygon = [...basePolygon] + newPolygon[vertexIndex] = position + updatePreviewPolygon(newPolygon) }, - [polygon], + [polygon, updatePreviewPolygon], ) // Commit polygon changes @@ -152,9 +194,9 @@ export const PolygonEditor: React.FC = ({ if (previewPolygonRef.current) { onPolygonChange(previewPolygonRef.current) } - setPreviewPolygon(null) + updatePreviewPolygon(null) setDragState(null) - }, [onPolygonChange]) + }, [onPolygonChange, updatePreviewPolygon]) // Handle adding a new vertex at midpoint const handleAddVertex = useCallback( @@ -166,10 +208,13 @@ export const PolygonEditor: React.FC = ({ ...basePolygon.slice(afterIndex + 1), ] - setPreviewPolygon(newPolygon) - return afterIndex + 1 // Return new vertex index + updatePreviewPolygon(newPolygon) + return { + polygon: newPolygon, + vertexIndex: afterIndex + 1, + } }, - [polygon, previewPolygon], + [polygon, previewPolygon, updatePreviewPolygon], ) // Handle deleting a vertex @@ -180,9 +225,9 @@ export const PolygonEditor: React.FC = ({ const newPolygon = basePolygon.filter((_, i) => i !== index) onPolygonChange(newPolygon) - setPreviewPolygon(null) + updatePreviewPolygon(null) }, - [polygon, previewPolygon, onPolygonChange, minVertices], + [polygon, previewPolygon, onPolygonChange, minVertices, updatePreviewPolygon], ) // Listen to grid:move events to track cursor position @@ -212,9 +257,31 @@ export const PolygonEditor: React.FC = ({ } else if (dragState.mode === 'polygon') { const deltaX = newPosition[0] - dragState.initialPosition[0] const deltaZ = newPosition[1] - dragState.initialPosition[1] - setPreviewPolygon( + 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) } } } @@ -223,7 +290,7 @@ export const PolygonEditor: React.FC = ({ return () => { emitter.off('grid:move', onGridMove) } - }, [dragState, handleVertexDrag]) + }, [dragState, handleVertexDrag, updatePreviewPolygon]) // Set up pointer up listener for ending drag useEffect(() => { @@ -337,6 +404,7 @@ export const PolygonEditor: React.FC = ({ onPointerDown={(e) => { if (e.button !== 0) return e.stopPropagation() + setHoveredEdge(null) setDragState({ isDragging: true, mode: 'vertex', @@ -375,6 +443,7 @@ export const PolygonEditor: React.FC = ({ onPointerDown={(e) => { if (e.button !== 0) return e.stopPropagation() + setHoveredEdge(null) setDragState({ isDragging: true, mode: 'polygon', @@ -395,6 +464,62 @@ export const PolygonEditor: React.FC = ({ )} + {allowEdgeMove && + edgeHandles.map(({ index, length, midpoint, rotationY }) => { + const isHovered = hoveredEdge === index + const isDragging = dragState?.mode === 'edge' && dragState.edgeIndex === index + + return ( + { + if (e.button !== 0) return + e.stopPropagation() + }} + onPointerDown={(e) => { + if (e.button !== 0) return + e.stopPropagation() + 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, + }) + }} + onPointerEnter={(e) => { + e.stopPropagation() + setHoveredEdge(index) + }} + onPointerLeave={(e) => { + e.stopPropagation() + setHoveredEdge(null) + }} + position={[midpoint[0], editY + EDGE_HANDLE_HEIGHT / 2, midpoint[1]]} + rotation={[0, rotationY, 0]} + > + + + + ) + })} + {/* Midpoint handles - smaller green cylinders for adding vertices (hidden while dragging) */} {!dragState && midpoints.map(([x, z], index) => { @@ -413,12 +538,14 @@ export const PolygonEditor: React.FC = ({ onPointerDown={(e) => { if (e.button !== 0) return e.stopPropagation() - const newVertexIndex = handleAddVertex(index, [x!, z!]) - if (newVertexIndex >= 0) { + const insertedVertex = handleAddVertex(index, [x!, z!]) + if (insertedVertex.vertexIndex >= 0) { setDragState({ isDragging: true, - vertexIndex: newVertexIndex, + mode: 'vertex', + vertexIndex: insertedVertex.vertexIndex, initialPosition: [x!, z!], + initialPolygon: insertedVertex.polygon, pointerId: e.pointerId, }) setHoveredMidpoint(null) diff --git a/packages/editor/src/components/tools/slab/slab-boundary-editor.tsx b/packages/editor/src/components/tools/slab/slab-boundary-editor.tsx index 0d844bc50..60ab6b81f 100644 --- a/packages/editor/src/components/tools/slab/slab-boundary-editor.tsx +++ b/packages/editor/src/components/tools/slab/slab-boundary-editor.tsx @@ -31,6 +31,7 @@ export const SlabBoundaryEditor: React.FC = ({ slabId } return ( = ({ slabId, holeInde return ( Date: Wed, 6 May 2026 14:18:44 +0530 Subject: [PATCH 2/5] Add floorplan side resizing handles --- .../src/components/editor/floorplan-panel.tsx | 776 +++++++++++++++++- .../tools/ceiling/ceiling-boundary-editor.tsx | 1 + .../tools/ceiling/ceiling-hole-editor.tsx | 1 + .../tools/shared/polygon-editor.tsx | 14 +- 4 files changed, 744 insertions(+), 48 deletions(-) diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 3ac3362ba..5ca0e7fbb 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -152,6 +152,9 @@ const FLOORPLAN_POLYGON_VERTEX_ACTIVE_DOT_RADIUS_PX = 3 const FLOORPLAN_POLYGON_MIDPOINT_RADIUS_PX = 4 const FLOORPLAN_POLYGON_MIDPOINT_HOVER_RADIUS_PX = 4.6 const FLOORPLAN_POLYGON_MIDPOINT_DOT_RADIUS_PX = 1.8 +const FLOORPLAN_POLYGON_EDGE_HIT_STROKE_WIDTH_PX = 30 +const FLOORPLAN_POLYGON_EDGE_HOVER_GLOW_STROKE_WIDTH_PX = 12 +const FLOORPLAN_POLYGON_EDGE_VISIBLE_STROKE_WIDTH_PX = 4 const FLOORPLAN_MARQUEE_OUTLINE_WIDTH = 0.055 const FLOORPLAN_MARQUEE_GLOW_WIDTH = 0.14 const FLOORPLAN_HOVER_TRANSITION = 'opacity 180ms cubic-bezier(0.2, 0, 0, 1)' @@ -416,15 +419,25 @@ type SlabHoleBoundaryDraft = { type SlabVertexDragState = { pointerId: number slabId: SlabNode['id'] + mode?: 'vertex' | 'edge' vertexIndex: number visualOffset: Point2D + edgeIndex?: number + edgeNormal?: WallPlanPoint + initialPlanPoint?: WallPlanPoint + initialPolygon?: WallPlanPoint[] } type SlabHoleVertexDragState = { pointerId: number slabId: SlabNode['id'] holeIndex: number + mode?: 'vertex' | 'edge' vertexIndex: number + edgeIndex?: number + edgeNormal?: WallPlanPoint + initialPlanPoint?: WallPlanPoint + initialPolygon?: WallPlanPoint[] } type SlabHoleMoveDraft = { @@ -443,7 +456,12 @@ type CeilingBoundaryDraft = { type CeilingVertexDragState = { pointerId: number ceilingId: CeilingNode['id'] + mode?: 'vertex' | 'edge' vertexIndex: number + edgeIndex?: number + edgeNormal?: WallPlanPoint + initialPlanPoint?: WallPlanPoint + initialPolygon?: WallPlanPoint[] } type CeilingHoleBoundaryDraft = { @@ -456,7 +474,12 @@ type CeilingHoleVertexDragState = { pointerId: number ceilingId: CeilingNode['id'] holeIndex: number + mode?: 'vertex' | 'edge' vertexIndex: number + edgeIndex?: number + edgeNormal?: WallPlanPoint + initialPlanPoint?: WallPlanPoint + initialPolygon?: WallPlanPoint[] } type CeilingHoleMoveDraft = { @@ -785,6 +808,41 @@ function toWallPlanPoint(point: Point2D): WallPlanPoint { return [point.x, point.y] } +function getFloorplanEdgeNormal(start: WallPlanPoint, end: WallPlanPoint): WallPlanPoint | null { + const dx = end[0] - start[0] + const dy = end[1] - start[1] + const length = Math.hypot(dx, dy) + if (length < 1e-6) { + return null + } + + return [-dy / length, dx / length] +} + +function moveFloorplanPolygonEdge( + polygon: WallPlanPoint[], + edgeIndex: number, + edgeNormal: WallPlanPoint, + initialPlanPoint: WallPlanPoint, + nextPlanPoint: WallPlanPoint, +): WallPlanPoint[] { + if (polygon.length < 2) { + return polygon + } + + const edgeStartIndex = edgeIndex + const edgeEndIndex = (edgeStartIndex + 1) % polygon.length + const deltaX = nextPlanPoint[0] - initialPlanPoint[0] + const deltaY = nextPlanPoint[1] - initialPlanPoint[1] + const normalDistance = deltaX * edgeNormal[0] + deltaY * edgeNormal[1] + + return polygon.map((point, index) => + index === edgeStartIndex || index === edgeEndIndex + ? [point[0] + edgeNormal[0] * normalDistance, point[1] + edgeNormal[1] * normalDistance] + : point, + ) +} + function toSvgX(value: number): number { return value } @@ -1580,10 +1638,52 @@ function getPolygonBounds(points: Point2D[]) { } } +function rotateSvgPoint(point: SvgPoint, rotationDegrees: number): SvgPoint { + if (rotationDegrees === 0) { + return point + } + + const radians = (rotationDegrees * Math.PI) / 180 + const cos = Math.cos(radians) + const sin = Math.sin(radians) + + return { + x: point.x * cos - point.y * sin, + y: point.x * sin + point.y * cos, + } +} + +function projectSvgPointToSurface( + svgPoint: SvgPoint, + viewBox: { minX: number; minY: number; width: number; height: number }, + surfaceSize: { width: number; height: number }, +): SvgPoint | null { + if ( + !(surfaceSize.width > 0 && surfaceSize.height > 0 && viewBox.width > 0 && viewBox.height > 0) + ) { + return null + } + + if ( + svgPoint.x < viewBox.minX || + svgPoint.x > viewBox.minX + viewBox.width || + svgPoint.y < viewBox.minY || + svgPoint.y > viewBox.minY + viewBox.height + ) { + return null + } + + return { + x: ((svgPoint.x - viewBox.minX) / viewBox.width) * surfaceSize.width, + y: ((svgPoint.y - viewBox.minY) / viewBox.height) * surfaceSize.height, + } +} + function getFloorplanActionMenuPosition( points: Point2D[], viewBox: { minX: number; minY: number; width: number; height: number }, surfaceSize: { width: number; height: number }, + rotationDegrees = 0, ) { if (points.length === 0) { return null @@ -1595,7 +1695,7 @@ function getFloorplanActionMenuPosition( let maxY = Number.NEGATIVE_INFINITY for (const point of points) { - const svgPoint = toSvgPoint(point) + const svgPoint = rotateSvgPoint(toSvgPoint(point), rotationDegrees) minX = Math.min(minX, svgPoint.x) maxX = Math.max(maxX, svgPoint.x) minY = Math.min(minY, svgPoint.y) @@ -6371,9 +6471,11 @@ const FloorplanWallCurveHandleLayer = memo(function FloorplanWallCurveHandleLaye }) const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({ + edgeHandles = [], hoveredHandleId, midpointStyle = 'default', midpointHandles, + onEdgePointerDown, onHandleHoverChange, onMidpointPointerDown, onVertexDoubleClick, @@ -6382,6 +6484,13 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({ unitsPerPixel, vertexHandles, }: { + edgeHandles?: Array<{ + nodeId: string + edgeIndex: number + start: WallPlanPoint + end: WallPlanPoint + isActive?: boolean + }> vertexHandles: Array<{ nodeId: string vertexIndex: number @@ -6411,11 +6520,85 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({ edgeIndex: number, event: ReactPointerEvent, ) => void + onEdgePointerDown?: ( + nodeId: string, + edgeIndex: number, + event: ReactPointerEvent, + ) => void palette: FloorplanPalette unitsPerPixel: number }) { return ( <> + {edgeHandles.map(({ nodeId, edgeIndex, start, end, isActive }) => { + const handleId = `${nodeId}:edge:${edgeIndex}` + const isHovered = hoveredHandleId === handleId + const startSvg = toSvgPlanPoint(start) + const endSvg = toSvgPlanPoint(end) + const visibleStroke = isActive ? palette.endpointHandleActiveStroke : palette.selectedStroke + + return ( + { + event.stopPropagation() + }} + onPointerEnter={() => onHandleHoverChange(handleId)} + onPointerLeave={() => onHandleHoverChange(null)} + > + + + onEdgePointerDown(nodeId, edgeIndex, event) + : undefined + } + /> + + ) + })} + {vertexHandles.map(({ nodeId, vertexIndex, point, isActive }) => { const handleId = `${nodeId}:vertex:${vertexIndex}` const isHovered = hoveredHandleId === handleId @@ -8429,6 +8612,33 @@ export function FloorplanPanel() { } }) }, [selectedSlabEntry, shouldShowSlabBoundaryHandles, slabVertexDragState]) + const slabEdgeHandles = useMemo(() => { + if (!shouldShowSlabBoundaryHandles) { + return [] + } + + const handlePolygon = getSlabHandlePolygon(selectedSlabEntry).map(toWallPlanPoint) + + return handlePolygon.flatMap((start, edgeIndex, polygon) => { + const end = polygon[(edgeIndex + 1) % polygon.length] + if (!end) { + return [] + } + + return [ + { + nodeId: selectedSlabEntry.slab.id, + edgeIndex, + start, + end, + isActive: + slabVertexDragState?.slabId === selectedSlabEntry.slab.id && + slabVertexDragState.mode === 'edge' && + slabVertexDragState.edgeIndex === edgeIndex, + }, + ] + }) + }, [selectedSlabEntry, shouldShowSlabBoundaryHandles, slabVertexDragState]) const ceilingVertexHandles = useMemo(() => { if (!shouldShowCeilingBoundaryHandles) { return [] @@ -8461,6 +8671,31 @@ export function FloorplanPanel() { } }) }, [ceilingVertexDragState, selectedCeilingEntry, shouldShowCeilingBoundaryHandles]) + const ceilingEdgeHandles = useMemo(() => { + if (!shouldShowCeilingBoundaryHandles) { + return [] + } + + return selectedCeilingEntry.polygon.flatMap((point, edgeIndex, polygon) => { + const nextPoint = polygon[(edgeIndex + 1) % polygon.length] + if (!nextPoint) { + return [] + } + + return [ + { + nodeId: selectedCeilingEntry.ceiling.id, + edgeIndex, + start: toWallPlanPoint(point), + end: toWallPlanPoint(nextPoint), + isActive: + ceilingVertexDragState?.ceilingId === selectedCeilingEntry.ceiling.id && + ceilingVertexDragState.mode === 'edge' && + ceilingVertexDragState.edgeIndex === edgeIndex, + }, + ] + }) + }, [ceilingVertexDragState, selectedCeilingEntry, shouldShowCeilingBoundaryHandles]) const slabHoleVertexHandles = useMemo(() => { if ( !( @@ -8519,6 +8754,36 @@ export function FloorplanPanel() { shouldShowSlabHoleBoundaryHandles, slabHoleVertexDragState, ]) + const slabHoleEdgeHandles = useMemo(() => { + if (!(shouldShowSlabHoleBoundaryHandles && selectedSlabEntry && selectedSlabEditingHole)) { + return [] + } + + return selectedSlabEditingHole.flatMap((point, edgeIndex, polygon) => { + const nextPoint = polygon[(edgeIndex + 1) % polygon.length] + if (!nextPoint) { + return [] + } + + return [ + { + nodeId: selectedSlabEntry.slab.id, + edgeIndex, + start: toWallPlanPoint(point), + end: toWallPlanPoint(nextPoint), + isActive: + slabHoleVertexDragState?.slabId === selectedSlabEntry.slab.id && + slabHoleVertexDragState.mode === 'edge' && + slabHoleVertexDragState.edgeIndex === edgeIndex, + }, + ] + }) + }, [ + selectedSlabEditingHole, + selectedSlabEntry, + shouldShowSlabHoleBoundaryHandles, + slabHoleVertexDragState, + ]) const ceilingHoleVertexHandles = useMemo(() => { if ( !( @@ -8577,6 +8842,38 @@ export function FloorplanPanel() { selectedCeilingEntry, shouldShowCeilingHoleBoundaryHandles, ]) + const ceilingHoleEdgeHandles = useMemo(() => { + if ( + !(shouldShowCeilingHoleBoundaryHandles && selectedCeilingEntry && selectedCeilingEditingHole) + ) { + return [] + } + + return selectedCeilingEditingHole.flatMap((point, edgeIndex, polygon) => { + const nextPoint = polygon[(edgeIndex + 1) % polygon.length] + if (!nextPoint) { + return [] + } + + return [ + { + nodeId: selectedCeilingEntry.ceiling.id, + edgeIndex, + start: toWallPlanPoint(point), + end: toWallPlanPoint(nextPoint), + isActive: + ceilingHoleVertexDragState?.ceilingId === selectedCeilingEntry.ceiling.id && + ceilingHoleVertexDragState.mode === 'edge' && + ceilingHoleVertexDragState.edgeIndex === edgeIndex, + }, + ] + }) + }, [ + ceilingHoleVertexDragState, + selectedCeilingEditingHole, + selectedCeilingEntry, + shouldShowCeilingHoleBoundaryHandles, + ]) const siteVertexHandles = useMemo(() => { if (!(shouldShowSiteBoundaryHandles && visibleSitePolygon)) { return [] @@ -8944,16 +9241,26 @@ export function FloorplanPanel() { const selectedOpeningActionMenuPosition = useMemo( () => selectedOpeningEntry - ? getFloorplanActionMenuPosition(selectedOpeningEntry.polygon, viewBox, surfaceSize) + ? getFloorplanActionMenuPosition( + selectedOpeningEntry.polygon, + viewBox, + surfaceSize, + floorplanSceneRotationDeg, + ) : null, - [selectedOpeningEntry, surfaceSize, viewBox], + [floorplanSceneRotationDeg, selectedOpeningEntry, surfaceSize, viewBox], ) const selectedItemActionMenuPosition = useMemo( () => selectedItemEntry - ? getFloorplanActionMenuPosition(selectedItemEntry.polygon, viewBox, surfaceSize) + ? getFloorplanActionMenuPosition( + selectedItemEntry.polygon, + viewBox, + surfaceSize, + floorplanSceneRotationDeg, + ) : null, - [selectedItemEntry, surfaceSize, viewBox], + [floorplanSceneRotationDeg, selectedItemEntry, surfaceSize, viewBox], ) const selectedSpawnActionMenuPosition = useMemo(() => { if (!selectedSpawnEntry) { @@ -8994,6 +9301,7 @@ export function FloorplanPanel() { ], viewBox, surfaceSize, + floorplanSceneRotationDeg, ) }, [floorplanSceneRotationDeg, selectedSpawnEntry, surfaceSize, viewBox]) const selectedSlabActionMenuPosition = useMemo(() => { @@ -9002,7 +9310,12 @@ export function FloorplanPanel() { } if (selectedSlabEditingHole) { - return getFloorplanActionMenuPosition(selectedSlabEditingHole, viewBox, surfaceSize) + return getFloorplanActionMenuPosition( + selectedSlabEditingHole, + viewBox, + surfaceSize, + floorplanSceneRotationDeg, + ) } return selectedSlabEntry @@ -9010,35 +9323,70 @@ export function FloorplanPanel() { getSlabHandlePolygon(selectedSlabEntry), viewBox, surfaceSize, + floorplanSceneRotationDeg, ) : null - }, [selectedSlabEditingHole, selectedSlabEntry, slabHoleMoveDraft, surfaceSize, viewBox]) + }, [ + floorplanSceneRotationDeg, + selectedSlabEditingHole, + selectedSlabEntry, + slabHoleMoveDraft, + surfaceSize, + viewBox, + ]) const selectedCeilingActionMenuPosition = useMemo(() => { if (ceilingHoleMoveDraft) { return null } if (selectedCeilingEditingHole) { - return getFloorplanActionMenuPosition(selectedCeilingEditingHole, viewBox, surfaceSize) + return getFloorplanActionMenuPosition( + selectedCeilingEditingHole, + viewBox, + surfaceSize, + floorplanSceneRotationDeg, + ) } return selectedCeilingEntry - ? getFloorplanActionMenuPosition(selectedCeilingEntry.polygon, viewBox, surfaceSize) + ? getFloorplanActionMenuPosition( + selectedCeilingEntry.polygon, + viewBox, + surfaceSize, + floorplanSceneRotationDeg, + ) : null - }, [ceilingHoleMoveDraft, selectedCeilingEditingHole, selectedCeilingEntry, surfaceSize, viewBox]) + }, [ + ceilingHoleMoveDraft, + floorplanSceneRotationDeg, + selectedCeilingEditingHole, + selectedCeilingEntry, + surfaceSize, + viewBox, + ]) const selectedWallActionMenuPosition = useMemo( () => selectedWallEntry - ? getFloorplanActionMenuPosition(selectedWallEntry.polygon, viewBox, surfaceSize) + ? getFloorplanActionMenuPosition( + selectedWallEntry.polygon, + viewBox, + surfaceSize, + floorplanSceneRotationDeg, + ) : null, - [selectedWallEntry, surfaceSize, viewBox], + [floorplanSceneRotationDeg, selectedWallEntry, surfaceSize, viewBox], ) const selectedFenceActionMenuPosition = useMemo( () => selectedFenceEntry - ? getFloorplanActionMenuPosition(selectedFenceEntry.centerline, viewBox, surfaceSize) + ? getFloorplanActionMenuPosition( + selectedFenceEntry.centerline, + viewBox, + surfaceSize, + floorplanSceneRotationDeg, + ) : null, - [selectedFenceEntry, surfaceSize, viewBox], + [floorplanSceneRotationDeg, selectedFenceEntry, surfaceSize, viewBox], ) const selectedStairActionMenuPosition = useMemo( () => @@ -9047,9 +9395,10 @@ export function FloorplanPanel() { selectedStairEntry.hitPolygons.flat(), viewBox, surfaceSize, + floorplanSceneRotationDeg, ) : null, - [selectedStairEntry, surfaceSize, viewBox], + [floorplanSceneRotationDeg, selectedStairEntry, surfaceSize, viewBox], ) const selectedRoofActionMenuPosition = useMemo( () => @@ -9058,9 +9407,10 @@ export function FloorplanPanel() { selectedRoofEntry.segments.flatMap(({ polygon }) => polygon), viewBox, surfaceSize, + floorplanSceneRotationDeg, ) : null, - [selectedRoofEntry, surfaceSize, viewBox], + [floorplanSceneRotationDeg, selectedRoofEntry, surfaceSize, viewBox], ) const floorplanCursorAnchorPosition = useMemo(() => { if ( @@ -9070,23 +9420,23 @@ export function FloorplanPanel() { viewBox.width > 0 && viewBox.height > 0 ) { - const svgPoint = toSvgPlanPoint(cursorPoint) - - if ( - svgPoint.x >= viewBox.minX && - svgPoint.x <= viewBox.minX + viewBox.width && - svgPoint.y >= viewBox.minY && - svgPoint.y <= viewBox.minY + viewBox.height - ) { - return { - x: ((svgPoint.x - viewBox.minX) / viewBox.width) * surfaceSize.width, - y: ((svgPoint.y - viewBox.minY) / viewBox.height) * surfaceSize.height, - } - } + return projectSvgPointToSurface( + rotateSvgPoint(toSvgPlanPoint(cursorPoint), floorplanSceneRotationDeg), + viewBox, + surfaceSize, + ) } return floorplanCursorPosition - }, [cursorPoint, floorplanCursorPosition, surfaceSize.height, surfaceSize.width, viewBox]) + }, [ + cursorPoint, + floorplanCursorPosition, + floorplanSceneRotationDeg, + surfaceSize, + surfaceSize.height, + surfaceSize.width, + viewBox, + ]) useEffect(() => { setHoveredGuideCorner(null) @@ -9129,19 +9479,25 @@ export function FloorplanPanel() { hoveredGuideCorner, ) - if ( - handleSvg.x < viewBox.minX || - handleSvg.x > viewBox.minX + viewBox.width || - handleSvg.y < viewBox.minY || - handleSvg.y > viewBox.minY + viewBox.height - ) { + const centerPosition = projectSvgPointToSurface( + rotateSvgPoint(centerSvg, floorplanSceneRotationDeg), + viewBox, + surfaceSize, + ) + const handlePosition = projectSvgPointToSurface( + rotateSvgPoint(handleSvg, floorplanSceneRotationDeg), + viewBox, + surfaceSize, + ) + + if (!(centerPosition && handlePosition)) { return null } - const centerX = ((centerSvg.x - viewBox.minX) / viewBox.width) * surfaceSize.width - const centerY = ((centerSvg.y - viewBox.minY) / viewBox.height) * surfaceSize.height - const handleX = ((handleSvg.x - viewBox.minX) / viewBox.width) * surfaceSize.width - const handleY = ((handleSvg.y - viewBox.minY) / viewBox.height) * surfaceSize.height + const centerX = centerPosition.x + const centerY = centerPosition.y + const handleX = handlePosition.x + const handleY = handlePosition.y let directionX = handleX - centerX let directionY = handleY - centerY @@ -9168,8 +9524,10 @@ export function FloorplanPanel() { } }, [ hoveredGuideCorner, + floorplanSceneRotationDeg, selectedGuide, selectedGuideDimensions, + surfaceSize, surfaceSize.height, surfaceSize.width, viewBox, @@ -10684,6 +11042,32 @@ export function FloorplanPanel() { return currentDraft } + if ( + dragState.mode === 'edge' && + dragState.edgeIndex !== undefined && + dragState.edgeNormal && + dragState.initialPlanPoint && + dragState.initialPolygon + ) { + const nextPolygon = moveFloorplanPolygonEdge( + dragState.initialPolygon, + dragState.edgeIndex, + dragState.edgeNormal, + dragState.initialPlanPoint, + snappedPoint, + ) + + if (polygonsEqual(currentDraft.polygon, nextPolygon)) { + return currentDraft + } + + sfxEmitter.emit('sfx:grid-snap') + return { + ...currentDraft, + polygon: nextPolygon, + } + } + const currentPoint = currentDraft.polygon[dragState.vertexIndex] if (currentPoint && pointsEqual(currentPoint, snappedPoint)) { return currentDraft @@ -10781,6 +11165,32 @@ export function FloorplanPanel() { return currentDraft } + if ( + dragState.mode === 'edge' && + dragState.edgeIndex !== undefined && + dragState.edgeNormal && + dragState.initialPlanPoint && + dragState.initialPolygon + ) { + const nextPolygon = moveFloorplanPolygonEdge( + dragState.initialPolygon, + dragState.edgeIndex, + dragState.edgeNormal, + dragState.initialPlanPoint, + snappedPoint, + ) + + if (polygonsEqual(currentDraft.polygon, nextPolygon)) { + return currentDraft + } + + sfxEmitter.emit('sfx:grid-snap') + return { + ...currentDraft, + polygon: nextPolygon, + } + } + const currentPoint = currentDraft.polygon[dragState.vertexIndex] if (currentPoint && pointsEqual(currentPoint, snappedPoint)) { return currentDraft @@ -10882,6 +11292,32 @@ export function FloorplanPanel() { return currentDraft } + if ( + dragState.mode === 'edge' && + dragState.edgeIndex !== undefined && + dragState.edgeNormal && + dragState.initialPlanPoint && + dragState.initialPolygon + ) { + const nextPolygon = moveFloorplanPolygonEdge( + dragState.initialPolygon, + dragState.edgeIndex, + dragState.edgeNormal, + dragState.initialPlanPoint, + snappedPoint, + ) + + if (polygonsEqual(currentDraft.polygon, nextPolygon)) { + return currentDraft + } + + sfxEmitter.emit('sfx:grid-snap') + return { + ...currentDraft, + polygon: nextPolygon, + } + } + const currentPoint = currentDraft.polygon[dragState.vertexIndex] if (currentPoint && pointsEqual(currentPoint, snappedPoint)) { return currentDraft @@ -11066,6 +11502,32 @@ export function FloorplanPanel() { return currentDraft } + if ( + dragState.mode === 'edge' && + dragState.edgeIndex !== undefined && + dragState.edgeNormal && + dragState.initialPlanPoint && + dragState.initialPolygon + ) { + const nextPolygon = moveFloorplanPolygonEdge( + dragState.initialPolygon, + dragState.edgeIndex, + dragState.edgeNormal, + dragState.initialPlanPoint, + snappedPoint, + ) + + if (polygonsEqual(currentDraft.polygon, nextPolygon)) { + return currentDraft + } + + sfxEmitter.emit('sfx:grid-snap') + return { + ...currentDraft, + polygon: nextPolygon, + } + } + const currentPoint = currentDraft.polygon[dragState.vertexIndex] if (currentPoint && pointsEqual(currentPoint, snappedPoint)) { return currentDraft @@ -13640,6 +14102,69 @@ export function FloorplanPanel() { }, [displaySlabPolygons], ) + const handleSlabEdgePointerDown = useCallback( + (slabId: SlabNode['id'], handleEdgeIndex: number, event: ReactPointerEvent) => { + if (event.button !== 0) { + return + } + + event.preventDefault() + event.stopPropagation() + setHoveredSlabHandleId(null) + + const slabEntry = displaySlabPolygons.find(({ slab }) => slab.id === slabId) + if (!slabEntry) { + return + } + + const basePolygon = slabEntry.polygon.map(toWallPlanPoint) + const handlePolygon = getSlabHandlePolygon(slabEntry) + const handleStartPoint = handlePolygon[handleEdgeIndex] + const handleEndPoint = handlePolygon[(handleEdgeIndex + 1) % handlePolygon.length] + if (!(handleStartPoint && handleEndPoint)) { + return + } + + const handleMidpoint = { + x: (handleStartPoint.x + handleEndPoint.x) / 2, + y: (handleStartPoint.y + handleEndPoint.y) / 2, + } + const edgeIndex = getClosestPolygonEdgeIndex(handleMidpoint, slabEntry.polygon) + const startPoint = basePolygon[edgeIndex] + const endPoint = basePolygon[(edgeIndex + 1) % basePolygon.length] + if (!(startPoint && endPoint)) { + return + } + + const edgeNormal = getFloorplanEdgeNormal(startPoint, endPoint) + if (!edgeNormal) { + return + } + + const initialPlanPoint = + getPlanPointFromClientPoint(event.clientX, event.clientY) ?? + ([(startPoint[0] + endPoint[0]) / 2, (startPoint[1] + endPoint[1]) / 2] as WallPlanPoint) + + setSlabBoundaryDraft({ + slabId, + polygon: basePolygon, + visualOffsets: getSlabVisualOffsets(slabEntry), + }) + setSlabVertexDragState({ + pointerId: event.pointerId, + slabId, + mode: 'edge', + vertexIndex: edgeIndex, + visualOffset: { x: 0, y: 0 }, + edgeIndex, + edgeNormal, + initialPlanPoint, + initialPolygon: basePolygon, + }) + setCursorPoint(initialPlanPoint) + }, + [displaySlabPolygons, getPlanPointFromClientPoint], + ) const handleCeilingVertexPointerDown = useCallback( ( ceilingId: CeilingNode['id'], @@ -13750,6 +14275,55 @@ export function FloorplanPanel() { }, [displayCeilingPolygons], ) + const handleCeilingEdgePointerDown = useCallback( + (ceilingId: CeilingNode['id'], edgeIndex: number, event: ReactPointerEvent) => { + if (event.button !== 0) { + return + } + + event.preventDefault() + event.stopPropagation() + setHoveredCeilingHandleId(null) + + const ceilingEntry = displayCeilingPolygons.find(({ ceiling }) => ceiling.id === ceilingId) + if (!ceilingEntry) { + return + } + + const basePolygon = ceilingEntry.polygon.map(toWallPlanPoint) + const startPoint = basePolygon[edgeIndex] + const endPoint = basePolygon[(edgeIndex + 1) % basePolygon.length] + if (!(startPoint && endPoint)) { + return + } + + const edgeNormal = getFloorplanEdgeNormal(startPoint, endPoint) + if (!edgeNormal) { + return + } + + const initialPlanPoint = + getPlanPointFromClientPoint(event.clientX, event.clientY) ?? + ([(startPoint[0] + endPoint[0]) / 2, (startPoint[1] + endPoint[1]) / 2] as WallPlanPoint) + + setCeilingBoundaryDraft({ + ceilingId, + polygon: basePolygon, + }) + setCeilingVertexDragState({ + pointerId: event.pointerId, + ceilingId, + mode: 'edge', + vertexIndex: edgeIndex, + edgeIndex, + edgeNormal, + initialPlanPoint, + initialPolygon: basePolygon, + }) + setCursorPoint(initialPlanPoint) + }, + [displayCeilingPolygons, getPlanPointFromClientPoint], + ) const handleSlabHoleVertexPointerDown = useCallback( (slabId: SlabNode['id'], vertexIndex: number, event: ReactPointerEvent) => { if (event.button !== 0) { @@ -13860,6 +14434,59 @@ export function FloorplanPanel() { }, [displaySlabPolygons, editingHole], ) + const handleSlabHoleEdgePointerDown = useCallback( + (slabId: SlabNode['id'], edgeIndex: number, event: ReactPointerEvent) => { + if (event.button !== 0) { + return + } + + event.preventDefault() + event.stopPropagation() + setHoveredSlabHandleId(null) + + const slabEntry = displaySlabPolygons.find(({ slab }) => slab.id === slabId) + const holeIndex = editingHole?.nodeId === slabId ? editingHole.holeIndex : null + const hole = holeIndex !== null ? slabEntry?.holes[holeIndex] : null + if (!(slabEntry && holeIndex !== null && hole)) { + return + } + + const basePolygon = hole.map(toWallPlanPoint) + const startPoint = basePolygon[edgeIndex] + const endPoint = basePolygon[(edgeIndex + 1) % basePolygon.length] + if (!(startPoint && endPoint)) { + return + } + + const edgeNormal = getFloorplanEdgeNormal(startPoint, endPoint) + if (!edgeNormal) { + return + } + + const initialPlanPoint = + getPlanPointFromClientPoint(event.clientX, event.clientY) ?? + ([(startPoint[0] + endPoint[0]) / 2, (startPoint[1] + endPoint[1]) / 2] as WallPlanPoint) + + setSlabHoleBoundaryDraft({ + slabId, + holeIndex, + polygon: basePolygon, + }) + setSlabHoleVertexDragState({ + pointerId: event.pointerId, + slabId, + holeIndex, + mode: 'edge', + vertexIndex: edgeIndex, + edgeIndex, + edgeNormal, + initialPlanPoint, + initialPolygon: basePolygon, + }) + setCursorPoint(initialPlanPoint) + }, + [displaySlabPolygons, editingHole, getPlanPointFromClientPoint], + ) const handleCeilingHoleVertexPointerDown = useCallback( ( ceilingId: CeilingNode['id'], @@ -13982,6 +14609,59 @@ export function FloorplanPanel() { }, [displayCeilingPolygons, editingHole], ) + const handleCeilingHoleEdgePointerDown = useCallback( + (ceilingId: CeilingNode['id'], edgeIndex: number, event: ReactPointerEvent) => { + if (event.button !== 0) { + return + } + + event.preventDefault() + event.stopPropagation() + setHoveredCeilingHandleId(null) + + const ceilingEntry = displayCeilingPolygons.find(({ ceiling }) => ceiling.id === ceilingId) + const holeIndex = editingHole?.nodeId === ceilingId ? editingHole.holeIndex : null + const hole = holeIndex !== null ? ceilingEntry?.holes[holeIndex] : null + if (!(ceilingEntry && holeIndex !== null && hole)) { + return + } + + const basePolygon = hole.map(toWallPlanPoint) + const startPoint = basePolygon[edgeIndex] + const endPoint = basePolygon[(edgeIndex + 1) % basePolygon.length] + if (!(startPoint && endPoint)) { + return + } + + const edgeNormal = getFloorplanEdgeNormal(startPoint, endPoint) + if (!edgeNormal) { + return + } + + const initialPlanPoint = + getPlanPointFromClientPoint(event.clientX, event.clientY) ?? + ([(startPoint[0] + endPoint[0]) / 2, (startPoint[1] + endPoint[1]) / 2] as WallPlanPoint) + + setCeilingHoleBoundaryDraft({ + ceilingId, + holeIndex, + polygon: basePolygon, + }) + setCeilingHoleVertexDragState({ + pointerId: event.pointerId, + ceilingId, + holeIndex, + mode: 'edge', + vertexIndex: edgeIndex, + edgeIndex, + edgeNormal, + initialPlanPoint, + initialPolygon: basePolygon, + }) + setCursorPoint(initialPlanPoint) + }, + [displayCeilingPolygons, editingHole, getPlanPointFromClientPoint], + ) const handleSiteVertexPointerDown = useCallback( (siteId: SiteNode['id'], vertexIndex: number, event: ReactPointerEvent) => { if (event.button !== 0) { @@ -15282,9 +15962,13 @@ export function FloorplanPanel() { /> + handleSlabEdgePointerDown(nodeId as SlabNode['id'], edgeIndex, event) + } onHandleHoverChange={setHoveredSlabHandleId} onMidpointPointerDown={(nodeId, edgeIndex, event) => handleSlabMidpointPointerDown(nodeId as SlabNode['id'], edgeIndex, event) @@ -15301,9 +15985,13 @@ export function FloorplanPanel() { /> + handleSlabHoleEdgePointerDown(nodeId as SlabNode['id'], edgeIndex, event) + } onHandleHoverChange={setHoveredSlabHandleId} onMidpointPointerDown={(nodeId, edgeIndex, event) => handleSlabHoleMidpointPointerDown(nodeId as SlabNode['id'], edgeIndex, event) @@ -15320,9 +16008,13 @@ export function FloorplanPanel() { /> + handleCeilingEdgePointerDown(nodeId as CeilingNode['id'], edgeIndex, event) + } onHandleHoverChange={setHoveredCeilingHandleId} onMidpointPointerDown={(nodeId, edgeIndex, event) => handleCeilingMidpointPointerDown(nodeId as CeilingNode['id'], edgeIndex, event) @@ -15339,9 +16031,13 @@ export function FloorplanPanel() { /> + handleCeilingHoleEdgePointerDown(nodeId as CeilingNode['id'], edgeIndex, event) + } onHandleHoverChange={setHoveredCeilingHandleId} onMidpointPointerDown={(nodeId, edgeIndex, event) => handleCeilingHoleMidpointPointerDown( diff --git a/packages/editor/src/components/tools/ceiling/ceiling-boundary-editor.tsx b/packages/editor/src/components/tools/ceiling/ceiling-boundary-editor.tsx index 0c6da435a..9b4e7f62a 100644 --- a/packages/editor/src/components/tools/ceiling/ceiling-boundary-editor.tsx +++ b/packages/editor/src/components/tools/ceiling/ceiling-boundary-editor.tsx @@ -31,6 +31,7 @@ export const CeilingBoundaryEditor: React.FC = ({ ce return ( = ({ ceilingId, return ( = ({ 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 = ( @@ -383,7 +385,7 @@ export const PolygonEditor: React.FC = ({ const isHovered = hoveredVertex === index const isDragging = dragState?.mode === 'vertex' && dragState.vertexIndex === index const radius = 0.1 - const height = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02) + const height = handleHeight return ( = ({ pointerId: e.pointerId, }) }} - position={[ - polygonCenter[0], - editY + Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02) + 0.08, - polygonCenter[1], - ]} + position={[polygonCenter[0], editY + handleHeight + 0.08, polygonCenter[1]]} > @@ -507,7 +505,7 @@ export const PolygonEditor: React.FC = ({ e.stopPropagation() setHoveredEdge(null) }} - position={[midpoint[0], editY + EDGE_HANDLE_HEIGHT / 2, midpoint[1]]} + position={[midpoint[0], edgeHandleY, midpoint[1]]} rotation={[0, rotationY, 0]} > @@ -525,7 +523,7 @@ export const PolygonEditor: React.FC = ({ midpoints.map(([x, z], index) => { const isHovered = hoveredMidpoint === index const radius = 0.06 - const height = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02) + const height = handleHeight return ( Date: Wed, 6 May 2026 15:13:37 +0530 Subject: [PATCH 3/5] Fix item surface placement and remove item height annotations --- .../spatial-grid/spatial-grid-manager.ts | 3 +- packages/core/src/schema/index.ts | 7 +- packages/core/src/schema/nodes/item.ts | 14 + .../editor/wall-measurement-label.tsx | 51 +-- .../tools/item/placement-strategies.ts | 71 +++- .../tools/item/use-placement-coordinator.tsx | 365 ++++++++++++------ 6 files changed, 334 insertions(+), 177 deletions(-) diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index 39bdf4b8b..b9708753f 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -1,5 +1,5 @@ import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema' -import { getScaledDimensions } from '../../schema' +import { getScaledDimensions, isLowProfileItemSurface } from '../../schema' import useScene from '../../store/use-scene' import { SpatialGrid } from './spatial-grid' import { WallSpatialGrid } from './wall-spatial-grid' @@ -582,6 +582,7 @@ export class SpatialGridManager { if (node.type !== 'item') continue const item = node as ItemNode if (item.asset.attachTo) continue + if (isLowProfileItemSurface(item)) continue if (ignoreSet.has(item.id)) continue if (resolveNodeLevelId(item, nodes) !== levelId) continue diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index ed182320a..4b708fb93 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -55,7 +55,12 @@ export type { TemperatureControl, ToggleControl, } from './nodes/item' -export { getScaledDimensions, ItemNode } from './nodes/item' +export { + getScaledDimensions, + ItemNode, + isLowProfileItemSurface, + LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT, +} from './nodes/item' export { LevelNode } from './nodes/level' export type { RoofSurfaceMaterialRole, RoofSurfaceMaterialSpec } from './nodes/roof' export { getEffectiveRoofSurfaceMaterial, RoofNode } from './nodes/roof' diff --git a/packages/core/src/schema/nodes/item.ts b/packages/core/src/schema/nodes/item.ts index 160e724c8..5fb0f15ed 100644 --- a/packages/core/src/schema/nodes/item.ts +++ b/packages/core/src/schema/nodes/item.ts @@ -135,6 +135,20 @@ export const ItemNode = BaseNode.extend({ export type ItemNode = z.infer +export const LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT = 0.1 + +/** + * Low, floor-resting items like rugs and parking mats can receive items visually, + * but should not become item parents or block normal floor placement. + */ +export function isLowProfileItemSurface(item: ItemNode): boolean { + if (item.asset.attachTo) return false + const surfaceHeight = item.asset.surface + ? item.asset.surface.height * item.scale[1] + : getScaledDimensions(item)[1] + return surfaceHeight <= LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT +} + /** * Returns the effective world-space dimensions of an item after applying its scale. * Use this everywhere item.asset.dimensions is used for spatial calculations. diff --git a/packages/editor/src/components/editor/wall-measurement-label.tsx b/packages/editor/src/components/editor/wall-measurement-label.tsx index 60b041ceb..3b24257f5 100755 --- a/packages/editor/src/components/editor/wall-measurement-label.tsx +++ b/packages/editor/src/components/editor/wall-measurement-label.tsx @@ -4,7 +4,6 @@ import { type AnyNodeId, calculateLevelMiters, DEFAULT_WALL_HEIGHT, - getScaledDimensions, getWallCurveLength, getWallMiterBoundaryPoints, getWallPlanFootprint, @@ -428,30 +427,6 @@ function buildMeasurementGuide( } } -type HeightGuide = { - start: Vec3 - end: Vec3 - labelPosition: Vec3 -} - -function buildItemHeightGuide(item: ItemNode): { guide: HeightGuide; height: number } | null { - const [width, height, depth] = getScaledDimensions(item) - - if (!Number.isFinite(height) || height < 0.01) return null - - const x = Number.isFinite(width) ? width / 2 + 0.18 : 0.18 - const z = Number.isFinite(depth) ? depth / 2 + 0.18 : 0.18 - - return { - height, - guide: { - start: [x, 0, z], - end: [x, height, z], - labelPosition: [x, height / 2, z], - }, - } -} - function MeasurementBar({ start, end, color }: { start: Vec3; end: Vec3; color: string }) { const segment = useMemo(() => { const startVector = new THREE.Vector3(...start) @@ -535,7 +510,7 @@ function SelectedMeasurementAnnotation({ node }: { node: WallNode | ItemNode }) return } - return + return null } function WallMeasurementAnnotation({ wall }: { wall: WallNode }) { @@ -600,27 +575,3 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) { ) } - -function ItemHeightMeasurementAnnotation({ item }: { item: ItemNode }) { - const theme = useViewer((state) => state.theme) - const unit = useViewer((state) => state.unit) - const isNight = theme === 'dark' - const color = isNight ? '#ffffff' : '#111111' - const shadowColor = isNight ? '#111111' : '#ffffff' - - const measurement = useMemo(() => buildItemHeightGuide(item), [item]) - - if (!measurement) return null - - return ( - - - - - ) -} diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index 05abcb5fc..20781ec1b 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -10,7 +10,7 @@ import type { WallNode, } from '@pascal-app/core' import { getScaledDimensions, sceneRegistry, useScene } from '@pascal-app/core' -import { Euler, Quaternion, Vector3 } from 'three' +import { Euler, Matrix3, Quaternion, Vector3 } from 'three' import { calculateCursorRotation, calculateItemRotation, @@ -31,6 +31,56 @@ import type { } from './placement-types' const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1] +const LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT = 0.1 +const UPWARD_SURFACE_NORMAL_MIN_Y = 0.75 +const AUTO_SURFACE_MIN_LOCAL_Y = 0.1 + +function isLowProfileItemSurface(item: ItemNode): boolean { + if (item.asset.attachTo) return false + const surfaceHeight = item.asset.surface + ? item.asset.surface.height * item.scale[1] + : getScaledDimensions(item)[1] + return surfaceHeight <= LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT +} + +function getWorldNormalY(event: ItemEvent): number | null { + if (!event.normal) return null + + const normal = new Vector3(event.normal[0], event.normal[1], event.normal[2]) + normal.applyNormalMatrix(new Matrix3().getNormalMatrix(event.object.matrixWorld)).normalize() + return normal.y +} + +function isUpwardItemSurfaceHit(event: ItemEvent): boolean { + const normalY = getWorldNormalY(event) + return normalY !== null && normalY >= UPWARD_SURFACE_NORMAL_MIN_Y +} + +function getSurfacePlacementHeight(surfaceItem: ItemNode, event: ItemEvent, localPos: Vector3) { + if (isLowProfileItemSurface(surfaceItem)) return null + if (!isUpwardItemSurfaceHit(event)) return null + + if (surfaceItem.asset.surface) { + return surfaceItem.asset.surface.height * surfaceItem.scale[1] + } + + if (localPos.y < AUTO_SURFACE_MIN_LOCAL_Y) return null + return localPos.y +} + +function isDescendantOfItem( + candidate: ItemNode, + ancestor: ItemNode, + nodes: Record, +): boolean { + let parentId = candidate.parentId + while (parentId) { + if (parentId === ancestor.id) return true + const parent = nodes[parentId as AnyNodeId] + parentId = parent?.parentId ?? null + } + return false +} // ============================================================================ // FLOOR STRATEGY @@ -434,8 +484,11 @@ export const itemSurfaceStrategy = { const surfaceItem = event.node as ItemNode // Don't surface-place on the draft itself if (surfaceItem.id === ctx.draftItem?.id) return null - // Surface item must declare a surface - if (!surfaceItem.asset.surface) return null + if (ctx.state.surface === 'item-surface' && ctx.state.surfaceItemId === surfaceItem.id) { + return null + } + const nodes = useScene.getState().nodes + if (ctx.draftItem && isDescendantOfItem(surfaceItem, ctx.draftItem, nodes)) return null // Size check: our footprint must fit on surface item's footprint const ourDims = ctx.draftItem @@ -449,10 +502,12 @@ export const itemSurfaceStrategy = { const worldPos = new Vector3(event.position[0], event.position[1], event.position[2]) const localPos = surfaceMesh.worldToLocal(worldPos) + const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos) + if (surfaceHeight === null) return null const x = snapToGrid(localPos.x, ourDims[0]) const z = snapToGrid(localPos.z, ourDims[2]) - const y = surfaceItem.asset.surface.height * surfaceItem.scale[1] + const y = surfaceHeight const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z)) @@ -485,10 +540,11 @@ export const itemSurfaceStrategy = { move(ctx: PlacementContext, event: ItemEvent): PlacementResult | null { if (ctx.state.surface !== 'item-surface') return null if (!(ctx.state.surfaceItemId && ctx.draftItem)) return null + if (event.node.id !== ctx.state.surfaceItemId) return null const nodes = useScene.getState().nodes const surfaceItem = nodes[ctx.state.surfaceItemId as AnyNodeId] as ItemNode | undefined - if (!surfaceItem?.asset.surface) return null + if (!surfaceItem) return null const surfaceMesh = sceneRegistry.nodes.get(ctx.state.surfaceItemId) if (!surfaceMesh) return null @@ -496,10 +552,12 @@ export const itemSurfaceStrategy = { const ourDims = getScaledDimensions(ctx.draftItem) const worldPos = new Vector3(event.position[0], event.position[1], event.position[2]) const localPos = surfaceMesh.worldToLocal(worldPos) + const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos) + if (surfaceHeight === null) return null const x = snapToGrid(localPos.x, ourDims[0]) const z = snapToGrid(localPos.z, ourDims[2]) - const y = surfaceItem.asset.surface.height * surfaceItem.scale[1] + const y = surfaceHeight const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z)) @@ -519,6 +577,7 @@ export const itemSurfaceStrategy = { click(ctx: PlacementContext, _event: ItemEvent): CommitResult | null { if (ctx.state.surface !== 'item-surface') return null if (!(ctx.draftItem && ctx.state.surfaceItemId)) return null + if (_event.node.id !== ctx.state.surfaceItemId) return null return { nodeUpdate: { diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 3e9a8059b..14758e5c9 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -17,13 +17,11 @@ import { } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' -import { createPortal, useFrame } from '@react-three/fiber' -import { useEffect, useRef, useState } from 'react' +import { useFrame } from '@react-three/fiber' +import { useEffect, useMemo, useRef, useState } from 'react' import { - BoxGeometry, Box3, BufferGeometry, - EdgesGeometry, Euler, Float32BufferAttribute, type Group, @@ -199,21 +197,121 @@ function getFallbackPreviewBounds( ): PreviewBounds { const dims = item ? getScaledDimensions(item) : (asset.dimensions ?? DEFAULT_DIMENSIONS) return { - min: [ - -dims[0] / 2, - 0, - attachTo === 'wall-side' ? -dims[2] : -dims[2] / 2, - ], - max: [ - dims[0] / 2, - dims[1], - attachTo === 'wall-side' ? 0 : dims[2] / 2, - ], + min: [-dims[0] / 2, 0, attachTo === 'wall-side' ? -dims[2] : -dims[2] / 2], + max: [dims[0] / 2, dims[1], attachTo === 'wall-side' ? 0 : dims[2] / 2], dimensions: dims, center: [0, dims[1] / 2, attachTo === 'wall-side' ? -dims[2] / 2 : 0], } } +function createLineGeometry(points: number[] = [0, 0, 0, 0, 0, 0]): BufferGeometry { + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute(points, 3)) + return geometry +} + +function getBoxEdgePoints(bounds: PreviewBounds): number[] { + const [width, height, depth] = bounds.dimensions + const [centerX, centerY, centerZ] = bounds.center + const minX = centerX - width / 2 + const maxX = centerX + width / 2 + const minY = centerY - height / 2 + const maxY = centerY + height / 2 + const minZ = centerZ - depth / 2 + const maxZ = centerZ + depth / 2 + + return [ + minX, + minY, + minZ, + maxX, + minY, + minZ, + maxX, + minY, + minZ, + maxX, + minY, + maxZ, + maxX, + minY, + maxZ, + minX, + minY, + maxZ, + minX, + minY, + maxZ, + minX, + minY, + minZ, + + minX, + maxY, + minZ, + maxX, + maxY, + minZ, + maxX, + maxY, + minZ, + maxX, + maxY, + maxZ, + maxX, + maxY, + maxZ, + minX, + maxY, + maxZ, + minX, + maxY, + maxZ, + minX, + maxY, + minZ, + + minX, + minY, + minZ, + minX, + maxY, + minZ, + maxX, + minY, + minZ, + maxX, + maxY, + minZ, + maxX, + minY, + maxZ, + maxX, + maxY, + maxZ, + minX, + minY, + maxZ, + minX, + maxY, + maxZ, + ] +} + +function updateLineGeometry(ref: React.RefObject, points: number[]) { + const geometry = ref.current?.geometry + if (!geometry) return + + const attribute = geometry.getAttribute('position') as Float32BufferAttribute | undefined + if (!attribute || attribute.array.length !== points.length) { + geometry.setAttribute('position', new Float32BufferAttribute(points, 3)) + } else { + attribute.set(points) + attribute.needsUpdate = true + } + geometry.computeBoundingSphere() +} + // Shared materials for placement cursor - we just change colors, not swap materials // Note: EdgesGeometry doesn't work with dashed lines, so using solid lines const edgeMaterial = new LineBasicNodeMaterial({ @@ -269,11 +367,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const shiftFreeRef = useRef(false) const previewBoundsSignatureRef = useRef(null) const meshPreviewAppliedRef = useRef(false) - const dimensionBoundsRef = useRef(null) - const [measurementTargetState, setMeasurementTargetState] = useState<{ - id: string - object: Object3D - } | null>(null) + const [dimensionBounds, setDimensionBounds] = useState(null) // Store config callbacks in refs to avoid re-running effect when they change const configRef = useRef(config) @@ -291,23 +385,33 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (previewBoundsSignatureRef.current === signature) return previewBoundsSignatureRef.current = signature - const nextBoxGeometry = new BoxGeometry(width, height, depth) - nextBoxGeometry.translate(centerX, centerY, centerZ) - const nextEdgesGeometry = new EdgesGeometry(nextBoxGeometry) - const nextBasePlaneGeometry = new PlaneGeometry(width, depth) nextBasePlaneGeometry.rotateX(-Math.PI / 2) nextBasePlaneGeometry.translate(centerX, 0.01, centerZ) - edgesRef.current.geometry.dispose() - edgesRef.current.geometry = nextEdgesGeometry - basePlaneRef.current.geometry.dispose() + updateLineGeometry(edgesRef, getBoxEdgePoints(bounds)) + + const oldBasePlaneGeometry = basePlaneRef.current.geometry basePlaneRef.current.geometry = nextBasePlaneGeometry - nextBoxGeometry.dispose() + oldBasePlaneGeometry.dispose() } const updateDimensionGuides = (bounds: PreviewBounds) => { - dimensionBoundsRef.current = bounds + setDimensionBounds((current) => { + if ( + current && + current.dimensions[0] === bounds.dimensions[0] && + current.dimensions[1] === bounds.dimensions[1] && + current.dimensions[2] === bounds.dimensions[2] && + current.center[0] === bounds.center[0] && + current.center[1] === bounds.center[1] && + current.center[2] === bounds.center[2] + ) { + return current + } + return bounds + }) + const [width, , depth] = bounds.dimensions const [centerX, , centerZ] = bounds.center const minX = centerX - width / 2 @@ -388,10 +492,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea ] const applyPoints = (ref: React.RefObject, points: number[]) => { - const geometry = new BufferGeometry() - geometry.setAttribute('position', new Float32BufferAttribute(points, 3)) - ref.current!.geometry.dispose() - ref.current!.geometry = geometry + updateLineGeometry(ref, points) } applyPoints(measurementWidthRef, widthPoints) @@ -767,6 +868,32 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // ---- Item Surface Handlers ---- + const detachItemSurfaceToFloor = (event: ItemEvent) => { + const buildingLocalPoint = worldToBuildingLocal( + event.position[0], + event.position[1], + event.position[2], + ) + const wx = Math.round(buildingLocalPoint.x * 2) / 2 + const wz = Math.round(buildingLocalPoint.z * 2) / 2 + const floorPos: [number, number, number] = [wx, 0, wz] + + Object.assign(placementState.current, { surface: 'floor', surfaceItemId: null }) + gridPosition.current.set(wx, 0, wz) + cursorGroupRef.current.position.set(wx, 0, wz) + + const draft = draftNode.current + if (draft) { + draft.position = floorPos + useScene.getState().updateNode(draft.id, { + parentId: useViewer.getState().selection.levelId as string, + position: floorPos, + }) + } + + revalidate() + } + const onItemEnter = (event: ItemEvent) => { if (event.node.id === draftNode.current?.id) return const result = itemSurfaceStrategy.enter(getContext(), event) @@ -800,6 +927,24 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea return } + if (ctx.state.surface === 'item-surface' && event.node.id !== ctx.state.surfaceItemId) { + const enterResult = itemSurfaceStrategy.enter( + { ...ctx, state: { ...ctx.state, surface: 'floor', surfaceItemId: null } }, + event, + ) + + event.stopPropagation() + if (enterResult) { + applyTransition(enterResult) + if (draftNode.current && enterResult.nodeUpdate.parentId) { + useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate) + } + } else { + detachItemSurfaceToFloor(event) + } + return + } + if (!draftNode.current) { const enterResult = itemSurfaceStrategy.enter(getContext(), event) if (!enterResult) return @@ -846,30 +991,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // building-local. Convert from world via worldToBuildingLocal instead, // otherwise the wireframe jumps to a surface-local-coordinate ghost // position until the next mouse move. - const buildingLocalLeave = worldToBuildingLocal( - event.position[0], - event.position[1], - event.position[2], - ) - const wx = Math.round(buildingLocalLeave.x * 2) / 2 - const wz = Math.round(buildingLocalLeave.z * 2) / 2 - const floorPos: [number, number, number] = [wx, 0, wz] - - Object.assign(placementState.current, { surface: 'floor', surfaceItemId: null }) - gridPosition.current.set(wx, 0, wz) - cursorGroupRef.current.position.x = wx - cursorGroupRef.current.position.z = wz - - const draft = draftNode.current - if (draft) { - draft.position = floorPos - useScene.getState().updateNode(draft.id, { - parentId: useViewer.getState().selection.levelId as string, - position: floorPos, - }) - } - - revalidate() + detachItemSurfaceToFloor(event) } const onItemClick = (event: ItemEvent) => { @@ -927,11 +1049,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea return } - lastRawPos.current.set( - event.localPosition[0], - event.localPosition[1], - event.localPosition[2], - ) + lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2]) const result = ceilingStrategy.move(getContext(), event) if (!result) return @@ -1147,16 +1265,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea asset.attachTo, gridSnapStep, ) - updatePreviewGeometry( - draft - ? (expandBoundsToGrid( - getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null) ?? getFallbackPreviewBounds(draft, asset, asset.attachTo), - asset.attachTo, - gridSnapStep, - )) - : fallbackBounds, - ) - updateDimensionGuides(fallbackBounds) + const previewBounds = draft + ? expandBoundsToGrid( + getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null) ?? + getFallbackPreviewBounds(draft, asset, asset.attachTo), + asset.attachTo, + gridSnapStep, + ) + : fallbackBounds + updatePreviewGeometry(previewBounds) + updateDimensionGuides(previewBounds) // ---- Undo protection ---- // Undo replaces the entire `nodes` object with a previous snapshot, which doesn't @@ -1242,10 +1360,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const meshBounds = draft ? getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null) : null - updatePreviewGeometry( - meshBounds ? expandBoundsToGrid(meshBounds, asset.attachTo, gridSnapStep) : fallbackBounds, - ) - updateDimensionGuides(fallbackBounds) + const previewBounds = meshBounds + ? expandBoundsToGrid(meshBounds, asset.attachTo, gridSnapStep) + : fallbackBounds + updatePreviewGeometry(previewBounds) + updateDimensionGuides(previewBounds) }, [gridSnapStep, asset, draftNode]) // Wall/ceiling items are managed by their own surface entry events (ensureDraft / reparent). const viewerLevelId = useViewer((s) => s.selection.levelId) @@ -1263,19 +1382,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (!draftNode.current) return const mesh = sceneRegistry.nodes.get(draftNode.current.id) if (!mesh) return - if ( - measurementTargetState?.id !== draftNode.current.id || - measurementTargetState.object !== mesh - ) { - setMeasurementTargetState({ id: draftNode.current.id, object: mesh }) - } - if (!meshPreviewAppliedRef.current) { const previewBounds = getPreviewBoundsFromObject(mesh) if (previewBounds) { - updatePreviewGeometry( - expandBoundsToGrid(previewBounds, asset.attachTo, useEditor.getState().gridSnapStep), - ) + const expandedBounds = expandBoundsToGrid( + previewBounds, + asset.attachTo, + useEditor.getState().gridSnapStep, + ) + updatePreviewGeometry(expandedBounds) + updateDimensionGuides(expandedBounds) meshPreviewAppliedRef.current = true } } @@ -1319,68 +1435,75 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea ? getScaledDimensions(initialDraft) : (config.asset?.dimensions ?? DEFAULT_DIMENSIONS) const dims = getGridAlignedDimensions(rawDims, initialAttachTo, gridSnapStep) - const initialBoxGeometry = new BoxGeometry(dims[0], dims[1], dims[2]) const wallSideZOffset = initialAttachTo === 'wall-side' ? -dims[2] / 2 : 0 - initialBoxGeometry.translate(0, dims[1] / 2, wallSideZOffset) - - // Base plane geometry (colored rectangle on the ground) - const basePlaneGeometry = new PlaneGeometry(dims[0], dims[2]) - basePlaneGeometry.rotateX(-Math.PI / 2) // Make it horizontal - basePlaneGeometry.translate(0, 0.01, wallSideZOffset) // Slightly above ground to avoid z-fighting const initialDimensionBounds = expandBoundsToGrid( getFallbackPreviewBounds(initialDraft, config.asset!, initialAttachTo), initialAttachTo, gridSnapStep, ) - const widthLabel = formatMeasurement(initialDimensionBounds.dimensions[0], unit) - const depthLabel = formatMeasurement(initialDimensionBounds.dimensions[2], unit) - const heightLabel = formatMeasurement(initialDimensionBounds.dimensions[1], unit) + const initialEdgeGeometry = useMemo( + () => createLineGeometry(getBoxEdgePoints(initialDimensionBounds)), + [ + initialDimensionBounds.center[0], + initialDimensionBounds.center[1], + initialDimensionBounds.center[2], + initialDimensionBounds.dimensions[0], + initialDimensionBounds.dimensions[1], + initialDimensionBounds.dimensions[2], + ], + ) + const basePlaneGeometry = useMemo(() => { + const geometry = new PlaneGeometry(dims[0], dims[2]) + geometry.rotateX(-Math.PI / 2) + geometry.translate(0, 0.01, wallSideZOffset) + return geometry + }, [dims[0], dims[2], wallSideZOffset]) + const initialWidthGuideGeometry = useMemo(() => createLineGeometry(), []) + const initialDepthGuideGeometry = useMemo(() => createLineGeometry(), []) + const initialHeightGuideGeometry = useMemo(() => createLineGeometry(), []) + const currentDimensionBounds = dimensionBounds ?? initialDimensionBounds + const widthLabel = formatMeasurement(currentDimensionBounds.dimensions[0], unit) + const depthLabel = formatMeasurement(currentDimensionBounds.dimensions[2], unit) + const heightLabel = formatMeasurement(currentDimensionBounds.dimensions[1], unit) const widthLabelPosition: [number, number, number] = [ - initialDimensionBounds.center[0], + currentDimensionBounds.center[0], 0.04, - initialDimensionBounds.center[2] + initialDimensionBounds.dimensions[2] / 2 + 0.24, + currentDimensionBounds.center[2] + currentDimensionBounds.dimensions[2] / 2 + 0.24, ] const depthLabelPosition: [number, number, number] = [ - initialDimensionBounds.center[0] + initialDimensionBounds.dimensions[0] / 2 + 0.24, + currentDimensionBounds.center[0] + currentDimensionBounds.dimensions[0] / 2 + 0.24, 0.04, - initialDimensionBounds.center[2], + currentDimensionBounds.center[2], ] const heightLabelPosition: [number, number, number] = [ - initialDimensionBounds.center[0] - initialDimensionBounds.dimensions[0] / 2 - 0.24, - initialDimensionBounds.dimensions[1] / 2, - initialDimensionBounds.center[2] - initialDimensionBounds.dimensions[2] / 2, + currentDimensionBounds.center[0] - currentDimensionBounds.dimensions[0] / 2 - 0.24, + currentDimensionBounds.dimensions[1] / 2, + currentDimensionBounds.center[2] - currentDimensionBounds.dimensions[2] / 2, ] - const measurementTarget = - draftNode.current && measurementTargetState?.id === draftNode.current.id - ? measurementTargetState.object - : null const measurementContent = ( <> - - + /> - - + /> - - + />
- - - - {measurementTarget ? createPortal(measurementContent, measurementTarget) : measurementContent} + + {measurementContent} Date: Wed, 6 May 2026 15:56:46 +0530 Subject: [PATCH 4/5] Fix floor item placement cursor height --- .../components/tools/item/placement-strategies.ts | 3 +-- .../tools/item/use-placement-coordinator.tsx | 12 +++++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index 20781ec1b..6d93b6de5 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -33,7 +33,6 @@ import type { const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1] const LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT = 0.1 const UPWARD_SURFACE_NORMAL_MIN_Y = 0.75 -const AUTO_SURFACE_MIN_LOCAL_Y = 0.1 function isLowProfileItemSurface(item: ItemNode): boolean { if (item.asset.attachTo) return false @@ -64,7 +63,7 @@ function getSurfacePlacementHeight(surfaceItem: ItemNode, event: ItemEvent, loca return surfaceItem.asset.surface.height * surfaceItem.scale[1] } - if (localPos.y < AUTO_SURFACE_MIN_LOCAL_Y) return null + if (!Number.isFinite(localPos.y)) return null return localPos.y } diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 14758e5c9..b6d7b7da7 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -514,6 +514,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea ceilingId: null, surfaceItemId: null, } + if (!asset.attachTo && placementState.current.surface === 'floor') { + gridPosition.current.y = 0 + cursorGroupRef.current.position.y = 0 + } // ---- Helpers ---- @@ -641,9 +645,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea previousGridPos = [...result.gridPosition] gridPosition.current.set(...result.gridPosition) - // Only update X and Z for cursor - useFrame will handle Y (slab elevation) - cursorGroupRef.current.position.x = result.cursorPosition[0] - cursorGroupRef.current.position.z = result.cursorPosition[2] + cursorGroupRef.current.position.set( + result.cursorPosition[0], + result.cursorPosition[1], + result.cursorPosition[2], + ) const draft = draftNode.current if (draft) draft.position = result.gridPosition From c0a21058d46f12a2616661cd851d8b8c6055f964 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 7 May 2026 17:01:06 +0530 Subject: [PATCH 5/5] fix list --- .../tools/item/placement-strategies.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index 6d93b6de5..f4d4abd16 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -9,7 +9,12 @@ import type { WallEvent, WallNode, } from '@pascal-app/core' -import { getScaledDimensions, sceneRegistry, useScene } from '@pascal-app/core' +import { + getScaledDimensions, + isLowProfileItemSurface, + sceneRegistry, + useScene, +} from '@pascal-app/core' import { Euler, Matrix3, Quaternion, Vector3 } from 'three' import { calculateCursorRotation, @@ -31,17 +36,8 @@ import type { } from './placement-types' const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1] -const LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT = 0.1 const UPWARD_SURFACE_NORMAL_MIN_Y = 0.75 -function isLowProfileItemSurface(item: ItemNode): boolean { - if (item.asset.attachTo) return false - const surfaceHeight = item.asset.surface - ? item.asset.surface.height * item.scale[1] - : getScaledDimensions(item)[1] - return surfaceHeight <= LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT -} - function getWorldNormalY(event: ItemEvent): number | null { if (!event.normal) return null