diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 6e30a35f..8c7778ba 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -96,44 +96,6 @@ export {
pointToKey,
type WallMiterData,
} from './systems/wall/wall-mitering'
-export {
- clampPlanValue,
- doesPolygonIntersectSelectionBounds,
- getDistanceToWallSegment,
- getFloorplanSelectionBounds,
- getPlanPointDistance,
- getRotatedRectanglePolygon,
- getThickPlanLinePolygon,
- interpolatePlanPoint,
- isPointInsidePolygon,
- isPointInsidePolygonWithHoles,
- isPointInsideSelectionBounds,
- movePlanPointTowards,
- pointMatchesWallPlanPoint,
- rotatePlanVector,
-} from './plan/geometry'
-export {
- buildFloorplanItemEntry,
- collectLevelDescendants,
- getItemFloorplanTransform,
-} from './plan/items'
-export {
- buildFloorplanStairEntry,
- computeFloorplanStairSegmentTransforms,
- getFloorplanStairSegmentPolygon,
-} from './plan/stairs'
-export type {
- FloorplanItemEntry,
- FloorplanLineSegment,
- FloorplanNodeTransform,
- FloorplanSelectionBounds,
- FloorplanStairArrowEntry,
- FloorplanStairEntry,
- FloorplanStairSegmentEntry,
- LevelDescendantMap,
- StairSegmentTransform,
-} from './plan/types'
-export { getFloorplanWall, getFloorplanWallThickness } from './plan/walls'
export { WallSystem } from './systems/wall/wall-system'
export { WindowSystem } from './systems/window/window-system'
export type { SceneGraph } from './utils/clone-scene-graph'
diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx
new file mode 100644
index 00000000..12bc6f53
--- /dev/null
+++ b/packages/editor/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx
@@ -0,0 +1,191 @@
+'use client'
+
+import { memo } from 'react'
+
+const FLOORPLAN_MEASUREMENT_LINE_WIDTH = 1.35
+const FLOORPLAN_MEASUREMENT_LINE_OPACITY = 0.95
+const FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE = 0.15
+const FLOORPLAN_MEASUREMENT_LABEL_OPACITY = 0.98
+const FLOORPLAN_MEASUREMENT_EXTENSION_DASH = '0.08 0.12'
+const FLOORPLAN_MEASUREMENT_END_TICK = 0.18
+
+export type LinearMeasurementOverlay = {
+ id: string
+ dimensionLineEnd: { x1: number; y1: number; x2: number; y2: number }
+ dimensionLineStart: { x1: number; y1: number; x2: number; y2: number }
+ extensionStart: { x1: number; y1: number; x2: number; y2: number }
+ extensionEnd: { x1: number; y1: number; x2: number; y2: number }
+ label: string
+ labelX: number
+ labelY: number
+ labelAngleDeg: number
+ extensionStroke?: string
+ isSelected?: boolean
+ labelFill?: string
+ stroke?: string
+}
+
+type FloorplanMeasurementPalette = {
+ measurementStroke: string
+}
+
+type FloorplanMeasurementLineProps = {
+ palette: FloorplanMeasurementPalette
+ segment: { x1: number; y1: number; x2: number; y2: number }
+ isSelected?: boolean
+ dashed?: boolean
+ stroke?: string
+}
+
+function FloorplanMeasurementLine({
+ palette,
+ segment,
+ isSelected,
+ dashed = false,
+ stroke,
+}: FloorplanMeasurementLineProps) {
+ const lineOpacity = isSelected
+ ? FLOORPLAN_MEASUREMENT_LINE_OPACITY
+ : FLOORPLAN_MEASUREMENT_LINE_OPACITY * 0.4
+
+ return (
+
+ )
+}
+
+type FloorplanMeasurementTickProps = {
+ palette: FloorplanMeasurementPalette
+ x: number
+ y: number
+ angleDeg: number
+ isSelected?: boolean
+ stroke?: string
+}
+
+function FloorplanMeasurementTick({
+ palette,
+ x,
+ y,
+ angleDeg,
+ isSelected,
+ stroke,
+}: FloorplanMeasurementTickProps) {
+ const radians = (angleDeg * Math.PI) / 180
+ const nx = -Math.sin(radians)
+ const ny = Math.cos(radians)
+ const half = FLOORPLAN_MEASUREMENT_END_TICK / 2
+
+ return (
+
+ )
+}
+
+type FloorplanMeasurementsLayerProps = {
+ className: string
+ measurements: LinearMeasurementOverlay[]
+ palette: FloorplanMeasurementPalette
+}
+
+export const FloorplanMeasurementsLayer = memo(function FloorplanMeasurementsLayer({
+ className,
+ measurements,
+ palette,
+}: FloorplanMeasurementsLayerProps) {
+ if (measurements.length === 0) {
+ return null
+ }
+
+ return (
+ <>
+ {measurements.map((measurement) => (
+
+
+
+
+
+
+
+
+ {measurement.label}
+
+
+ ))}
+ >
+ )
+})
diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-roof-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-roof-layer.tsx
new file mode 100644
index 00000000..447cd734
--- /dev/null
+++ b/packages/editor/src/components/editor-2d/renderers/floorplan-roof-layer.tsx
@@ -0,0 +1,98 @@
+'use client'
+
+import { memo } from 'react'
+import type { Point2D, RoofNode, RoofSegmentNode } from '@pascal-app/core'
+import { toSvgX, toSvgY } from '../svg-paths'
+
+type FloorplanLineSegment = {
+ start: Point2D
+ end: Point2D
+}
+
+type FloorplanRoofSegmentEntry = {
+ segment: RoofSegmentNode
+ points: string
+ ridgeLine: FloorplanLineSegment | null
+}
+
+type FloorplanRoofEntry = {
+ roof: RoofNode
+ segments: FloorplanRoofSegmentEntry[]
+}
+
+type FloorplanRoofLayerProps = {
+ highlightedIdSet: ReadonlySet
+ roofEntries: FloorplanRoofEntry[]
+ selectedIdSet: ReadonlySet
+}
+
+export const FloorplanRoofLayer = memo(function FloorplanRoofLayer({
+ highlightedIdSet,
+ roofEntries,
+ selectedIdSet,
+}: FloorplanRoofLayerProps) {
+ if (roofEntries.length === 0) {
+ return null
+ }
+
+ return (
+ <>
+ {roofEntries.map(({ roof, segments }) => {
+ const roofSelected = selectedIdSet.has(roof.id)
+ const roofHighlighted = highlightedIdSet.has(roof.id)
+ const hasSelectedSegment = segments.some(({ segment }) => selectedIdSet.has(segment.id))
+ const hasHighlightedSegment = segments.some(({ segment }) =>
+ highlightedIdSet.has(segment.id),
+ )
+ const isRoofActive =
+ roofSelected || roofHighlighted || hasSelectedSegment || hasHighlightedSegment
+
+ return (
+
+ {segments.map(({ points, ridgeLine, segment }) => {
+ const isSegmentSelected = selectedIdSet.has(segment.id)
+ const isSegmentHighlighted = highlightedIdSet.has(segment.id)
+ const isSegmentActive = isSegmentSelected || isSegmentHighlighted
+
+ return (
+
+
+ {ridgeLine ? (
+
+ ) : null}
+
+ )
+ })}
+
+ )
+ })}
+ >
+ )
+})
diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-stair-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-stair-layer.tsx
new file mode 100644
index 00000000..b73b8887
--- /dev/null
+++ b/packages/editor/src/components/editor-2d/renderers/floorplan-stair-layer.tsx
@@ -0,0 +1,430 @@
+'use client'
+
+import type { Point2D, StairNode, StairSegmentNode } from '@pascal-app/core'
+import { memo, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent } from 'react'
+import {
+ buildSvgAnnularSectorPath,
+ buildSvgArcPath,
+ buildSvgArrowHeadPoints,
+ formatSvgPolygonPoints,
+ getArcPlanPoint,
+ toSvgX,
+ toSvgY,
+} from '../svg-paths'
+
+type FloorplanPolygonEntry = {
+ points: string
+ polygon: Point2D[]
+}
+
+type FloorplanStairSegmentEntry = {
+ segment: StairSegmentNode
+ points: string
+ treadBars: FloorplanPolygonEntry[]
+}
+
+type FloorplanStairArrowEntry = {
+ head: Point2D[]
+ polyline: Point2D[]
+}
+
+type FloorplanStairEntry = {
+ arrow: FloorplanStairArrowEntry | null
+ hitPolygons: Point2D[][]
+ stair: StairNode
+ segments: FloorplanStairSegmentEntry[]
+}
+
+type FloorplanPalette = {
+ deleteFill: string
+ deleteStroke: string
+}
+
+type FloorplanStairLayerProps = {
+ canFocusStairs: boolean
+ canSelectStairs: boolean
+ cursor: string
+ highlightedIdSet: ReadonlySet
+ hitStrokeWidth: number
+ hoveredStairId: StairNode['id'] | null
+ isDeleteMode: boolean
+ onStairDoubleClick: (stair: StairNode, event: ReactMouseEvent) => void
+ onStairHoverChange: (stairId: StairNode['id'] | null) => void
+ onStairHoverEnter: (stairId: StairNode['id']) => void
+ onStairPointerDown: (stairId: StairNode['id'], event: ReactPointerEvent) => void
+ onStairSelect: (stairId: StairNode['id'], event: ReactMouseEvent) => void
+ palette: FloorplanPalette
+ selectedIdSet: ReadonlySet
+ stairEntries: FloorplanStairEntry[]
+}
+
+function clamp(value: number, min: number, max: number) {
+ return Math.min(max, Math.max(min, value))
+}
+
+function getNormalizedFloorplanStairSweepAngle(stair: StairNode) {
+ const stairType = stair.stairType ?? 'straight'
+ const baseSweepAngle =
+ stair.sweepAngle ?? (stairType === 'spiral' ? Math.PI * 2 : Math.PI / 2)
+
+ if (Math.abs(baseSweepAngle) >= Math.PI * 2) {
+ return Math.sign(baseSweepAngle || 1) * (Math.PI * 2 - 0.001)
+ }
+
+ return baseSweepAngle
+}
+
+export const FloorplanStairLayer = memo(function FloorplanStairLayer({
+ canFocusStairs,
+ canSelectStairs,
+ cursor,
+ highlightedIdSet,
+ hitStrokeWidth,
+ hoveredStairId,
+ isDeleteMode,
+ onStairDoubleClick,
+ onStairHoverChange,
+ onStairHoverEnter,
+ onStairPointerDown,
+ onStairSelect,
+ palette,
+ selectedIdSet,
+ stairEntries,
+}: FloorplanStairLayerProps) {
+ if (stairEntries.length === 0) {
+ return null
+ }
+
+ return (
+ <>
+ {stairEntries.map(({ arrow, hitPolygons, stair, segments }) => {
+ const stairSelected = selectedIdSet.has(stair.id)
+ const stairHighlighted = highlightedIdSet.has(stair.id)
+ const segmentSelected = segments.some(({ segment }) => selectedIdSet.has(segment.id))
+ const segmentHighlighted = segments.some(({ segment }) => highlightedIdSet.has(segment.id))
+ const isHovered = hoveredStairId === stair.id
+ const isDeleteHovered = isDeleteMode && isHovered
+ const isSelectionActive =
+ stairSelected || stairHighlighted || segmentSelected || segmentHighlighted
+ const stairType = stair.stairType ?? 'straight'
+ const normalizedSweepAngle = getNormalizedFloorplanStairSweepAngle(stair)
+ const sectorStartAngle = stair.rotation - normalizedSweepAngle / 2
+ const sectorEndAngle = sectorStartAngle + normalizedSweepAngle
+ const stairCenter = {
+ x: stair.position[0],
+ y: stair.position[2],
+ }
+ const innerRadius = Math.max(
+ stairType === 'spiral' ? 0.05 : 0.2,
+ stair.innerRadius ?? (stairType === 'spiral' ? 0.2 : 0.9),
+ )
+ const outerRadius = innerRadius + stair.width
+ const centerlineRadius = innerRadius + stair.width / 2
+ const curvedStroke = isDeleteHovered
+ ? palette.deleteStroke
+ : isSelectionActive
+ ? '#2563eb'
+ : 'rgba(31, 41, 55, 0.9)'
+ const curvedAccent = isDeleteHovered
+ ? palette.deleteStroke
+ : isSelectionActive
+ ? '#1d4ed8'
+ : 'rgba(23, 23, 23, 0.96)'
+ const curvedFill = isDeleteHovered
+ ? palette.deleteFill
+ : isSelectionActive
+ ? 'rgba(59, 130, 246, 0.16)'
+ : '#ffffff'
+ const straightAccent = isDeleteHovered
+ ? palette.deleteStroke
+ : isSelectionActive
+ ? '#1d4ed8'
+ : 'rgba(23, 23, 23, 0.96)'
+ const straightStroke = isDeleteHovered
+ ? palette.deleteStroke
+ : isSelectionActive
+ ? '#1d4ed8'
+ : 'rgba(23, 23, 23, 0.88)'
+ const straightTread = isDeleteHovered
+ ? palette.deleteStroke
+ : isSelectionActive
+ ? 'rgba(37, 99, 235, 0.78)'
+ : 'rgba(38, 38, 38, 0.62)'
+ const straightFill = isDeleteHovered
+ ? palette.deleteFill
+ : isSelectionActive
+ ? 'rgba(59, 130, 246, 0.08)'
+ : 'rgba(255, 255, 255, 0.02)'
+ const curvedOuterLineWidth = isSelectionActive ? '2' : '1.4'
+ const curvedInnerLineWidth = isSelectionActive ? '1.7' : '1.2'
+ const stairSymbol =
+ stairType === 'spiral' ? (
+ <>
+
+
+
+ {Array.from({ length: Math.max(6, stair.stepCount) }, (_, index) => {
+ const stepCount = Math.max(6, stair.stepCount)
+ const stepSweep = normalizedSweepAngle / stepCount
+ const angle = sectorStartAngle + stepSweep * index
+ const innerPoint = getArcPlanPoint(stairCenter, innerRadius, angle)
+ const outerPoint = getArcPlanPoint(stairCenter, outerRadius, angle)
+ const dashedFromIndex = Math.floor(stepCount * 0.68)
+
+ return (
+ = dashedFromIndex ? '0.1 0.08' : undefined}
+ strokeWidth={index === stepCount - 1 ? '1.8' : '1.15'}
+ vectorEffect="non-scaling-stroke"
+ x1={toSvgX(innerPoint.x)}
+ x2={toSvgX(outerPoint.x)}
+ y1={toSvgY(innerPoint.y)}
+ y2={toSvgY(outerPoint.y)}
+ />
+ )
+ })}
+
+ {(() => {
+ const directionAngle = sectorStartAngle + normalizedSweepAngle * 0.86
+ const arrowPoint = getArcPlanPoint(stairCenter, centerlineRadius, directionAngle)
+ const tangentAngle =
+ directionAngle + (normalizedSweepAngle >= 0 ? Math.PI / 2 : -Math.PI / 2)
+
+ return (
+
+ )
+ })()}
+ >
+ ) : stairType === 'curved' ? (
+ <>
+
+
+
+ {Array.from({ length: Math.max(4, stair.stepCount) + 1 }, (_, index) => {
+ const stepCount = Math.max(4, stair.stepCount)
+ const stepSweep = normalizedSweepAngle / stepCount
+ const angle = sectorStartAngle + stepSweep * index
+ const innerPoint = getArcPlanPoint(stairCenter, innerRadius, angle)
+ const outerPoint = getArcPlanPoint(stairCenter, outerRadius, angle)
+
+ return (
+
+ )
+ })}
+
+ {(() => {
+ const stepCount = Math.max(4, stair.stepCount)
+ const stepSweep = normalizedSweepAngle / stepCount
+ const arrowAngle = sectorEndAngle - stepSweep * 0.8
+ const arrowPoint = getArcPlanPoint(stairCenter, centerlineRadius, arrowAngle)
+ const tangentAngle =
+ arrowAngle + (normalizedSweepAngle >= 0 ? Math.PI / 2 : -Math.PI / 2)
+
+ return (
+
+ )
+ })()}
+ >
+ ) : (
+ <>
+ {segments.map(({ points, segment, treadBars }) => (
+
+
+ {treadBars.map((treadBar, treadIndex) => (
+
+ ))}
+
+ ))}
+ {arrow?.polyline && arrow.polyline.length >= 2 ? (
+ <>
+
+
+
+ >
+ ) : null}
+ >
+ )
+
+ return (
+ {
+ event.stopPropagation()
+ onStairSelect(stair.id, event)
+ }
+ : undefined
+ }
+ onDoubleClick={
+ canFocusStairs
+ ? (event) => {
+ event.stopPropagation()
+ onStairDoubleClick(stair, event)
+ }
+ : undefined
+ }
+ onPointerEnter={canSelectStairs ? () => onStairHoverEnter(stair.id) : undefined}
+ onPointerLeave={canSelectStairs ? () => onStairHoverChange(null) : undefined}
+ onPointerDown={
+ canFocusStairs && stairSelected
+ ? (event) => {
+ if (event.button === 0) {
+ onStairPointerDown(stair.id, event)
+ }
+ }
+ : undefined
+ }
+ pointerEvents={canSelectStairs ? undefined : 'none'}
+ style={canSelectStairs ? { cursor } : undefined}
+ >
+ {hitPolygons.map((polygon, polygonIndex) => (
+
+ ))}
+ {stair.name || 'Staircase'}
+ {stairSymbol}
+
+ )
+ })}
+ >
+ )
+})
diff --git a/packages/editor/src/components/editor-2d/svg-paths.ts b/packages/editor/src/components/editor-2d/svg-paths.ts
new file mode 100644
index 00000000..e9e30a17
--- /dev/null
+++ b/packages/editor/src/components/editor-2d/svg-paths.ts
@@ -0,0 +1,121 @@
+'use client'
+
+import type { Point2D } from '@pascal-app/core'
+
+function toSvgX(value: number) {
+ return -value
+}
+
+function toSvgY(value: number) {
+ return -value
+}
+
+function toSvgPoint(point: Point2D) {
+ return {
+ x: toSvgX(point.x),
+ y: toSvgY(point.y),
+ }
+}
+
+export function formatPolygonPath(points: Point2D[], holes: Point2D[][] = []) {
+ const formatSubpath = (subpathPoints: Point2D[]) => {
+ const [firstPoint, ...restPoints] = subpathPoints
+ if (!firstPoint) {
+ return null
+ }
+
+ const firstSvgPoint = toSvgPoint(firstPoint)
+
+ return [
+ `M ${firstSvgPoint.x} ${firstSvgPoint.y}`,
+ ...restPoints.map((point) => {
+ const svgPoint = toSvgPoint(point)
+ return `L ${svgPoint.x} ${svgPoint.y}`
+ }),
+ 'Z',
+ ].join(' ')
+ }
+
+ return [points, ...holes].map(formatSubpath).filter(Boolean).join(' ')
+}
+
+export function buildSvgPolylinePath(points: Point2D[]) {
+ if (points.length < 2) {
+ return null
+ }
+
+ return points
+ .map((point, index) => {
+ const svgPoint = toSvgPoint(point)
+ return `${index === 0 ? 'M' : 'L'} ${svgPoint.x} ${svgPoint.y}`
+ })
+ .join(' ')
+}
+
+export function getArcPlanPoint(center: Point2D, radius: number, angle: number): Point2D {
+ return {
+ x: center.x + Math.cos(angle) * radius,
+ y: center.y + Math.sin(angle) * radius,
+ }
+}
+
+export function buildSvgArcPath(
+ center: Point2D,
+ radius: number,
+ startAngle: number,
+ endAngle: number,
+) {
+ const start = getArcPlanPoint(center, radius, startAngle)
+ const end = getArcPlanPoint(center, radius, endAngle)
+ const delta = endAngle - startAngle
+ const largeArcFlag = Math.abs(delta) > Math.PI ? 1 : 0
+ const sweepFlag = delta >= 0 ? 1 : 0
+
+ return `M ${toSvgX(start.x)} ${toSvgY(start.y)} A ${radius} ${radius} 0 ${largeArcFlag} ${sweepFlag} ${toSvgX(end.x)} ${toSvgY(end.y)}`
+}
+
+export function buildSvgAnnularSectorPath(
+ center: Point2D,
+ innerRadius: number,
+ outerRadius: number,
+ startAngle: number,
+ endAngle: number,
+) {
+ const outerStart = getArcPlanPoint(center, outerRadius, startAngle)
+ const outerEnd = getArcPlanPoint(center, outerRadius, endAngle)
+ const innerEnd = getArcPlanPoint(center, innerRadius, endAngle)
+ const innerStart = getArcPlanPoint(center, innerRadius, startAngle)
+ const delta = endAngle - startAngle
+ const largeArcFlag = Math.abs(delta) > Math.PI ? 1 : 0
+ const sweepFlag = delta >= 0 ? 1 : 0
+ const reverseSweepFlag = sweepFlag ? 0 : 1
+
+ return [
+ `M ${toSvgX(outerStart.x)} ${toSvgY(outerStart.y)}`,
+ `A ${outerRadius} ${outerRadius} 0 ${largeArcFlag} ${sweepFlag} ${toSvgX(outerEnd.x)} ${toSvgY(outerEnd.y)}`,
+ `L ${toSvgX(innerEnd.x)} ${toSvgY(innerEnd.y)}`,
+ `A ${innerRadius} ${innerRadius} 0 ${largeArcFlag} ${reverseSweepFlag} ${toSvgX(innerStart.x)} ${toSvgY(innerStart.y)}`,
+ 'Z',
+ ].join(' ')
+}
+
+export function formatSvgPolygonPoints(points: Point2D[]) {
+ return points
+ .map((point) => `${toSvgX(point.x)},${toSvgY(point.y)}`)
+ .join(' ')
+}
+
+export function buildSvgArrowHeadPoints(point: Point2D, angle: number, size: number) {
+ const left = {
+ x: point.x - size * Math.cos(angle - Math.PI / 6),
+ y: point.y - size * Math.sin(angle - Math.PI / 6),
+ }
+ const right = {
+ x: point.x - size * Math.cos(angle + Math.PI / 6),
+ y: point.y - size * Math.sin(angle + Math.PI / 6),
+ }
+
+ return formatSvgPolygonPoints([point, left, right])
+}
+
+export { toSvgPoint, toSvgX, toSvgY }
diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx
index 7d51d30c..ccca22ca 100644
--- a/packages/editor/src/components/editor/floorplan-panel.tsx
+++ b/packages/editor/src/components/editor/floorplan-panel.tsx
@@ -6,14 +6,10 @@ import {
type AnyNodeId,
type BuildingNode,
type CeilingNode,
- buildFloorplanItemEntry,
- buildFloorplanStairEntry as buildSharedFloorplanStairEntry,
calculateLevelMiters,
- collectLevelDescendants as collectSharedLevelDescendants,
DoorNode,
emitter,
type FenceNode,
- getFloorplanWall as getSharedFloorplanWall,
type GridEvent,
type GuideNode,
getWallChordFrame,
@@ -21,7 +17,6 @@ import {
getWallCurveLength,
getWallMidpointHandlePoint,
getWallPlanFootprint,
- type FloorplanNodeTransform as SharedFloorplanNodeTransform,
type ItemNode,
ItemNode as ItemNodeSchema,
isCurvedWall,
@@ -31,7 +26,6 @@ import {
type Point2D,
type RoofNode,
type RoofSegmentNode,
- rotatePlanVector as rotateSharedPlanVector,
sceneRegistry,
type SiteNode,
SlabNode,
@@ -64,14 +58,33 @@ import { useShallow } from 'zustand/react/shallow'
import { FloorplanActionMenuLayer as Editor2dFloorplanActionMenuLayer } from '../editor-2d/floorplan-action-menu-layer'
import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay'
import { FloorplanDraftLayer } from '../editor-2d/renderers/floorplan-draft-layer'
+import {
+ FloorplanMeasurementsLayer,
+ type LinearMeasurementOverlay,
+} from '../editor-2d/renderers/floorplan-measurements-layer'
import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee-layer'
+import { FloorplanRoofLayer } from '../editor-2d/renderers/floorplan-roof-layer'
+import { FloorplanStairLayer } from '../editor-2d/renderers/floorplan-stair-layer'
+import {
+ buildSvgPolylinePath,
+ formatPolygonPath,
+ getArcPlanPoint,
+} from '../editor-2d/svg-paths'
+import {
+ buildFloorplanItemEntry,
+ buildFloorplanStairEntry as buildSharedFloorplanStairEntry,
+ collectLevelDescendants as collectSharedLevelDescendants,
+ getFloorplanWall as getSharedFloorplanWall,
+ type FloorplanNodeTransform as SharedFloorplanNodeTransform,
+ rotatePlanVector as rotateSharedPlanVector,
+} from '../../lib/floorplan'
import { sfxEmitter } from '../../lib/sfx-bus'
import { cn } from '../../lib/utils'
import useEditor, { type FloorplanSelectionTool } from '../../store/use-editor'
import {
getFloorplanHitNodeId,
getFloorplanSelectionIdsInBounds as getSelectionIdsInBoundsFromTool,
-} from '../tools/floorplan/selection-tool'
+} from '../../lib/floorplan/selection-tool'
import { snapToHalf } from '../tools/item/placement-math'
import {
DEFAULT_STAIR_ATTACHMENT_SIDE,
@@ -139,17 +152,12 @@ const FLOORPLAN_ENDPOINT_HOVER_RING_STROKE_WIDTH = 7
const FLOORPLAN_MARQUEE_DRAG_THRESHOLD_PX = 4
const FLOORPLAN_MEASUREMENT_OFFSET = 0.46
const FLOORPLAN_MEASUREMENT_EXTENSION_OVERSHOOT = 0.08
-const FLOORPLAN_MEASUREMENT_LINE_WIDTH = 1.35
const FLOORPLAN_MEASUREMENT_LINE_OUTLINE_WIDTH = 0
-const FLOORPLAN_MEASUREMENT_LINE_OPACITY = 0.95
const FLOORPLAN_MEASUREMENT_LINE_OUTLINE_OPACITY = 0
const FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE = 0.15
-const FLOORPLAN_MEASUREMENT_LABEL_OPACITY = 0.98
const FLOORPLAN_MEASUREMENT_LABEL_STROKE_WIDTH = 0
const FLOORPLAN_MEASUREMENT_LABEL_GAP = 0.56
const FLOORPLAN_MEASUREMENT_LABEL_LINE_PADDING = 0.14
-const FLOORPLAN_MEASUREMENT_EXTENSION_DASH = '0.08 0.12'
-const FLOORPLAN_MEASUREMENT_END_TICK = 0.18
const FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET = 0.34
const FLOORPLAN_WALL_INNER_MEASUREMENT_OFFSET = 0.24
const FLOORPLAN_WALL_OUTER_MEASUREMENT_STROKE = 'rgba(59, 130, 246, 0.95)'
@@ -183,13 +191,6 @@ const FLOORPLAN_GUIDE_ROTATION_FINE_SNAP_DEGREES = 1
const FLOORPLAN_SITE_COLOR = '#10b981'
const FLOORPLAN_NODE_FOOTPRINT_STROKE_WIDTH = FLOORPLAN_OPENING_STROKE_WIDTH / 2
const FLOORPLAN_NODE_FOOTPRINT_CROSS_STROKE_WIDTH = FLOORPLAN_NODE_FOOTPRINT_STROKE_WIDTH * 0.7
-const FLOORPLAN_STAIR_OUTLINE_BAND_THICKNESS = FLOORPLAN_OPENING_STROKE_WIDTH
-const FLOORPLAN_STAIR_OUTLINE_MAX_FRACTION = 0.18
-const FLOORPLAN_STAIR_TREAD_BAND_THICKNESS = FLOORPLAN_OPENING_STROKE_WIDTH * 0.82
-const FLOORPLAN_STAIR_TREAD_MIN_THICKNESS = FLOORPLAN_OPENING_DETAIL_STROKE_WIDTH * 1.5
-const FLOORPLAN_STAIR_ARROW_BAND_THICKNESS = FLOORPLAN_STAIR_TREAD_BAND_THICKNESS
-const FLOORPLAN_STAIR_ARROW_HEAD_MIN_SIZE = 0.14
-const FLOORPLAN_STAIR_ARROW_HEAD_MAX_SIZE = 0.24
type FloorplanViewport = {
centerX: number
centerY: number
@@ -434,11 +435,6 @@ type ZonePolygonEntry = {
points: string
}
-type FloorplanNodeTransform = {
- position: Point2D
- rotation: number
-}
-
type FloorplanLineSegment = {
start: Point2D
end: Point2D
@@ -1322,28 +1318,6 @@ function formatPolygonPoints(points: Point2D[]): string {
.join(' ')
}
-function formatPolygonPath(points: Point2D[], holes: Point2D[][] = []): string {
- const formatSubpath = (subpathPoints: Point2D[]) => {
- const [firstPoint, ...restPoints] = subpathPoints
- if (!firstPoint) {
- return null
- }
-
- const firstSvgPoint = toSvgPoint(firstPoint)
-
- return [
- `M ${firstSvgPoint.x} ${firstSvgPoint.y}`,
- ...restPoints.map((point) => {
- const svgPoint = toSvgPoint(point)
- return `L ${svgPoint.x} ${svgPoint.y}`
- }),
- 'Z',
- ].join(' ')
- }
-
- return [points, ...holes].map(formatSubpath).filter(Boolean).join(' ')
-}
-
function toFloorplanPolygon(points: Array<[number, number]>): Point2D[] {
return points.map(([x, y]) => ({ x, y }))
}
@@ -1473,164 +1447,6 @@ function movePlanPointTowards(start: Point2D, end: Point2D, distance: number): P
return interpolatePlanPoint(start, end, Math.min(1, distance / totalDistance))
}
-function getFloorplanStairSegmentCenterLine(polygon: Point2D[]): FloorplanLineSegment | null {
- if (polygon.length < 4) {
- return null
- }
-
- const [backLeft, backRight, frontRight, frontLeft] = polygon
-
- return {
- start: interpolatePlanPoint(backLeft!, backRight!, 0.5),
- end: interpolatePlanPoint(frontLeft!, frontRight!, 0.5),
- }
-}
-
-function getFloorplanStairInnerPolygon(polygon: Point2D[]): Point2D[] {
- if (polygon.length < 4) {
- return polygon
- }
-
- const [backLeft, backRight, frontRight, frontLeft] = polygon
- const outerWidth = getPlanPointDistance(backLeft!, backRight!)
- const outerLength = getPlanPointDistance(backLeft!, frontLeft!)
- const widthInset = Math.min(
- FLOORPLAN_STAIR_OUTLINE_BAND_THICKNESS,
- outerWidth * FLOORPLAN_STAIR_OUTLINE_MAX_FRACTION,
- )
- const lengthInset = Math.min(
- FLOORPLAN_STAIR_OUTLINE_BAND_THICKNESS,
- outerLength * FLOORPLAN_STAIR_OUTLINE_MAX_FRACTION,
- )
-
- const insetBackLeft = movePlanPointTowards(backLeft!, frontLeft!, lengthInset)
- const insetBackRight = movePlanPointTowards(backRight!, frontRight!, lengthInset)
- const insetFrontLeft = movePlanPointTowards(frontLeft!, backLeft!, lengthInset)
- const insetFrontRight = movePlanPointTowards(frontRight!, backRight!, lengthInset)
-
- const innerPolygon = [
- movePlanPointTowards(insetBackLeft, insetBackRight, widthInset),
- movePlanPointTowards(insetBackRight, insetBackLeft, widthInset),
- movePlanPointTowards(insetFrontRight, insetFrontLeft, widthInset),
- movePlanPointTowards(insetFrontLeft, insetFrontRight, widthInset),
- ]
-
- const innerWidth = getPlanPointDistance(innerPolygon[0]!, innerPolygon[1]!)
- const innerLength = getPlanPointDistance(innerPolygon[0]!, innerPolygon[3]!)
-
- return innerWidth > 0.06 && innerLength > 0.06 ? innerPolygon : polygon
-}
-
-function getFloorplanStairTreadLines(
- segment: StairSegmentNode,
- innerPolygon: Point2D[],
-): FloorplanLineSegment[] {
- if (segment.segmentType !== 'stair' || segment.stepCount <= 1 || innerPolygon.length < 4) {
- return []
- }
-
- const [backLeft, backRight, frontRight, frontLeft] = innerPolygon
- const treadLines: FloorplanLineSegment[] = []
-
- for (let stepIndex = 1; stepIndex < segment.stepCount; stepIndex += 1) {
- const t = stepIndex / segment.stepCount
- treadLines.push({
- start: interpolatePlanPoint(backLeft!, frontLeft!, t),
- end: interpolatePlanPoint(backRight!, frontRight!, t),
- })
- }
-
- return treadLines
-}
-
-function getThickPlanLinePolygon(line: FloorplanLineSegment, thickness: number): Point2D[] {
- const dx = line.end.x - line.start.x
- const dy = line.end.y - line.start.y
- const length = Math.hypot(dx, dy)
-
- if (length <= Number.EPSILON || thickness <= 0) {
- return [line.start, line.end, line.end, line.start]
- }
-
- const halfThickness = thickness / 2
- const normalX = (-dy / length) * halfThickness
- const normalY = (dx / length) * halfThickness
-
- return [
- { x: line.start.x + normalX, y: line.start.y + normalY },
- { x: line.end.x + normalX, y: line.end.y + normalY },
- { x: line.end.x - normalX, y: line.end.y - normalY },
- { x: line.start.x - normalX, y: line.start.y - normalY },
- ]
-}
-
-function getPolylineBandPolygons(points: Point2D[], thickness: number): FloorplanPolygonEntry[] {
- if (points.length < 2 || thickness <= 0) {
- return []
- }
-
- const polygons: FloorplanPolygonEntry[] = []
-
- for (let pointIndex = 1; pointIndex < points.length; pointIndex += 1) {
- const start = points[pointIndex - 1]!
- const end = points[pointIndex]!
-
- if (getPlanPointDistance(start, end) <= Number.EPSILON) {
- continue
- }
-
- const polygon = getThickPlanLinePolygon({ start, end }, thickness)
- polygons.push({
- points: formatPolygonPoints(polygon),
- polygon,
- })
- }
-
- return polygons
-}
-
-function getArcPlanPoint(center: Point2D, radius: number, angle: number): Point2D {
- return {
- x: center.x + Math.cos(angle) * radius,
- y: center.y + Math.sin(angle) * radius,
- }
-}
-
-function buildSvgArcPath(center: Point2D, radius: number, startAngle: number, endAngle: number) {
- const start = getArcPlanPoint(center, radius, startAngle)
- const end = getArcPlanPoint(center, radius, endAngle)
- const delta = endAngle - startAngle
- const largeArcFlag = Math.abs(delta) > Math.PI ? 1 : 0
- const sweepFlag = delta >= 0 ? 1 : 0
-
- return `M ${toSvgX(start.x)} ${toSvgY(start.y)} A ${radius} ${radius} 0 ${largeArcFlag} ${sweepFlag} ${toSvgX(end.x)} ${toSvgY(end.y)}`
-}
-
-function buildSvgAnnularSectorPath(
- center: Point2D,
- innerRadius: number,
- outerRadius: number,
- startAngle: number,
- endAngle: number,
-) {
- const outerStart = getArcPlanPoint(center, outerRadius, startAngle)
- const outerEnd = getArcPlanPoint(center, outerRadius, endAngle)
- const innerEnd = getArcPlanPoint(center, innerRadius, endAngle)
- const innerStart = getArcPlanPoint(center, innerRadius, startAngle)
- const delta = endAngle - startAngle
- const largeArcFlag = Math.abs(delta) > Math.PI ? 1 : 0
- const sweepFlag = delta >= 0 ? 1 : 0
- const reverseSweepFlag = sweepFlag ? 0 : 1
-
- return [
- `M ${toSvgX(outerStart.x)} ${toSvgY(outerStart.y)}`,
- `A ${outerRadius} ${outerRadius} 0 ${largeArcFlag} ${sweepFlag} ${toSvgX(outerEnd.x)} ${toSvgY(outerEnd.y)}`,
- `L ${toSvgX(innerEnd.x)} ${toSvgY(innerEnd.y)}`,
- `A ${innerRadius} ${innerRadius} 0 ${largeArcFlag} ${reverseSweepFlag} ${toSvgX(innerStart.x)} ${toSvgY(innerStart.y)}`,
- 'Z',
- ].join(' ')
-}
-
function getNormalizedFloorplanStairSweepAngle(stair: StairNode) {
const stairType = stair.stairType ?? 'straight'
const baseSweepAngle =
@@ -1676,472 +1492,6 @@ function getFloorplanCurvedStairHitPolygon(stair: StairNode): Point2D[] {
return [...outerPoints, ...innerPoints.reverse()]
}
-function buildFloorplanArrowHead(
- point: Point2D,
- angle: number,
- size: number,
-): FloorplanPolygonEntry {
- const left = {
- x: point.x - size * Math.cos(angle - Math.PI / 6),
- y: point.y - size * Math.sin(angle - Math.PI / 6),
- }
- const right = {
- x: point.x - size * Math.cos(angle + Math.PI / 6),
- y: point.y - size * Math.sin(angle + Math.PI / 6),
- }
-
- return {
- points: formatPolygonPoints([point, left, right]),
- polygon: [point, left, right],
- }
-}
-
-function getFloorplanStairTreadThickness(segment: StairSegmentNode, innerPolygon: Point2D[]) {
- if (segment.segmentType !== 'stair' || segment.stepCount <= 1 || innerPolygon.length < 4) {
- return 0
- }
-
- const innerWidth = getPlanPointDistance(innerPolygon[0]!, innerPolygon[1]!)
- const innerLength = getPlanPointDistance(innerPolygon[0]!, innerPolygon[3]!)
- const treadRun = innerLength / Math.max(segment.stepCount, 1)
- return clamp(
- Math.min(FLOORPLAN_STAIR_TREAD_BAND_THICKNESS, innerWidth * 0.12, treadRun * 0.44),
- FLOORPLAN_STAIR_TREAD_MIN_THICKNESS,
- FLOORPLAN_STAIR_TREAD_BAND_THICKNESS,
- )
-}
-
-function getFloorplanStairTreadBars(
- segment: StairSegmentNode,
- innerPolygon: Point2D[],
- treadThickness = getFloorplanStairTreadThickness(segment, innerPolygon),
-): FloorplanPolygonEntry[] {
- const treadLines = getFloorplanStairTreadLines(segment, innerPolygon)
- if (treadLines.length === 0 || treadThickness <= 0) {
- return []
- }
-
- return treadLines.map((line) => {
- const polygon = getThickPlanLinePolygon(line, treadThickness)
- return {
- points: formatPolygonPoints(polygon),
- polygon,
- }
- })
-}
-
-type FloorplanStairArrowSide = 'back' | 'front' | 'left' | 'right'
-
-function getFloorplanStairSegmentCenterPoint(segment: FloorplanStairSegmentEntry): Point2D | null {
- if (segment.centerLine) {
- return interpolatePlanPoint(segment.centerLine.start, segment.centerLine.end, 0.5)
- }
-
- if (segment.polygon.length < 4) {
- return null
- }
-
- const [backLeft, backRight, frontRight, frontLeft] = segment.polygon
-
- return {
- x: (backLeft!.x + backRight!.x + frontRight!.x + frontLeft!.x) / 4,
- y: (backLeft!.y + backRight!.y + frontRight!.y + frontLeft!.y) / 4,
- }
-}
-
-function getFloorplanStairSegmentSidePoint(
- segment: FloorplanStairSegmentEntry,
- side: FloorplanStairArrowSide,
-): Point2D | null {
- if (segment.polygon.length < 4) {
- return null
- }
-
- const [backLeft, backRight, frontRight, frontLeft] = segment.polygon
-
- switch (side) {
- case 'back':
- return interpolatePlanPoint(backLeft!, backRight!, 0.5)
- case 'front':
- return interpolatePlanPoint(frontLeft!, frontRight!, 0.5)
- case 'left':
- return interpolatePlanPoint(backLeft!, frontLeft!, 0.5)
- case 'right':
- return interpolatePlanPoint(backRight!, frontRight!, 0.5)
- }
-}
-
-function getFloorplanStairExitSide(
- nextSegment: StairSegmentNode | undefined,
-): FloorplanStairArrowSide {
- if (!nextSegment) {
- return 'front'
- }
-
- // `attachmentSide` describes the next segment's turn direction. The floorplan transform
- // attaches `left` turns to the previous segment's positive local X edge and `right` turns
- // to the negative local X edge, so the arrow needs to mirror that convention here.
- if (nextSegment.attachmentSide === 'left') {
- return 'right'
- }
- if (nextSegment.attachmentSide === 'right') {
- return 'left'
- }
-
- return 'front'
-}
-
-function appendUniquePlanPoint(points: Point2D[], point: Point2D | null) {
- if (!point) {
- return
- }
-
- const lastPoint = points[points.length - 1]
- if (lastPoint && getPlanPointDistance(lastPoint, point) <= 0.001) {
- return
- }
-
- points.push(point)
-}
-
-function buildFloorplanStairArrow(
- segments: FloorplanStairSegmentEntry[],
-): FloorplanStairArrowEntry | null {
- const rawPoints: Point2D[] = []
-
- for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex += 1) {
- const segment = segments[segmentIndex]!
- const nextSegment = segments[segmentIndex + 1]?.segment
- const entryPoint = getFloorplanStairSegmentSidePoint(segment, 'back')
- const exitPoint = getFloorplanStairSegmentSidePoint(
- segment,
- getFloorplanStairExitSide(nextSegment),
- )
-
- if (!(entryPoint && exitPoint)) {
- continue
- }
-
- appendUniquePlanPoint(rawPoints, entryPoint)
-
- const isStraightSegment = getPlanPointDistance(entryPoint, exitPoint) <= 0.001
- if (isStraightSegment) {
- continue
- }
-
- const exitSide = getFloorplanStairExitSide(nextSegment)
- if (exitSide === 'front') {
- appendUniquePlanPoint(rawPoints, exitPoint)
- continue
- }
-
- appendUniquePlanPoint(rawPoints, getFloorplanStairSegmentCenterPoint(segment))
- appendUniquePlanPoint(rawPoints, exitPoint)
- }
-
- if (rawPoints.length < 2) {
- return null
- }
-
- const firstPoint = rawPoints[0]!
- const secondPoint = rawPoints[1]!
- const beforeLastPoint = rawPoints[rawPoints.length - 2]!
- const lastPoint = rawPoints[rawPoints.length - 1]!
- const firstLength = getPlanPointDistance(firstPoint, secondPoint)
- const lastLength = getPlanPointDistance(beforeLastPoint, lastPoint)
-
- if (firstLength <= Number.EPSILON || lastLength <= Number.EPSILON) {
- return null
- }
-
- const polyline = [
- movePlanPointTowards(firstPoint, secondPoint, Math.min(0.24, firstLength * 0.18)),
- ...rawPoints.slice(1, -1),
- movePlanPointTowards(lastPoint, beforeLastPoint, Math.min(0.3, lastLength * 0.22)),
- ]
- const arrowTailPoint = polyline[polyline.length - 2]
- const arrowTip = polyline[polyline.length - 1]
-
- if (!(arrowTailPoint && arrowTip)) {
- return null
- }
-
- const arrowBodyLength = getPlanPointDistance(arrowTailPoint, arrowTip)
- if (arrowBodyLength <= Number.EPSILON) {
- return null
- }
-
- const arrowHeadLength = clamp(
- arrowBodyLength * 0.72,
- FLOORPLAN_STAIR_ARROW_HEAD_MIN_SIZE,
- FLOORPLAN_STAIR_ARROW_HEAD_MAX_SIZE,
- )
- const arrowHeadBase = movePlanPointTowards(arrowTip, arrowTailPoint, arrowHeadLength)
- const directionX = arrowTip.x - arrowHeadBase.x
- const directionY = arrowTip.y - arrowHeadBase.y
- const directionLength = Math.hypot(directionX, directionY)
-
- if (directionLength <= Number.EPSILON) {
- return null
- }
-
- const normalX = -directionY / directionLength
- const normalY = directionX / directionLength
- const arrowHeadHalfWidth = arrowHeadLength * 0.34
-
- return {
- head: [
- arrowTip,
- {
- x: arrowHeadBase.x + normalX * arrowHeadHalfWidth,
- y: arrowHeadBase.y + normalY * arrowHeadHalfWidth,
- },
- {
- x: arrowHeadBase.x - normalX * arrowHeadHalfWidth,
- y: arrowHeadBase.y - normalY * arrowHeadHalfWidth,
- },
- ],
- polyline,
- }
-}
-
-function collectLevelDescendants(levelNode: LevelNode, nodes: Record): AnyNode[] {
- const descendants: AnyNode[] = []
- const stack = [...levelNode.children].reverse() as AnyNodeId[]
-
- while (stack.length > 0) {
- const nodeId = stack.pop()
- if (!nodeId) {
- continue
- }
-
- const node = nodes[nodeId]
- if (!node) {
- continue
- }
-
- descendants.push(node)
-
- if ('children' in node && Array.isArray(node.children) && node.children.length > 0) {
- for (let index = node.children.length - 1; index >= 0; index -= 1) {
- stack.push(node.children[index] as AnyNodeId)
- }
- }
- }
-
- return descendants
-}
-
-function getItemFloorplanTransform(
- item: ItemNode,
- nodeById: ReadonlyMap,
- cache: Map,
-): FloorplanNodeTransform | null {
- const cached = cache.get(item.id)
- if (cached !== undefined) {
- return cached
- }
-
- const localRotation = item.rotation[1] ?? 0
- let result: FloorplanNodeTransform | null = null
- const itemMetadata =
- typeof item.metadata === 'object' && item.metadata !== null && !Array.isArray(item.metadata)
- ? (item.metadata as Record)
- : null
-
- if (itemMetadata?.isTransient === true) {
- const live = useLiveTransforms.getState().get(item.id)
- if (live) {
- result = {
- position: {
- x: live.position[0],
- y: live.position[2],
- },
- rotation: live.rotation,
- }
-
- cache.set(item.id, result)
- return result
- }
- }
-
- if (item.parentId) {
- const parentNode = nodeById.get(item.parentId as AnyNodeId)
-
- if (parentNode?.type === 'wall') {
- const wallRotation = -Math.atan2(
- parentNode.end[1] - parentNode.start[1],
- parentNode.end[0] - parentNode.start[0],
- )
- const wallLocalZ =
- item.asset.attachTo === 'wall-side'
- ? ((parentNode.thickness ?? 0.1) / 2) * (item.side === 'back' ? -1 : 1)
- : item.position[2]
- const [offsetX, offsetY] = rotatePlanVector(item.position[0], wallLocalZ, wallRotation)
-
- result = {
- position: {
- x: parentNode.start[0] + offsetX,
- y: parentNode.start[1] + offsetY,
- },
- rotation: wallRotation + localRotation,
- }
- } else if (parentNode?.type === 'item') {
- const parentTransform = getItemFloorplanTransform(parentNode, nodeById, cache)
- if (parentTransform) {
- const [offsetX, offsetY] = rotatePlanVector(
- item.position[0],
- item.position[2],
- parentTransform.rotation,
- )
- result = {
- position: {
- x: parentTransform.position.x + offsetX,
- y: parentTransform.position.y + offsetY,
- },
- rotation: parentTransform.rotation + localRotation,
- }
- }
- } else {
- result = {
- position: { x: item.position[0], y: item.position[2] },
- rotation: localRotation,
- }
- }
- } else {
- result = {
- position: { x: item.position[0], y: item.position[2] },
- rotation: localRotation,
- }
- }
-
- cache.set(item.id, result)
- return result
-}
-
-type StairSegmentTransform = {
- position: [number, number, number]
- rotation: number
-}
-
-function computeFloorplanStairSegmentTransforms(
- segments: StairSegmentNode[],
-): StairSegmentTransform[] {
- const transforms: StairSegmentTransform[] = []
- let currentX = 0
- let currentY = 0
- let currentZ = 0
- let currentRotation = 0
-
- for (let index = 0; index < segments.length; index += 1) {
- const segment = segments[index]!
-
- if (index === 0) {
- transforms.push({
- position: [currentX, currentY, currentZ],
- rotation: currentRotation,
- })
- continue
- }
-
- const previousSegment = segments[index - 1]!
- let attachX = 0
- let attachY = previousSegment.height
- let attachZ = previousSegment.length
- let rotationDelta = 0
-
- if (segment.attachmentSide === 'left') {
- attachX = previousSegment.width / 2
- attachZ = previousSegment.length / 2
- rotationDelta = Math.PI / 2
- } else if (segment.attachmentSide === 'right') {
- attachX = -previousSegment.width / 2
- attachZ = previousSegment.length / 2
- rotationDelta = -Math.PI / 2
- }
-
- const [rotatedAttachX, rotatedAttachZ] = rotatePlanVector(attachX, attachZ, currentRotation)
- currentX += rotatedAttachX
- currentY += attachY
- currentZ += rotatedAttachZ
- currentRotation += rotationDelta
-
- transforms.push({
- position: [currentX, currentY, currentZ],
- rotation: currentRotation,
- })
- }
-
- return transforms
-}
-
-function getFloorplanStairSegmentPolygon(
- stair: StairNode,
- segment: StairSegmentNode,
- transform: StairSegmentTransform,
-): Point2D[] {
- const halfWidth = segment.width / 2
- const localCorners: Array<[number, number]> = [
- [-halfWidth, 0],
- [halfWidth, 0],
- [halfWidth, segment.length],
- [-halfWidth, segment.length],
- ]
-
- return localCorners.map(([localX, localY]) => {
- const [segmentX, segmentY] = rotatePlanVector(localX, localY, transform.rotation)
- const groupX = transform.position[0] + segmentX
- const groupY = transform.position[2] + segmentY
- const [worldOffsetX, worldOffsetY] = rotatePlanVector(groupX, groupY, stair.rotation)
-
- return {
- x: stair.position[0] + worldOffsetX,
- y: stair.position[2] + worldOffsetY,
- }
- })
-}
-
-function buildFloorplanStairEntry(
- stair: StairNode,
- segments: StairSegmentNode[],
-): FloorplanStairEntry | null {
- const stairType = stair.stairType ?? 'straight'
-
- if (segments.length === 0 && stairType === 'straight') {
- return null
- }
-
- const transforms = computeFloorplanStairSegmentTransforms(segments)
- const segmentEntries = segments.map((segment, index) => {
- const polygon = getFloorplanStairSegmentPolygon(stair, segment, transforms[index]!)
- const centerLine = getFloorplanStairSegmentCenterLine(polygon)
- const innerPolygon = getFloorplanStairInnerPolygon(polygon)
- const treadThickness = getFloorplanStairTreadThickness(segment, innerPolygon)
-
- return {
- centerLine,
- innerPoints: formatPolygonPoints(innerPolygon),
- innerPolygon,
- segment,
- points: formatPolygonPoints(polygon),
- polygon,
- treadBars: getFloorplanStairTreadBars(segment, innerPolygon, treadThickness),
- treadThickness,
- }
- })
- const hitPolygons =
- stairType === 'straight'
- ? segmentEntries.map(({ polygon }) => polygon)
- : [getFloorplanCurvedStairHitPolygon(stair)]
-
- return {
- arrow: buildFloorplanStairArrow(segmentEntries),
- hitPolygons,
- stair,
- segments: segmentEntries,
- }
-}
-
function isPointInsidePolygonWithHoles(
point: Point2D,
polygon: Point2D[],
@@ -2213,19 +1563,6 @@ function pointMatchesWallPlanPoint(
return Math.abs(point.x - planPoint[0]) <= epsilon && Math.abs(point.y - planPoint[1]) <= epsilon
}
-function buildSvgPolylinePath(points: Point2D[]): string | null {
- if (points.length < 2) {
- return null
- }
-
- return points
- .map((point, index) => {
- const svgPoint = toSvgPoint(point)
- return `${index === 0 ? 'M' : 'L'} ${svgPoint.x} ${svgPoint.y}`
- })
- .join(' ')
-}
-
function getFloorplanFenceLength(fence: FenceNode) {
return isCurvedWall(fence)
? getWallCurveLength(fence)
@@ -2444,22 +1781,6 @@ function getFloorplanWall(wall: WallNode): WallNode {
}
}
-type LinearMeasurementOverlay = {
- id: string
- dimensionLineEnd: { x1: number; y1: number; x2: number; y2: number }
- dimensionLineStart: { x1: number; y1: number; x2: number; y2: number }
- extensionStart: { x1: number; y1: number; x2: number; y2: number }
- extensionEnd: { x1: number; y1: number; x2: number; y2: number }
- label: string
- labelX: number
- labelY: number
- labelAngleDeg: number
- extensionStroke?: string
- isSelected?: boolean
- labelFill?: string
- stroke?: string
-}
-
function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
if (unit === 'imperial') {
const feet = value * 3.280_84
@@ -2528,77 +1849,6 @@ function formatArea(areaSqM: number, unit: 'metric' | 'imperial') {
)
}
-function FloorplanMeasurementLine({
- palette,
- segment,
- isSelected,
- dashed = false,
- stroke,
-}: {
- palette: FloorplanPalette
- segment: { x1: number; y1: number; x2: number; y2: number }
- isSelected?: boolean
- dashed?: boolean
- stroke?: string
-}) {
- const lineOpacity = isSelected
- ? FLOORPLAN_MEASUREMENT_LINE_OPACITY
- : FLOORPLAN_MEASUREMENT_LINE_OPACITY * 0.4
-
- return (
- <>
-
- >
- )
-}
-
-function FloorplanMeasurementTick({
- palette,
- x,
- y,
- angleDeg,
- isSelected,
- stroke,
-}: {
- palette: FloorplanPalette
- x: number
- y: number
- angleDeg: number
- isSelected?: boolean
- stroke?: string
-}) {
- const radians = (angleDeg * Math.PI) / 180
- const nx = -Math.sin(radians)
- const ny = Math.cos(radians)
- const half = FLOORPLAN_MEASUREMENT_END_TICK / 2
-
- return (
-
- )
-}
function getWallMeasurementOverlay(
wall: WallNode,
@@ -4486,75 +3736,11 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
return null
})}
- {wallMeasurements.map((measurement) => (
-
-
-
-
-
-
-
-
- {measurement.label}
-
-
- ))}
+
>
)
})
@@ -4816,81 +4002,6 @@ const FloorplanFenceLayer = memo(function FloorplanFenceLayer({
)
})
-const FloorplanRoofLayer = memo(function FloorplanRoofLayer({
- highlightedIdSet,
- roofEntries,
- selectedIdSet,
-}: {
- highlightedIdSet: ReadonlySet
- roofEntries: FloorplanRoofEntry[]
- selectedIdSet: ReadonlySet
-}) {
- if (roofEntries.length === 0) {
- return null
- }
-
- return (
- <>
- {roofEntries.map(({ roof, segments }) => {
- const roofSelected = selectedIdSet.has(roof.id)
- const roofHighlighted = highlightedIdSet.has(roof.id)
- const hasSelectedSegment = segments.some(({ segment }) => selectedIdSet.has(segment.id))
- const hasHighlightedSegment = segments.some(({ segment }) =>
- highlightedIdSet.has(segment.id),
- )
- const isRoofActive =
- roofSelected || roofHighlighted || hasSelectedSegment || hasHighlightedSegment
-
- return (
-
- {segments.map(({ points, ridgeLine, segment }) => {
- const isSegmentSelected = selectedIdSet.has(segment.id)
- const isSegmentHighlighted = highlightedIdSet.has(segment.id)
- const isSegmentActive = isSegmentSelected || isSegmentHighlighted
-
- return (
-
-
- {ridgeLine ? (
-
- ) : null}
-
- )
- })}
-
- )
- })}
- >
- )
-})
-
const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
canFocusItems,
canFocusStairs,
@@ -4944,344 +4055,6 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
return null
}
- const stairNodes = stairEntries.map(({ arrow, hitPolygons, stair, segments }) => {
- const stairSelected = selectedIdSet.has(stair.id)
- const stairHighlighted = highlightedIdSet.has(stair.id)
- const segmentSelected = segments.some(({ segment }) => selectedIdSet.has(segment.id))
- const segmentHighlighted = segments.some(({ segment }) => highlightedIdSet.has(segment.id))
- const isHovered = hoveredStairId === stair.id
- const isDeleteHovered = isDeleteMode && isHovered
- const isSelectionActive =
- stairSelected || stairHighlighted || segmentSelected || segmentHighlighted
- const stairType = stair.stairType ?? 'straight'
- const normalizedSweepAngle = (() => {
- const baseSweepAngle =
- stair.sweepAngle ?? (stairType === 'spiral' ? Math.PI * 2 : Math.PI / 2)
- if (Math.abs(baseSweepAngle) >= Math.PI * 2) {
- return Math.sign(baseSweepAngle || 1) * (Math.PI * 2 - 0.001)
- }
- return baseSweepAngle
- })()
- const sectorStartAngle = stair.rotation - normalizedSweepAngle / 2
- const sectorEndAngle = sectorStartAngle + normalizedSweepAngle
- const stairCenter = {
- x: stair.position[0],
- y: stair.position[2],
- }
- const innerRadius = Math.max(
- stairType === 'spiral' ? 0.05 : 0.2,
- stair.innerRadius ?? (stairType === 'spiral' ? 0.2 : 0.9),
- )
- const outerRadius = innerRadius + stair.width
- const centerlineRadius = innerRadius + stair.width / 2
- const curvedStroke = isDeleteHovered
- ? palette.deleteStroke
- : isSelectionActive
- ? '#2563eb'
- : 'rgba(31, 41, 55, 0.9)'
- const curvedAccent = isDeleteHovered
- ? palette.deleteStroke
- : isSelectionActive
- ? '#1d4ed8'
- : 'rgba(23, 23, 23, 0.96)'
- const curvedFill = isDeleteHovered
- ? palette.deleteFill
- : isSelectionActive
- ? 'rgba(59, 130, 246, 0.16)'
- : '#ffffff'
- const straightAccent = isDeleteHovered
- ? palette.deleteStroke
- : isSelectionActive
- ? '#1d4ed8'
- : 'rgba(23, 23, 23, 0.96)'
- const straightStroke = isDeleteHovered
- ? palette.deleteStroke
- : isSelectionActive
- ? '#1d4ed8'
- : 'rgba(23, 23, 23, 0.88)'
- const straightTread = isDeleteHovered
- ? palette.deleteStroke
- : isSelectionActive
- ? 'rgba(37, 99, 235, 0.78)'
- : 'rgba(38, 38, 38, 0.62)'
- const straightFill = isDeleteHovered
- ? palette.deleteFill
- : isSelectionActive
- ? 'rgba(59, 130, 246, 0.08)'
- : 'rgba(255, 255, 255, 0.02)'
- const curvedOuterLineWidth = isSelectionActive ? '2' : '1.4'
- const curvedInnerLineWidth = isSelectionActive ? '1.7' : '1.2'
- const stairSymbol =
- stairType === 'spiral' ? (
- <>
-
-
-
- {Array.from({ length: Math.max(6, stair.stepCount) }, (_, index) => {
- const stepCount = Math.max(6, stair.stepCount)
- const stepSweep = normalizedSweepAngle / stepCount
- const angle = sectorStartAngle + stepSweep * index
- const innerPoint = getArcPlanPoint(stairCenter, innerRadius, angle)
- const outerPoint = getArcPlanPoint(stairCenter, outerRadius, angle)
- const dashedFromIndex = Math.floor(stepCount * 0.68)
-
- return (
- = dashedFromIndex ? '0.1 0.08' : undefined}
- strokeWidth={index === stepCount - 1 ? '1.8' : '1.15'}
- vectorEffect="non-scaling-stroke"
- x1={toSvgX(innerPoint.x)}
- x2={toSvgX(outerPoint.x)}
- y1={toSvgY(innerPoint.y)}
- y2={toSvgY(outerPoint.y)}
- />
- )
- })}
-
- {(() => {
- const directionAngle = sectorStartAngle + normalizedSweepAngle * 0.86
- const arrowPoint = getArcPlanPoint(stairCenter, centerlineRadius, directionAngle)
- const tangentAngle =
- directionAngle + (normalizedSweepAngle >= 0 ? Math.PI / 2 : -Math.PI / 2)
- const arrowHead = buildFloorplanArrowHead(
- arrowPoint,
- tangentAngle,
- clamp(stair.width * 0.18, 0.12, 0.18),
- )
-
- return (
-
- )
- })()}
- >
- ) : stairType === 'curved' ? (
- <>
-
-
-
- {Array.from({ length: Math.max(4, stair.stepCount) + 1 }, (_, index) => {
- const stepCount = Math.max(4, stair.stepCount)
- const stepSweep = normalizedSweepAngle / stepCount
- const angle = sectorStartAngle + stepSweep * index
- const innerPoint = getArcPlanPoint(stairCenter, innerRadius, angle)
- const outerPoint = getArcPlanPoint(stairCenter, outerRadius, angle)
-
- return (
-
- )
- })}
-
- {(() => {
- const stepCount = Math.max(4, stair.stepCount)
- const stepSweep = normalizedSweepAngle / stepCount
- const arrowAngle = sectorEndAngle - stepSweep * 0.8
- const arrowPoint = getArcPlanPoint(stairCenter, centerlineRadius, arrowAngle)
- const tangentAngle =
- arrowAngle + (normalizedSweepAngle >= 0 ? Math.PI / 2 : -Math.PI / 2)
- const arrowHead = buildFloorplanArrowHead(
- arrowPoint,
- tangentAngle,
- clamp(stair.width * 0.16, 0.1, 0.16),
- )
-
- return (
-
- )
- })()}
- >
- ) : (
- <>
- {segments.map(({ points, segment, treadBars }) => (
-
-
- {treadBars.map((treadBar, treadIndex) => (
-
- ))}
-
- ))}
- {arrow?.polyline && arrow.polyline.length >= 2 && (
- <>
- `${toSvgX(point.x)},${toSvgY(point.y)}`).join(' ')}
- pointerEvents="none"
- stroke={straightAccent}
- strokeWidth="1.15"
- vectorEffect="non-scaling-stroke"
- />
-
-
- >
- )}
- >
- )
-
- return (
- {
- event.stopPropagation()
- onStairSelect(stair.id, event)
- }
- : undefined
- }
- onDoubleClick={
- canFocusStairs
- ? (event) => {
- event.stopPropagation()
- onStairDoubleClick(stair, event)
- }
- : undefined
- }
- onPointerEnter={canSelectStairs ? () => onStairHoverEnter(stair.id) : undefined}
- onPointerLeave={canSelectStairs ? () => onStairHoverChange(null) : undefined}
- onPointerDown={
- canFocusStairs && stairSelected
- ? (event) => {
- if (event.button === 0) {
- onStairPointerDown(stair.id, event)
- }
- }
- : undefined
- }
- pointerEvents={canSelectStairs ? undefined : 'none'}
- style={canSelectStairs ? { cursor: EDITOR_CURSOR } : undefined}
- >
- {hitPolygons.map((polygon, polygonIndex) => (
-
- ))}
- {stair.name || 'Staircase'}
- {stairSymbol}
-
- )
- })
-
const itemNodes = itemEntries.map(({ item, points, polygon }) => {
const isSelected = selectedIdSet.has(item.id)
const isHighlighted = highlightedIdSet.has(item.id)
@@ -5430,7 +4203,51 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
})
return (
- <>{isFurnishContextActive ? [...stairNodes, ...itemNodes] : [...itemNodes, ...stairNodes]}>
+ <>
+ {isFurnishContextActive ? (
+ <>
+
+ {itemNodes}
+ >
+ ) : (
+ <>
+ {itemNodes}
+
+ >
+ )}
+ >
)
})
@@ -9256,6 +8073,26 @@ export function FloorplanPanel() {
return
}
+ const movingFence = fences.find((fence) => fence.id === movingNode.id)
+ const watchedFenceIds = new Set([movingNode.id])
+
+ if (movingFence) {
+ for (const fence of fences) {
+ if (fence.id === movingFence.id) {
+ continue
+ }
+
+ if (
+ pointsEqual(fence.start, movingFence.start) ||
+ pointsEqual(fence.start, movingFence.end) ||
+ pointsEqual(fence.end, movingFence.start) ||
+ pointsEqual(fence.end, movingFence.end)
+ ) {
+ watchedFenceIds.add(fence.id)
+ }
+ }
+ }
+
const refreshFencePreview = () => {
setMovingFloorplanNodeRevision((current) => current + 1)
}
@@ -9263,13 +8100,16 @@ export function FloorplanPanel() {
refreshFencePreview()
const unsubscribe = useLiveTransforms.subscribe((state, previousState) => {
- if (state.transforms !== previousState.transforms) {
- refreshFencePreview()
+ for (const fenceId of watchedFenceIds) {
+ if (state.transforms.get(fenceId) !== previousState.transforms.get(fenceId)) {
+ refreshFencePreview()
+ break
+ }
}
})
return unsubscribe
- }, [movingNode])
+ }, [fences, movingNode])
useEffect(() => {
if (!(movingNode?.type === 'roof' || movingNode?.type === 'roof-segment')) {
@@ -13019,137 +11859,17 @@ export function FloorplanPanel() {
selectedIdSet={selectedIdSet}
/>
- {movingOpeningPlacementMeasurements.map((measurement) => (
-
-
-
-
-
-
-
-
- {measurement.label}
-
-
- ))}
+
- {selectedItemClearanceMeasurements.map((measurement) => (
-
-
-
-
-
-
-
-
- {measurement.label}
-
-
- ))}
+
{/* Zone labels: always visible so users can click to select zones from any mode */}