Extract floorplan render logic into editor 2D modules

This commit is contained in:
sudhir
2026-04-26 23:02:03 +05:30
parent ed4d6dbdef
commit 45f0867a74
13 changed files with 999 additions and 1436 deletions
-38
View File
@@ -96,44 +96,6 @@ export {
pointToKey, pointToKey,
type WallMiterData, type WallMiterData,
} from './systems/wall/wall-mitering' } 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 { WallSystem } from './systems/wall/wall-system'
export { WindowSystem } from './systems/window/window-system' export { WindowSystem } from './systems/window/window-system'
export type { SceneGraph } from './utils/clone-scene-graph' export type { SceneGraph } from './utils/clone-scene-graph'
@@ -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 (
<line
shapeRendering="geometricPrecision"
stroke={stroke ?? palette.measurementStroke}
strokeDasharray={dashed ? FLOORPLAN_MEASUREMENT_EXTENSION_DASH : undefined}
strokeLinecap="round"
strokeOpacity={lineOpacity}
strokeWidth={FLOORPLAN_MEASUREMENT_LINE_WIDTH}
vectorEffect="non-scaling-stroke"
x1={segment.x1}
x2={segment.x2}
y1={segment.y1}
y2={segment.y2}
/>
)
}
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 (
<line
shapeRendering="geometricPrecision"
stroke={stroke ?? palette.measurementStroke}
strokeLinecap="round"
strokeOpacity={
isSelected ? FLOORPLAN_MEASUREMENT_LINE_OPACITY : FLOORPLAN_MEASUREMENT_LINE_OPACITY * 0.4
}
strokeWidth={FLOORPLAN_MEASUREMENT_LINE_WIDTH}
vectorEffect="non-scaling-stroke"
x1={x - nx * half}
x2={x + nx * half}
y1={y - ny * half}
y2={y + ny * half}
/>
)
}
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) => (
<g className={className} key={measurement.id} pointerEvents="none" style={{ userSelect: 'none' }}>
<FloorplanMeasurementLine
dashed
isSelected={measurement.isSelected}
palette={palette}
segment={measurement.extensionStart}
stroke={measurement.extensionStroke}
/>
<FloorplanMeasurementLine
isSelected={measurement.isSelected}
palette={palette}
segment={measurement.dimensionLineStart}
stroke={measurement.stroke}
/>
<FloorplanMeasurementLine
isSelected={measurement.isSelected}
palette={palette}
segment={measurement.dimensionLineEnd}
stroke={measurement.stroke}
/>
<FloorplanMeasurementLine
dashed
isSelected={measurement.isSelected}
palette={palette}
segment={measurement.extensionEnd}
stroke={measurement.extensionStroke}
/>
<FloorplanMeasurementTick
angleDeg={measurement.labelAngleDeg}
isSelected={measurement.isSelected}
palette={palette}
stroke={measurement.stroke}
x={measurement.dimensionLineStart.x1}
y={measurement.dimensionLineStart.y1}
/>
<FloorplanMeasurementTick
angleDeg={measurement.labelAngleDeg}
isSelected={measurement.isSelected}
palette={palette}
stroke={measurement.stroke}
x={measurement.dimensionLineEnd.x2}
y={measurement.dimensionLineEnd.y2}
/>
<text
dominantBaseline="central"
fill={measurement.labelFill ?? palette.measurementStroke}
fillOpacity={
measurement.isSelected
? FLOORPLAN_MEASUREMENT_LABEL_OPACITY
: FLOORPLAN_MEASUREMENT_LABEL_OPACITY * 0.4
}
fontFamily="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace"
fontSize={FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE}
fontWeight="600"
textAnchor="middle"
transform={`rotate(${measurement.labelAngleDeg} ${measurement.labelX} ${measurement.labelY}) translate(0, -0.04)`}
x={measurement.labelX}
y={measurement.labelY}
>
{measurement.label}
</text>
</g>
))}
</>
)
})
@@ -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<string>
roofEntries: FloorplanRoofEntry[]
selectedIdSet: ReadonlySet<string>
}
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 (
<g key={roof.id} pointerEvents="none">
{segments.map(({ points, ridgeLine, segment }) => {
const isSegmentSelected = selectedIdSet.has(segment.id)
const isSegmentHighlighted = highlightedIdSet.has(segment.id)
const isSegmentActive = isSegmentSelected || isSegmentHighlighted
return (
<g key={segment.id}>
<polygon
fill={
isSegmentActive
? 'rgba(14, 165, 233, 0.2)'
: isRoofActive
? 'rgba(14, 165, 233, 0.14)'
: 'rgba(14, 165, 233, 0.08)'
}
points={points}
stroke={
isSegmentActive
? '#0369a1'
: isRoofActive
? '#0ea5e9'
: 'rgba(14, 165, 233, 0.65)'
}
strokeWidth={isSegmentActive ? '2.25' : isRoofActive ? '1.75' : '1.1'}
vectorEffect="non-scaling-stroke"
/>
{ridgeLine ? (
<line
fill="none"
stroke={isSegmentActive ? '#0f172a' : 'rgba(3, 105, 161, 0.75)'}
strokeWidth={isSegmentActive ? '2' : '1.4'}
vectorEffect="non-scaling-stroke"
x1={toSvgX(ridgeLine.start.x)}
x2={toSvgX(ridgeLine.end.x)}
y1={toSvgY(ridgeLine.start.y)}
y2={toSvgY(ridgeLine.end.y)}
/>
) : null}
</g>
)
})}
</g>
)
})}
</>
)
})
@@ -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<string>
hitStrokeWidth: number
hoveredStairId: StairNode['id'] | null
isDeleteMode: boolean
onStairDoubleClick: (stair: StairNode, event: ReactMouseEvent<SVGElement>) => void
onStairHoverChange: (stairId: StairNode['id'] | null) => void
onStairHoverEnter: (stairId: StairNode['id']) => void
onStairPointerDown: (stairId: StairNode['id'], event: ReactPointerEvent<SVGElement>) => void
onStairSelect: (stairId: StairNode['id'], event: ReactMouseEvent<SVGElement>) => void
palette: FloorplanPalette
selectedIdSet: ReadonlySet<string>
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' ? (
<>
<path
d={buildSvgAnnularSectorPath(
stairCenter,
innerRadius,
outerRadius,
sectorStartAngle,
sectorEndAngle,
)}
fill={curvedFill}
pointerEvents="none"
/>
<path
d={buildSvgArcPath(stairCenter, outerRadius, sectorStartAngle, sectorEndAngle)}
fill="none"
pointerEvents="none"
stroke={curvedStroke}
strokeWidth={curvedOuterLineWidth}
vectorEffect="non-scaling-stroke"
/>
<path
d={buildSvgArcPath(stairCenter, innerRadius, sectorStartAngle, sectorEndAngle)}
fill="none"
pointerEvents="none"
stroke={curvedStroke}
strokeWidth={curvedInnerLineWidth}
vectorEffect="non-scaling-stroke"
/>
{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 (
<line
key={`${stair.id}:spiral-step:${index}`}
pointerEvents="none"
stroke={index === stepCount - 1 ? curvedAccent : curvedStroke}
strokeDasharray={index >= 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)}
/>
)
})}
<circle
cx={toSvgX(stairCenter.x)}
cy={toSvgY(stairCenter.y)}
fill="#ffffff"
pointerEvents="none"
r={Math.max(innerRadius * 0.18, 0.06)}
stroke={curvedAccent}
strokeWidth="1.2"
vectorEffect="non-scaling-stroke"
/>
{(() => {
const directionAngle = sectorStartAngle + normalizedSweepAngle * 0.86
const arrowPoint = getArcPlanPoint(stairCenter, centerlineRadius, directionAngle)
const tangentAngle =
directionAngle + (normalizedSweepAngle >= 0 ? Math.PI / 2 : -Math.PI / 2)
return (
<polygon
fill={curvedAccent}
key={`${stair.id}:spiral-arrow`}
pointerEvents="none"
points={buildSvgArrowHeadPoints(
arrowPoint,
tangentAngle,
clamp(stair.width * 0.18, 0.12, 0.18),
)}
/>
)
})()}
</>
) : stairType === 'curved' ? (
<>
<path
d={buildSvgAnnularSectorPath(
stairCenter,
innerRadius,
outerRadius,
sectorStartAngle,
sectorEndAngle,
)}
fill={curvedFill}
pointerEvents="none"
/>
<path
d={buildSvgArcPath(stairCenter, outerRadius, sectorStartAngle, sectorEndAngle)}
fill="none"
pointerEvents="none"
stroke={curvedStroke}
strokeWidth={curvedOuterLineWidth}
vectorEffect="non-scaling-stroke"
/>
<path
d={buildSvgArcPath(stairCenter, innerRadius, sectorStartAngle, sectorEndAngle)}
fill="none"
pointerEvents="none"
stroke={curvedStroke}
strokeWidth={curvedInnerLineWidth}
vectorEffect="non-scaling-stroke"
/>
{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 (
<line
key={`${stair.id}:curved-step:${index}`}
pointerEvents="none"
stroke={curvedStroke}
strokeWidth={index === 0 || index === stepCount ? '1.5' : '1.1'}
vectorEffect="non-scaling-stroke"
x1={toSvgX(innerPoint.x)}
x2={toSvgX(outerPoint.x)}
y1={toSvgY(innerPoint.y)}
y2={toSvgY(outerPoint.y)}
/>
)
})}
<path
d={buildSvgArcPath(
stairCenter,
centerlineRadius,
sectorStartAngle + (normalizedSweepAngle / Math.max(4, stair.stepCount)) * 0.55,
sectorEndAngle - (normalizedSweepAngle / Math.max(4, stair.stepCount)) * 0.55,
)}
fill="none"
pointerEvents="none"
stroke={curvedAccent}
strokeDasharray="0.08 0.11"
strokeWidth="1.1"
vectorEffect="non-scaling-stroke"
/>
{(() => {
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 (
<polygon
fill={curvedAccent}
key={`${stair.id}:curved-arrow`}
pointerEvents="none"
points={buildSvgArrowHeadPoints(
arrowPoint,
tangentAngle,
clamp(stair.width * 0.16, 0.1, 0.16),
)}
/>
)
})()}
</>
) : (
<>
{segments.map(({ points, segment, treadBars }) => (
<g key={segment.id}>
<polygon
fill={straightFill}
pointerEvents="none"
points={points}
stroke={straightStroke}
strokeWidth={isSelectionActive ? '2' : '1.35'}
vectorEffect="non-scaling-stroke"
/>
{treadBars.map((treadBar, treadIndex) => (
<polygon
fill={straightTread}
key={`${segment.id}:tread:${treadIndex}`}
pointerEvents="none"
points={segment.segmentType === 'landing' ? '' : treadBar.points}
/>
))}
</g>
))}
{arrow?.polyline && arrow.polyline.length >= 2 ? (
<>
<polyline
fill="none"
points={formatSvgPolygonPoints(arrow.polyline)}
pointerEvents="none"
stroke={straightAccent}
strokeWidth="1.15"
vectorEffect="non-scaling-stroke"
/>
<circle
cx={toSvgX(arrow.polyline[0]!.x)}
cy={toSvgY(arrow.polyline[0]!.y)}
fill={straightAccent}
pointerEvents="none"
r="0.045"
/>
<polygon
fill={straightAccent}
points={formatSvgPolygonPoints(arrow.head)}
pointerEvents="none"
/>
</>
) : null}
</>
)
return (
<g
key={stair.id}
onClick={
canSelectStairs
? (event) => {
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) => (
<polygon
fill="transparent"
key={`${stair.id}:hit:${polygonIndex}`}
points={formatSvgPolygonPoints(polygon)}
pointerEvents={canSelectStairs ? 'all' : 'none'}
stroke="transparent"
strokeLinejoin="round"
strokeWidth={hitStrokeWidth}
vectorEffect="non-scaling-stroke"
/>
))}
<title>{stair.name || 'Staircase'}</title>
{stairSymbol}
</g>
)
})}
</>
)
})
@@ -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 }
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
import type { Point2D } from '../systems/wall/wall-mitering' import type { Point2D } from '@pascal-app/core'
import type { FloorplanLineSegment, FloorplanSelectionBounds } from './types' import type { FloorplanLineSegment, FloorplanSelectionBounds } from './types'
export function clampPlanValue(value: number, min: number, max: number) { export function clampPlanValue(value: number, min: number, max: number) {
@@ -0,0 +1,38 @@
export {
clampPlanValue,
doesPolygonIntersectSelectionBounds,
getDistanceToWallSegment,
getFloorplanSelectionBounds,
getPlanPointDistance,
getRotatedRectanglePolygon,
getThickPlanLinePolygon,
interpolatePlanPoint,
isPointInsidePolygon,
isPointInsidePolygonWithHoles,
isPointInsideSelectionBounds,
movePlanPointTowards,
pointMatchesWallPlanPoint,
rotatePlanVector,
} from './geometry'
export {
buildFloorplanItemEntry,
collectLevelDescendants,
getItemFloorplanTransform,
} from './items'
export {
buildFloorplanStairEntry,
computeFloorplanStairSegmentTransforms,
getFloorplanStairSegmentPolygon,
} from './stairs'
export type {
FloorplanItemEntry,
FloorplanLineSegment,
FloorplanNodeTransform,
FloorplanSelectionBounds,
FloorplanStairArrowEntry,
FloorplanStairEntry,
FloorplanStairSegmentEntry,
LevelDescendantMap,
StairSegmentTransform,
} from './types'
export { getFloorplanWall, getFloorplanWallThickness } from './walls'
@@ -1,6 +1,11 @@
import { getScaledDimensions } from '../schema' import {
import type { AnyNode, AnyNodeId, ItemNode, LevelNode } from '../schema' getScaledDimensions,
import useLiveTransforms from '../store/use-live-transforms' type AnyNode,
type AnyNodeId,
type ItemNode,
type LevelNode,
useLiveTransforms,
} from '@pascal-app/core'
import { getRotatedRectanglePolygon, rotatePlanVector } from './geometry' import { getRotatedRectanglePolygon, rotatePlanVector } from './geometry'
import type { FloorplanItemEntry, FloorplanNodeTransform, LevelDescendantMap } from './types' import type { FloorplanItemEntry, FloorplanNodeTransform, LevelDescendantMap } from './types'
@@ -2,6 +2,7 @@ import type {
CeilingNode, CeilingNode,
DoorNode, DoorNode,
ItemNode, ItemNode,
Point2D,
RoofNode, RoofNode,
RoofSegmentNode, RoofSegmentNode,
SlabNode, SlabNode,
@@ -14,9 +15,8 @@ import {
getDistanceToWallSegment, getDistanceToWallSegment,
isPointInsidePolygon, isPointInsidePolygon,
isPointInsidePolygonWithHoles, isPointInsidePolygonWithHoles,
type FloorplanSelectionBounds, } from './geometry'
type Point2D, import type { FloorplanSelectionBounds } from './types'
} from '@pascal-app/core'
type OpeningNode = WindowNode | DoorNode type OpeningNode = WindowNode | DoorNode
@@ -1,5 +1,4 @@
import type { StairNode, StairSegmentNode } from '../schema' import type { Point2D, StairNode, StairSegmentNode } from '@pascal-app/core'
import type { Point2D } from '../systems/wall/wall-mitering'
import { import {
clampPlanValue, clampPlanValue,
getPlanPointDistance, getPlanPointDistance,
@@ -1,5 +1,4 @@
import type { AnyNode, ItemNode, StairNode, StairSegmentNode } from '../schema' import type { AnyNode, ItemNode, Point2D, StairNode, StairSegmentNode } from '@pascal-app/core'
import type { Point2D } from '../systems/wall/wall-mitering'
export type FloorplanNodeTransform = { export type FloorplanNodeTransform = {
position: Point2D position: Point2D
@@ -1,4 +1,4 @@
import type { WallNode } from '../schema' import type { WallNode } from '@pascal-app/core'
const FLOORPLAN_WALL_THICKNESS_SCALE = 1.18 const FLOORPLAN_WALL_THICKNESS_SCALE = 1.18
const FLOORPLAN_MIN_VISIBLE_WALL_THICKNESS = 0.13 const FLOORPLAN_MIN_VISIBLE_WALL_THICKNESS = 0.13