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 (