Extract floorplan render logic into editor 2D modules
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
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 type { AnyNode, AnyNodeId, ItemNode, LevelNode } from '../schema'
|
||||
import useLiveTransforms from '../store/use-live-transforms'
|
||||
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'
|
||||
|
||||
+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
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { StairNode, StairSegmentNode } from '../schema'
|
||||
import type { Point2D } from '../systems/wall/wall-mitering'
|
||||
import type { Point2D, StairNode, StairSegmentNode } from '@pascal-app/core'
|
||||
import {
|
||||
clampPlanValue,
|
||||
getPlanPointDistance,
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { AnyNode, ItemNode, StairNode, StairSegmentNode } from '../schema'
|
||||
import type { Point2D } from '../systems/wall/wall-mitering'
|
||||
import type { AnyNode, ItemNode, Point2D, StairNode, StairSegmentNode } from '@pascal-app/core'
|
||||
|
||||
export type FloorplanNodeTransform = {
|
||||
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_MIN_VISIBLE_WALL_THICKNESS = 0.13
|
||||
Reference in New Issue
Block a user