Fix floorplan player move and action menu anchoring

This commit is contained in:
sudhir
2026-05-06 13:54:05 +05:30
parent 5f57e9bb09
commit 70933adf9d
7 changed files with 600 additions and 52 deletions
@@ -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,
]
@@ -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) {
@@ -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({
<path
d={minorGridPath}
fill="none"
opacity={palette.minorGridOpacity}
opacity={palette.majorGridOpacity}
shapeRendering="crispEdges"
stroke={palette.minorGrid}
strokeWidth={FLOORPLAN_MINOR_GRID_STROKE_WIDTH}
stroke={palette.majorGrid}
strokeWidth={FLOORPLAN_MAJOR_GRID_STROKE_WIDTH}
vectorEffect="non-scaling-stroke"
/>
<path
d={majorGridPath}
fill="none"
opacity={palette.majorGridOpacity}
opacity={palette.minorGridOpacity}
shapeRendering="crispEdges"
stroke={palette.majorGrid}
strokeWidth={FLOORPLAN_MAJOR_GRID_STROKE_WIDTH}
stroke={palette.minorGrid}
strokeWidth={FLOORPLAN_MINOR_GRID_STROKE_WIDTH}
vectorEffect="non-scaling-stroke"
/>
</>
@@ -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<string>
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<SVGElement>) => void
onItemSelect: (itemId: ItemNode['id'], event: ReactMouseEvent<SVGElement>) => void
onSpawnDoubleClick: (spawn: SpawnNode, event: ReactMouseEvent<SVGElement>) => void
onSpawnHoverChange: (spawnId: SpawnNode['id'] | null) => void
onSpawnHoverEnter: (spawnId: SpawnNode['id']) => void
onSpawnPointerDown: (spawnId: SpawnNode['id'], event: ReactPointerEvent<SVGElement>) => void
onSpawnSelect: (spawnId: SpawnNode['id'], event: ReactMouseEvent<SVGElement>) => void
onStairDoubleClick: (stair: StairNode, event: ReactMouseEvent<SVGElement>) => 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<SVGElement>) => void
palette: FloorplanPalette
selectedIdSet: ReadonlySet<string>
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 (
<g
key={spawn.id}
onClick={
canSelectSpawns
? (event) => {
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})`}
>
<title>{spawn.name || 'Spawn Point'}</title>
<circle
fill="none"
pointerEvents="none"
r={FLOORPLAN_SPAWN_HIT_RADIUS}
stroke={isDeleteHovered ? palette.deleteStroke : '#22c55e'}
strokeOpacity={isDeleteHovered || isSelectionActive ? 0.2 : 0.14}
strokeWidth={0.18}
style={{
opacity: showHighlight || isSelectionActive ? 1 : 0,
transition: FLOORPLAN_HOVER_TRANSITION,
}}
vectorEffect="non-scaling-stroke"
/>
<circle
fill={fill}
fillOpacity={isDeleteHovered ? 0.18 : isSelectionActive ? 0.18 : 0.1}
pointerEvents="none"
r={FLOORPLAN_SPAWN_RING_RADIUS}
stroke={stroke}
strokeOpacity={isSelectionActive || isHovered ? 0.95 : 0.82}
strokeWidth={FLOORPLAN_SPAWN_RING_STROKE_WIDTH}
vectorEffect="non-scaling-stroke"
/>
<polygon
fill={fill}
fillOpacity={isDeleteHovered ? 0.82 : 0.92}
points={FLOORPLAN_SPAWN_ARROW_POINTS}
pointerEvents="none"
stroke={stroke}
strokeLinejoin="round"
strokeWidth={0.055}
vectorEffect="non-scaling-stroke"
/>
<rect
fill={fill}
fillOpacity={isDeleteHovered ? 0.78 : 0.88}
height={FLOORPLAN_SPAWN_BODY_HEIGHT}
pointerEvents="none"
rx={0.045}
stroke={stroke}
strokeWidth={0.045}
vectorEffect="non-scaling-stroke"
width={FLOORPLAN_SPAWN_BODY_WIDTH}
x={-FLOORPLAN_SPAWN_BODY_WIDTH / 2}
y={-FLOORPLAN_SPAWN_BODY_HEIGHT / 2}
/>
<circle
fill={isDeleteHovered ? palette.deleteStroke : '#dcfce7'}
pointerEvents="none"
r={0.09}
stroke={stroke}
strokeWidth={0.035}
vectorEffect="non-scaling-stroke"
/>
<circle
fill="transparent"
pointerEvents="all"
r={FLOORPLAN_SPAWN_HIT_RADIUS}
stroke="transparent"
/>
</g>
)
})
return (
<>
{isFurnishContextActive ? (
@@ -5446,10 +5623,12 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
stairEntries={stairEntries}
/>
{itemNodes}
{spawnNodes}
</>
) : (
<>
{itemNodes}
{spawnNodes}
<FloorplanStairLayer
canFocusStairs={canFocusStairs}
canSelectStairs={canSelectStairs}
@@ -6501,10 +6680,12 @@ export function FloorplanPanel() {
roofs,
site,
slabs,
spawns,
walls,
zones,
} = useFloorplanSceneData({ buildingId, levelId })
const buildingRotationDeg = (buildingRotationY * 180) / Math.PI
const floorplanSceneRotationDeg = FLOORPLAN_VIEW_ROTATION_DEG - buildingRotationDeg
const [draftStart, setDraftStart] = useState<WallPlanPoint | null>(null)
const [draftEnd, setDraftEnd] = useState<WallPlanPoint | null>(null)
@@ -6558,6 +6739,7 @@ export function FloorplanPanel() {
const [hoveredSlabId, setHoveredSlabId] = useState<SlabNode['id'] | null>(null)
const [hoveredCeilingId, setHoveredCeilingId] = useState<CeilingNode['id'] | null>(null)
const [hoveredItemId, setHoveredItemId] = useState<ItemNode['id'] | null>(null)
const [hoveredSpawnId, setHoveredSpawnId] = useState<SpawnNode['id'] | null>(null)
const [hoveredStairId, setHoveredStairId] = useState<StairNode['id'] | null>(null)
const [hoveredZoneId, setHoveredZoneId] = useState<ZoneNodeType['id'] | null>(null)
const [hoveredEndpointId, setHoveredEndpointId] = useState<string | null>(null)
@@ -7145,6 +7327,24 @@ export function FloorplanPanel() {
),
[levelDescendantNodes],
)
const floorplanSpawnEntries = useMemo<FloorplanSpawnEntry[]>(
() =>
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<string, SharedFloorplanNodeTransform | null>()
@@ -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<string | null>(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<SVGElement>) => {
emitFloorplanNodeClick(spawnId, 'click', event)
},
[emitFloorplanNodeClick],
)
const handleStairSelect = useCallback(
(stairId: StairNode['id'], event: ReactMouseEvent<SVGElement>) => {
emitFloorplanNodeClick(stairId, 'click', event)
@@ -12347,6 +12675,73 @@ export function FloorplanPanel() {
},
[emitFloorplanNodeClick],
)
const handleSpawnDoubleClick = useCallback(
(spawn: SpawnNode, event: ReactMouseEvent<SVGElement>) => {
emitFloorplanNodeClick(spawn.id, 'double-click', event)
emitter.emit('camera-controls:focus', { nodeId: spawn.id })
},
[emitFloorplanNodeClick],
)
const handleSpawnPointerDown = useCallback(
(spawnId: SpawnNode['id'], event: ReactPointerEvent<SVGElement>) => {
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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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<SVGElement>) => {
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() {
<g
ref={floorplanSceneRef}
transform={buildingRotationDeg !== 0 ? `rotate(${buildingRotationDeg})` : undefined}
transform={
floorplanSceneRotationDeg !== 0 ? `rotate(${floorplanSceneRotationDeg})` : undefined
}
>
<FloorplanGridLayer
majorGridPath={majorGridPath}
@@ -14691,11 +15097,14 @@ export function FloorplanPanel() {
<FloorplanNodeLayer
canFocusItems={canFocusFloorplanItems}
canFocusSpawns={canFocusFloorplanSpawns}
canFocusStairs={canFocusFloorplanStairs}
canSelectItems={canSelectFloorplanItems}
canSelectSpawns={canSelectFloorplanSpawns}
canSelectStairs={canSelectFloorplanStairs}
highlightedIdSet={highlightedFloorplanIdSet}
hoveredItemId={hoveredItemId}
hoveredSpawnId={hoveredSpawnId}
hoveredStairId={hoveredStairId}
isDeleteMode={isDeleteMode}
isFurnishContextActive={isFloorplanFurnishContextActive}
@@ -14705,6 +15114,11 @@ export function FloorplanPanel() {
onItemHoverEnter={handleFloorplanItemHoverEnter}
onItemPointerDown={handleItemPointerDown}
onItemSelect={handleItemSelect}
onSpawnDoubleClick={handleSpawnDoubleClick}
onSpawnHoverChange={handleSpawnHoverChange}
onSpawnHoverEnter={handleFloorplanSpawnHoverEnter}
onSpawnPointerDown={handleSpawnPointerDown}
onSpawnSelect={handleSpawnSelect}
onStairDoubleClick={handleStairDoubleClick}
onStairHoverChange={handleStairHoverChange}
onStairHoverEnter={handleFloorplanStairHoverEnter}
@@ -14712,6 +15126,7 @@ export function FloorplanPanel() {
onStairSelect={handleStairSelect}
palette={palette}
selectedIdSet={selectedIdSet}
spawnEntries={floorplanSpawnEntries}
stairEntries={renderedFloorplanStairEntries}
unit={unit}
wallSelectionHatchId={wallSelectionHatchId}
@@ -11,6 +11,7 @@ import {
type RoofNode,
type SiteNode,
type SlabNode,
type SpawnNode,
useScene,
type WallNode,
type WindowNode,
@@ -113,6 +114,7 @@ export function useFloorplanSceneData({
)
const levelGuides = useLevelChildren(levelId, (node): node is GuideNode => 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,
}
@@ -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<PolygonEditorProps> = ({
polygon,
@@ -44,6 +59,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
levelId,
surfaceHeight = 0,
allowPolygonMove = false,
allowEdgeMove = false,
}) => {
const [levelNode, setLevelNode] = useState<Object3D | null>(() =>
levelId ? (sceneRegistry.nodes.get(levelId) ?? null) : null,
@@ -89,6 +105,11 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
const previewPolygonRef = useRef<Array<[number, number]> | 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<PolygonEditorProps> = ({
const [hoveredVertex, setHoveredVertex] = useState<number | null>(null)
const [hoveredMidpoint, setHoveredMidpoint] = useState<number | null>(null)
const [hoveredEdge, setHoveredEdge] = useState<number | null>(null)
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
const lineRef = useRef<Line>(null!)
@@ -106,7 +128,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
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<PolygonEditorProps> = ({
})
}, [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<PolygonEditorProps> = ({
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<PolygonEditorProps> = ({
...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<PolygonEditorProps> = ({
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<PolygonEditorProps> = ({
} 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<PolygonEditorProps> = ({
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<PolygonEditorProps> = ({
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<PolygonEditorProps> = ({
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<PolygonEditorProps> = ({
</mesh>
)}
{allowEdgeMove &&
edgeHandles.map(({ index, length, midpoint, rotationY }) => {
const isHovered = hoveredEdge === index
const isDragging = dragState?.mode === 'edge' && dragState.edgeIndex === index
return (
<mesh
key={`edge-${index}`}
layers={EDITOR_LAYER}
onClick={(e) => {
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]}
>
<boxGeometry args={[length, EDGE_HANDLE_HEIGHT, EDGE_HANDLE_THICKNESS]} />
<meshStandardMaterial
color={isDragging ? '#22c55e' : '#94a3b8'}
opacity={isDragging ? 0.5 : isHovered ? 0.38 : 0.14}
transparent
/>
</mesh>
)
})}
{/* 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<PolygonEditorProps> = ({
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)
@@ -31,6 +31,7 @@ export const SlabBoundaryEditor: React.FC<SlabBoundaryEditorProps> = ({ slabId }
return (
<PolygonEditor
allowEdgeMove
color="#a3a3a3"
levelId={resolveLevelId(slab, useScene.getState().nodes)}
minVertices={3}
@@ -36,6 +36,7 @@ export const SlabHoleEditor: React.FC<SlabHoleEditorProps> = ({ slabId, holeInde
return (
<PolygonEditor
allowEdgeMove
allowPolygonMove
color="#ef4444"
levelId={resolveLevelId(slab, useScene.getState().nodes)} // red for holes