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 ( - +