Extract floorplan render logic into editor 2D modules

This commit is contained in:
sudhir
2026-04-26 23:02:03 +05:30
parent ed4d6dbdef
commit 45f0867a74
13 changed files with 999 additions and 1436 deletions
-38
View File
@@ -96,44 +96,6 @@ export {
pointToKey,
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'
-263
View File
@@ -1,263 +0,0 @@
import type { Point2D } from '../systems/wall/wall-mitering'
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
}
-139
View File
@@ -1,139 +0,0 @@
import { getScaledDimensions } from '../schema'
import type { AnyNode, AnyNodeId, ItemNode, LevelNode } from '../schema'
import useLiveTransforms from '../store/use-live-transforms'
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),
}
}
-462
View File
@@ -1,462 +0,0 @@
import type { StairNode, StairSegmentNode } from '../schema'
import type { Point2D } from '../systems/wall/wall-mitering'
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,
}
}
-52
View File
@@ -1,52 +0,0 @@
import type { AnyNode, ItemNode, StairNode, StairSegmentNode } from '../schema'
import type { Point2D } from '../systems/wall/wall-mitering'
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>
-23
View File
@@ -1,23 +0,0 @@
import type { WallNode } from '../schema'
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),
}
}