diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx
index 32af88e0..8d25a5eb 100644
--- a/apps/editor/components/build-tab.tsx
+++ b/apps/editor/components/build-tab.tsx
@@ -53,7 +53,7 @@ const BUILD_TYPES: BuildType[] = [
{ id: 'window', label: 'Window', iconSrc: '/icons/window.png', kind: 'window' },
{ id: 'column', label: 'Column', iconSrc: '/icons/column.png', kind: 'column' },
{ id: 'shelf', label: 'Shelf', iconSrc: '/icons/shelf.png', kind: 'shelf' },
- { id: 'spawn', label: 'Spawn Point', iconSrc: '/icons/site.png', kind: 'spawn' },
+ { id: 'spawn', label: 'Spawn Point', iconSrc: '/icons/spawn-point.png', kind: 'spawn' },
{ id: 'painting', label: 'Painting', iconSrc: '/icons/paint.png', mode: 'material-paint' },
]
diff --git a/apps/editor/public/icons/site-flag.png b/apps/editor/public/icons/site-flag.png
new file mode 100644
index 00000000..593e7192
Binary files /dev/null and b/apps/editor/public/icons/site-flag.png differ
diff --git a/apps/editor/public/icons/spawn-point.png b/apps/editor/public/icons/spawn-point.png
new file mode 100644
index 00000000..0c2b05d3
Binary files /dev/null and b/apps/editor/public/icons/spawn-point.png differ
diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx
index 04e0761c..c71a62b8 100644
--- a/packages/editor/src/components/editor/floorplan-panel.tsx
+++ b/packages/editor/src/components/editor/floorplan-panel.tsx
@@ -75,9 +75,10 @@ import {
} from '../../lib/floorplan'
import { guideEmitter } from '../../lib/guide-events'
import { sfxEmitter } from '../../lib/sfx-bus'
+import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
import { cn } from '../../lib/utils'
import type { GuideUiState, NavigationSyncPose } from '../../store/use-editor'
-import useEditor from '../../store/use-editor'
+import useEditor, { selectSiteFloorplanContext } from '../../store/use-editor'
import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer'
import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay'
import { FloorplanSiteKeyHandler } from '../editor-2d/floorplan-hotkey-handlers'
@@ -217,7 +218,6 @@ const FLOORPLAN_GUIDE_HANDLE_HINT_PADDING_X = 92
const FLOORPLAN_GUIDE_HANDLE_HINT_PADDING_Y = 48
const FLOORPLAN_GUIDE_ROTATION_SNAP_DEGREES = 45
const FLOORPLAN_GUIDE_ROTATION_FINE_SNAP_DEGREES = 1
-const FLOORPLAN_SITE_COLOR = '#10b981'
const FLOORPLAN_VIEW_ROTATION_DEG = 90
const FLOORPLAN_ROTATION_DEGREES_PER_PIXEL = 0.35
const FLOORPLAN_VIEW_ANIMATION_TIME_CONSTANT_MS = 90
@@ -2483,7 +2483,7 @@ function polygonsEqual(a: WallPlanPoint[], b: Array<[number, number]>): boolean
return false
}
- return pointsEqual(point, otherPoint)
+ return Math.abs(point[0] - otherPoint[0]) < 1e-6 && Math.abs(point[1] - otherPoint[1]) < 1e-6
})
)
}
@@ -3710,29 +3710,175 @@ const FloorplanReferenceFloorLayer = memo(function FloorplanReferenceFloorLayer(
})
const FloorplanSiteLayer = memo(function FloorplanSiteLayer({
- isEditing,
+ isHighlighted,
+ palette,
sitePolygon,
}: {
- isEditing: boolean
+ isHighlighted: boolean
+ palette: FloorplanPalette
sitePolygon: SitePolygonEntry | null
}) {
if (!sitePolygon) {
return null
}
+ const stroke = isHighlighted ? palette.cursor : palette.measurementStroke
+ const dashLength = isHighlighted ? 8 : 7
+ const gapLength = isHighlighted ? 5 : 6
+ const strokeWidth = isHighlighted ? 2.2 : 1.5
+ const contrastStrokeWidth = isHighlighted ? 4.2 : 3.4
+ const dashPattern = `${dashLength} ${gapLength}`
+
return (
-
+ <>
+
+
+ >
+ )
+})
+
+const FloorplanSiteEdgeLabelLayer = memo(function FloorplanSiteEdgeLabelLayer({
+ labelBackground,
+ labelText,
+ palette,
+ sceneRotationDeg,
+ shouldShow,
+ sitePolygon,
+ unit,
+ unitsPerPixel,
+}: {
+ labelBackground: string
+ labelText: string
+ palette: FloorplanPalette
+ sceneRotationDeg: number
+ shouldShow: boolean
+ sitePolygon: SitePolygonEntry | null
+ unit: 'metric' | 'imperial'
+ unitsPerPixel: number
+}) {
+ if (!(shouldShow && sitePolygon && sitePolygon.polygon.length >= 2)) {
+ return null
+ }
+
+ const upx = unitsPerPixel
+ const fontSize = Math.max(upx * 10, 0.08)
+ const padX = upx * 6
+ const padY = upx * 3
+ const minWidth = upx * 38
+ const labelOffset = upx * 18
+ const centroid = sitePolygon.polygon.reduce(
+ (acc, point) => ({
+ x: acc.x + point.x / sitePolygon.polygon.length,
+ y: acc.y + point.y / sitePolygon.polygon.length,
+ }),
+ { x: 0, y: 0 },
+ )
+ const edges = sitePolygon.polygon.flatMap((start, edgeIndex, polygon) => {
+ const end = polygon[(edgeIndex + 1) % polygon.length]
+ if (!end) {
+ return []
+ }
+
+ const dx = end.x - start.x
+ const dy = end.y - start.y
+ const length = Math.hypot(dx, dy)
+ if (length < 1e-6) {
+ return []
+ }
+
+ const midX = (start.x + end.x) / 2
+ const midY = (start.y + end.y) / 2
+ let normalX = -dy / length
+ let normalY = dx / length
+ if (normalX * (midX - centroid.x) + normalY * (midY - centroid.y) < 0) {
+ normalX = -normalX
+ normalY = -normalY
+ }
+
+ let labelAngleDeg = (Math.atan2(dy, dx) * 180) / Math.PI
+ let screenDeg = labelAngleDeg + sceneRotationDeg
+ screenDeg = ((((screenDeg + 180) % 360) + 360) % 360) - 180
+ if (screenDeg > 90) {
+ labelAngleDeg -= 180
+ } else if (screenDeg <= -90) {
+ labelAngleDeg += 180
+ }
+
+ const label = formatMeasurement(length, unit)
+ const width = Math.max(label.length * upx * 6.4 + padX * 2, minWidth)
+ const height = fontSize + padY * 2
+
+ return [
+ {
+ edgeIndex,
+ height,
+ label,
+ labelAngleDeg,
+ labelX: midX + normalX * labelOffset,
+ labelY: midY + normalY * labelOffset,
+ width,
+ },
+ ]
+ })
+
+ return (
+
+ {edges.map(({ edgeIndex, height, label, labelAngleDeg, labelX, labelY, width }) => (
+
+
+
+ {label}
+
+
+ ))}
+
)
})
@@ -4035,7 +4181,7 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
onVertexDoubleClick: (
nodeId: string,
vertexIndex: number,
- event: ReactPointerEvent,
+ event: ReactPointerEvent | ReactMouseEvent,
) => void
onMidpointPointerDown: (
nodeId: string,
@@ -4050,6 +4196,8 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
palette: FloorplanPalette
unitsPerPixel: number
}) {
+ const vertexPointerDoubleClickRef = useRef(null)
+
return (
<>
{edgeHandles.map(({ nodeId, edgeIndex, start, end, isActive }) => {
@@ -4057,7 +4205,7 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
const isHovered = hoveredHandleId === handleId
const startSvg = toSvgPlanPoint(start)
const endSvg = toSvgPlanPoint(end)
- const visibleStroke = isActive ? palette.endpointHandleActiveStroke : palette.selectedStroke
+ const visibleStroke = isActive || isHovered ? palette.cursor : palette.measurementStroke
return (
{
const handleId = `${nodeId}:vertex:${vertexIndex}`
const isHovered = hoveredHandleId === handleId
- const stroke = isActive ? palette.endpointHandleActiveStroke : palette.endpointHandleStroke
+ const hoveredEdgePrefix = `${nodeId}:edge:`
+ const hoveredMidpointPrefix = `${nodeId}:midpoint:`
+ const hoveredEdgeIndex = hoveredHandleId?.startsWith(hoveredEdgePrefix)
+ ? Number(hoveredHandleId.slice(hoveredEdgePrefix.length))
+ : hoveredHandleId?.startsWith(hoveredMidpointPrefix)
+ ? Number(hoveredHandleId.slice(hoveredMidpointPrefix.length))
+ : null
+ const isNearHoveredEdge =
+ Number.isInteger(hoveredEdgeIndex) &&
+ hoveredEdgeIndex !== null &&
+ (vertexIndex === hoveredEdgeIndex ||
+ vertexIndex === (hoveredEdgeIndex + 1) % vertexHandles.length)
+ const stroke =
+ isActive || isHovered || isNearHoveredEdge ? palette.cursor : palette.measurementStroke
+ const handleOpacity = isActive || isHovered || isNearHoveredEdge ? 1 : 0
const outerRadius =
(isActive
? FLOORPLAN_POLYGON_VERTEX_ACTIVE_RADIUS_PX
@@ -4154,7 +4316,7 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
strokeOpacity={0.18}
strokeWidth={FLOORPLAN_ENDPOINT_HOVER_GLOW_STROKE_WIDTH}
style={{
- opacity: isHovered ? 1 : 0,
+ opacity: handleOpacity,
transition: FLOORPLAN_HOVER_TRANSITION,
}}
vectorEffect="non-scaling-stroke"
@@ -4163,19 +4325,23 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
cx={svgPoint.x}
cy={svgPoint.y}
fill={isActive ? palette.endpointHandleActiveFill : palette.endpointHandleFill}
- fillOpacity={0.96}
+ fillOpacity={0.96 * handleOpacity}
pointerEvents="none"
r={outerRadius}
stroke={stroke}
+ strokeOpacity={handleOpacity}
strokeWidth="0.045"
+ style={{ transition: FLOORPLAN_HOVER_TRANSITION }}
vectorEffect="non-scaling-stroke"
/>
{
event.preventDefault()
event.stopPropagation()
- onVertexDoubleClick(nodeId, vertexIndex, event as any)
+ if (vertexPointerDoubleClickRef.current === handleId) {
+ vertexPointerDoubleClickRef.current = null
+ return
+ }
+
+ onVertexDoubleClick(nodeId, vertexIndex, event)
}}
onPointerDown={(event) => {
+ if (event.button === 0 && event.detail >= 2) {
+ vertexPointerDoubleClickRef.current = handleId
+ window.setTimeout(() => {
+ if (vertexPointerDoubleClickRef.current === handleId) {
+ vertexPointerDoubleClickRef.current = null
+ }
+ }, 400)
+ onVertexDoubleClick(nodeId, vertexIndex, event)
+ return
+ }
+
onVertexPointerDown(nodeId, vertexIndex, event)
}}
pointerEvents="all"
@@ -4204,12 +4386,14 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
{midpointHandles.map(({ nodeId, edgeIndex, point }) => {
const handleId = `${nodeId}:midpoint:${edgeIndex}`
const isHovered = hoveredHandleId === handleId
+ const isEdgeHovered = hoveredHandleId === `${nodeId}:edge:${edgeIndex}`
+ const isVisible = isHovered || isEdgeHovered
const isAddHandle = midpointStyle === 'add'
const stroke = isAddHandle
? '#111827'
- : isHovered
- ? palette.endpointHandleHoverStroke
- : palette.endpointHandleStroke
+ : isVisible
+ ? palette.cursor
+ : palette.measurementStroke
const radius =
(isAddHandle
? isHovered
@@ -4241,7 +4425,7 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
strokeOpacity={0.16}
strokeWidth={FLOORPLAN_ENDPOINT_HOVER_RING_STROKE_WIDTH}
style={{
- opacity: isHovered ? 1 : 0,
+ opacity: isVisible ? 1 : 0,
transition: FLOORPLAN_HOVER_TRANSITION,
}}
vectorEffect="non-scaling-stroke"
@@ -4250,12 +4434,13 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
cx={svgPoint.x}
cy={svgPoint.y}
fill={isAddHandle ? '#ffffff' : palette.surface}
- fillOpacity={isAddHandle ? 1 : 0.94}
+ fillOpacity={isVisible ? (isAddHandle ? 1 : 0.94) : 0}
pointerEvents="none"
r={radius}
stroke={stroke}
- strokeOpacity={0.9}
+ strokeOpacity={isVisible ? 0.9 : 0}
strokeWidth={isAddHandle ? '1.4' : '0.035'}
+ style={{ transition: FLOORPLAN_HOVER_TRANSITION }}
vectorEffect="non-scaling-stroke"
/>
{isAddHandle ? (
@@ -4263,8 +4448,10 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
)}
@@ -4374,6 +4564,7 @@ export function FloorplanPanel() {
const curvingFence = useEditor((state) => state.curvingFence)
const phase = useEditor((state) => state.phase)
const mode = useEditor((state) => state.mode)
+ const activeHandleDrag = useEditor((state) => state.activeHandleDrag)
const setPhase = useEditor((state) => state.setPhase)
const setMovingFenceEndpoint = useEditor((state) => state.setMovingFenceEndpoint)
const setMovingNode = useEditor((state) => state.setMovingNode)
@@ -4553,6 +4744,18 @@ export function FloorplanPanel() {
[elevatorIds],
),
)
+ const siteLivePolygon = useLiveNodeOverrides(
+ useCallback(
+ (state) => {
+ if (!site?.id) {
+ return null
+ }
+
+ return (state.overrides.get(site.id)?.polygon as SiteNode['polygon'] | undefined) ?? null
+ },
+ [site?.id],
+ ),
+ )
const [stairBuildPreviewPoint, setStairBuildPreviewPoint] = useState(null)
const [stairBuildPreviewRotation, setStairBuildPreviewRotation] = useState(0)
const [isSpacePanPressed, setIsSpacePanPressed] = useState(false)
@@ -4616,12 +4819,14 @@ export function FloorplanPanel() {
)
const sitePolygonEntry = useMemo(() => {
- const polygonPoints = site?.polygon?.points
+ const polygonPoints = siteLivePolygon?.points ?? site?.polygon?.points
if (!(site && polygonPoints)) {
return null
}
- const polygon = toFloorplanPolygon(polygonPoints)
+ const polygon = polygonPoints.map(([x, z]) =>
+ worldToFloorplanLocalPoint(x, z, buildingPosition, buildingRotationY),
+ )
if (polygon.length < 3) {
return null
}
@@ -4631,7 +4836,7 @@ export function FloorplanPanel() {
polygon,
points: formatPolygonPoints(polygon),
}
- }, [site])
+ }, [buildingPosition, buildingRotationY, site, siteLivePolygon])
const displaySitePolygon = useMemo(() => {
if (!sitePolygonEntry) {
return null
@@ -4649,6 +4854,28 @@ export function FloorplanPanel() {
points: formatPolygonPoints(polygon),
}
}, [siteBoundaryDraft, sitePolygonEntry])
+ const siteBoundaryWorldPolygon = useCallback(
+ (polygon: WallPlanPoint[]) =>
+ polygon.map((point) => {
+ const worldPoint = floorplanLocalToWorldPoint(point, buildingPosition, buildingRotationY)
+ return [worldPoint.x, worldPoint.z] as [number, number]
+ }),
+ [buildingPosition, buildingRotationY],
+ )
+ const setSiteBoundaryLivePreview = useCallback(
+ (siteId: SiteNode['id'], polygon: WallPlanPoint[]) => {
+ useLiveNodeOverrides.getState().set(siteId, {
+ polygon: {
+ type: 'polygon',
+ points: siteBoundaryWorldPolygon(polygon),
+ },
+ })
+ },
+ [siteBoundaryWorldPolygon],
+ )
+ const clearSiteBoundaryLivePreview = useCallback((siteId: SiteNode['id']) => {
+ useLiveNodeOverrides.getState().clearFields(siteId, ['polygon'])
+ }, [])
const movingOpeningType =
movingNode?.type === 'door' || movingNode?.type === 'window' ? movingNode.type : null
@@ -5331,8 +5558,14 @@ export function FloorplanPanel() {
!movingNode &&
!movingFenceEndpoint &&
isFloorplanItemContextActive
- const visibleSitePolygon = phase === 'site' ? displaySitePolygon : null
- const shouldShowSiteBoundaryHandles = isSiteEditActive && visibleSitePolygon !== null
+ const visibleSitePolygon = displaySitePolygon
+ const canUseSiteBoundaryVertexHandles =
+ visibleSitePolygon !== null && (isSiteEditActive || mode === 'select')
+ const isSiteBoundaryHighlighted = isSiteEditActive || siteVertexDragState !== null
+ const shouldShowSiteEdgeLabels =
+ Boolean(visibleSitePolygon) &&
+ activeHandleDrag?.nodeId === visibleSitePolygon?.site.id &&
+ activeHandleDrag?.label === SITE_BOUNDARY_DRAG_LABEL
const visibleZonePolygons = displayZonePolygons
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds])
const highlightedFloorplanIdSet = useMemo(
@@ -5369,7 +5602,7 @@ export function FloorplanPanel() {
return toSvgSelectionBounds(visibleMarqueeBounds)
}, [visibleMarqueeBounds])
const siteVertexHandles = useMemo(() => {
- if (!(shouldShowSiteBoundaryHandles && visibleSitePolygon)) {
+ if (!(canUseSiteBoundaryVertexHandles && visibleSitePolygon)) {
return []
}
@@ -5381,9 +5614,24 @@ export function FloorplanPanel() {
siteVertexDragState?.siteId === visibleSitePolygon.site.id &&
siteVertexDragState.vertexIndex === vertexIndex,
}))
- }, [shouldShowSiteBoundaryHandles, siteVertexDragState, visibleSitePolygon])
+ }, [canUseSiteBoundaryVertexHandles, siteVertexDragState, visibleSitePolygon])
+ const siteEdgeHandles = useMemo(() => {
+ if (!(canUseSiteBoundaryVertexHandles && visibleSitePolygon && !siteVertexDragState)) {
+ return []
+ }
+
+ return visibleSitePolygon.polygon.map((point, edgeIndex, polygon) => {
+ const nextPoint = polygon[(edgeIndex + 1) % polygon.length]
+ return {
+ nodeId: visibleSitePolygon.site.id,
+ edgeIndex,
+ start: toWallPlanPoint(point),
+ end: toWallPlanPoint(nextPoint ?? point),
+ }
+ })
+ }, [canUseSiteBoundaryVertexHandles, siteVertexDragState, visibleSitePolygon])
const siteMidpointHandles = useMemo(() => {
- if (!(shouldShowSiteBoundaryHandles && visibleSitePolygon && !siteVertexDragState)) {
+ if (!(canUseSiteBoundaryVertexHandles && visibleSitePolygon && !siteVertexDragState)) {
return []
}
@@ -5398,7 +5646,7 @@ export function FloorplanPanel() {
] as WallPlanPoint,
}
})
- }, [shouldShowSiteBoundaryHandles, siteVertexDragState, visibleSitePolygon])
+ }, [canUseSiteBoundaryVertexHandles, siteVertexDragState, visibleSitePolygon])
const draftPolygon = useMemo(() => {
if (!(levelId && draftStart && draftEnd && isSegmentLongEnough(draftStart, draftEnd))) {
@@ -6917,10 +7165,28 @@ export function FloorplanPanel() {
setHoveredWallCurveHandleId(null)
}, [])
const clearSiteBoundaryInteraction = useCallback(() => {
+ const draft = siteBoundaryDraftRef.current
+ if (draft) {
+ clearSiteBoundaryLivePreview(draft.siteId)
+ const editor = useEditor.getState()
+ if (
+ editor.activeHandleDrag?.nodeId === draft.siteId &&
+ editor.activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL
+ ) {
+ editor.setActiveHandleDrag(null)
+ }
+ }
+
+ siteBoundaryDraftRef.current = null
setSiteVertexDragState(null)
setSiteBoundaryDraft(null)
setHoveredSiteHandleId(null)
- }, [])
+ }, [clearSiteBoundaryLivePreview])
+ const exitSiteEditingToSelect = useCallback(() => {
+ setPhase('structure')
+ setStructureLayer('elements')
+ setMode('select')
+ }, [setMode, setPhase, setStructureLayer])
const clearDraft = useCallback(() => {
clearWallPlacementDraft()
@@ -7629,12 +7895,12 @@ export function FloorplanPanel() {
}, [clearWallCurveDrag, clearWallEndpointDrag])
useEffect(() => {
- if (shouldShowSiteBoundaryHandles) {
+ if (canUseSiteBoundaryVertexHandles) {
return
}
clearSiteBoundaryInteraction()
- }, [clearSiteBoundaryInteraction, shouldShowSiteBoundaryHandles])
+ }, [canUseSiteBoundaryVertexHandles, clearSiteBoundaryInteraction])
useEffect(() => {
const dragState = siteVertexDragState
@@ -7657,26 +7923,27 @@ export function FloorplanPanel() {
const snappedPoint: WallPlanPoint = [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])]
setCursorPoint(snappedPoint)
- setSiteBoundaryDraft((currentDraft) => {
- if (!currentDraft || currentDraft.siteId !== dragState.siteId) {
- return currentDraft
- }
+ const currentDraft = siteBoundaryDraftRef.current
+ if (!currentDraft || currentDraft.siteId !== dragState.siteId) {
+ return
+ }
- const currentPoint = currentDraft.polygon[dragState.vertexIndex]
- if (currentPoint && pointsEqual(currentPoint, snappedPoint)) {
- return currentDraft
- }
+ const currentPoint = currentDraft.polygon[dragState.vertexIndex]
+ if (currentPoint && pointsEqual(currentPoint, snappedPoint)) {
+ return
+ }
- sfxEmitter.emit('sfx:grid-snap')
+ sfxEmitter.emit('sfx:grid-snap')
- const nextPolygon = [...currentDraft.polygon]
- nextPolygon[dragState.vertexIndex] = snappedPoint
-
- return {
- ...currentDraft,
- polygon: nextPolygon,
- }
- })
+ const nextPolygon = [...currentDraft.polygon]
+ nextPolygon[dragState.vertexIndex] = snappedPoint
+ const nextDraft = {
+ ...currentDraft,
+ polygon: nextPolygon,
+ }
+ siteBoundaryDraftRef.current = nextDraft
+ setSiteBoundaryDraft(nextDraft)
+ setSiteBoundaryLivePreview(dragState.siteId, nextPolygon)
}
const commitSiteVertexDrag = (event: PointerEvent) => {
@@ -7685,11 +7952,13 @@ export function FloorplanPanel() {
}
const draft = siteBoundaryDraftRef.current
+ const worldPolygon = draft ? siteBoundaryWorldPolygon(draft.polygon) : null
if (
draft &&
+ worldPolygon &&
site &&
draft.siteId === site.id &&
- !polygonsEqual(draft.polygon, site.polygon?.points ?? [])
+ !polygonsEqual(worldPolygon, site.polygon?.points ?? [])
) {
const suppressClick = (clickEvent: MouseEvent) => {
clickEvent.stopImmediatePropagation()
@@ -7704,13 +7973,14 @@ export function FloorplanPanel() {
updateNode(draft.siteId, {
polygon: {
type: 'polygon',
- points: draft.polygon,
+ points: worldPolygon,
},
})
sfxEmitter.emit('sfx:structure-build')
}
clearSiteBoundaryInteraction()
+ exitSiteEditingToSelect()
setCursorPoint(null)
}
@@ -7720,6 +7990,7 @@ export function FloorplanPanel() {
}
clearSiteBoundaryInteraction()
+ exitSiteEditingToSelect()
setCursorPoint(null)
}
@@ -7734,8 +8005,11 @@ export function FloorplanPanel() {
}
}, [
clearSiteBoundaryInteraction,
+ exitSiteEditingToSelect,
getPlanPointFromClientPoint,
+ setSiteBoundaryLivePreview,
site,
+ siteBoundaryWorldPolygon,
siteVertexDragState,
updateNode,
])
@@ -9249,10 +9523,19 @@ export function FloorplanPanel() {
return
}
- setSiteBoundaryDraft({
+ if (useEditor.getState().phase !== 'site') {
+ useEditor.setState({ catalogCategory: null, mode: 'select', phase: 'site', tool: null })
+ }
+ selectSiteFloorplanContext()
+
+ const nextDraft = {
siteId,
polygon: displaySitePolygon.polygon.map(toWallPlanPoint),
- })
+ }
+ siteBoundaryDraftRef.current = nextDraft
+ setSiteBoundaryDraft(nextDraft)
+ setSiteBoundaryLivePreview(siteId, nextDraft.polygon)
+ useEditor.getState().setActiveHandleDrag({ nodeId: siteId, label: SITE_BOUNDARY_DRAG_LABEL })
setSiteVertexDragState({
pointerId: event.pointerId,
siteId,
@@ -9260,10 +9543,14 @@ export function FloorplanPanel() {
})
setCursorPoint(toWallPlanPoint(vertexPoint))
},
- [displaySitePolygon],
+ [displaySitePolygon, setSiteBoundaryLivePreview],
)
const handleSiteVertexDoubleClick = useCallback(
- (siteId: SiteNode['id'], vertexIndex: number, event: ReactPointerEvent) => {
+ (
+ siteId: SiteNode['id'],
+ vertexIndex: number,
+ event: ReactPointerEvent | ReactMouseEvent,
+ ) => {
if (event.button !== 0) {
return
}
@@ -9275,7 +9562,6 @@ export function FloorplanPanel() {
return
}
- siteBoundaryDraftRef.current = null
clearSiteBoundaryInteraction()
updateNode(siteId, {
@@ -9319,10 +9605,19 @@ export function FloorplanPanel() {
...basePolygon.slice(insertIndex),
]
- setSiteBoundaryDraft({
+ if (useEditor.getState().phase !== 'site') {
+ useEditor.setState({ catalogCategory: null, mode: 'select', phase: 'site', tool: null })
+ }
+ selectSiteFloorplanContext()
+
+ const nextDraft = {
siteId,
polygon: nextPolygon,
- })
+ }
+ siteBoundaryDraftRef.current = nextDraft
+ setSiteBoundaryDraft(nextDraft)
+ setSiteBoundaryLivePreview(siteId, nextPolygon)
+ useEditor.getState().setActiveHandleDrag({ nodeId: siteId, label: SITE_BOUNDARY_DRAG_LABEL })
setSiteVertexDragState({
pointerId: event.pointerId,
siteId,
@@ -9330,7 +9625,7 @@ export function FloorplanPanel() {
})
setCursorPoint(insertedPoint)
},
- [displaySitePolygon],
+ [displaySitePolygon, setSiteBoundaryLivePreview],
)
const handlePointerLeave = useCallback(() => {
@@ -10020,8 +10315,6 @@ export function FloorplanPanel() {
selectedGuideId={selectedGuideId}
/>
-
-
{/* Stair is fully registry-driven for committed nodes
(`def.floorplan` on the stair kind). This layer only
carries the in-flight stair preview, which lives outside
@@ -10058,24 +10351,6 @@ export function FloorplanPanel() {
unitsPerPixel={floorplanUnitsPerPixel}
/>
-
- handleSiteMidpointPointerDown(nodeId as SiteNode['id'], edgeIndex, event)
- }
- onVertexDoubleClick={(nodeId, vertexIndex, event) =>
- handleSiteVertexDoubleClick(nodeId as SiteNode['id'], vertexIndex, event)
- }
- onVertexPointerDown={(nodeId, vertexIndex, event) =>
- handleSiteVertexPointerDown(nodeId as SiteNode['id'], vertexIndex, event)
- }
- palette={palette}
- unitsPerPixel={floorplanUnitsPerPixel}
- vertexHandles={siteVertexHandles}
- />
-
{isMarqueeSelectionToolActive && (
+
+
+
+ handleSiteMidpointPointerDown(nodeId as SiteNode['id'], edgeIndex, event)
+ }
+ onVertexDoubleClick={(nodeId, vertexIndex, event) =>
+ handleSiteVertexDoubleClick(nodeId as SiteNode['id'], vertexIndex, event)
+ }
+ onVertexPointerDown={(nodeId, vertexIndex, event) =>
+ handleSiteVertexPointerDown(nodeId as SiteNode['id'], vertexIndex, event)
+ }
+ palette={palette}
+ unitsPerPixel={floorplanUnitsPerPixel}
+ vertexHandles={siteVertexHandles}
+ />
+
+
+
{/* "Magnetic" wall-snap beacon — per-kind glyph at the active
draft / endpoint-move snap point. Same store + coord space as
the alignment guides. */}
diff --git a/packages/editor/src/components/editor/site-edge-labels.tsx b/packages/editor/src/components/editor/site-edge-labels.tsx
index c3fc7c6c..8cd99b06 100644
--- a/packages/editor/src/components/editor/site-edge-labels.tsx
+++ b/packages/editor/src/components/editor/site-edge-labels.tsx
@@ -1,12 +1,15 @@
'use client'
import type { SiteNode } from '@pascal-app/core'
-import { sceneRegistry, useScene } from '@pascal-app/core'
+import { sceneRegistry, useLiveNodeOverrides, useScene } from '@pascal-app/core'
import { getSceneTheme, useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { createPortal, useFrame, useThree } from '@react-three/fiber'
import { useCallback, useMemo, useRef, useState } from 'react'
import { type Camera, type Object3D, Vector3 } from 'three'
+import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
+import useEditor from '../../store/use-editor'
+import { formatMeasurement } from './measurement-pill'
type ViewportSize = {
width: number
@@ -24,17 +27,6 @@ function calculateHtmlPosition(el: Object3D, camera: Camera, size: ViewportSize)
return [htmlPosition.x * widthHalf + widthHalf, -htmlPosition.y * heightHalf + heightHalf]
}
-function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
- if (unit === 'imperial') {
- const feet = value * 3.280_84
- const wholeFeet = Math.floor(feet)
- const inches = Math.round((feet - wholeFeet) * 12)
- if (inches === 12) return `${wholeFeet + 1}'0"`
- return `${wholeFeet}'${inches}"`
- }
- return `${Number.parseFloat(value.toFixed(2))}m`
-}
-
export function SiteEdgeLabels() {
// Narrow subscription to just the site node — subscribing to the full
// s.nodes dict re-rendered this on every wall/level mutation even though
@@ -45,6 +37,7 @@ export function SiteEdgeLabels() {
const node = state.nodes[firstRoot]
return node?.type === 'site' ? (node as SiteNode) : null
})
+ const activeHandleDrag = useEditor((state) => state.activeHandleDrag)
const unit = useViewer((state) => state.unit)
const cameraMode = useViewer((state) => state.cameraMode)
const isNight = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
@@ -56,6 +49,15 @@ export function SiteEdgeLabels() {
)
const siteNodeId = siteNode?.id
+ const livePolygon = useLiveNodeOverrides((state) => {
+ if (!siteNodeId) return null
+ return (state.overrides.get(siteNodeId)?.polygon as SiteNode['polygon'] | undefined) ?? null
+ })
+ const polygon = livePolygon?.points ?? siteNode?.polygon?.points ?? []
+ const shouldShowLabels =
+ Boolean(siteNodeId) &&
+ activeHandleDrag?.nodeId === siteNodeId &&
+ activeHandleDrag?.label === SITE_BOUNDARY_DRAG_LABEL
const color = isNight ? '#ffffff' : '#111111'
const shadowColor = isNight ? '#111111' : '#ffffff'
@@ -77,7 +79,6 @@ export function SiteEdgeLabels() {
})
const edges = useMemo(() => {
- const polygon = siteNode?.polygon?.points ?? []
if (polygon.length < 2) return []
return polygon.map(([x1, z1], i) => {
const [x2, z2] = polygon[(i + 1) % polygon.length]!
@@ -86,9 +87,9 @@ export function SiteEdgeLabels() {
const dist = Math.sqrt((x2 - x1!) ** 2 + (z2 - z1!) ** 2)
return { midX, midZ, dist }
})
- }, [siteNode?.polygon?.points])
+ }, [polygon])
- if (!siteObj || edges.length === 0) return null
+ if (!shouldShowLabels || !siteObj || edges.length === 0) return null
return createPortal(
<>
diff --git a/packages/editor/src/components/tools/shared/polygon-editor.tsx b/packages/editor/src/components/tools/shared/polygon-editor.tsx
index ce8c4b6f..2257bcf9 100644
--- a/packages/editor/src/components/tools/shared/polygon-editor.tsx
+++ b/packages/editor/src/components/tools/shared/polygon-editor.tsx
@@ -1,7 +1,7 @@
import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
import { SCENE_LAYER, useViewer } from '@pascal-app/viewer'
import { createPortal, type ThreeEvent } from '@react-three/fiber'
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
BufferGeometry,
Color,
@@ -98,6 +98,26 @@ export interface PolygonEditorProps {
allowPolygonMove?: boolean
/** Whether polygon edges can be dragged along their perpendicular normal. */
allowEdgeMove?: boolean
+ /** Called just before a vertex drag session starts. */
+ onBeforeVertexDrag?: (vertexIndex: number, position: [number, number]) => void
+ /** Called when a vertex handle enters or leaves hover. */
+ onVertexHoverChange?: (vertexIndex: number | null) => void
+ /** Called when a midpoint add-vertex handle enters or leaves hover. */
+ onMidpointHoverChange?: (edgeIndex: number | null) => void
+ /** Called when any polygon drag starts or ends. */
+ onDragStateChange?: (isDragging: boolean) => void
+ /** Called once when a polygon drag starts. */
+ onDragStart?: () => void
+ /** Called once when a polygon drag commits on pointer release. */
+ onDragCommit?: () => void
+ /** Whether to render the editor-owned polygon outline. */
+ showBorderLine?: boolean
+ /** Whether midpoint handles can add new vertices. */
+ showMidpointHandles?: boolean
+ /** Optional vertex handle renderer for host-specific affordances. */
+ renderVertexHandle?: PolygonVertexHandleRenderer
+ /** Optional midpoint handle renderer for host-specific add-vertex affordances. */
+ renderMidpointHandle?: PolygonMidpointHandleRenderer
}
/**
@@ -120,7 +140,7 @@ function getEdgeNormal(start: [number, number], end: [number, number]): [number,
type HandleClickHandler = (event: ThreeEvent) => void
type HandlePointerHandler = (event: ThreeEvent) => void
-type HandleHandlers = {
+export type PolygonHandleHandlers = {
onClick?: HandleClickHandler
onDoubleClick?: HandleClickHandler
onPointerDown?: HandlePointerHandler
@@ -128,14 +148,42 @@ type HandleHandlers = {
onPointerLeave?: HandlePointerHandler
}
+export type PolygonVertexHandleRenderProps = {
+ canDelete: boolean
+ handleProps: PolygonHandleHandlers
+ height: number
+ index: number
+ isDragging: boolean
+ isHovered: boolean
+ point: [number, number]
+ position: [number, number, number]
+ radius: number
+}
+
+export type PolygonVertexHandleRenderer = (props: PolygonVertexHandleRenderProps) => React.ReactNode
+
+export type PolygonMidpointHandleRenderProps = {
+ handleProps: PolygonHandleHandlers
+ height: number
+ index: number
+ isHovered: boolean
+ point: [number, number]
+ position: [number, number, number]
+ radius: number
+}
+
+export type PolygonMidpointHandleRenderer = (
+ props: PolygonMidpointHandleRenderProps,
+) => React.ReactNode
+
function usePolygonNodeMaterial(color: string, opacity = 1): MeshBasicNodeMaterial {
const material = useMemo(
() =>
new MeshBasicNodeMaterial({
- color: new Color(color),
+ color: new Color('#ffffff'),
depthTest: false,
depthWrite: true,
- opacity,
+ opacity: 1,
transparent: true,
}),
[],
@@ -168,7 +216,7 @@ function OutlinedCylinderHandle({
color: string
opacity?: number
position: [number, number, number]
-} & HandleHandlers) {
+} & PolygonHandleHandlers) {
const geometry = useMemo(() => new CylinderGeometry(radius, radius, height, 16), [height, radius])
const material = usePolygonNodeMaterial(color, opacity)
useEffect(() => () => geometry.dispose(), [geometry])
@@ -197,7 +245,7 @@ function OutlinedCrossHandle({
}: {
color: string
position: [number, number, number]
-} & HandleHandlers) {
+} & PolygonHandleHandlers) {
const geometry = useMemo(() => createMoveCrossHandleGeometry(), [])
const material = usePolygonNodeMaterial(color)
const hitGeometry = useMemo(() => new CylinderGeometry(0.24, 0.24, 0.18, 24), [])
@@ -241,7 +289,7 @@ function OutlinedEdgeArrowHandle({
position: [number, number, number]
rotationY: number
scale: number
-} & HandleHandlers) {
+} & PolygonHandleHandlers) {
const material = useArrowMaterial()
useEffect(() => {
material.color.set(color)
@@ -273,6 +321,16 @@ export const PolygonEditor: React.FC = ({
surfaceHeight = 0,
allowPolygonMove = false,
allowEdgeMove = false,
+ onBeforeVertexDrag,
+ onVertexHoverChange,
+ onMidpointHoverChange,
+ onDragStateChange,
+ onDragStart,
+ onDragCommit,
+ showBorderLine = true,
+ showMidpointHandles = true,
+ renderMidpointHandle,
+ renderVertexHandle,
}) => {
const [levelNode, setLevelNode] = useState(() =>
levelId ? (sceneRegistry.nodes.get(levelId) ?? null) : null,
@@ -319,6 +377,21 @@ export const PolygonEditor: React.FC = ({
const previewPolygonRef = useRef | null>(null)
const previousInputDraggingRef = useRef(false)
+ const onDragStateChangeRef = useRef(onDragStateChange)
+ useEffect(() => {
+ onDragStateChangeRef.current = onDragStateChange
+ }, [onDragStateChange])
+
+ const onDragStartRef = useRef(onDragStart)
+ useEffect(() => {
+ onDragStartRef.current = onDragStart
+ }, [onDragStart])
+
+ const onDragCommitRef = useRef(onDragCommit)
+ useEffect(() => {
+ onDragCommitRef.current = onDragCommit
+ }, [onDragCommit])
+
const onPolygonPreviewRef = useRef(onPolygonPreview)
useEffect(() => {
onPolygonPreviewRef.current = onPolygonPreview
@@ -344,13 +417,32 @@ export const PolygonEditor: React.FC = ({
const [hoveredEdge, setHoveredEdge] = useState(null)
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
+ useEffect(() => {
+ onVertexHoverChange?.(hoveredVertex)
+ }, [hoveredVertex, onVertexHoverChange])
+
+ useEffect(() => () => onVertexHoverChange?.(null), [onVertexHoverChange])
+
+ useEffect(() => {
+ onMidpointHoverChange?.(hoveredMidpoint)
+ }, [hoveredMidpoint, onMidpointHoverChange])
+
+ useEffect(() => () => onMidpointHoverChange?.(null), [onMidpointHoverChange])
+
const lineRef = useRef(null!)
const previousPositionRef = useRef<[number, number] | null>(null)
+ useEffect(() => {
+ onDragStateChangeRef.current?.(dragState?.isDragging ?? false)
+ }, [dragState?.isDragging])
+
+ useEffect(() => () => onDragStateChangeRef.current?.(false), [])
+
const startDrag = useCallback((nextDragState: DragState) => {
previousInputDraggingRef.current = useViewer.getState().inputDragging
useViewer.getState().setInputDragging(true)
setDragState(nextDragState)
+ onDragStartRef.current?.()
}, [])
useEffect(() => {
@@ -483,6 +575,7 @@ export const PolygonEditor: React.FC = ({
if (previewPolygonRef.current) {
onPolygonChange(previewPolygonRef.current)
}
+ onDragCommitRef.current?.()
updatePreviewPolygon(null)
setDragState(null)
}, [onPolygonChange, updatePreviewPolygon])
@@ -522,8 +615,9 @@ export const PolygonEditor: React.FC = ({
// Listen to grid:move events to track cursor position
useEffect(() => {
const onGridMove = (event: GridEvent) => {
- const gridX = snapToHalf(event.localPosition[0])
- const gridZ = snapToHalf(event.localPosition[2])
+ const point = levelNode ? event.localPosition : event.position
+ const gridX = snapToHalf(point[0])
+ const gridZ = snapToHalf(point[2])
const newPosition: [number, number] = [gridX, gridZ]
// Play snap sound when cursor moves to a new grid cell during drag
@@ -579,7 +673,7 @@ export const PolygonEditor: React.FC = ({
return () => {
emitter.off('grid:move', onGridMove)
}
- }, [dragState, handleVertexDrag, updatePreviewPolygon])
+ }, [dragState, handleVertexDrag, levelNode, updatePreviewPolygon])
// Set up pointer up listener for ending drag
useEffect(() => {
@@ -624,7 +718,7 @@ export const PolygonEditor: React.FC = ({
// Update line geometry when polygon changes
useEffect(() => {
- if (!lineRef.current || displayPolygon.length < 2) return
+ if (!showBorderLine || !lineRef.current || displayPolygon.length < 2) return
const positions: number[] = []
for (const [x, z] of displayPolygon) {
@@ -639,7 +733,7 @@ export const PolygonEditor: React.FC = ({
lineRef.current.geometry.dispose()
lineRef.current.geometry = geometry
- }, [displayPolygon, editY])
+ }, [displayPolygon, editY, showBorderLine])
if (displayPolygon.length < minVertices) return null
@@ -656,24 +750,26 @@ export const PolygonEditor: React.FC = ({
const editorContent = (
{/* Border line */}
- element conflicts with SVG type
- ref={lineRef}
- renderOrder={10}
- >
-
-
-
+ {showBorderLine && (
+ element conflicts with SVG type
+ ref={lineRef}
+ renderOrder={10}
+ >
+
+
+
+ )}
{/* Vertex handles - blue cylinders that match surface height */}
{displayPolygon.map(([x, z], index) => {
@@ -681,45 +777,69 @@ export const PolygonEditor: React.FC = ({
const isDragging = dragState?.mode === 'vertex' && dragState.vertexIndex === index
const radius = 0.1
const height = handleHeight
+ const point: [number, number] = [x!, z!]
+ const position: [number, number, number] = [x!, editY + height / 2, z!]
+ const handleProps: PolygonHandleHandlers = {
+ onClick: (e) => {
+ if (e.button !== 0) return
+ e.stopPropagation()
+ },
+ onDoubleClick: (e) => {
+ if (e.button !== 0) return
+ e.stopPropagation()
+ if (canDelete) {
+ handleDeleteVertex(index)
+ }
+ },
+ onPointerDown: (e) => {
+ if (e.button !== 0) return
+ e.stopPropagation()
+ setHoveredEdge(null)
+ onBeforeVertexDrag?.(index, point)
+ startDrag({
+ isDragging: true,
+ mode: 'vertex',
+ vertexIndex: index,
+ initialPosition: [x!, z!],
+ initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]),
+ pointerId: e.pointerId,
+ })
+ },
+ onPointerEnter: (e) => {
+ e.stopPropagation()
+ setHoveredVertex(index)
+ },
+ onPointerLeave: (e) => {
+ e.stopPropagation()
+ setHoveredVertex(null)
+ },
+ }
+
+ if (renderVertexHandle) {
+ return (
+
+ {renderVertexHandle({
+ canDelete,
+ handleProps,
+ height,
+ index,
+ isDragging,
+ isHovered,
+ point,
+ position,
+ radius,
+ })}
+
+ )
+ }
return (
{
- if (e.button !== 0) return
- e.stopPropagation()
- }}
- onDoubleClick={(e) => {
- if (e.button !== 0) return
- e.stopPropagation()
- if (canDelete) {
- handleDeleteVertex(index)
- }
- }}
- onPointerDown={(e) => {
- if (e.button !== 0) return
- e.stopPropagation()
- setHoveredEdge(null)
- startDrag({
- isDragging: true,
- mode: 'vertex',
- vertexIndex: index,
- initialPosition: [x!, z!],
- initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]),
- pointerId: e.pointerId,
- })
- }}
- onPointerEnter={(e) => {
- e.stopPropagation()
- setHoveredVertex(index)
- }}
- onPointerLeave={(e) => {
- e.stopPropagation()
- setHoveredVertex(null)
- }}
- position={[x!, editY + height / 2, z!]}
+ {...handleProps}
+ position={position}
radius={radius}
/>
)
@@ -830,47 +950,70 @@ export const PolygonEditor: React.FC = ({
})}
{/* Midpoint handles - smaller green cylinders for adding vertices (hidden while dragging) */}
- {!dragState &&
+ {showMidpointHandles &&
+ !dragState &&
midpoints.map(([x, z], index) => {
const isHovered = hoveredMidpoint === index
const radius = 0.06
const height = handleHeight
+ const point: [number, number] = [x!, z!]
+ const position: [number, number, number] = [x!, editY + height / 2, z!]
+ const handleProps: PolygonHandleHandlers = {
+ onClick: (e) => {
+ if (e.button !== 0) return
+ e.stopPropagation()
+ },
+ onPointerDown: (e) => {
+ if (e.button !== 0) return
+ e.stopPropagation()
+ onBeforeVertexDrag?.(index + 1, point)
+ const insertedVertex = handleAddVertex(index, point)
+ if (insertedVertex.vertexIndex >= 0) {
+ startDrag({
+ isDragging: true,
+ mode: 'vertex',
+ vertexIndex: insertedVertex.vertexIndex,
+ initialPosition: point,
+ initialPolygon: insertedVertex.polygon,
+ pointerId: e.pointerId,
+ })
+ setHoveredMidpoint(null)
+ }
+ },
+ onPointerEnter: (e) => {
+ e.stopPropagation()
+ setHoveredMidpoint(index)
+ },
+ onPointerLeave: (e) => {
+ e.stopPropagation()
+ setHoveredMidpoint(null)
+ },
+ }
+
+ if (renderMidpointHandle) {
+ return (
+
+ {renderMidpointHandle({
+ handleProps,
+ height,
+ index,
+ isHovered,
+ point,
+ position,
+ radius,
+ })}
+
+ )
+ }
return (
{
- if (e.button !== 0) return
- e.stopPropagation()
- }}
- onPointerDown={(e) => {
- if (e.button !== 0) return
- e.stopPropagation()
- const insertedVertex = handleAddVertex(index, [x!, z!])
- if (insertedVertex.vertexIndex >= 0) {
- startDrag({
- isDragging: true,
- mode: 'vertex',
- vertexIndex: insertedVertex.vertexIndex,
- initialPosition: [x!, z!],
- initialPolygon: insertedVertex.polygon,
- pointerId: e.pointerId,
- })
- setHoveredMidpoint(null)
- }
- }}
- onPointerEnter={(e) => {
- e.stopPropagation()
- setHoveredMidpoint(index)
- }}
- onPointerLeave={(e) => {
- e.stopPropagation()
- setHoveredMidpoint(null)
- }}
+ {...handleProps}
opacity={isHovered ? 1 : 0.7}
- position={[x!, editY + height / 2, z!]}
+ position={position}
radius={radius}
/>
)
diff --git a/packages/editor/src/components/tools/site/site-boundary-editor.tsx b/packages/editor/src/components/tools/site/site-boundary-editor.tsx
index 3fc38f11..5a77f886 100644
--- a/packages/editor/src/components/tools/site/site-boundary-editor.tsx
+++ b/packages/editor/src/components/tools/site/site-boundary-editor.tsx
@@ -1,19 +1,319 @@
-import { type SiteNode, useScene } from '@pascal-app/core'
-import { useCallback } from 'react'
-import { PolygonEditor } from '../shared/polygon-editor'
+import { emitter, type SiteNode, useLiveNodeOverrides, useScene } from '@pascal-app/core'
+import { SCENE_LAYER } from '@pascal-app/viewer'
+import { useGLTF } from '@react-three/drei/core/Gltf'
+import { useFrame } from '@react-three/fiber'
+import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { Color, CylinderGeometry, DoubleSide, type Mesh, type Object3D, RingGeometry } from 'three'
+import { MeshBasicNodeMaterial } from 'three/webgpu'
+import { EDITOR_LAYER } from '../../../lib/constants'
+import { sfxEmitter } from '../../../lib/sfx-bus'
+import { SITE_BOUNDARY_DRAG_LABEL } from '../../../lib/site-boundary'
+import useEditor, { selectSiteFloorplanContext } from '../../../store/use-editor'
+import {
+ ARROW_COLOR,
+ ARROW_HOVER_COLOR,
+ NO_RAYCAST,
+ useInvisibleHitAreaMaterial,
+} from '../../editor/node-arrow-handles'
+import {
+ PolygonEditor,
+ type PolygonHandleHandlers,
+ type PolygonMidpointHandleRenderProps,
+ type PolygonVertexHandleRenderProps,
+} from '../shared/polygon-editor'
+import { SITE_FLAG_MODEL_URL } from './site-flag-model'
+
+const SITE_FLAG_BASE_Y = 0
+const SITE_FLAG_HIT_HEIGHT = 0.5
+const SITE_FLAG_HIT_RADIUS = 0.24
+const SITE_FLAG_HIT_Y = SITE_FLAG_BASE_Y + SITE_FLAG_HIT_HEIGHT / 2
+const SITE_FLAG_MODEL_MIN_Y = -0.034798760316334665
+const SITE_FLAG_SCALE = 0.5
+const SITE_FLAG_ACTIVE_LIFT = 0.08
+const SITE_FLAG_MODEL_Y = SITE_FLAG_BASE_Y - SITE_FLAG_MODEL_MIN_Y * SITE_FLAG_SCALE
+const SITE_FLAG_HALO_INNER_RADIUS = 0.08
+const SITE_FLAG_HALO_OUTER_RADIUS = SITE_FLAG_HIT_RADIUS * 0.95
+const SITE_FLAG_HALO_Y = SITE_FLAG_BASE_Y + 0.002
+const SITE_FLAG_HALO_COLOR = '#6366f1'
+
+type TintableMaterial = {
+ color?: Color
+ depthWrite: boolean
+ opacity: number
+ needsUpdate: boolean
+ transparent: boolean
+}
+
+function SiteFlagModel({
+ active,
+ hovered,
+ opacity = 1,
+ scale = SITE_FLAG_SCALE,
+}: {
+ active: boolean
+ hovered: boolean
+ opacity?: number
+ scale?: number
+}) {
+ const { scene } = useGLTF(SITE_FLAG_MODEL_URL, true)
+ const modelRef = useRef(null)
+ const flagScene = useMemo(() => {
+ const cloned = scene.clone(true)
+
+ cloned.traverse((object) => {
+ object.layers.set(SCENE_LAYER)
+
+ if ((object as Mesh).isMesh) {
+ const mesh = object as Mesh
+ mesh.castShadow = false
+ mesh.frustumCulled = false
+ mesh.raycast = NO_RAYCAST
+ mesh.receiveShadow = false
+ mesh.renderOrder = 1010
+ mesh.material = new MeshBasicNodeMaterial({
+ color: new Color(ARROW_COLOR),
+ depthTest: false,
+ depthWrite: opacity >= 0.999,
+ opacity,
+ transparent: opacity < 0.999,
+ })
+ }
+ })
+
+ return cloned
+ }, [opacity, scene])
+
+ useEffect(() => {
+ const color = new Color(active || hovered ? ARROW_HOVER_COLOR : ARROW_COLOR)
+
+ flagScene.traverse((object) => {
+ if ((object as Mesh).isMesh) {
+ const mesh = object as Mesh
+ const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
+
+ for (const material of materials as Array) {
+ material.color?.copy(color)
+ material.opacity = opacity
+ material.transparent = opacity < 0.999
+ material.depthWrite = opacity >= 0.999
+ material.needsUpdate = true
+ }
+ }
+ })
+ }, [active, flagScene, hovered, opacity])
+
+ useFrame((_, delta) => {
+ const model = modelRef.current
+ if (!model) return
+
+ const smoothing = 1 - Math.exp(-delta * 18)
+ const targetY = SITE_FLAG_MODEL_Y + (active ? SITE_FLAG_ACTIVE_LIFT : 0)
+ model.position.y += (targetY - model.position.y) * smoothing
+ })
+
+ useEffect(
+ () => () => {
+ flagScene.traverse((object) => {
+ if ((object as Mesh).isMesh) {
+ const materials = (object as Mesh).material
+ if (Array.isArray(materials)) {
+ materials.forEach((material) => {
+ material.dispose()
+ })
+ } else {
+ materials.dispose()
+ }
+ }
+ })
+ },
+ [flagScene],
+ )
+
+ return (
+
+ )
+}
+
+function SiteFlagHoverHalo({ visible }: { visible: boolean }) {
+ const haloRef = useRef(null)
+ const geometry = useMemo(() => {
+ const nextGeometry = new RingGeometry(
+ SITE_FLAG_HALO_INNER_RADIUS,
+ SITE_FLAG_HALO_OUTER_RADIUS,
+ 56,
+ )
+ nextGeometry.rotateX(-Math.PI / 2)
+ return nextGeometry
+ }, [])
+ const material = useMemo(
+ () =>
+ new MeshBasicNodeMaterial({
+ color: new Color(SITE_FLAG_HALO_COLOR),
+ depthTest: true,
+ depthWrite: false,
+ opacity: 0,
+ side: DoubleSide,
+ transparent: true,
+ }),
+ [],
+ )
+
+ useFrame((_, delta) => {
+ const mesh = haloRef.current
+ if (!mesh) return
+
+ const smoothing = 1 - Math.exp(-delta * 18)
+ const targetOpacity = visible ? 0.52 : 0
+ const targetScale = visible ? 1 : 0.08
+ const nextOpacity = material.opacity + (targetOpacity - material.opacity) * smoothing
+ const nextScale = mesh.scale.x + (targetScale - mesh.scale.x) * smoothing
+
+ material.opacity = nextOpacity
+ mesh.scale.setScalar(nextScale)
+ mesh.visible = visible || nextOpacity > 0.01
+ })
+
+ useEffect(
+ () => () => {
+ geometry.dispose()
+ material.dispose()
+ },
+ [geometry, material],
+ )
+
+ return (
+
+ )
+}
+
+function SiteFlagFallback({
+ active,
+ hovered,
+ opacity = 1,
+}: {
+ active: boolean
+ hovered: boolean
+ opacity?: number
+}) {
+ const color = active || hovered ? ARROW_HOVER_COLOR : ARROW_COLOR
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function SiteBoundaryFlagHandle({
+ active = false,
+ baseY,
+ handleProps,
+ hovered,
+ modelOpacity = 1,
+ modelScale = SITE_FLAG_SCALE,
+ modelVisible = true,
+ point,
+}: {
+ active?: boolean
+ baseY: number
+ handleProps: PolygonHandleHandlers
+ hovered: boolean
+ modelOpacity?: number
+ modelScale?: number
+ modelVisible?: boolean
+ point: [number, number]
+}) {
+ const hitGeometry = useMemo(
+ () =>
+ new CylinderGeometry(SITE_FLAG_HIT_RADIUS, SITE_FLAG_HIT_RADIUS, SITE_FLAG_HIT_HEIGHT, 24),
+ [],
+ )
+ const hitMaterial = useInvisibleHitAreaMaterial()
+
+ useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
+
+ return (
+
+
+ {(modelVisible || active || hovered) && (
+ }
+ >
+
+
+ )}
+
+
+ )
+}
-/**
- * Site boundary editor - allows editing site polygon when in site phase
- * Uses the generic PolygonEditor component
- */
export const SiteBoundaryEditor: React.FC = () => {
const nodes = useScene((state) => state.nodes)
const rootNodeIds = useScene((state) => state.rootNodeIds)
const updateNode = useScene((state) => state.updateNode)
+ const phase = useEditor((state) => state.phase)
+ const mode = useEditor((state) => state.mode)
+ const [hoveredVertex, setHoveredVertex] = useState(null)
+ const [hoveredMidpoint, setHoveredMidpoint] = useState(null)
- // Get the site node (first root node)
const siteNode = rootNodeIds[0] ? nodes[rootNodeIds[0]] : null
const site = siteNode?.type === 'site' ? (siteNode as SiteNode) : null
+ const siteId = site?.id
+ const isSiteEditing = phase === 'site'
+ const showSiteHandles = isSiteEditing || mode === 'select'
+ const isSiteBoundaryHighlighted =
+ isSiteEditing || hoveredVertex !== null || hoveredMidpoint !== null
+ const [isDraggingSiteBoundary, setIsDraggingSiteBoundary] = useState(false)
+ const isDraggingSiteBoundaryRef = useRef(false)
+ const livePolygon = useLiveNodeOverrides(
+ useCallback(
+ (state) => {
+ if (!siteId) return null
+ return (state.overrides.get(siteId)?.polygon as SiteNode['polygon'] | undefined) ?? null
+ },
+ [siteId],
+ ),
+ )
+ const displayPolygon =
+ !(isDraggingSiteBoundary || isDraggingSiteBoundaryRef.current) && livePolygon?.points
+ ? livePolygon.points
+ : site?.polygon?.points
const handlePolygonChange = useCallback(
(newPolygon: Array<[number, number]>) => {
@@ -29,14 +329,166 @@ export const SiteBoundaryEditor: React.FC = () => {
[site, updateNode],
)
- if (!site?.polygon?.points || site.polygon.points.length < 3) return null
+ const handlePolygonPreview = useCallback(
+ (previewPolygon: ReadonlyArray | null) => {
+ if (!siteId) return
+
+ if (!previewPolygon) {
+ useLiveNodeOverrides.getState().clearFields(siteId, ['polygon'])
+ return
+ }
+
+ useLiveNodeOverrides.getState().set(siteId, {
+ polygon: {
+ type: 'polygon',
+ points: previewPolygon.map(([x, z]) => [x, z] as [number, number]),
+ },
+ })
+ },
+ [siteId],
+ )
+
+ const handleSiteBoundaryDragChange = useCallback(
+ (isDragging: boolean) => {
+ isDraggingSiteBoundaryRef.current = isDragging
+ setIsDraggingSiteBoundary(isDragging)
+
+ if (!siteId) return
+
+ const editor = useEditor.getState()
+ if (isDragging) {
+ editor.setActiveHandleDrag({ nodeId: siteId, label: SITE_BOUNDARY_DRAG_LABEL })
+ } else if (
+ editor.activeHandleDrag?.nodeId === siteId &&
+ editor.activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL
+ ) {
+ editor.setActiveHandleDrag(null)
+ }
+
+ if (!isDragging) {
+ useLiveNodeOverrides.getState().clearFields(siteId, ['polygon'])
+ }
+ },
+ [siteId],
+ )
+
+ useEffect(
+ () => () => {
+ if (!siteId) return
+ const editor = useEditor.getState()
+ if (
+ editor.activeHandleDrag?.nodeId === siteId &&
+ editor.activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL
+ ) {
+ editor.setActiveHandleDrag(null)
+ }
+ useLiveNodeOverrides.getState().clearFields(siteId, ['polygon'])
+ isDraggingSiteBoundaryRef.current = false
+ },
+ [siteId],
+ )
+
+ useEffect(() => {
+ if (isSiteEditing && siteId) {
+ selectSiteFloorplanContext()
+ }
+ }, [isSiteEditing, siteId])
+
+ const activateSiteEditing = useCallback(() => {
+ isDraggingSiteBoundaryRef.current = true
+ setIsDraggingSiteBoundary(true)
+ if (useEditor.getState().phase !== 'site') {
+ useEditor.setState({ catalogCategory: null, mode: 'select', phase: 'site', tool: null })
+ }
+ selectSiteFloorplanContext()
+ }, [])
+
+ const exitSiteEditing = useCallback(() => {
+ const editor = useEditor.getState()
+ editor.setPhase('structure')
+ editor.setStructureLayer('elements')
+ editor.setMode('select')
+ }, [])
+
+ useEffect(() => {
+ if (!isSiteEditing) return
+
+ const onGridClick = () => {
+ if (useEditor.getState().phase !== 'site') return
+ exitSiteEditing()
+ }
+
+ emitter.on('grid:click', onGridClick)
+ return () => {
+ emitter.off('grid:click', onGridClick)
+ }
+ }, [exitSiteEditing, isSiteEditing])
+
+ const renderSiteFlagVertex = useCallback(
+ ({
+ handleProps,
+ height,
+ isDragging,
+ isHovered,
+ point,
+ position,
+ }: PolygonVertexHandleRenderProps) => {
+ const baseY = position[1] - height / 2
+
+ return (
+
+ )
+ },
+ [],
+ )
+ const renderSiteFlagMidpoint = useCallback(
+ ({ handleProps, height, isHovered, point, position }: PolygonMidpointHandleRenderProps) => {
+ const baseY = position[1] - height / 2
+
+ return (
+
+ )
+ },
+ [],
+ )
+
+ if (!displayPolygon || displayPolygon.length < 3) return null
+ if (!showSiteHandles) return null
return (
{
+ sfxEmitter.emit('sfx:item-place')
+ exitSiteEditing()
+ }}
+ onDragStart={() => sfxEmitter.emit('sfx:item-pick')}
+ onDragStateChange={handleSiteBoundaryDragChange}
+ onMidpointHoverChange={setHoveredMidpoint}
onPolygonChange={handlePolygonChange}
- polygon={site.polygon.points}
+ onPolygonPreview={handlePolygonPreview}
+ onVertexHoverChange={setHoveredVertex}
+ polygon={displayPolygon}
+ renderMidpointHandle={renderSiteFlagMidpoint}
+ renderVertexHandle={renderSiteFlagVertex}
+ showBorderLine={isSiteBoundaryHighlighted}
+ showMidpointHandles={showSiteHandles}
/>
)
}
diff --git a/packages/editor/src/components/tools/site/site-flag-model.ts b/packages/editor/src/components/tools/site/site-flag-model.ts
new file mode 100644
index 00000000..fce30c7c
--- /dev/null
+++ b/packages/editor/src/components/tools/site/site-flag-model.ts
@@ -0,0 +1,18 @@
+const SITE_FLAG_MODEL_BASE64 = [
+ 'Z2xURgIAAADgAwAAAAMAAEpTT057ImFzc2V0Ijp7ImdlbmVyYXRvciI6ImdsVEYtVHJhbnNmb3JtIHY0LjMuMCIsInZlcnNpb24i',
+ 'OiIyLjAifSwiYWNjZXNzb3JzIjpbeyJ0eXBlIjoiU0NBTEFSIiwiY29tcG9uZW50VHlwZSI6NTEyMywiY291bnQiOjE2OH0seyJ0',
+ 'eXBlIjoiVkVDMyIsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjozMCwibWF4IjpbMC4wMDY3MzM3MTM2NzE1NjUwNTYsMC4w',
+ 'MDYyNDAyODE3NDIwNjYxNDUsMC4wMDAzNDc5ODc2MDMxNjMzNDY2NV0sIm1pbiI6Wy0wLjAwMDMwMTM2NjExODk0NzA0NCwtMC4w',
+ 'MDAzNDc5ODc2MDMxNjMzNDY2NSwtMC4wMDAzNDc5ODc4NjUwOTc4MjA3Nl19XSwiYnVmZmVyVmlld3MiOlt7ImJ1ZmZlciI6MCwi',
+ 'Ynl0ZU9mZnNldCI6MCwiYnl0ZUxlbmd0aCI6MTkzfV0sImJ1ZmZlcnMiOlt7ImJ5dGVMZW5ndGgiOjE5M31dLCJtZXNoZXMiOlt7',
+ 'InByaW1pdGl2ZXMiOlt7ImF0dHJpYnV0ZXMiOnsiUE9TSVRJT04iOjF9LCJtb2RlIjo0LCJpbmRpY2VzIjowLCJleHRlbnNpb25z',
+ 'Ijp7IktIUl9kcmFjb19tZXNoX2NvbXByZXNzaW9uIjp7ImJ1ZmZlclZpZXciOjAsImF0dHJpYnV0ZXMiOnsiUE9TSVRJT04iOjB9',
+ 'fX19XX1dLCJub2RlcyI6W3sic2NhbGUiOlsxMDAsMTAwLDEwMF0sIm1lc2giOjB9XSwic2NlbmVzIjpbeyJub2RlcyI6WzBdfV0s',
+ 'InNjZW5lIjowLCJleHRlbnNpb25zVXNlZCI6WyJLSFJfZHJhY29fbWVzaF9jb21wcmVzc2lvbiJdLCJleHRlbnNpb25zUmVxdWly',
+ 'ZWQiOlsiS0hSX2RyYWNvX21lc2hfY29tcHJlc3Npb24iXX0gICDEAAAAQklOAERSQUNPAgIBAQAAAB44ADcDAA7f6qkv1VVRZfUo',
+ '1qKKAgEBEAH/AAABAAkDAAACAQEBAA0DEREXaQaJCEUEiQg1EwsuVMV2v4nwGqm5Tl7xigAAAFMDACwAZQAcaFlXAMoAAJS7Umpl',
+ 'CAIAyqteasnAqAAAAEt9IJaxqKVWGb4CAIBXSq0MAKDUyQAA/CcAQABAmVEBAADA1KdWGV4BKPOK/1QGgAWQDAAAAAD/DwAArQCe',
+ 'uRtytrkkcra5h4bmOwwAAAA=',
+].join('')
+
+export const SITE_FLAG_MODEL_URL = `data:model/gltf-binary;base64,${SITE_FLAG_MODEL_BASE64}`
diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx
index d9009345..d942d25f 100644
--- a/packages/editor/src/components/tools/tool-manager.tsx
+++ b/packages/editor/src/components/tools/tool-manager.tsx
@@ -90,8 +90,9 @@ export const ToolManager: React.FC = () => {
| CeilingNode['id']
| undefined
- // Show site boundary editor when in site phase (toggle controls entry/exit)
- const showSiteBoundaryEditor = phase === 'site'
+ // Keep the site vertex flags available in select mode; the editor component
+ // switches to full polygon editing only after a flag activates site mode.
+ const showSiteBoundaryEditor = phase === 'site' || mode === 'select'
// Show slab boundary editor when in structure/select mode with a slab selected (but not editing a hole)
const showSlabBoundaryEditor =
diff --git a/packages/editor/src/components/ui/action-menu/control-modes.tsx b/packages/editor/src/components/ui/action-menu/control-modes.tsx
index 74d46f98..5cd84fcf 100644
--- a/packages/editor/src/components/ui/action-menu/control-modes.tsx
+++ b/packages/editor/src/components/ui/action-menu/control-modes.tsx
@@ -6,7 +6,7 @@ import { useViewer } from '@pascal-app/viewer'
import { type LucideIcon, Trash2 } from 'lucide-react'
import Image from 'next/image'
import { cn } from './../../../lib/utils'
-import useEditor from './../../../store/use-editor'
+import useEditor, { selectSiteFloorplanContext } from './../../../store/use-editor'
import { ActionButton } from './action-button'
type ControlId = 'select' | 'box-select' | 'site-edit' | 'zone' | 'delete'
@@ -34,7 +34,7 @@ const controls: ControlConfig[] = [
},
{
id: 'site-edit',
- imageSrc: '/icons/site.png',
+ imageSrc: '/icons/site-flag.png',
label: 'Edit site',
color: 'hover:bg-white/5',
activeColor: 'bg-white/10 hover:bg-white/10',
@@ -100,12 +100,8 @@ export function ControlModes() {
setMode('select')
setStructureLayer('elements')
} else if (isGroundFloor) {
- // Enter site editing — set state directly to preserve level selection.
- // setPhase('site') calls viewer.resetSelection() which clears levelId,
- // breaking the 2D floorplan (it needs a level to render the SVG).
useEditor.setState({ phase: 'site', mode: 'select', tool: null, catalogCategory: null })
- // Clear object selection so the polygon editor handles receive pointer events
- useViewer.getState().setSelection({ selectedIds: [] })
+ selectSiteFloorplanContext()
}
return
}
diff --git a/packages/editor/src/components/ui/action-menu/structure-tools.tsx b/packages/editor/src/components/ui/action-menu/structure-tools.tsx
index 76e7d6ef..30f897ee 100644
--- a/packages/editor/src/components/ui/action-menu/structure-tools.tsx
+++ b/packages/editor/src/components/ui/action-menu/structure-tools.tsx
@@ -23,6 +23,6 @@ export const tools: ToolConfig[] = [
{ id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' },
{ id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' },
{ id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
- { id: 'spawn', iconSrc: '/icons/site.png', label: 'Spawn Point' },
+ { id: 'spawn', iconSrc: '/icons/spawn-point.png', label: 'Spawn Point' },
{ id: 'shelf', iconSrc: '/icons/shelf.png', label: 'Shelf' },
]
diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx
index 4bc8ffea..788054f2 100644
--- a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx
+++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx
@@ -1569,7 +1569,7 @@ export function SitePanel({ projectId, onUploadAsset, onDeleteAsset }: SitePanel
'h-5 w-5 object-contain transition-all',
phase !== 'site' && 'opacity-60 grayscale',
)}
- src="/icons/site.png"
+ src="/icons/site-flag.png"
/>
{siteNode.name || 'Site'}
diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx
index 4ca4fe18..d973df1b 100644
--- a/packages/editor/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx
+++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx
@@ -50,7 +50,13 @@ export const SpawnTreeNode = memo(function SpawnTreeNode({
expanded={false}
hasChildren={false}
icon={
-
+
}
isHovered={isHovered}
isLast={isLast}
diff --git a/packages/editor/src/lib/site-boundary.ts b/packages/editor/src/lib/site-boundary.ts
new file mode 100644
index 00000000..2e68718d
--- /dev/null
+++ b/packages/editor/src/lib/site-boundary.ts
@@ -0,0 +1 @@
+export const SITE_BOUNDARY_DRAG_LABEL = 'site-boundary'
diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx
index 1d6d2079..d67a9b5b 100644
--- a/packages/editor/src/store/use-editor.tsx
+++ b/packages/editor/src/store/use-editor.tsx
@@ -2,6 +2,7 @@
import type { AssetInput } from '@pascal-app/core'
import {
+ type AnyNode,
type AnyNodeId,
type BuildingNode,
type CeilingNode,
@@ -422,6 +423,10 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState =
const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05]
+type SelectDefaultBuildingAndLevelOptions = {
+ forceGroundLevel?: boolean
+}
+
function normalizeModeForPhase(phase: Phase, mode: Mode | undefined): Mode {
if (phase === 'site') {
return 'select'
@@ -558,49 +563,94 @@ export function hasCustomPersistedEditorUiState(
)
}
+function getDefaultLevelId(
+ buildingNode: BuildingNode,
+ nodes: Record,
+): LevelNode['id'] | null {
+ const levels = buildingNode.children
+ .map((childId) => nodes[childId as AnyNodeId])
+ .filter((node): node is LevelNode => node?.type === 'level')
+
+ if (levels.length === 0) {
+ return null
+ }
+
+ const groundLevel = levels.find((level) => level.level === 0)
+ if (groundLevel) {
+ return groundLevel.id
+ }
+
+ const firstLevel = levels[0]
+ if (!firstLevel) {
+ return null
+ }
+
+ let lowestLevel = firstLevel
+ for (const level of levels.slice(1)) {
+ if (level.level < lowestLevel.level) {
+ lowestLevel = level
+ }
+ }
+
+ return lowestLevel.id
+}
+
/**
* Selects the first building and level 0 in the scene.
* Safe to call any time — no-ops if already selected or scene is empty.
*/
-export function selectDefaultBuildingAndLevel() {
+export function selectDefaultBuildingAndLevel(options: SelectDefaultBuildingAndLevelOptions = {}) {
const viewer = useViewer.getState()
const scene = useScene.getState()
- let buildingId = viewer.selection.buildingId
+ const selectedBuilding = viewer.selection.buildingId
+ ? scene.nodes[viewer.selection.buildingId]
+ : null
+ let buildingNode =
+ selectedBuilding?.type === 'building' ? (selectedBuilding as BuildingNode) : null
// If no building selected, find the first one from site's children
- if (!buildingId) {
+ if (!buildingNode) {
const siteNode = scene.rootNodeIds[0] ? scene.nodes[scene.rootNodeIds[0]] : null
if (siteNode?.type === 'site') {
- const firstBuilding = siteNode.children
- .map((childId) => scene.nodes[childId as AnyNodeId])
- .find((node) => node?.type === 'building')
- if (firstBuilding) {
- buildingId = firstBuilding.id as BuildingNode['id']
- viewer.setSelection({ buildingId })
- }
+ buildingNode =
+ siteNode.children
+ .map((childId) => scene.nodes[childId as AnyNodeId])
+ .find((node): node is BuildingNode => node?.type === 'building') ?? null
}
}
- // If no level selected, find level 0 in the building
- if (buildingId && !viewer.selection.levelId) {
- const buildingNode = scene.nodes[buildingId] as BuildingNode
- const level0Id = buildingNode.children.find((childId) => {
- const levelNode = scene.nodes[childId] as LevelNode
- return levelNode?.type === 'level' && levelNode.level === 0
- })
- if (level0Id) {
- viewer.setSelection({ levelId: level0Id as LevelNode['id'] })
- } else {
- // Fallback to first level if level 0 doesn't exist
- const firstLevelId = buildingNode.children.find(
- (childId) => scene.nodes[childId]?.type === 'level',
- )
- if (firstLevelId) {
- viewer.setSelection({ levelId: firstLevelId as LevelNode['id'] })
- }
- }
+ if (!buildingNode) {
+ return
}
+
+ const selectedLevel = viewer.selection.levelId ? scene.nodes[viewer.selection.levelId] : null
+ const selectedLevelBelongsToBuilding =
+ selectedLevel?.type === 'level' && selectedLevel.parentId === buildingNode.id
+ const shouldSelectDefaultLevel = options.forceGroundLevel || !selectedLevelBelongsToBuilding
+ const defaultLevelId = shouldSelectDefaultLevel
+ ? getDefaultLevelId(buildingNode, scene.nodes as Record)
+ : null
+
+ const selectionUpdate: Parameters[0] = {}
+ if (viewer.selection.buildingId !== buildingNode.id) {
+ selectionUpdate.buildingId = buildingNode.id
+ }
+ if (defaultLevelId) {
+ selectionUpdate.levelId = defaultLevelId
+ }
+
+ if (Object.keys(selectionUpdate).length > 0) {
+ viewer.setSelection(selectionUpdate)
+ }
+}
+
+export function selectSiteFloorplanContext() {
+ selectDefaultBuildingAndLevel({ forceGroundLevel: true })
+ useViewer.getState().setSelection({
+ selectedIds: [],
+ zoneId: null,
+ })
}
const useEditor = create()(
@@ -631,12 +681,9 @@ const useEditor = create()(
set({ mode: 'select', tool: null, catalogCategory: null })
}
- const viewer = useViewer.getState()
-
switch (phase) {
case 'site':
- // In Site mode, we zoom out and deselect specific levels/buildings
- viewer.resetSelection()
+ selectSiteFloorplanContext()
break
case 'structure':
diff --git a/packages/nodes/src/site/definition.ts b/packages/nodes/src/site/definition.ts
index 70dd62b0..b8d8375e 100644
--- a/packages/nodes/src/site/definition.ts
+++ b/packages/nodes/src/site/definition.ts
@@ -39,7 +39,7 @@ export const siteDefinition: NodeDefinition = {
presentation: {
label: 'Site',
description: 'The top-level container holding buildings, zones, and the property boundary.',
- icon: { kind: 'url', src: '/icons/site.png' },
+ icon: { kind: 'url', src: '/icons/site-flag.png' },
paletteSection: 'site',
paletteOrder: 5,
},
diff --git a/packages/nodes/src/site/renderer.tsx b/packages/nodes/src/site/renderer.tsx
index 17c9315e..33e3ba34 100644
--- a/packages/nodes/src/site/renderer.tsx
+++ b/packages/nodes/src/site/renderer.tsx
@@ -4,6 +4,7 @@ import {
type AnyNodeId,
type SiteNode,
type SlabNode,
+ useLiveNodeOverrides,
useRegistry,
useScene,
} from '@pascal-app/core'
@@ -51,6 +52,10 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
useRegistry(node.id, 'site', ref)
const bgColor = useViewer((state) => getSceneTheme(state.sceneTheme).ground)
+ const livePolygon = useLiveNodeOverrides(
+ (state) => (state.overrides.get(node.id)?.polygon as SiteNode['polygon'] | undefined) ?? null,
+ )
+ const polygonPoints = livePolygon?.points ?? node.polygon?.points
// Lit (not Basic) so the site ground receives the directional shadow — Basic
// is unlit, which is why shadows used to stop dead at the slab edge. polygonOffset
@@ -102,9 +107,9 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
// Ground shape: site polygon with slab footprints punched as holes
const groundShape = useMemo(() => {
- if (!node?.polygon?.points || node.polygon.points.length < 3) return null
+ if (!polygonPoints || polygonPoints.length < 3) return null
- const pts = node.polygon.points
+ const pts = polygonPoints
const shape = new Shape()
shape.moveTo(pts[0]![0], -pts[0]![1])
for (let i = 1; i < pts.length; i++) shape.lineTo(pts[i]![0], -pts[i]![1])
@@ -122,13 +127,13 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
}
return shape
- }, [node?.polygon?.points, slabPolygons])
+ }, [polygonPoints, slabPolygons])
// Create boundary line geometry
const lineGeometry = useMemo(() => {
- if (!node?.polygon?.points || node.polygon.points.length < 2) return null
- return createBoundaryLineGeometry(node.polygon.points)
- }, [node?.polygon?.points])
+ if (!polygonPoints || polygonPoints.length < 2) return null
+ return createBoundaryLineGeometry(polygonPoints)
+ }, [polygonPoints])
const handlers = useNodeEvents(node, 'site')
diff --git a/packages/nodes/src/spawn/__tests__/parity.test.ts b/packages/nodes/src/spawn/__tests__/parity.test.ts
index 3c1d8d01..458b5ccd 100644
--- a/packages/nodes/src/spawn/__tests__/parity.test.ts
+++ b/packages/nodes/src/spawn/__tests__/parity.test.ts
@@ -66,7 +66,7 @@ describe('spawn definition', () => {
])
})
- test('floorplan uses indigo marker color and selected rotation affordance', () => {
+ test('floorplan uses footprint marker oriented to the spawn view and selected rotation affordance', () => {
const spawn = SpawnNode.parse({
id: 'spawn_test1234567890ab',
position: [1, 0, 2],
@@ -102,8 +102,15 @@ describe('spawn definition', () => {
},
} satisfies GeometryContext)
+ expect(geometry.kind).toBe('group')
+ const marker = geometry.kind === 'group' ? geometry.children[0] : null
+ expect(marker?.kind).toBe('group')
+ if (marker?.kind === 'group') {
+ expect(marker.transform?.rotate).toBe(-spawn.rotation)
+ }
+
const flat = flattenFloorplan(geometry)
- expect(flat.some((entry) => entry.kind === 'polygon' && entry.fill === '#818cf8')).toBe(true)
+ expect(flat.some((entry) => entry.kind === 'path' && entry.stroke === '#818cf8')).toBe(true)
expect(flat.some((entry) => entry.kind === 'rotate-arrow')).toBe(true)
})
diff --git a/packages/nodes/src/spawn/definition.ts b/packages/nodes/src/spawn/definition.ts
index f942b893..e5e7f41a 100644
--- a/packages/nodes/src/spawn/definition.ts
+++ b/packages/nodes/src/spawn/definition.ts
@@ -104,7 +104,7 @@ export const spawnDefinition: NodeDefinition = {
presentation: {
label: 'Spawn Point',
description: 'Player or camera origin within a level. One per level.',
- icon: { kind: 'url', src: '/icons/site.png' },
+ icon: { kind: 'url', src: '/icons/spawn-point.png' },
paletteSection: 'structure',
paletteOrder: 90, // bottom of structure list — matches legacy palette order
},
diff --git a/packages/nodes/src/spawn/floorplan.ts b/packages/nodes/src/spawn/floorplan.ts
index 2922369a..ac72b1ab 100644
--- a/packages/nodes/src/spawn/floorplan.ts
+++ b/packages/nodes/src/spawn/floorplan.ts
@@ -2,12 +2,45 @@ import type { FloorplanGeometry, FloorplanPoint, GeometryContext } from '@pascal
import type { SpawnNode } from './schema'
const SPAWN_COLOR = '#818cf8'
+const SPAWN_MARKER_HIT_RADIUS = 0.52
+const FOOTPRINT_ICON_SCALE = 0.045
+const FOOTPRINT_ICON_CENTER = 12
const ROTATE_ARROW_CORNER_OFFSET = 0.22
+const iconPoint = (x: number, y: number) =>
+ `${formatIconCoord(x - FOOTPRINT_ICON_CENTER)} ${formatIconCoord(y - FOOTPRINT_ICON_CENTER)}`
+const iconY = (y: number) => formatIconCoord(y - FOOTPRINT_ICON_CENTER)
+const iconX = (x: number) => formatIconCoord(x - FOOTPRINT_ICON_CENTER)
+const iconArcRadius = formatIconCoord(2)
+
+const FOOTPRINT_LEFT_PATH = [
+ `M ${iconPoint(4, 16)}`,
+ `V ${iconY(13.62)}`,
+ `C ${iconPoint(4, 11.5)} ${iconPoint(2.97, 10.5)} ${iconPoint(3, 8)}`,
+ `C ${iconPoint(3.03, 5.28)} ${iconPoint(4.49, 2)} ${iconPoint(7.5, 2)}`,
+ `C ${iconPoint(9.37, 2)} ${iconPoint(10, 3.8)} ${iconPoint(10, 5.5)}`,
+ `C ${iconPoint(10, 8.61)} ${iconPoint(8, 11.16)} ${iconPoint(8, 14.18)}`,
+ `V ${iconY(16)}`,
+ `A ${iconArcRadius} ${iconArcRadius} 0 1 1 ${iconPoint(4, 16)}`,
+ 'Z',
+].join(' ')
+
+const FOOTPRINT_RIGHT_PATH = [
+ `M ${iconPoint(20, 20)}`,
+ `V ${iconY(17.62)}`,
+ `C ${iconPoint(20, 15.5)} ${iconPoint(21.03, 14.5)} ${iconPoint(21, 12)}`,
+ `C ${iconPoint(20.97, 9.28)} ${iconPoint(19.51, 6)} ${iconPoint(16.5, 6)}`,
+ `C ${iconPoint(14.63, 6)} ${iconPoint(14, 7.8)} ${iconPoint(14, 9.5)}`,
+ `C ${iconPoint(14, 12.61)} ${iconPoint(16, 15.16)} ${iconPoint(16, 18.18)}`,
+ `V ${iconY(20)}`,
+ `A ${iconArcRadius} ${iconArcRadius} 0 1 0 ${iconPoint(20, 20)}`,
+ 'Z',
+].join(' ')
+
/**
- * 2D floor-plan marker for a spawn point. A small filled circle at the
- * spawn's position, with a triangular arrow indicating the facing
- * direction (rotation around Y, looking down at the X-Z plane).
+ * 2D floor-plan marker for a spawn point. Uses the same footprint icon
+ * as the walkthrough-mode control and rotates it toward the spawn's
+ * first-person starting view.
*
* Color matches the 3D renderer's indigo spawn material so the user
* sees the same visual identity in both views.
@@ -17,36 +50,63 @@ const ROTATE_ARROW_CORNER_OFFSET = 0.22
export function buildSpawnFloorplan(node: SpawnNode, ctx: GeometryContext): FloorplanGeometry {
const [px, , pz] = node.position
const ry = node.rotation
+ const planRotation = -ry
const isSelected = ctx.viewState?.selected ?? false
const children: FloorplanGeometry[] = [
{
kind: 'group',
- transform: { translate: [px, pz], rotate: ry },
+ transform: { translate: [px, pz], rotate: planRotation },
children: [
- // Direction-pointing triangle, base centered at origin, tip in -Z
- // (forward). Matches the 3D arrow's orientation.
- {
- kind: 'polygon',
- points: [
- [0, -0.28],
- [-0.18, 0.12],
- [0.18, 0.12],
- ],
- fill: SPAWN_COLOR,
- opacity: 0.85,
- },
- // Spawn body marker — circle outline so the spawn is legible at
- // small zoom levels where the triangle would shrink past visibility.
{
kind: 'circle',
cx: 0,
cy: 0,
- r: 0.34,
+ r: SPAWN_MARKER_HIT_RADIUS,
+ fill: 'transparent',
+ pointerEvents: 'all',
+ },
+ {
+ kind: 'path',
+ d: FOOTPRINT_LEFT_PATH,
stroke: SPAWN_COLOR,
- strokeWidth: 0.025,
- fill: SPAWN_COLOR,
- opacity: 0.18,
+ strokeWidth: 2,
+ vectorEffect: 'non-scaling-stroke',
+ strokeLinecap: 'round',
+ strokeLinejoin: 'round',
+ fill: 'none',
+ },
+ {
+ kind: 'path',
+ d: FOOTPRINT_RIGHT_PATH,
+ stroke: SPAWN_COLOR,
+ strokeWidth: 2,
+ vectorEffect: 'non-scaling-stroke',
+ strokeLinecap: 'round',
+ strokeLinejoin: 'round',
+ fill: 'none',
+ },
+ {
+ kind: 'line',
+ x1: iconX(16),
+ y1: iconY(17),
+ x2: iconX(20),
+ y2: iconY(17),
+ stroke: SPAWN_COLOR,
+ strokeWidth: 2,
+ vectorEffect: 'non-scaling-stroke',
+ strokeLinecap: 'round',
+ },
+ {
+ kind: 'line',
+ x1: iconX(4),
+ y1: iconY(13),
+ x2: iconX(8),
+ y2: iconY(13),
+ stroke: SPAWN_COLOR,
+ strokeWidth: 2,
+ vectorEffect: 'non-scaling-stroke',
+ strokeLinecap: 'round',
},
],
},
@@ -55,8 +115,8 @@ export function buildSpawnFloorplan(node: SpawnNode, ctx: GeometryContext): Floo
if (isSelected) {
const cornerLocalX = 0.34 + ROTATE_ARROW_CORNER_OFFSET
const cornerLocalZ = 0.34 + ROTATE_ARROW_CORNER_OFFSET
- const [cornerX, cornerZ] = rotatePlanVector(cornerLocalX, cornerLocalZ, ry)
- const [radialX, radialZ] = rotatePlanVector(1, 1, ry)
+ const [cornerX, cornerZ] = rotatePlanVector(cornerLocalX, cornerLocalZ, planRotation)
+ const [radialX, radialZ] = rotatePlanVector(1, 1, planRotation)
children.push({
kind: 'rotate-arrow',
point: [px + cornerX, pz + cornerZ],
@@ -77,3 +137,8 @@ function rotatePlanVector(x: number, y: number, rotation: number): FloorplanPoin
const s = Math.sin(rotation)
return [x * c - y * s, x * s + y * c]
}
+
+function formatIconCoord(value: number): number {
+ const scaled = value * FOOTPRINT_ICON_SCALE
+ return Number(scaled.toFixed(4))
+}
diff --git a/packages/nodes/src/spawn/panel.tsx b/packages/nodes/src/spawn/panel.tsx
index b4352c0c..e8a29302 100644
--- a/packages/nodes/src/spawn/panel.tsx
+++ b/packages/nodes/src/spawn/panel.tsx
@@ -94,7 +94,12 @@ export default function SpawnPanel() {
const storedRotationDegrees = Math.round((node.rotation * 180) / Math.PI)
return (
-
+