Extract floorplan render logic into editor 2D modules
This commit is contained in:
@@ -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
@@ -0,0 +1,263 @@
|
||||
import type { Point2D } from '@pascal-app/core'
|
||||
import type { FloorplanLineSegment, FloorplanSelectionBounds } from './types'
|
||||
|
||||
export function clampPlanValue(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
export function rotatePlanVector(x: number, y: number, rotation: number): [number, number] {
|
||||
const cos = Math.cos(rotation)
|
||||
const sin = Math.sin(rotation)
|
||||
return [x * cos + y * sin, -x * sin + y * cos]
|
||||
}
|
||||
|
||||
export function getRotatedRectanglePolygon(
|
||||
center: Point2D,
|
||||
width: number,
|
||||
depth: number,
|
||||
rotation: number,
|
||||
): Point2D[] {
|
||||
const halfWidth = width / 2
|
||||
const halfDepth = depth / 2
|
||||
const corners: Array<[number, number]> = [
|
||||
[-halfWidth, -halfDepth],
|
||||
[halfWidth, -halfDepth],
|
||||
[halfWidth, halfDepth],
|
||||
[-halfWidth, halfDepth],
|
||||
]
|
||||
|
||||
return corners.map(([localX, localY]) => {
|
||||
const [offsetX, offsetY] = rotatePlanVector(localX, localY, rotation)
|
||||
return {
|
||||
x: center.x + offsetX,
|
||||
y: center.y + offsetY,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function interpolatePlanPoint(start: Point2D, end: Point2D, t: number): Point2D {
|
||||
return {
|
||||
x: start.x + (end.x - start.x) * t,
|
||||
y: start.y + (end.y - start.y) * t,
|
||||
}
|
||||
}
|
||||
|
||||
export function getPlanPointDistance(start: Point2D, end: Point2D): number {
|
||||
return Math.hypot(end.x - start.x, end.y - start.y)
|
||||
}
|
||||
|
||||
export function movePlanPointTowards(start: Point2D, end: Point2D, distance: number): Point2D {
|
||||
const totalDistance = getPlanPointDistance(start, end)
|
||||
if (totalDistance <= Number.EPSILON || distance <= 0) {
|
||||
return start
|
||||
}
|
||||
|
||||
return interpolatePlanPoint(start, end, Math.min(1, distance / totalDistance))
|
||||
}
|
||||
|
||||
export 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 },
|
||||
]
|
||||
}
|
||||
|
||||
export function getFloorplanSelectionBounds(
|
||||
start: [number, number],
|
||||
end: [number, number],
|
||||
): FloorplanSelectionBounds {
|
||||
return {
|
||||
minX: Math.min(start[0], end[0]),
|
||||
maxX: Math.max(start[0], end[0]),
|
||||
minY: Math.min(start[1], end[1]),
|
||||
maxY: Math.max(start[1], end[1]),
|
||||
}
|
||||
}
|
||||
|
||||
export function isPointInsideSelectionBounds(point: Point2D, bounds: FloorplanSelectionBounds) {
|
||||
return (
|
||||
point.x >= bounds.minX &&
|
||||
point.x <= bounds.maxX &&
|
||||
point.y >= bounds.minY &&
|
||||
point.y <= bounds.maxY
|
||||
)
|
||||
}
|
||||
|
||||
export function isPointInsidePolygon(point: Point2D, polygon: Point2D[]) {
|
||||
let isInside = false
|
||||
|
||||
for (
|
||||
let currentIndex = 0, previousIndex = polygon.length - 1;
|
||||
currentIndex < polygon.length;
|
||||
previousIndex = currentIndex, currentIndex += 1
|
||||
) {
|
||||
const current = polygon[currentIndex]
|
||||
const previous = polygon[previousIndex]
|
||||
|
||||
if (!(current && previous)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const intersects =
|
||||
current.y > point.y !== previous.y > point.y &&
|
||||
point.x <
|
||||
((previous.x - current.x) * (point.y - current.y)) / (previous.y - current.y) + current.x
|
||||
|
||||
if (intersects) {
|
||||
isInside = !isInside
|
||||
}
|
||||
}
|
||||
|
||||
return isInside
|
||||
}
|
||||
|
||||
export function isPointInsidePolygonWithHoles(
|
||||
point: Point2D,
|
||||
polygon: Point2D[],
|
||||
holes: Point2D[][] = [],
|
||||
) {
|
||||
return (
|
||||
isPointInsidePolygon(point, polygon) && !holes.some((hole) => isPointInsidePolygon(point, hole))
|
||||
)
|
||||
}
|
||||
|
||||
function getLineOrientation(start: Point2D, end: Point2D, point: Point2D) {
|
||||
return (end.x - start.x) * (point.y - start.y) - (end.y - start.y) * (point.x - start.x)
|
||||
}
|
||||
|
||||
function isPointOnSegment(point: Point2D, start: Point2D, end: Point2D) {
|
||||
const epsilon = 1e-9
|
||||
|
||||
return (
|
||||
Math.abs(getLineOrientation(start, end, point)) <= epsilon &&
|
||||
point.x >= Math.min(start.x, end.x) - epsilon &&
|
||||
point.x <= Math.max(start.x, end.x) + epsilon &&
|
||||
point.y >= Math.min(start.y, end.y) - epsilon &&
|
||||
point.y <= Math.max(start.y, end.y) + epsilon
|
||||
)
|
||||
}
|
||||
|
||||
function doSegmentsIntersect(
|
||||
firstStart: Point2D,
|
||||
firstEnd: Point2D,
|
||||
secondStart: Point2D,
|
||||
secondEnd: Point2D,
|
||||
) {
|
||||
const orientation1 = getLineOrientation(firstStart, firstEnd, secondStart)
|
||||
const orientation2 = getLineOrientation(firstStart, firstEnd, secondEnd)
|
||||
const orientation3 = getLineOrientation(secondStart, secondEnd, firstStart)
|
||||
const orientation4 = getLineOrientation(secondStart, secondEnd, firstEnd)
|
||||
|
||||
const hasProperIntersection =
|
||||
((orientation1 > 0 && orientation2 < 0) || (orientation1 < 0 && orientation2 > 0)) &&
|
||||
((orientation3 > 0 && orientation4 < 0) || (orientation3 < 0 && orientation4 > 0))
|
||||
|
||||
if (hasProperIntersection) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
isPointOnSegment(secondStart, firstStart, firstEnd) ||
|
||||
isPointOnSegment(secondEnd, firstStart, firstEnd) ||
|
||||
isPointOnSegment(firstStart, secondStart, secondEnd) ||
|
||||
isPointOnSegment(firstEnd, secondStart, secondEnd)
|
||||
)
|
||||
}
|
||||
|
||||
export function doesPolygonIntersectSelectionBounds(
|
||||
polygon: Point2D[],
|
||||
bounds: FloorplanSelectionBounds,
|
||||
) {
|
||||
if (polygon.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (polygon.some((point) => isPointInsideSelectionBounds(point, bounds))) {
|
||||
return true
|
||||
}
|
||||
|
||||
const boundsCorners: [Point2D, Point2D, Point2D, Point2D] = [
|
||||
{ x: bounds.minX, y: bounds.minY },
|
||||
{ x: bounds.maxX, y: bounds.minY },
|
||||
{ x: bounds.maxX, y: bounds.maxY },
|
||||
{ x: bounds.minX, y: bounds.maxY },
|
||||
]
|
||||
|
||||
if (boundsCorners.some((corner) => isPointInsidePolygon(corner, polygon))) {
|
||||
return true
|
||||
}
|
||||
|
||||
const boundsEdges = [
|
||||
[boundsCorners[0], boundsCorners[1]],
|
||||
[boundsCorners[1], boundsCorners[2]],
|
||||
[boundsCorners[2], boundsCorners[3]],
|
||||
[boundsCorners[3], boundsCorners[0]],
|
||||
] as const
|
||||
|
||||
for (let index = 0; index < polygon.length; index += 1) {
|
||||
const start = polygon[index]
|
||||
const end = polygon[(index + 1) % polygon.length]
|
||||
|
||||
if (!(start && end)) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const [edgeStart, edgeEnd] of boundsEdges) {
|
||||
if (doSegmentsIntersect(start, end, edgeStart, edgeEnd)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function getDistanceToWallSegment(
|
||||
point: Point2D,
|
||||
start: [number, number],
|
||||
end: [number, number],
|
||||
) {
|
||||
const dx = end[0] - start[0]
|
||||
const dy = end[1] - start[1]
|
||||
const lengthSquared = dx * dx + dy * dy
|
||||
|
||||
if (lengthSquared <= Number.EPSILON) {
|
||||
return Math.hypot(point.x - start[0], point.y - start[1])
|
||||
}
|
||||
|
||||
const projection = clampPlanValue(
|
||||
((point.x - start[0]) * dx + (point.y - start[1]) * dy) / lengthSquared,
|
||||
0,
|
||||
1,
|
||||
)
|
||||
const projectedX = start[0] + dx * projection
|
||||
const projectedY = start[1] + dy * projection
|
||||
|
||||
return Math.hypot(point.x - projectedX, point.y - projectedY)
|
||||
}
|
||||
|
||||
export function pointMatchesWallPlanPoint(
|
||||
point: Point2D | undefined,
|
||||
planPoint: [number, number],
|
||||
epsilon = 1e-6,
|
||||
): boolean {
|
||||
if (!point) {
|
||||
return false
|
||||
}
|
||||
|
||||
return Math.abs(point.x - planPoint[0]) <= epsilon && Math.abs(point.y - planPoint[1]) <= epsilon
|
||||
}
|
||||
@@ -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'
|
||||
@@ -0,0 +1,144 @@
|
||||
import {
|
||||
getScaledDimensions,
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type ItemNode,
|
||||
type LevelNode,
|
||||
useLiveTransforms,
|
||||
} from '@pascal-app/core'
|
||||
import { getRotatedRectanglePolygon, rotatePlanVector } from './geometry'
|
||||
import type { FloorplanItemEntry, FloorplanNodeTransform, LevelDescendantMap } from './types'
|
||||
|
||||
export function collectLevelDescendants(
|
||||
levelNode: LevelNode,
|
||||
nodes: Record<string, AnyNode>,
|
||||
): 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
|
||||
}
|
||||
|
||||
export function getItemFloorplanTransform(
|
||||
item: ItemNode,
|
||||
nodeById: LevelDescendantMap,
|
||||
cache: Map<string, FloorplanNodeTransform | null>,
|
||||
): 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<string, unknown>)
|
||||
: 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
|
||||
}
|
||||
|
||||
export function buildFloorplanItemEntry(
|
||||
item: ItemNode,
|
||||
nodeById: LevelDescendantMap,
|
||||
cache: Map<string, FloorplanNodeTransform | null>,
|
||||
): FloorplanItemEntry | null {
|
||||
const transform = getItemFloorplanTransform(item, nodeById, cache)
|
||||
if (!transform) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [width, , depth] = getScaledDimensions(item)
|
||||
return {
|
||||
item,
|
||||
polygon: getRotatedRectanglePolygon(transform.position, width, depth, transform.rotation),
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -2,6 +2,7 @@ import type {
|
||||
CeilingNode,
|
||||
DoorNode,
|
||||
ItemNode,
|
||||
Point2D,
|
||||
RoofNode,
|
||||
RoofSegmentNode,
|
||||
SlabNode,
|
||||
@@ -14,9 +15,8 @@ import {
|
||||
getDistanceToWallSegment,
|
||||
isPointInsidePolygon,
|
||||
isPointInsidePolygonWithHoles,
|
||||
type FloorplanSelectionBounds,
|
||||
type Point2D,
|
||||
} from '@pascal-app/core'
|
||||
} from './geometry'
|
||||
import type { FloorplanSelectionBounds } from './types'
|
||||
|
||||
type OpeningNode = WindowNode | DoorNode
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
import type { Point2D, StairNode, StairSegmentNode } from '@pascal-app/core'
|
||||
import {
|
||||
clampPlanValue,
|
||||
getPlanPointDistance,
|
||||
getThickPlanLinePolygon,
|
||||
interpolatePlanPoint,
|
||||
movePlanPointTowards,
|
||||
rotatePlanVector,
|
||||
} from './geometry'
|
||||
import type {
|
||||
FloorplanLineSegment,
|
||||
FloorplanStairArrowEntry,
|
||||
FloorplanStairEntry,
|
||||
FloorplanStairSegmentEntry,
|
||||
StairSegmentTransform,
|
||||
} from './types'
|
||||
|
||||
const FLOORPLAN_STAIR_OUTLINE_BAND_THICKNESS = 0.05
|
||||
const FLOORPLAN_STAIR_OUTLINE_MAX_FRACTION = 0.18
|
||||
const FLOORPLAN_STAIR_TREAD_BAND_THICKNESS = 0.05 * 0.82
|
||||
const FLOORPLAN_STAIR_TREAD_MIN_THICKNESS = 0.02 * 1.5
|
||||
const FLOORPLAN_STAIR_ARROW_HEAD_MIN_SIZE = 0.14
|
||||
const FLOORPLAN_STAIR_ARROW_HEAD_MAX_SIZE = 0.24
|
||||
|
||||
type FloorplanStairArrowSide = 'back' | 'front' | 'left' | 'right'
|
||||
|
||||
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 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 clampPlanValue(
|
||||
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),
|
||||
): Point2D[][] {
|
||||
const treadLines = getFloorplanStairTreadLines(segment, innerPolygon)
|
||||
if (treadLines.length === 0 || treadThickness <= 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return treadLines.map((line) => getThickPlanLinePolygon(line, treadThickness))
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
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 getFloorplanArcPoint(center: Point2D, radius: number, angle: number): Point2D {
|
||||
return {
|
||||
x: center.x + Math.cos(angle) * radius,
|
||||
y: center.y + Math.sin(angle) * radius,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function getFloorplanCurvedStairHitPolygon(stair: StairNode): Point2D[] {
|
||||
const stairType = stair.stairType ?? 'straight'
|
||||
const sweepAngle = getNormalizedFloorplanStairSweepAngle(stair)
|
||||
const startAngle = stair.rotation - sweepAngle / 2
|
||||
const endAngle = startAngle + sweepAngle
|
||||
const center = {
|
||||
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 outerArcLength = Math.abs(sweepAngle) * outerRadius
|
||||
const segmentCount = Math.max(
|
||||
24,
|
||||
Math.ceil(Math.abs(sweepAngle) / (Math.PI / 24)),
|
||||
Math.ceil(outerArcLength / 0.14),
|
||||
)
|
||||
const outerPoints: Point2D[] = []
|
||||
const innerPoints: Point2D[] = []
|
||||
|
||||
for (let index = 0; index <= segmentCount; index += 1) {
|
||||
const t = index / segmentCount
|
||||
const angle = startAngle + (endAngle - startAngle) * t
|
||||
outerPoints.push(getFloorplanArcPoint(center, outerRadius, angle))
|
||||
innerPoints.push(getFloorplanArcPoint(center, innerRadius, angle))
|
||||
}
|
||||
|
||||
return [...outerPoints, ...innerPoints.reverse()]
|
||||
}
|
||||
|
||||
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 = clampPlanValue(
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export 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,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export 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,
|
||||
innerPolygon,
|
||||
segment,
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { AnyNode, ItemNode, Point2D, StairNode, StairSegmentNode } from '@pascal-app/core'
|
||||
|
||||
export type FloorplanNodeTransform = {
|
||||
position: Point2D
|
||||
rotation: number
|
||||
}
|
||||
|
||||
export type FloorplanLineSegment = {
|
||||
start: Point2D
|
||||
end: Point2D
|
||||
}
|
||||
|
||||
export type FloorplanItemEntry = {
|
||||
item: ItemNode
|
||||
polygon: Point2D[]
|
||||
}
|
||||
|
||||
export type FloorplanStairSegmentEntry = {
|
||||
centerLine: FloorplanLineSegment | null
|
||||
innerPolygon: Point2D[]
|
||||
segment: StairSegmentNode
|
||||
polygon: Point2D[]
|
||||
treadBars: Point2D[][]
|
||||
treadThickness: number
|
||||
}
|
||||
|
||||
export type FloorplanStairArrowEntry = {
|
||||
head: Point2D[]
|
||||
polyline: Point2D[]
|
||||
}
|
||||
|
||||
export type FloorplanStairEntry = {
|
||||
arrow: FloorplanStairArrowEntry | null
|
||||
hitPolygons: Point2D[][]
|
||||
stair: StairNode
|
||||
segments: FloorplanStairSegmentEntry[]
|
||||
}
|
||||
|
||||
export type FloorplanSelectionBounds = {
|
||||
minX: number
|
||||
maxX: number
|
||||
minY: number
|
||||
maxY: number
|
||||
}
|
||||
|
||||
export type StairSegmentTransform = {
|
||||
position: [number, number, number]
|
||||
rotation: number
|
||||
}
|
||||
|
||||
export type LevelDescendantMap = ReadonlyMap<string, AnyNode>
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { WallNode } from '@pascal-app/core'
|
||||
|
||||
const FLOORPLAN_WALL_THICKNESS_SCALE = 1.18
|
||||
const FLOORPLAN_MIN_VISIBLE_WALL_THICKNESS = 0.13
|
||||
const FLOORPLAN_MAX_EXTRA_THICKNESS = 0.035
|
||||
|
||||
export function getFloorplanWallThickness(wall: WallNode): number {
|
||||
const baseThickness = wall.thickness ?? 0.1
|
||||
const scaledThickness = baseThickness * FLOORPLAN_WALL_THICKNESS_SCALE
|
||||
|
||||
return Math.min(
|
||||
baseThickness + FLOORPLAN_MAX_EXTRA_THICKNESS,
|
||||
Math.max(baseThickness, scaledThickness, FLOORPLAN_MIN_VISIBLE_WALL_THICKNESS),
|
||||
)
|
||||
}
|
||||
|
||||
export function getFloorplanWall(wall: WallNode): WallNode {
|
||||
return {
|
||||
...wall,
|
||||
// Slightly exaggerate thin walls so the 2D plan stays legible without drifting from BIM data.
|
||||
thickness: getFloorplanWallThickness(wall),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user