From 70933adf9d2927741b91d5d15354b91466f1a636 Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 6 May 2026 13:54:05 +0530 Subject: [PATCH] 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 d08f3e84..fefaf949 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 e9e30a17..8a3fc3d0 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 f5188012..3ac3362b 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 6839a1bc..3c742009 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 0d844bc5..60ab6b81 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 (