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 slab: FloorplanActionMenuEntry
ceiling: FloorplanActionMenuEntry ceiling: FloorplanActionMenuEntry
opening: FloorplanActionMenuEntry opening: FloorplanActionMenuEntry
spawn: FloorplanActionMenuEntry
stair: FloorplanActionMenuEntry stair: FloorplanActionMenuEntry
roof: FloorplanActionMenuEntry roof: FloorplanActionMenuEntry
offsetY?: number offsetY?: number
@@ -38,6 +39,7 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
slab, slab,
ceiling, ceiling,
opening, opening,
spawn,
stair, stair,
roof, roof,
offsetY = 10, offsetY = 10,
@@ -59,6 +61,7 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
slab, slab,
ceiling, ceiling,
opening, opening,
spawn,
stair, stair,
roof, roof,
] ]
@@ -3,11 +3,11 @@
import type { Point2D } from '@pascal-app/core' import type { Point2D } from '@pascal-app/core'
function toSvgX(value: number) { function toSvgX(value: number) {
return -value return value
} }
function toSvgY(value: number) { function toSvgY(value: number) {
return -value return value
} }
function toSvgPoint(point: Point2D) { function toSvgPoint(point: Point2D) {
@@ -100,9 +100,7 @@ export function buildSvgAnnularSectorPath(
} }
export function formatSvgPolygonPoints(points: Point2D[]) { export function formatSvgPolygonPoints(points: Point2D[]) {
return points return points.map((point) => `${toSvgX(point.x)},${toSvgY(point.y)}`).join(' ')
.map((point) => `${toSvgX(point.x)},${toSvgY(point.y)}`)
.join(' ')
} }
export function buildSvgArrowHeadPoints(point: Point2D, angle: number, size: number) { export function buildSvgArrowHeadPoints(point: Point2D, angle: number, size: number) {
@@ -29,6 +29,7 @@ import {
type RoofSegmentNode, type RoofSegmentNode,
type SiteNode, type SiteNode,
SlabNode, SlabNode,
type SpawnNode,
type StairNode, type StairNode,
StairNode as StairNodeSchema, StairNode as StairNodeSchema,
type StairSegmentNode, type StairSegmentNode,
@@ -215,6 +216,13 @@ const FLOORPLAN_TRACE_STRUCTURE_SELECTED_FILL_OPACITY = 0.34
const FLOORPLAN_SITE_COLOR = '#10b981' const FLOORPLAN_SITE_COLOR = '#10b981'
const FLOORPLAN_NODE_FOOTPRINT_STROKE_WIDTH = FLOORPLAN_OPENING_STROKE_WIDTH / 2 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_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 = { type FloorplanViewport = {
centerX: number centerX: number
centerY: number centerY: number
@@ -587,6 +595,12 @@ type FloorplanItemEntry = {
depth: number depth: number
} }
type FloorplanSpawnEntry = {
spawn: SpawnNode
position: Point2D
rotation: number
}
type ReferenceFloorData = { type ReferenceFloorData = {
ceilingPolygons: CeilingPolygonEntry[] ceilingPolygons: CeilingPolygonEntry[]
fenceEntries: FloorplanFenceEntry[] fenceEntries: FloorplanFenceEntry[]
@@ -772,11 +786,11 @@ function toWallPlanPoint(point: Point2D): WallPlanPoint {
} }
function toSvgX(value: number): number { function toSvgX(value: number): number {
return -value return value
} }
function toSvgY(value: number): number { function toSvgY(value: number): number {
return -value return value
} }
function toSvgPoint(point: Point2D): SvgPoint { function toSvgPoint(point: Point2D): SvgPoint {
@@ -915,11 +929,11 @@ function getGuideRotateCursor(isDarkMode: boolean) {
} }
function getGuideSvgRotation(rotationY: number) { function getGuideSvgRotation(rotationY: number) {
return normalizeAngle(Math.PI - rotationY) return normalizeAngle(-rotationY)
} }
function getGuideSceneRotationFromSvgRotation(rotationSvg: number) { function getGuideSceneRotationFromSvgRotation(rotationSvg: number) {
return normalizeAngle(Math.PI - rotationSvg) return normalizeAngle(-rotationSvg)
} }
function buildGuideTranslateDraft( function buildGuideTranslateDraft(
@@ -2938,6 +2952,37 @@ function buildGridPath(
return commands.join(' ') 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( function findClosestWallPoint(
point: WallPlanPoint, point: WallPlanPoint,
walls: WallNode[], walls: WallNode[],
@@ -3264,20 +3309,20 @@ const FloorplanGridLayer = memo(function FloorplanGridLayer({
<path <path
d={minorGridPath} d={minorGridPath}
fill="none" fill="none"
opacity={palette.minorGridOpacity} opacity={palette.majorGridOpacity}
shapeRendering="crispEdges" shapeRendering="crispEdges"
stroke={palette.minorGrid} stroke={palette.majorGrid}
strokeWidth={FLOORPLAN_MINOR_GRID_STROKE_WIDTH} strokeWidth={FLOORPLAN_MAJOR_GRID_STROKE_WIDTH}
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
/> />
<path <path
d={majorGridPath} d={majorGridPath}
fill="none" fill="none"
opacity={palette.majorGridOpacity} opacity={palette.minorGridOpacity}
shapeRendering="crispEdges" shapeRendering="crispEdges"
stroke={palette.majorGrid} stroke={palette.minorGrid}
strokeWidth={FLOORPLAN_MAJOR_GRID_STROKE_WIDTH} strokeWidth={FLOORPLAN_MINOR_GRID_STROKE_WIDTH}
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
/> />
</> </>
@@ -5193,11 +5238,14 @@ function FloorplanItemImage({
const FloorplanNodeLayer = memo(function FloorplanNodeLayer({ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
canFocusItems, canFocusItems,
canFocusSpawns,
canFocusStairs, canFocusStairs,
canSelectItems, canSelectItems,
canSelectSpawns,
canSelectStairs, canSelectStairs,
highlightedIdSet, highlightedIdSet,
hoveredItemId, hoveredItemId,
hoveredSpawnId,
hoveredStairId, hoveredStairId,
isDeleteMode, isDeleteMode,
isFurnishContextActive, isFurnishContextActive,
@@ -5207,6 +5255,11 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
onItemHoverEnter, onItemHoverEnter,
onItemPointerDown, onItemPointerDown,
onItemSelect, onItemSelect,
onSpawnDoubleClick,
onSpawnHoverChange,
onSpawnHoverEnter,
onSpawnPointerDown,
onSpawnSelect,
onStairDoubleClick, onStairDoubleClick,
onStairHoverChange, onStairHoverChange,
onStairHoverEnter, onStairHoverEnter,
@@ -5214,16 +5267,20 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
onStairSelect, onStairSelect,
palette, palette,
selectedIdSet, selectedIdSet,
spawnEntries,
stairEntries, stairEntries,
unit, unit,
wallSelectionHatchId, wallSelectionHatchId,
}: { }: {
canFocusItems: boolean canFocusItems: boolean
canFocusSpawns: boolean
canFocusStairs: boolean canFocusStairs: boolean
canSelectItems: boolean canSelectItems: boolean
canSelectSpawns: boolean
canSelectStairs: boolean canSelectStairs: boolean
highlightedIdSet: ReadonlySet<string> highlightedIdSet: ReadonlySet<string>
hoveredItemId: ItemNode['id'] | null hoveredItemId: ItemNode['id'] | null
hoveredSpawnId: SpawnNode['id'] | null
hoveredStairId: StairNode['id'] | null hoveredStairId: StairNode['id'] | null
isDeleteMode: boolean isDeleteMode: boolean
isFurnishContextActive: boolean isFurnishContextActive: boolean
@@ -5233,6 +5290,11 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
onItemHoverEnter: (itemId: ItemNode['id']) => void onItemHoverEnter: (itemId: ItemNode['id']) => void
onItemPointerDown: (itemId: ItemNode['id'], event: ReactPointerEvent<SVGElement>) => void onItemPointerDown: (itemId: ItemNode['id'], event: ReactPointerEvent<SVGElement>) => void
onItemSelect: (itemId: ItemNode['id'], event: ReactMouseEvent<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 onStairDoubleClick: (stair: StairNode, event: ReactMouseEvent<SVGElement>) => void
onStairHoverChange: (stairId: StairNode['id'] | null) => void onStairHoverChange: (stairId: StairNode['id'] | null) => void
onStairHoverEnter: (stairId: StairNode['id']) => void onStairHoverEnter: (stairId: StairNode['id']) => void
@@ -5240,11 +5302,12 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
onStairSelect: (stairId: StairNode['id'], event: ReactMouseEvent<SVGElement>) => void onStairSelect: (stairId: StairNode['id'], event: ReactMouseEvent<SVGElement>) => void
palette: FloorplanPalette palette: FloorplanPalette
selectedIdSet: ReadonlySet<string> selectedIdSet: ReadonlySet<string>
spawnEntries: FloorplanSpawnEntry[]
stairEntries: FloorplanStairEntry[] stairEntries: FloorplanStairEntry[]
unit: 'metric' | 'imperial' unit: 'metric' | 'imperial'
wallSelectionHatchId: string wallSelectionHatchId: string
}) { }) {
if (itemEntries.length === 0 && stairEntries.length === 0) { if (itemEntries.length === 0 && stairEntries.length === 0 && spawnEntries.length === 0) {
return null 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 ( return (
<> <>
{isFurnishContextActive ? ( {isFurnishContextActive ? (
@@ -5446,10 +5623,12 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
stairEntries={stairEntries} stairEntries={stairEntries}
/> />
{itemNodes} {itemNodes}
{spawnNodes}
</> </>
) : ( ) : (
<> <>
{itemNodes} {itemNodes}
{spawnNodes}
<FloorplanStairLayer <FloorplanStairLayer
canFocusStairs={canFocusStairs} canFocusStairs={canFocusStairs}
canSelectStairs={canSelectStairs} canSelectStairs={canSelectStairs}
@@ -6501,10 +6680,12 @@ export function FloorplanPanel() {
roofs, roofs,
site, site,
slabs, slabs,
spawns,
walls, walls,
zones, zones,
} = useFloorplanSceneData({ buildingId, levelId }) } = useFloorplanSceneData({ buildingId, levelId })
const buildingRotationDeg = (buildingRotationY * 180) / Math.PI const buildingRotationDeg = (buildingRotationY * 180) / Math.PI
const floorplanSceneRotationDeg = FLOORPLAN_VIEW_ROTATION_DEG - buildingRotationDeg
const [draftStart, setDraftStart] = useState<WallPlanPoint | null>(null) const [draftStart, setDraftStart] = useState<WallPlanPoint | null>(null)
const [draftEnd, setDraftEnd] = 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 [hoveredSlabId, setHoveredSlabId] = useState<SlabNode['id'] | null>(null)
const [hoveredCeilingId, setHoveredCeilingId] = useState<CeilingNode['id'] | null>(null) const [hoveredCeilingId, setHoveredCeilingId] = useState<CeilingNode['id'] | null>(null)
const [hoveredItemId, setHoveredItemId] = useState<ItemNode['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 [hoveredStairId, setHoveredStairId] = useState<StairNode['id'] | null>(null)
const [hoveredZoneId, setHoveredZoneId] = useState<ZoneNodeType['id'] | null>(null) const [hoveredZoneId, setHoveredZoneId] = useState<ZoneNodeType['id'] | null>(null)
const [hoveredEndpointId, setHoveredEndpointId] = useState<string | null>(null) const [hoveredEndpointId, setHoveredEndpointId] = useState<string | null>(null)
@@ -7145,6 +7327,24 @@ export function FloorplanPanel() {
), ),
[levelDescendantNodes], [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 floorplanItemEntries = useMemo(() => {
const transformCache = new Map<string, SharedFloorplanNodeTransform | null>() const transformCache = new Map<string, SharedFloorplanNodeTransform | null>()
@@ -7504,6 +7704,13 @@ export function FloorplanPanel() {
return floorplanItemEntries.find(({ item }) => item.id === selectedIds[0]) ?? null return floorplanItemEntries.find(({ item }) => item.id === selectedIds[0]) ?? null
}, [floorplanItemEntries, selectedIds]) }, [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(() => { const selectedItemClearanceMeasurements = useMemo(() => {
if (!selectedItemEntry) { if (!selectedItemEntry) {
return [] as LinearMeasurementOverlay[] return [] as LinearMeasurementOverlay[]
@@ -7837,6 +8044,7 @@ export function FloorplanPanel() {
const isCeilingMoveActive = movingNode?.type === 'ceiling' const isCeilingMoveActive = movingNode?.type === 'ceiling'
const isFenceMoveActive = movingNode?.type === 'fence' const isFenceMoveActive = movingNode?.type === 'fence'
const isWallMoveActive = movingNode?.type === 'wall' const isWallMoveActive = movingNode?.type === 'wall'
const isSpawnMoveActive = movingNode?.type === 'spawn'
const isWallCurveActive = curvingWall?.type === 'wall' const isWallCurveActive = curvingWall?.type === 'wall'
const isFenceCurveActive = curvingFence?.type === 'fence' const isFenceCurveActive = curvingFence?.type === 'fence'
const isFenceEndpointMoveActive = movingFenceEndpoint !== null const isFenceEndpointMoveActive = movingFenceEndpoint !== null
@@ -7855,6 +8063,7 @@ export function FloorplanPanel() {
isCeilingMoveActive || isCeilingMoveActive ||
isFenceMoveActive || isFenceMoveActive ||
isWallMoveActive || isWallMoveActive ||
isSpawnMoveActive ||
isWallCurveActive || isWallCurveActive ||
isFenceCurveActive || isFenceCurveActive ||
isFenceEndpointMoveActive || isFenceEndpointMoveActive ||
@@ -7976,6 +8185,7 @@ export function FloorplanPanel() {
!movingFenceEndpoint && !movingFenceEndpoint &&
isFloorplanStructureContextActive) || isFloorplanStructureContextActive) ||
isDeleteMode isDeleteMode
const canSelectFloorplanSpawns = canSelectFloorplanStairs
const canSelectFloorplanItems = const canSelectFloorplanItems =
(mode === 'select' && (mode === 'select' &&
floorplanSelectionTool === 'click' && floorplanSelectionTool === 'click' &&
@@ -7989,6 +8199,7 @@ export function FloorplanPanel() {
!movingNode && !movingNode &&
!movingFenceEndpoint && !movingFenceEndpoint &&
isFloorplanStructureContextActive isFloorplanStructureContextActive
const canFocusFloorplanSpawns = canFocusFloorplanStairs
const canFocusFloorplanItems = const canFocusFloorplanItems =
mode === 'select' && mode === 'select' &&
floorplanSelectionTool === 'click' && floorplanSelectionTool === 'click' &&
@@ -8744,6 +8955,47 @@ export function FloorplanPanel() {
: null, : null,
[selectedItemEntry, surfaceSize, viewBox], [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(() => { const selectedSlabActionMenuPosition = useMemo(() => {
if (slabHoleMoveDraft) { if (slabHoleMoveDraft) {
return null return null
@@ -9049,31 +9301,35 @@ export function FloorplanPanel() {
() => getVisibleGridSteps(viewBox.width, surfaceSize.width), () => getVisibleGridSteps(viewBox.width, surfaceSize.width),
[surfaceSize.width, viewBox.width], [surfaceSize.width, viewBox.width],
) )
const gridBounds = useMemo(
() => getRotatedViewBoxBounds(viewBox, floorplanSceneRotationDeg),
[floorplanSceneRotationDeg, viewBox],
)
const minorGridPath = useMemo( const minorGridPath = useMemo(
() => () =>
buildGridPath( buildGridPath(
viewBox.minX, gridBounds.minX,
viewBox.minX + viewBox.width, gridBounds.maxX,
viewBox.minY, gridBounds.minY,
viewBox.minY + viewBox.height, gridBounds.maxY,
gridSteps.minorStep, gridSteps.minorStep,
{ {
excludeStep: gridSteps.majorStep, excludeStep: gridSteps.majorStep,
}, },
), ),
[gridSteps.majorStep, gridSteps.minorStep, viewBox], [gridBounds, gridSteps.majorStep, gridSteps.minorStep],
) )
const majorGridPath = useMemo( const majorGridPath = useMemo(
() => () =>
buildGridPath( buildGridPath(
viewBox.minX, gridBounds.minX,
viewBox.minX + viewBox.width, gridBounds.maxX,
viewBox.minY, gridBounds.minY,
viewBox.minY + viewBox.height, gridBounds.maxY,
gridSteps.majorStep, gridSteps.majorStep,
), ),
[gridSteps.majorStep, viewBox], [gridBounds, gridSteps.majorStep],
) )
const floorplanUnitsPerPixel = viewBox.width / Math.max(surfaceSize.width, 1) const floorplanUnitsPerPixel = viewBox.width / Math.max(surfaceSize.width, 1)
@@ -9808,6 +10064,30 @@ export function FloorplanPanel() {
return unsubscribe return unsubscribe
}, [movingNode, scheduleMovingFloorplanNodeRefresh]) }, [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(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null const target = event.target as HTMLElement | null
@@ -9831,7 +10111,9 @@ export function FloorplanPanel() {
} }
if ( 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') (event.key === 'r' || event.key === 'R' || event.key === 't' || event.key === 'T')
) { ) {
setMovingFloorplanNodeRevision((current) => current + 1) setMovingFloorplanNodeRevision((current) => current + 1)
@@ -11070,7 +11352,7 @@ export function FloorplanPanel() {
const hoveredWallIdRef = useRef<string | null>(null) const hoveredWallIdRef = useRef<string | null>(null)
const floorplanGridLocalY = useMemo(() => { const floorplanGridLocalY = useMemo(() => {
if (movingNode?.type === 'item') { if (movingNode?.type === 'item' || movingNode?.type === 'spawn') {
return movingNode.position[1] return movingNode.position[1]
} }
@@ -11107,8 +11389,8 @@ export function FloorplanPanel() {
const snappedPoint = getSnappedFloorplanPoint(planPoint) const snappedPoint = getSnappedFloorplanPoint(planPoint)
const cos = Math.cos(buildingRotationY) const cos = Math.cos(buildingRotationY)
const sin = Math.sin(buildingRotationY) const sin = Math.sin(buildingRotationY)
const worldX = buildingPosition[0] + snappedPoint[0] * cos - snappedPoint[1] * sin const worldX = buildingPosition[0] + snappedPoint[0] * cos + snappedPoint[1] * sin
const worldZ = buildingPosition[2] + snappedPoint[0] * sin + snappedPoint[1] * cos const worldZ = buildingPosition[2] - snappedPoint[0] * sin + snappedPoint[1] * cos
emitter.emit(`grid:${eventType}` as any, { emitter.emit(`grid:${eventType}` as any, {
nativeEvent: nativeEvent.nativeEvent as any, nativeEvent: nativeEvent.nativeEvent as any,
@@ -11918,6 +12200,14 @@ export function FloorplanPanel() {
[syncDeleteHoveredId], [syncDeleteHoveredId],
) )
const handleSpawnHoverChange = useCallback(
(spawnId: SpawnNode['id'] | null) => {
setHoveredSpawnId(spawnId)
syncDeleteHoveredId(spawnId)
},
[syncDeleteHoveredId],
)
const handleStairHoverChange = useCallback( const handleStairHoverChange = useCallback(
(stairId: StairNode['id'] | null) => { (stairId: StairNode['id'] | null) => {
setHoveredStairId(stairId) setHoveredStairId(stairId)
@@ -11941,6 +12231,7 @@ export function FloorplanPanel() {
handleSlabHoverChange(null) handleSlabHoverChange(null)
handleCeilingHoverChange(null) handleCeilingHoverChange(null)
handleStairHoverChange(null) handleStairHoverChange(null)
handleSpawnHoverChange(null)
handleZoneHoverChange(null) handleZoneHoverChange(null)
handleItemHoverChange(itemId) handleItemHoverChange(itemId)
}, },
@@ -11950,6 +12241,7 @@ export function FloorplanPanel() {
handleOpeningHoverChange, handleOpeningHoverChange,
handleCeilingHoverChange, handleCeilingHoverChange,
handleSlabHoverChange, handleSlabHoverChange,
handleSpawnHoverChange,
handleStairHoverChange, handleStairHoverChange,
handleWallHoverChange, handleWallHoverChange,
handleZoneHoverChange, handleZoneHoverChange,
@@ -11963,6 +12255,7 @@ export function FloorplanPanel() {
handleSlabHoverChange(null) handleSlabHoverChange(null)
handleCeilingHoverChange(null) handleCeilingHoverChange(null)
handleStairHoverChange(null) handleStairHoverChange(null)
handleSpawnHoverChange(null)
handleZoneHoverChange(null) handleZoneHoverChange(null)
handleFenceHoverChange(fenceId) handleFenceHoverChange(fenceId)
}, },
@@ -11972,6 +12265,7 @@ export function FloorplanPanel() {
handleOpeningHoverChange, handleOpeningHoverChange,
handleCeilingHoverChange, handleCeilingHoverChange,
handleSlabHoverChange, handleSlabHoverChange,
handleSpawnHoverChange,
handleStairHoverChange, handleStairHoverChange,
handleWallHoverChange, handleWallHoverChange,
handleZoneHoverChange, handleZoneHoverChange,
@@ -11985,6 +12279,7 @@ export function FloorplanPanel() {
handleSlabHoverChange(null) handleSlabHoverChange(null)
handleCeilingHoverChange(null) handleCeilingHoverChange(null)
handleWallHoverChange(null) handleWallHoverChange(null)
handleSpawnHoverChange(null)
handleZoneHoverChange(null) handleZoneHoverChange(null)
handleStairHoverChange(stairId) handleStairHoverChange(stairId)
}, },
@@ -11994,6 +12289,31 @@ export function FloorplanPanel() {
handleOpeningHoverChange, handleOpeningHoverChange,
handleCeilingHoverChange, handleCeilingHoverChange,
handleSlabHoverChange, 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, handleStairHoverChange,
handleWallHoverChange, handleWallHoverChange,
handleZoneHoverChange, handleZoneHoverChange,
@@ -12086,6 +12406,7 @@ export function FloorplanPanel() {
| OpeningNode['id'] | OpeningNode['id']
| SlabNode['id'] | SlabNode['id']
| CeilingNode['id'] | CeilingNode['id']
| SpawnNode['id']
| StairNode['id'] | StairNode['id']
| ZoneNodeType['id'], | ZoneNodeType['id'],
eventType: 'click' | 'double-click', eventType: 'click' | 'double-click',
@@ -12100,6 +12421,7 @@ export function FloorplanPanel() {
node.type === 'door' || node.type === 'door' ||
node.type === 'window' || node.type === 'window' ||
node.type === 'item' || node.type === 'item' ||
node.type === 'spawn' ||
node.type === 'stair' || node.type === 'stair' ||
node.type === 'zone') node.type === 'zone')
) )
@@ -12309,6 +12631,12 @@ export function FloorplanPanel() {
}, },
[emitFloorplanNodeClick], [emitFloorplanNodeClick],
) )
const handleSpawnSelect = useCallback(
(spawnId: SpawnNode['id'], event: ReactMouseEvent<SVGElement>) => {
emitFloorplanNodeClick(spawnId, 'click', event)
},
[emitFloorplanNodeClick],
)
const handleStairSelect = useCallback( const handleStairSelect = useCallback(
(stairId: StairNode['id'], event: ReactMouseEvent<SVGElement>) => { (stairId: StairNode['id'], event: ReactMouseEvent<SVGElement>) => {
emitFloorplanNodeClick(stairId, 'click', event) emitFloorplanNodeClick(stairId, 'click', event)
@@ -12347,6 +12675,73 @@ export function FloorplanPanel() {
}, },
[emitFloorplanNodeClick], [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( const handleItemPointerDown = useCallback(
(itemId: ItemNode['id'], event: ReactPointerEvent<SVGElement>) => { (itemId: ItemNode['id'], event: ReactPointerEvent<SVGElement>) => {
if (event.button !== 0) { if (event.button !== 0) {
@@ -13818,6 +14213,7 @@ export function FloorplanPanel() {
handleWallHoverChange(null) handleWallHoverChange(null)
handleSlabHoverChange(null) handleSlabHoverChange(null)
handleCeilingHoverChange(null) handleCeilingHoverChange(null)
handleSpawnHoverChange(null)
handleStairHoverChange(null) handleStairHoverChange(null)
handleZoneHoverChange(null) handleZoneHoverChange(null)
setHoveredEndpointId(null) setHoveredEndpointId(null)
@@ -13835,6 +14231,7 @@ export function FloorplanPanel() {
handleItemHoverChange, handleItemHoverChange,
handleOpeningHoverChange, handleOpeningHoverChange,
handleSlabHoverChange, handleSlabHoverChange,
handleSpawnHoverChange,
handleStairHoverChange, handleStairHoverChange,
handleWallHoverChange, handleWallHoverChange,
handleZoneHoverChange, handleZoneHoverChange,
@@ -13940,6 +14337,7 @@ export function FloorplanPanel() {
handleOpeningHoverChange(null) handleOpeningHoverChange(null)
handleWallHoverChange(null) handleWallHoverChange(null)
handleSlabHoverChange(null) handleSlabHoverChange(null)
handleSpawnHoverChange(null)
handleStairHoverChange(null) handleStairHoverChange(null)
handleZoneHoverChange(null) handleZoneHoverChange(null)
setHoveredEndpointId(null) setHoveredEndpointId(null)
@@ -13960,6 +14358,7 @@ export function FloorplanPanel() {
handleItemHoverChange, handleItemHoverChange,
handleOpeningHoverChange, handleOpeningHoverChange,
handleSlabHoverChange, handleSlabHoverChange,
handleSpawnHoverChange,
handleStairHoverChange, handleStairHoverChange,
handleWallHoverChange, handleWallHoverChange,
handleZoneHoverChange, handleZoneHoverChange,
@@ -14397,6 +14796,11 @@ export function FloorplanPanel() {
onDuplicate: handleSelectedOpeningDuplicate, onDuplicate: handleSelectedOpeningDuplicate,
onMove: handleSelectedOpeningMove, onMove: handleSelectedOpeningMove,
}} }}
spawn={{
position: selectedSpawnActionMenuPosition,
onDelete: handleSelectedSpawnDelete,
onMove: handleSelectedSpawnMove,
}}
roof={{ roof={{
position: selectedRoofActionMenuPosition, position: selectedRoofActionMenuPosition,
onDelete: handleSelectedRoofDelete, onDelete: handleSelectedRoofDelete,
@@ -14598,7 +15002,9 @@ export function FloorplanPanel() {
<g <g
ref={floorplanSceneRef} ref={floorplanSceneRef}
transform={buildingRotationDeg !== 0 ? `rotate(${buildingRotationDeg})` : undefined} transform={
floorplanSceneRotationDeg !== 0 ? `rotate(${floorplanSceneRotationDeg})` : undefined
}
> >
<FloorplanGridLayer <FloorplanGridLayer
majorGridPath={majorGridPath} majorGridPath={majorGridPath}
@@ -14691,11 +15097,14 @@ export function FloorplanPanel() {
<FloorplanNodeLayer <FloorplanNodeLayer
canFocusItems={canFocusFloorplanItems} canFocusItems={canFocusFloorplanItems}
canFocusSpawns={canFocusFloorplanSpawns}
canFocusStairs={canFocusFloorplanStairs} canFocusStairs={canFocusFloorplanStairs}
canSelectItems={canSelectFloorplanItems} canSelectItems={canSelectFloorplanItems}
canSelectSpawns={canSelectFloorplanSpawns}
canSelectStairs={canSelectFloorplanStairs} canSelectStairs={canSelectFloorplanStairs}
highlightedIdSet={highlightedFloorplanIdSet} highlightedIdSet={highlightedFloorplanIdSet}
hoveredItemId={hoveredItemId} hoveredItemId={hoveredItemId}
hoveredSpawnId={hoveredSpawnId}
hoveredStairId={hoveredStairId} hoveredStairId={hoveredStairId}
isDeleteMode={isDeleteMode} isDeleteMode={isDeleteMode}
isFurnishContextActive={isFloorplanFurnishContextActive} isFurnishContextActive={isFloorplanFurnishContextActive}
@@ -14705,6 +15114,11 @@ export function FloorplanPanel() {
onItemHoverEnter={handleFloorplanItemHoverEnter} onItemHoverEnter={handleFloorplanItemHoverEnter}
onItemPointerDown={handleItemPointerDown} onItemPointerDown={handleItemPointerDown}
onItemSelect={handleItemSelect} onItemSelect={handleItemSelect}
onSpawnDoubleClick={handleSpawnDoubleClick}
onSpawnHoverChange={handleSpawnHoverChange}
onSpawnHoverEnter={handleFloorplanSpawnHoverEnter}
onSpawnPointerDown={handleSpawnPointerDown}
onSpawnSelect={handleSpawnSelect}
onStairDoubleClick={handleStairDoubleClick} onStairDoubleClick={handleStairDoubleClick}
onStairHoverChange={handleStairHoverChange} onStairHoverChange={handleStairHoverChange}
onStairHoverEnter={handleFloorplanStairHoverEnter} onStairHoverEnter={handleFloorplanStairHoverEnter}
@@ -14712,6 +15126,7 @@ export function FloorplanPanel() {
onStairSelect={handleStairSelect} onStairSelect={handleStairSelect}
palette={palette} palette={palette}
selectedIdSet={selectedIdSet} selectedIdSet={selectedIdSet}
spawnEntries={floorplanSpawnEntries}
stairEntries={renderedFloorplanStairEntries} stairEntries={renderedFloorplanStairEntries}
unit={unit} unit={unit}
wallSelectionHatchId={wallSelectionHatchId} wallSelectionHatchId={wallSelectionHatchId}
@@ -11,6 +11,7 @@ import {
type RoofNode, type RoofNode,
type SiteNode, type SiteNode,
type SlabNode, type SlabNode,
type SpawnNode,
useScene, useScene,
type WallNode, type WallNode,
type WindowNode, type WindowNode,
@@ -113,6 +114,7 @@ export function useFloorplanSceneData({
) )
const levelGuides = useLevelChildren(levelId, (node): node is GuideNode => node?.type === 'guide') const levelGuides = useLevelChildren(levelId, (node): node is GuideNode => node?.type === 'guide')
const zones = useLevelChildren(levelId, (node): node is ZoneNodeType => node?.type === 'zone') 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( const roofs = useScene(
useShallow((state) => { useShallow((state) => {
if (!levelId) { if (!levelId) {
@@ -180,6 +182,7 @@ export function useFloorplanSceneData({
roofs, roofs,
site, site,
slabs, slabs,
spawns,
walls, walls,
zones, zones,
} }
@@ -10,8 +10,10 @@ const Y_OFFSET = 0.02
type DragState = { type DragState = {
isDragging: boolean isDragging: boolean
mode: 'vertex' | 'polygon' mode: 'vertex' | 'polygon' | 'edge'
vertexIndex: number | null vertexIndex: number | null
edgeIndex?: number
edgeNormal?: [number, number]
initialPosition: [number, number] initialPosition: [number, number]
initialPolygon: Array<[number, number]> initialPolygon: Array<[number, number]>
pointerId: number pointerId: number
@@ -28,6 +30,8 @@ export interface PolygonEditorProps {
surfaceHeight?: number surfaceHeight?: number
/** Whether to show the center handle that moves the entire polygon. */ /** Whether to show the center handle that moves the entire polygon. */
allowPolygonMove?: boolean 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 * Used by zone and site boundary editors
*/ */
const MIN_HANDLE_HEIGHT = 0.15 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> = ({ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
polygon, polygon,
@@ -44,6 +59,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
levelId, levelId,
surfaceHeight = 0, surfaceHeight = 0,
allowPolygonMove = false, allowPolygonMove = false,
allowEdgeMove = false,
}) => { }) => {
const [levelNode, setLevelNode] = useState<Object3D | null>(() => const [levelNode, setLevelNode] = useState<Object3D | null>(() =>
levelId ? (sceneRegistry.nodes.get(levelId) ?? null) : 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 [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
const previewPolygonRef = useRef<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 // Keep ref in sync
useEffect(() => { useEffect(() => {
previewPolygonRef.current = previewPolygon previewPolygonRef.current = previewPolygon
@@ -96,6 +117,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
const [hoveredVertex, setHoveredVertex] = useState<number | null>(null) const [hoveredVertex, setHoveredVertex] = useState<number | null>(null)
const [hoveredMidpoint, setHoveredMidpoint] = 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 [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
const lineRef = useRef<Line>(null!) const lineRef = useRef<Line>(null!)
@@ -106,7 +128,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
if (polygon !== lastPolygonRef.current) { if (polygon !== lastPolygonRef.current) {
lastPolygonRef.current = polygon lastPolygonRef.current = polygon
// External change (e.g. undo/redo) — clear any stale preview/drag state // External change (e.g. undo/redo) — clear any stale preview/drag state
if (previewPolygon) setPreviewPolygon(null) if (previewPolygon) updatePreviewPolygon(null)
if (dragState) setDragState(null) if (dragState) setDragState(null)
} }
@@ -134,17 +156,37 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
}) })
}, [displayPolygon]) }, [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 // Update vertex position using grid cursor position
const handleVertexDrag = useCallback( const handleVertexDrag = useCallback(
(vertexIndex: number, position: [number, number]) => { (vertexIndex: number, position: [number, number]) => {
setPreviewPolygon((prev) => { const basePolygon = previewPolygonRef.current ?? polygon
const basePolygon = prev ?? polygon
const newPolygon = [...basePolygon] const newPolygon = [...basePolygon]
newPolygon[vertexIndex] = position newPolygon[vertexIndex] = position
return newPolygon updatePreviewPolygon(newPolygon)
})
}, },
[polygon], [polygon, updatePreviewPolygon],
) )
// Commit polygon changes // Commit polygon changes
@@ -152,9 +194,9 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
if (previewPolygonRef.current) { if (previewPolygonRef.current) {
onPolygonChange(previewPolygonRef.current) onPolygonChange(previewPolygonRef.current)
} }
setPreviewPolygon(null) updatePreviewPolygon(null)
setDragState(null) setDragState(null)
}, [onPolygonChange]) }, [onPolygonChange, updatePreviewPolygon])
// Handle adding a new vertex at midpoint // Handle adding a new vertex at midpoint
const handleAddVertex = useCallback( const handleAddVertex = useCallback(
@@ -166,10 +208,13 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
...basePolygon.slice(afterIndex + 1), ...basePolygon.slice(afterIndex + 1),
] ]
setPreviewPolygon(newPolygon) updatePreviewPolygon(newPolygon)
return afterIndex + 1 // Return new vertex index return {
polygon: newPolygon,
vertexIndex: afterIndex + 1,
}
}, },
[polygon, previewPolygon], [polygon, previewPolygon, updatePreviewPolygon],
) )
// Handle deleting a vertex // Handle deleting a vertex
@@ -180,9 +225,9 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
const newPolygon = basePolygon.filter((_, i) => i !== index) const newPolygon = basePolygon.filter((_, i) => i !== index)
onPolygonChange(newPolygon) onPolygonChange(newPolygon)
setPreviewPolygon(null) updatePreviewPolygon(null)
}, },
[polygon, previewPolygon, onPolygonChange, minVertices], [polygon, previewPolygon, onPolygonChange, minVertices, updatePreviewPolygon],
) )
// Listen to grid:move events to track cursor position // Listen to grid:move events to track cursor position
@@ -212,9 +257,31 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
} else if (dragState.mode === 'polygon') { } else if (dragState.mode === 'polygon') {
const deltaX = newPosition[0] - dragState.initialPosition[0] const deltaX = newPosition[0] - dragState.initialPosition[0]
const deltaZ = newPosition[1] - dragState.initialPosition[1] const deltaZ = newPosition[1] - dragState.initialPosition[1]
setPreviewPolygon( updatePreviewPolygon(
dragState.initialPolygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number]), 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 () => { return () => {
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
} }
}, [dragState, handleVertexDrag]) }, [dragState, handleVertexDrag, updatePreviewPolygon])
// Set up pointer up listener for ending drag // Set up pointer up listener for ending drag
useEffect(() => { useEffect(() => {
@@ -337,6 +404,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
onPointerDown={(e) => { onPointerDown={(e) => {
if (e.button !== 0) return if (e.button !== 0) return
e.stopPropagation() e.stopPropagation()
setHoveredEdge(null)
setDragState({ setDragState({
isDragging: true, isDragging: true,
mode: 'vertex', mode: 'vertex',
@@ -375,6 +443,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
onPointerDown={(e) => { onPointerDown={(e) => {
if (e.button !== 0) return if (e.button !== 0) return
e.stopPropagation() e.stopPropagation()
setHoveredEdge(null)
setDragState({ setDragState({
isDragging: true, isDragging: true,
mode: 'polygon', mode: 'polygon',
@@ -395,6 +464,62 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
</mesh> </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) */} {/* Midpoint handles - smaller green cylinders for adding vertices (hidden while dragging) */}
{!dragState && {!dragState &&
midpoints.map(([x, z], index) => { midpoints.map(([x, z], index) => {
@@ -413,12 +538,14 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
onPointerDown={(e) => { onPointerDown={(e) => {
if (e.button !== 0) return if (e.button !== 0) return
e.stopPropagation() e.stopPropagation()
const newVertexIndex = handleAddVertex(index, [x!, z!]) const insertedVertex = handleAddVertex(index, [x!, z!])
if (newVertexIndex >= 0) { if (insertedVertex.vertexIndex >= 0) {
setDragState({ setDragState({
isDragging: true, isDragging: true,
vertexIndex: newVertexIndex, mode: 'vertex',
vertexIndex: insertedVertex.vertexIndex,
initialPosition: [x!, z!], initialPosition: [x!, z!],
initialPolygon: insertedVertex.polygon,
pointerId: e.pointerId, pointerId: e.pointerId,
}) })
setHoveredMidpoint(null) setHoveredMidpoint(null)
@@ -31,6 +31,7 @@ export const SlabBoundaryEditor: React.FC<SlabBoundaryEditorProps> = ({ slabId }
return ( return (
<PolygonEditor <PolygonEditor
allowEdgeMove
color="#a3a3a3" color="#a3a3a3"
levelId={resolveLevelId(slab, useScene.getState().nodes)} levelId={resolveLevelId(slab, useScene.getState().nodes)}
minVertices={3} minVertices={3}
@@ -36,6 +36,7 @@ export const SlabHoleEditor: React.FC<SlabHoleEditorProps> = ({ slabId, holeInde
return ( return (
<PolygonEditor <PolygonEditor
allowEdgeMove
allowPolygonMove allowPolygonMove
color="#ef4444" color="#ef4444"
levelId={resolveLevelId(slab, useScene.getState().nodes)} // red for holes levelId={resolveLevelId(slab, useScene.getState().nodes)} // red for holes