Refactor floorplan wall visuals and shared plan helpers

This commit is contained in:
sudhir
2026-04-22 15:34:22 +05:30
parent c0021cee24
commit a69591670d
12 changed files with 1805 additions and 267 deletions
+38
View File
@@ -96,6 +96,44 @@ 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
@@ -0,0 +1,263 @@
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
@@ -0,0 +1,139 @@
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),
}
}
+403
View File
@@ -0,0 +1,403 @@
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 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 {
if (segments.length === 0) {
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,
}
})
return {
arrow: buildFloorplanStairArrow(segmentEntries),
stair,
segments: segmentEntries,
}
}
+51
View File
@@ -0,0 +1,51 @@
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
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
@@ -0,0 +1,23 @@
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),
}
}
@@ -0,0 +1,76 @@
'use client'
import { memo, type MouseEvent as ReactMouseEvent } from 'react'
import useEditor from '../../store/use-editor'
import { NodeActionMenu } from '../editor/node-action-menu'
type SvgPoint = {
x: number
y: number
}
export type FloorplanActionMenuHandler = (event: ReactMouseEvent<HTMLButtonElement>) => void
export type FloorplanActionMenuEntry = {
position: SvgPoint | null
onDelete: FloorplanActionMenuHandler
onMove: FloorplanActionMenuHandler
onDuplicate?: FloorplanActionMenuHandler
}
type FloorplanActionMenuLayerProps = {
item: FloorplanActionMenuEntry
wall: FloorplanActionMenuEntry
slab: FloorplanActionMenuEntry
ceiling: FloorplanActionMenuEntry
opening: FloorplanActionMenuEntry
stair: FloorplanActionMenuEntry
offsetY?: number
}
export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
item,
wall,
slab,
ceiling,
opening,
stair,
offsetY = 10,
}: FloorplanActionMenuLayerProps) {
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
const movingNode = useEditor((state) => state.movingNode)
const curvingWall = useEditor((state) => state.curvingWall)
const curvingFence = useEditor((state) => state.curvingFence)
if (!isFloorplanHovered || movingNode || curvingWall || curvingFence) {
return null
}
const entries: FloorplanActionMenuEntry[] = [item, wall, slab, ceiling, opening, stair]
return (
<>
{entries.map((entry, index) =>
entry.position ? (
<div
className="absolute z-30"
key={index}
style={{
left: entry.position.x,
top: entry.position.y,
transform: `translate(-50%, calc(-100% - ${offsetY}px))`,
}}
>
<NodeActionMenu
onDelete={entry.onDelete}
onDuplicate={entry.onDuplicate}
onMove={entry.onMove}
onPointerDown={(event) => event.stopPropagation()}
onPointerUp={(event) => event.stopPropagation()}
/>
</div>
) : null,
)}
</>
)
})
@@ -0,0 +1,160 @@
'use client'
import { Icon } from '@iconify/react'
import { memo, useMemo } from 'react'
import useEditor, { type FloorplanSelectionTool } from '../../store/use-editor'
import { furnishTools } from '../ui/action-menu/furnish-tools'
import { tools as structureTools } from '../ui/action-menu/structure-tools'
type SvgPoint = {
x: number
y: number
}
type FloorplanCursorIndicator =
| {
kind: 'asset'
iconSrc: string
}
| {
kind: 'icon'
icon: string
}
type FloorplanCursorIndicatorOverlayProps = {
cursorPosition: SvgPoint | null
cursorAnchorPosition: SvgPoint | null
floorplanSelectionTool: FloorplanSelectionTool
movingOpeningType: 'door' | 'window' | null
isPanning: boolean
cursorColor: string
indicatorLineHeight?: number
indicatorBadgeOffsetX?: number
indicatorBadgeOffsetY?: number
}
export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndicatorOverlay({
cursorPosition,
cursorAnchorPosition,
floorplanSelectionTool,
movingOpeningType,
isPanning,
cursorColor,
indicatorLineHeight = 18,
indicatorBadgeOffsetX = 14,
indicatorBadgeOffsetY = 14,
}: FloorplanCursorIndicatorOverlayProps) {
const mode = useEditor((state) => state.mode)
const tool = useEditor((state) => state.tool)
const structureLayer = useEditor((state) => state.structureLayer)
const catalogCategory = useEditor((state) => state.catalogCategory)
const activeFloorplanToolConfig = useMemo(() => {
if (movingOpeningType) {
return structureTools.find((entry) => entry.id === movingOpeningType) ?? null
}
if (mode !== 'build' || !tool) {
return null
}
if (tool === 'item' && catalogCategory) {
return furnishTools.find((entry) => entry.catalogCategory === catalogCategory) ?? null
}
return structureTools.find((entry) => entry.id === tool) ?? null
}, [catalogCategory, mode, movingOpeningType, tool])
const indicator = useMemo<FloorplanCursorIndicator | null>(() => {
if (activeFloorplanToolConfig) {
return { kind: 'asset', iconSrc: activeFloorplanToolConfig.iconSrc }
}
if (mode === 'select' && floorplanSelectionTool === 'marquee' && structureLayer !== 'zones') {
return { kind: 'icon', icon: 'mdi:select-drag' }
}
if (mode === 'delete') {
return { kind: 'icon', icon: 'mdi:trash-can-outline' }
}
return null
}, [activeFloorplanToolConfig, floorplanSelectionTool, mode, structureLayer])
const position = mode === 'delete' ? cursorPosition : cursorAnchorPosition
if (!(indicator && position) || isPanning) {
return null
}
return (
<div
aria-hidden="true"
className="pointer-events-none absolute z-20"
style={{ left: position.x, top: position.y }}
>
{mode === 'delete' ? (
<div
className="flex h-8 w-8 items-center justify-center rounded-xl border border-white/5 bg-zinc-900/95 shadow-[0_8px_16px_-4px_rgba(0,0,0,0.3),0_4px_8px_-4px_rgba(0,0,0,0.2)]"
style={{
boxShadow: `0 8px 16px -4px rgba(0,0,0,0.3), 0 4px 8px -4px rgba(0,0,0,0.2), 0 0 18px ${cursorColor}22`,
transform: `translate(${indicatorBadgeOffsetX}px, ${indicatorBadgeOffsetY}px)`,
}}
>
{indicator.kind === 'asset' ? (
<img
alt=""
aria-hidden="true"
className="h-5 w-5 object-contain drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
src={indicator.iconSrc}
/>
) : (
<Icon
aria-hidden="true"
className="drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
color={cursorColor}
height={18}
icon={indicator.icon}
width={18}
/>
)}
</div>
) : (
<>
<div
className="absolute top-0 left-1/2 w-px -translate-x-1/2 -translate-y-full"
style={{
backgroundColor: cursorColor,
boxShadow: `0 0 12px ${cursorColor}55`,
height: indicatorLineHeight,
}}
/>
<div
className="absolute top-0 left-1/2 flex h-8 w-8 items-center justify-center rounded-xl border border-white/5 bg-zinc-900/95 shadow-[0_8px_16px_-4px_rgba(0,0,0,0.3),0_4px_8px_-4px_rgba(0,0,0,0.2)]"
style={{
transform: `translate(-50%, calc(-100% - ${indicatorLineHeight}px))`,
}}
>
{indicator.kind === 'asset' ? (
<img
alt=""
aria-hidden="true"
className="h-5 w-5 object-contain drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
src={indicator.iconSrc}
/>
) : (
<Icon
aria-hidden="true"
className="drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
color="white"
height={18}
icon={indicator.icon}
width={18}
/>
)}
</div>
</>
)}
</div>
)
})
@@ -0,0 +1,92 @@
'use client'
import { memo } from 'react'
type SvgLine = {
x1: number
y1: number
x2: number
y2: number
}
type FloorplanDraftLayerProps = {
draftPolygonPoints: string | null
polygonDraftPolygonPoints: string | null
polygonDraftPolylinePoints: string | null
polygonDraftClosingSegment: SvgLine | null
draftAnchorPoints: Array<{ x: number; y: number; isPrimary: boolean }>
draftFill: string
draftStroke: string
anchorFill: string
}
export const FloorplanDraftLayer = memo(function FloorplanDraftLayer({
draftPolygonPoints,
polygonDraftPolygonPoints,
polygonDraftPolylinePoints,
polygonDraftClosingSegment,
draftAnchorPoints,
draftFill,
draftStroke,
anchorFill,
}: FloorplanDraftLayerProps) {
return (
<>
{draftPolygonPoints && (
<polygon
fill={draftFill}
fillOpacity={0.35}
points={draftPolygonPoints}
stroke={draftStroke}
strokeDasharray="0.24 0.12"
strokeWidth="0.07"
vectorEffect="non-scaling-stroke"
/>
)}
{polygonDraftPolygonPoints && (
<polygon fill={draftFill} fillOpacity={0.2} points={polygonDraftPolygonPoints} stroke="none" />
)}
{polygonDraftPolylinePoints && (
<polyline
fill="none"
points={polygonDraftPolylinePoints}
stroke={draftStroke}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="0.08"
vectorEffect="non-scaling-stroke"
/>
)}
{polygonDraftClosingSegment && (
<line
stroke={draftStroke}
strokeDasharray="0.16 0.1"
strokeLinecap="round"
strokeOpacity={0.75}
strokeWidth="0.05"
vectorEffect="non-scaling-stroke"
x1={polygonDraftClosingSegment.x1}
x2={polygonDraftClosingSegment.x2}
y1={polygonDraftClosingSegment.y1}
y2={polygonDraftClosingSegment.y2}
/>
)}
{draftAnchorPoints.map((point, index) => (
<circle
cx={point.x}
cy={point.y}
fill={point.isPrimary ? anchorFill : draftStroke}
fillOpacity={0.95}
key={`polygon-draft-${index}`}
pointerEvents="none"
r={point.isPrimary ? 0.12 : 0.1}
vectorEffect="non-scaling-stroke"
/>
))}
</>
)
})
@@ -0,0 +1,58 @@
'use client'
import { memo } from 'react'
type SvgSelectionBounds = {
x: number
y: number
width: number
height: number
}
type FloorplanMarqueeLayerProps = {
bounds: SvgSelectionBounds | null
cursorColor: string
outlineWidth: number
glowWidth: number
}
export const FloorplanMarqueeLayer = memo(function FloorplanMarqueeLayer({
bounds,
cursorColor,
outlineWidth,
glowWidth,
}: FloorplanMarqueeLayerProps) {
if (!bounds) {
return null
}
return (
<>
<rect
fill={cursorColor}
fillOpacity={0.12}
height={bounds.height}
pointerEvents="none"
stroke={cursorColor}
strokeOpacity={0.26}
strokeWidth={glowWidth}
vectorEffect="non-scaling-stroke"
width={bounds.width}
x={bounds.x}
y={bounds.y}
/>
<rect
fill="none"
height={bounds.height}
pointerEvents="none"
stroke={cursorColor}
strokeOpacity={0.96}
strokeWidth={outlineWidth}
vectorEffect="non-scaling-stroke"
width={bounds.width}
x={bounds.x}
y={bounds.y}
/>
</>
)
})
@@ -6,16 +6,20 @@ import {
type AnyNodeId,
type BuildingNode,
type CeilingNode,
buildFloorplanItemEntry,
buildFloorplanStairEntry as buildSharedFloorplanStairEntry,
calculateLevelMiters,
collectLevelDescendants as collectSharedLevelDescendants,
DoorNode,
emitter,
getFloorplanWall as getSharedFloorplanWall,
type GridEvent,
type GuideNode,
getScaledDimensions,
getWallChordFrame,
getWallCurveLength,
getWallMidpointHandlePoint,
getWallPlanFootprint,
type FloorplanNodeTransform as SharedFloorplanNodeTransform,
type ItemNode,
ItemNode as ItemNodeSchema,
isCurvedWall,
@@ -23,6 +27,7 @@ import {
loadAssetUrl,
normalizeWallCurveOffset,
type Point2D,
rotatePlanVector as rotateSharedPlanVector,
type SiteNode,
SlabNode,
type StairNode,
@@ -50,9 +55,17 @@ import {
} from 'react'
import { createPortal } from 'react-dom'
import { useShallow } from 'zustand/react/shallow'
import { FloorplanActionMenuLayer as Editor2dFloorplanActionMenuLayer } from '../editor-2d/floorplan-action-menu-layer'
import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay'
import { FloorplanDraftLayer } from '../editor-2d/renderers/floorplan-draft-layer'
import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee-layer'
import { sfxEmitter } from '../../lib/sfx-bus'
import { cn } from '../../lib/utils'
import useEditor, { type FloorplanSelectionTool } from '../../store/use-editor'
import {
getFloorplanHitNodeId,
getFloorplanSelectionIdsInBounds as getSelectionIdsInBoundsFromTool,
} from '../tools/floorplan/selection-tool'
import { snapToHalf } from '../tools/item/placement-math'
import {
DEFAULT_STAIR_ATTACHMENT_SIDE,
@@ -118,15 +131,17 @@ const FLOORPLAN_ENDPOINT_HOVER_RING_STROKE_WIDTH = 7
const FLOORPLAN_MARQUEE_DRAG_THRESHOLD_PX = 4
const FLOORPLAN_MEASUREMENT_OFFSET = 0.46
const FLOORPLAN_MEASUREMENT_EXTENSION_OVERSHOOT = 0.08
const FLOORPLAN_MEASUREMENT_LINE_WIDTH = 1.2
const FLOORPLAN_MEASUREMENT_LINE_OUTLINE_WIDTH = 2.8
const FLOORPLAN_MEASUREMENT_LINE_OPACITY = 0.72
const FLOORPLAN_MEASUREMENT_LINE_OUTLINE_OPACITY = 0.9
const FLOORPLAN_MEASUREMENT_LINE_WIDTH = 1.35
const FLOORPLAN_MEASUREMENT_LINE_OUTLINE_WIDTH = 0
const FLOORPLAN_MEASUREMENT_LINE_OPACITY = 0.95
const FLOORPLAN_MEASUREMENT_LINE_OUTLINE_OPACITY = 0
const FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE = 0.15
const FLOORPLAN_MEASUREMENT_LABEL_OPACITY = 0.82
const FLOORPLAN_MEASUREMENT_LABEL_STROKE_WIDTH = 0.05
const FLOORPLAN_MEASUREMENT_LABEL_OPACITY = 0.98
const FLOORPLAN_MEASUREMENT_LABEL_STROKE_WIDTH = 0
const FLOORPLAN_MEASUREMENT_LABEL_GAP = 0.56
const FLOORPLAN_MEASUREMENT_LABEL_LINE_PADDING = 0.14
const FLOORPLAN_MEASUREMENT_EXTENSION_DASH = '0.08 0.12'
const FLOORPLAN_MEASUREMENT_END_TICK = 0.18
const FLOORPLAN_ACTION_MENU_HORIZONTAL_PADDING = 60
const FLOORPLAN_ACTION_MENU_MIN_ANCHOR_Y = 56
const FLOORPLAN_ACTION_MENU_OFFSET_Y = 10
@@ -417,6 +432,8 @@ type FloorplanPalette = {
selectedSlabFill: string
wallFill: string
wallStroke: string
wallInnerStroke: string
wallShadow: string
wallHoverStroke: string
deleteFill: string
deleteStroke: string
@@ -437,6 +454,9 @@ type FloorplanPalette = {
endpointHandleHoverStroke: string
endpointHandleActiveFill: string
endpointHandleActiveStroke: string
curveHandleFill: string
curveHandleStroke: string
curveHandleHoverStroke: string
}
const resizeCursorByDirection: Record<ResizeDirection, string> = {
@@ -1198,9 +1218,7 @@ function toFloorplanPolygon(points: Array<[number, number]>): Point2D[] {
}
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]
return rotateSharedPlanVector(x, y, rotation)
}
function getPolygonBounds(points: Point2D[]) {
@@ -1950,11 +1968,36 @@ function pointMatchesWallPlanPoint(
return Math.abs(point.x - planPoint[0]) <= epsilon && Math.abs(point.y - planPoint[1]) <= epsilon
}
function buildSvgPolylinePath(points: Point2D[]): string | null {
if (points.length < 2) {
return null
}
return points
.map((point, index) => {
const svgPoint = toSvgPoint(point)
return `${index === 0 ? 'M' : 'L'} ${svgPoint.x} ${svgPoint.y}`
})
.join(' ')
}
function getWallHoverSidePaths(polygon: Point2D[], wall: WallNode): [string, string] | null {
if (polygon.length < 4) {
return null
}
if (isCurvedWall(wall) && polygon.length >= 6 && polygon.length % 2 === 0) {
const sidePointCount = polygon.length / 2
const rightSidePath = buildSvgPolylinePath(polygon.slice(0, sidePointCount))
const leftSidePath = buildSvgPolylinePath(polygon.slice(sidePointCount).reverse())
if (!(rightSidePath && leftSidePath)) {
return null
}
return [rightSidePath, leftSidePath]
}
const startRight = polygon[0]
const endRight = polygon[1]
const hasEndCenterPoint = pointMatchesWallPlanPoint(polygon[2], wall.end)
@@ -1972,10 +2015,10 @@ function getWallHoverSidePaths(polygon: Point2D[], wall: WallNode): [string, str
const svgStartLeft = toSvgPoint(startLeft)
const svgEndLeft = toSvgPoint(endLeft)
return [
`M ${svgStartRight.x} ${svgStartRight.y} L ${svgEndRight.x} ${svgEndRight.y}`,
`M ${svgStartLeft.x} ${svgStartLeft.y} L ${svgEndLeft.x} ${svgEndLeft.y}`,
]
const rightSidePath = `M ${svgStartRight.x} ${svgStartRight.y} L ${svgEndRight.x} ${svgEndRight.y}`
const leftSidePath = `M ${svgStartLeft.x} ${svgStartLeft.y} L ${svgEndLeft.x} ${svgEndLeft.y}`
return [rightSidePath, leftSidePath]
}
function buildDraftWall(levelId: string, start: WallPlanPoint, end: WallPlanPoint): WallNode {
@@ -2149,38 +2192,26 @@ function FloorplanMeasurementLine({
palette,
segment,
isSelected,
dashed = false,
}: {
palette: FloorplanPalette
segment: { x1: number; y1: number; x2: number; y2: number }
isSelected?: boolean
dashed?: boolean
}) {
const lineOpacity = isSelected
? FLOORPLAN_MEASUREMENT_LINE_OPACITY
: FLOORPLAN_MEASUREMENT_LINE_OPACITY * 0.4
const outlineOpacity = isSelected
? FLOORPLAN_MEASUREMENT_LINE_OUTLINE_OPACITY
: FLOORPLAN_MEASUREMENT_LINE_OUTLINE_OPACITY * 0.4
return (
<>
<line
shapeRendering="geometricPrecision"
stroke={palette.surface}
strokeLinecap="round"
strokeOpacity={outlineOpacity}
strokeWidth={FLOORPLAN_MEASUREMENT_LINE_OUTLINE_WIDTH}
vectorEffect="non-scaling-stroke"
x1={segment.x1}
x2={segment.x2}
y1={segment.y1}
y2={segment.y2}
/>
<line
shapeRendering="geometricPrecision"
stroke={palette.measurementStroke}
strokeLinecap="round"
strokeOpacity={lineOpacity}
strokeWidth={FLOORPLAN_MEASUREMENT_LINE_WIDTH}
strokeDasharray={dashed ? FLOORPLAN_MEASUREMENT_EXTENSION_DASH : undefined}
vectorEffect="non-scaling-stroke"
x1={segment.x1}
x2={segment.x2}
@@ -2191,6 +2222,40 @@ function FloorplanMeasurementLine({
)
}
function FloorplanMeasurementTick({
palette,
x,
y,
angleDeg,
isSelected,
}: {
palette: FloorplanPalette
x: number
y: number
angleDeg: number
isSelected?: boolean
}) {
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={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}
/>
)
}
function getWallMeasurementOverlay(
wall: WallNode,
centerX: number,
@@ -2933,6 +2998,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
selectedIdSet,
slabPolygons,
wallPolygons,
wallSelectionHatchId,
unit,
}: {
canFocusGeometry: boolean
@@ -2958,6 +3024,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
selectedIdSet: ReadonlySet<string>
slabPolygons: SlabPolygonEntry[]
wallPolygons: WallPolygonEntry[]
wallSelectionHatchId: string
unit: 'metric' | 'imperial'
}) {
let minX = Number.POSITIVE_INFINITY,
@@ -2977,7 +3044,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
if (measurement) {
measurement.isSelected = selectedIdSet.has(wall.id)
}
return measurement ? [measurement] : []
return measurement?.isSelected ? [measurement] : []
})
return (
@@ -3069,13 +3136,14 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
const isHighlighted = highlightedIdSet.has(wall.id)
const isHovered = canSelectGeometry && hoveredWallId === wall.id
const isDeleteHovered = isDeleteMode && isHovered
const showSelectedWallChrome = isSelected || isHighlighted
const hoverStroke = isDeleteHovered
? palette.deleteWallHoverStroke
: isHighlighted
: showSelectedWallChrome
? palette.selectedStroke
: palette.wallHoverStroke
const hoverGlowOpacity = isDeleteHovered ? 0.14 : isHighlighted ? 0.22 : 0.16
const hoverRingOpacity = isDeleteHovered ? 0.38 : isHighlighted ? 0.6 : 0.48
const hoverGlowOpacity = isDeleteHovered ? 0.14 : showSelectedWallChrome ? 0.24 : 0.16
const hoverRingOpacity = isDeleteHovered ? 0.38 : showSelectedWallChrome ? 0.62 : 0.48
const hoverSidePaths = getWallHoverSidePaths(polygon, wall)
return (
@@ -3120,6 +3188,21 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
vectorEffect="non-scaling-stroke"
/>
))}
{showSelectedWallChrome &&
hoverSidePaths?.map((pathData, index) => (
<path
d={pathData}
fill="none"
key={`selected-ring-${index}`}
pointerEvents="none"
stroke={palette.selectedStroke}
strokeLinecap="round"
strokeLinejoin="round"
strokeOpacity={0.52}
strokeWidth={FLOORPLAN_WALL_HOVER_RING_STROKE_WIDTH}
vectorEffect="non-scaling-stroke"
/>
))}
{canSelectGeometry && (
<line
onClick={(event) => {
@@ -3142,13 +3225,23 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
y2={toSvgY(wall.end[1])}
/>
)}
{!isDeleteHovered ? (
<polygon
fill="none"
points={points}
pointerEvents="none"
stroke={palette.wallShadow}
strokeOpacity={showSelectedWallChrome ? 0.14 : 0.22}
strokeWidth={showSelectedWallChrome ? '1.2' : '1'}
transform="translate(0.01 0.012)"
vectorEffect="non-scaling-stroke"
/>
) : null}
<polygon
fill={
isDeleteHovered
? palette.deleteWallFill
: isHighlighted
? palette.selectedFill
: palette.wallFill
: palette.wallFill
}
onClick={
canSelectGeometry
@@ -3168,13 +3261,59 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
}
points={points}
stroke={
isDeleteHovered ? palette.deleteStroke : isHighlighted ? 'none' : palette.wallStroke
isDeleteHovered
? palette.deleteStroke
: showSelectedWallChrome
? palette.selectedStroke
: palette.wallStroke
}
strokeOpacity={1}
strokeWidth="0.06"
strokeWidth={showSelectedWallChrome ? '1.05' : '0.9'}
style={{ cursor: EDITOR_CURSOR }}
vectorEffect="non-scaling-stroke"
/>
{isSelected && !isDeleteHovered ? (
<polygon
fill={`url(#${wallSelectionHatchId})`}
opacity={0.95}
points={points}
pointerEvents="none"
/>
) : null}
{hoverSidePaths?.map((pathData, index) => (
<path
d={pathData}
fill="none"
key={`edge-inner-${index}`}
pointerEvents="none"
stroke={showSelectedWallChrome ? palette.selectedStroke : palette.wallInnerStroke}
strokeLinecap="round"
strokeLinejoin="round"
strokeOpacity={showSelectedWallChrome ? 0.42 : 0.82}
strokeWidth={showSelectedWallChrome ? '0.52' : '0.38'}
vectorEffect="non-scaling-stroke"
/>
))}
{hoverSidePaths?.map((pathData, index) => (
<path
d={pathData}
fill="none"
key={`edge-outer-${index}`}
pointerEvents="none"
stroke={
isDeleteHovered
? palette.deleteStroke
: showSelectedWallChrome
? palette.selectedStroke
: palette.wallStroke
}
strokeLinecap="round"
strokeLinejoin="round"
strokeOpacity={1}
strokeWidth={showSelectedWallChrome ? '1.2' : '1'}
vectorEffect="non-scaling-stroke"
/>
))}
</g>
)
})}
@@ -3495,6 +3634,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
isSelected={measurement.isSelected}
palette={palette}
segment={measurement.extensionStart}
dashed
/>
<FloorplanMeasurementLine
isSelected={measurement.isSelected}
@@ -3510,6 +3650,21 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
isSelected={measurement.isSelected}
palette={palette}
segment={measurement.extensionEnd}
dashed
/>
<FloorplanMeasurementTick
angleDeg={measurement.labelAngleDeg}
isSelected={measurement.isSelected}
palette={palette}
x={measurement.dimensionLineStart.x1}
y={measurement.dimensionLineStart.y1}
/>
<FloorplanMeasurementTick
angleDeg={measurement.labelAngleDeg}
isSelected={measurement.isSelected}
palette={palette}
x={measurement.dimensionLineEnd.x2}
y={measurement.dimensionLineEnd.y2}
/>
<text
dominantBaseline="central"
@@ -3522,12 +3677,6 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
fontFamily="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace"
fontSize={FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE}
fontWeight="600"
paintOrder="stroke"
stroke={palette.surface}
strokeLinecap="round"
strokeLinejoin="round"
strokeOpacity={measurement.isSelected ? 1 : 0.4}
strokeWidth={FLOORPLAN_MEASUREMENT_LABEL_STROKE_WIDTH}
textAnchor="middle"
transform={`rotate(${measurement.labelAngleDeg} ${measurement.labelX} ${measurement.labelY}) translate(0, -0.04)`}
x={measurement.labelX}
@@ -4406,10 +4555,8 @@ const FloorplanWallCurveHandleLayer = memo(function FloorplanWallCurveHandleLaye
{curveHandles.map(({ wall, point, isActive }) => {
const handleId = `curve:${wall.id}`
const isHovered = hoveredHandleId === handleId
const stroke = isActive ? palette.endpointHandleActiveStroke : palette.endpointHandleStroke
const hoverStroke = isActive
? palette.endpointHandleActiveStroke
: palette.endpointHandleHoverStroke
const stroke = palette.curveHandleStroke
const hoverStroke = palette.curveHandleHoverStroke
const svgPoint = toSvgPlanPoint(point)
const radius = isActive ? 0.16 : 0.14
@@ -4440,7 +4587,7 @@ const FloorplanWallCurveHandleLayer = memo(function FloorplanWallCurveHandleLaye
<circle
cx={svgPoint.x}
cy={svgPoint.y}
fill={isActive ? palette.endpointHandleActiveFill : palette.endpointHandleFill}
fill={palette.curveHandleFill}
fillOpacity={0.96}
pointerEvents="none"
r={radius}
@@ -4453,7 +4600,7 @@ const FloorplanWallCurveHandleLayer = memo(function FloorplanWallCurveHandleLaye
cy={svgPoint.y}
fill={stroke}
pointerEvents="none"
r={0.045}
r={0.05}
vectorEffect="non-scaling-stroke"
/>
<circle
@@ -5147,7 +5294,7 @@ export function FloorplanPanel() {
return [] as AnyNode[]
}
return collectLevelDescendants(nextLevelNode, state.nodes as Record<string, AnyNode>)
return collectSharedLevelDescendants(nextLevelNode, state.nodes as Record<string, AnyNode>)
}),
)
@@ -5530,27 +5677,19 @@ export function FloorplanPanel() {
[levelDescendantNodes],
)
const floorplanItemEntries = useMemo(() => {
const transformCache = new Map<string, FloorplanNodeTransform | null>()
const transformCache = new Map<string, SharedFloorplanNodeTransform | null>()
return floorplanItems.flatMap((item) => {
const transform = getItemFloorplanTransform(item, levelDescendantNodeById, transformCache)
if (!transform) {
const entry = buildFloorplanItemEntry(item, levelDescendantNodeById, transformCache)
if (!entry) {
return []
}
const [width, , depth] = getScaledDimensions(item)
const polygon = getRotatedRectanglePolygon(
transform.position,
width,
depth,
transform.rotation,
)
return [
{
item,
points: formatPolygonPoints(polygon),
polygon,
item: entry.item,
points: formatPolygonPoints(entry.polygon),
polygon: entry.polygon,
},
]
})
@@ -5579,8 +5718,25 @@ export function FloorplanPanel() {
(node): node is StairSegmentNode =>
node?.type === 'stair-segment' && node.visible !== false,
)
const entry = buildFloorplanStairEntry(displayStair, segments)
return entry ? [entry] : []
const entry = buildSharedFloorplanStairEntry(displayStair, segments)
if (!entry) {
return []
}
return [
{
...entry,
segments: entry.segments.map((segmentEntry) => ({
...segmentEntry,
innerPoints: formatPolygonPoints(segmentEntry.innerPolygon),
points: formatPolygonPoints(segmentEntry.polygon),
treadBars: segmentEntry.treadBars.map((polygon) => ({
points: formatPolygonPoints(polygon),
polygon,
})),
})),
},
]
}),
[
cursorPoint,
@@ -5704,7 +5860,23 @@ export function FloorplanPanel() {
metadata: { isTransient: true, isFloorplanPreview: true },
})
return buildFloorplanStairEntry(previewStair, [floorplanPreviewStairSegment])
const entry = buildSharedFloorplanStairEntry(previewStair, [floorplanPreviewStairSegment])
if (!entry) {
return null
}
return {
...entry,
segments: entry.segments.map((segmentEntry) => ({
...segmentEntry,
innerPoints: formatPolygonPoints(segmentEntry.innerPolygon),
points: formatPolygonPoints(segmentEntry.polygon),
treadBars: segmentEntry.treadBars.map((polygon) => ({
points: formatPolygonPoints(polygon),
polygon,
})),
})),
}
}, [
floorplanPreviewStairSegment,
isStairBuildActive,
@@ -6003,7 +6175,7 @@ export function FloorplanPanel() {
return null
}
const draftWall = getFloorplanWall(buildDraftWall(levelId, draftStart, draftEnd))
const draftWall = getSharedFloorplanWall(buildDraftWall(levelId, draftStart, draftEnd))
// Keep the live draft preview cheap; full level-wide mitering here runs on every mouse move.
return getWallPlanFootprint(draftWall, EMPTY_WALL_MITER_DATA)
}, [draftEnd, draftStart, levelId])
@@ -6375,14 +6547,16 @@ export function FloorplanPanel() {
slabStroke: '#71717a',
selectedSlabFill: '#b7b5f7',
wallFill: '#fafafa',
wallStroke: '#38bdf8',
wallHoverStroke: '#a1a1aa',
wallStroke: '#0f172a',
wallInnerStroke: 'rgba(51, 65, 85, 0.72)',
wallShadow: 'rgba(15, 23, 42, 0.12)',
wallHoverStroke: '#60a5fa',
deleteFill: '#f87171',
deleteStroke: '#ef4444',
deleteWallFill: '#ef4444',
deleteWallHoverStroke: '#fca5a5',
selectedFill: '#8381ed',
selectedStroke: '#8381ed',
selectedFill: '#fafafa',
selectedStroke: '#3b82f6',
draftFill: '#818cf8',
draftStroke: '#c7d2fe',
measurementStroke: '#cbd5e1',
@@ -6391,11 +6565,14 @@ export function FloorplanPanel() {
anchor: '#818cf8',
openingFill: '#0a0e1b',
openingStroke: '#fafafa',
endpointHandleFill: '#09090b',
endpointHandleStroke: '#a1a1aa',
endpointHandleHoverStroke: '#d4d4d8',
endpointHandleActiveFill: '#8381ed',
endpointHandleActiveStroke: '#8381ed',
endpointHandleFill: '#fff7ed',
endpointHandleStroke: '#c2410c',
endpointHandleHoverStroke: '#fb923c',
endpointHandleActiveFill: '#fff7ed',
endpointHandleActiveStroke: '#f97316',
curveHandleFill: '#ccfbf1',
curveHandleStroke: '#0f766e',
curveHandleHoverStroke: '#14b8a6',
}
: {
surface: '#ffffff',
@@ -6406,15 +6583,17 @@ export function FloorplanPanel() {
slabFill: '#c4c4cc',
slabStroke: '#52525b',
selectedSlabFill: '#b7b5f7',
wallFill: '#171717',
wallStroke: '#0284c7',
wallHoverStroke: '#71717a',
wallFill: '#ffffff',
wallStroke: '#0f172a',
wallInnerStroke: 'rgba(71, 85, 105, 0.58)',
wallShadow: 'rgba(15, 23, 42, 0.1)',
wallHoverStroke: '#60a5fa',
deleteFill: '#fca5a5',
deleteStroke: '#dc2626',
deleteWallFill: '#ef4444',
deleteWallHoverStroke: '#f87171',
selectedFill: '#8381ed',
selectedStroke: '#8381ed',
selectedFill: '#ffffff',
selectedStroke: '#3b82f6',
draftFill: '#6366f1',
draftStroke: '#4338ca',
measurementStroke: '#334155',
@@ -6423,14 +6602,18 @@ export function FloorplanPanel() {
anchor: '#4338ca',
openingFill: '#ffffff',
openingStroke: '#171717',
endpointHandleFill: '#ffffff',
endpointHandleStroke: '#71717a',
endpointHandleHoverStroke: '#52525b',
endpointHandleActiveFill: '#8381ed',
endpointHandleActiveStroke: '#8381ed',
endpointHandleFill: '#fff7ed',
endpointHandleStroke: '#c2410c',
endpointHandleHoverStroke: '#fb923c',
endpointHandleActiveFill: '#fff7ed',
endpointHandleActiveStroke: '#f97316',
curveHandleFill: '#ccfbf1',
curveHandleStroke: '#0f766e',
curveHandleHoverStroke: '#14b8a6',
},
[theme],
)
const wallSelectionHatchId = useMemo(() => `floorplan-wall-selection-hatch-${theme}`, [theme])
const gridSteps = useMemo(
() => getVisibleGridSteps(viewBox.width, surfaceSize.width),
[surfaceSize.width, viewBox.width],
@@ -8087,66 +8270,19 @@ export function FloorplanPanel() {
const getFloorplanHitIdAtPoint = useCallback(
(planPoint: WallPlanPoint) => {
const point = toPoint2D(planPoint)
const getItemHitId = () => {
if (!isFloorplanItemContextActive) {
return null
}
const itemHit = floorplanItemEntries.find(({ polygon }) =>
isPointInsidePolygon(point, polygon),
)
return itemHit?.item.id ?? null
}
if (phase === 'structure') {
const openingHit = openingsPolygons.find(({ polygon }) => {
if (isPointInsidePolygon(point, polygon)) {
return true
}
const centerLine = getOpeningCenterLine(polygon)
if (!centerLine) {
return false
}
return (
getDistanceToWallSegment(
point,
[centerLine.start.x, centerLine.start.y],
[centerLine.end.x, centerLine.end.y],
) <= floorplanOpeningHitTolerance
)
})
if (openingHit) {
return openingHit.opening.id
}
const stairHit = floorplanStairEntries.find(({ segments }) =>
segments.some(({ polygon }) => isPointInsidePolygon(point, polygon)),
)
if (stairHit) {
return stairHit.stair.id
}
const wallHit = displayWallPolygons.find(
({ wall, polygon }) =>
isPointInsidePolygon(point, polygon) ||
getDistanceToWallSegment(point, wall.start, wall.end) <= floorplanWallHitTolerance,
)
if (wallHit) {
return wallHit.wall.id
}
const slabHit = displaySlabPolygons.find(({ polygon, holes }) =>
isPointInsidePolygonWithHoles(point, polygon, holes),
)
if (slabHit) {
return slabHit.slab.id
}
}
return getItemHitId()
return getFloorplanHitNodeId({
point,
phase,
isItemContextActive: isFloorplanItemContextActive,
items: floorplanItemEntries,
openings: openingsPolygons,
stairs: floorplanStairEntries,
walls: displayWallPolygons,
slabs: displaySlabPolygons,
openingHitTolerance: floorplanOpeningHitTolerance,
wallHitTolerance: floorplanWallHitTolerance,
getOpeningCenterLine,
})
},
[
displaySlabPolygons,
@@ -8162,34 +8298,17 @@ export function FloorplanPanel() {
)
const getFloorplanSelectionIdsInBounds = useCallback(
(bounds: FloorplanSelectionBounds) => {
const itemIds = isFloorplanItemContextActive
? floorplanItemEntries
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
.map(({ item }) => item.id)
: []
if (phase !== 'structure') {
return itemIds
}
const wallIds = displayWallPolygons
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
.map(({ wall }) => wall.id)
const openingIds = openingsPolygons
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
.map(({ opening }) => opening.id)
const slabIds = displaySlabPolygons
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
.map(({ slab }) => slab.id)
const stairIds = floorplanStairEntries
.filter(({ segments }) =>
segments.some(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds)),
)
.map(({ stair }) => stair.id)
return Array.from(new Set([...itemIds, ...wallIds, ...openingIds, ...slabIds, ...stairIds]))
},
(bounds: FloorplanSelectionBounds) =>
getSelectionIdsInBoundsFromTool({
bounds,
phase,
isItemContextActive: isFloorplanItemContextActive,
items: floorplanItemEntries,
walls: displayWallPolygons,
openings: openingsPolygons,
slabs: displaySlabPolygons,
stairs: floorplanStairEntries,
}),
[
displaySlabPolygons,
displayWallPolygons,
@@ -9835,11 +9954,14 @@ export function FloorplanPanel() {
onDuplicateSelected={handleDuplicateFloorplanSelection}
/>
<div className="relative min-h-0 flex-1" ref={viewportHostRef}>
<FloorplanCursorIndicatorOverlay
<Editor2dFloorplanCursorIndicatorOverlay
cursorAnchorPosition={floorplanCursorAnchorPosition}
cursorColor={floorplanCursorColor}
cursorPosition={floorplanCursorPosition}
floorplanSelectionTool={floorplanSelectionTool}
indicatorBadgeOffsetX={FLOORPLAN_CURSOR_BADGE_OFFSET_X}
indicatorBadgeOffsetY={FLOORPLAN_CURSOR_BADGE_OFFSET_Y}
indicatorLineHeight={FLOORPLAN_CURSOR_INDICATOR_LINE_HEIGHT}
isPanning={isPanning}
movingOpeningType={movingOpeningType}
/>
@@ -9851,7 +9973,7 @@ export function FloorplanPanel() {
rotationModifierPressed={rotationModifierPressed}
/>
)}
<FloorplanActionMenuLayer
<Editor2dFloorplanActionMenuLayer
ceiling={{
position: selectedCeilingActionMenuPosition,
onDelete: handleSelectedCeilingDelete,
@@ -9885,6 +10007,7 @@ export function FloorplanPanel() {
onDelete: handleSelectedWallDelete,
onMove: handleSelectedWallMove,
}}
offsetY={FLOORPLAN_ACTION_MENU_OFFSET_Y}
/>
{!levelNode || levelNode.type !== 'level' ? (
@@ -9906,6 +10029,26 @@ export function FloorplanPanel() {
style={{ cursor: EDITOR_CURSOR }}
viewBox={`${viewBox.minX} ${viewBox.minY} ${viewBox.width} ${viewBox.height}`}
>
<defs>
<pattern
height="0.28"
id={wallSelectionHatchId}
patternTransform="rotate(45)"
patternUnits="userSpaceOnUse"
width="0.28"
>
<line
stroke={palette.selectedStroke}
strokeLinecap="round"
strokeOpacity={0.75}
strokeWidth="0.07"
x1="0"
x2="0"
y1="0"
y2="0.28"
/>
</pattern>
</defs>
<rect
fill={palette.surface}
height={viewBox.height}
@@ -9959,6 +10102,7 @@ export function FloorplanPanel() {
slabPolygons={displaySlabPolygons}
unit={unit}
wallPolygons={displayWallPolygons}
wallSelectionHatchId={wallSelectionHatchId}
/>
<FloorplanZoneLayer
@@ -10047,96 +10191,27 @@ export function FloorplanPanel() {
/>
)}
{visibleSvgMarqueeBounds && (
<>
<rect
fill={palette.cursor}
fillOpacity={0.12}
height={visibleSvgMarqueeBounds.height}
pointerEvents="none"
stroke={palette.cursor}
strokeOpacity={0.26}
strokeWidth={FLOORPLAN_MARQUEE_GLOW_WIDTH}
vectorEffect="non-scaling-stroke"
width={visibleSvgMarqueeBounds.width}
x={visibleSvgMarqueeBounds.x}
y={visibleSvgMarqueeBounds.y}
/>
<rect
fill="none"
height={visibleSvgMarqueeBounds.height}
pointerEvents="none"
stroke={palette.cursor}
strokeOpacity={0.96}
strokeWidth={FLOORPLAN_MARQUEE_OUTLINE_WIDTH}
vectorEffect="non-scaling-stroke"
width={visibleSvgMarqueeBounds.width}
x={visibleSvgMarqueeBounds.x}
y={visibleSvgMarqueeBounds.y}
/>
</>
)}
<FloorplanMarqueeLayer
bounds={visibleSvgMarqueeBounds}
cursorColor={palette.cursor}
glowWidth={FLOORPLAN_MARQUEE_GLOW_WIDTH}
outlineWidth={FLOORPLAN_MARQUEE_OUTLINE_WIDTH}
/>
{draftPolygon && (
<polygon
fill={palette.draftFill}
fillOpacity={0.35}
points={draftPolygonPoints ?? undefined}
stroke={palette.draftStroke}
strokeDasharray="0.24 0.12"
strokeWidth="0.07"
vectorEffect="non-scaling-stroke"
/>
)}
{polygonDraftPolygonPoints && (
<polygon
fill={palette.draftFill}
fillOpacity={0.2}
points={polygonDraftPolygonPoints}
stroke="none"
/>
)}
{polygonDraftPolylinePoints && (
<polyline
fill="none"
points={polygonDraftPolylinePoints}
stroke={palette.draftStroke}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="0.08"
vectorEffect="non-scaling-stroke"
/>
)}
{polygonDraftClosingSegment && (
<line
stroke={palette.draftStroke}
strokeDasharray="0.16 0.1"
strokeLinecap="round"
strokeOpacity={0.75}
strokeWidth="0.05"
vectorEffect="non-scaling-stroke"
x1={polygonDraftClosingSegment.x1}
x2={polygonDraftClosingSegment.x2}
y1={polygonDraftClosingSegment.y1}
y2={polygonDraftClosingSegment.y2}
/>
)}
{activePolygonDraftPoints.map((point, index) => (
<circle
cx={toSvgX(point[0])}
cy={toSvgY(point[1])}
fill={index === 0 ? palette.anchor : palette.draftStroke}
fillOpacity={0.95}
key={`polygon-draft-${index}`}
pointerEvents="none"
r={index === 0 ? 0.12 : 0.1}
vectorEffect="non-scaling-stroke"
/>
))}
<FloorplanDraftLayer
anchorFill={palette.anchor}
draftAnchorPoints={activePolygonDraftPoints.map((point, index) => ({
x: toSvgX(point[0]),
y: toSvgY(point[1]),
isPrimary: index === 0,
}))}
draftFill={palette.draftFill}
draftPolygonPoints={draftPolygonPoints}
draftStroke={palette.draftStroke}
polygonDraftClosingSegment={polygonDraftClosingSegment}
polygonDraftPolygonPoints={polygonDraftPolygonPoints}
polygonDraftPolylinePoints={polygonDraftPolylinePoints}
/>
<FloorplanWallEndpointLayer
endpointHandles={wallEndpointHandles}
@@ -0,0 +1,160 @@
import type { DoorNode, ItemNode, SlabNode, StairNode, WallNode, WindowNode } from '@pascal-app/core'
import {
doesPolygonIntersectSelectionBounds,
getDistanceToWallSegment,
isPointInsidePolygon,
isPointInsidePolygonWithHoles,
type FloorplanSelectionBounds,
type Point2D,
} from '@pascal-app/core'
type OpeningNode = WindowNode | DoorNode
type OpeningPolygonEntry = {
opening: OpeningNode
polygon: Point2D[]
}
type ItemEntry = {
item: ItemNode
polygon: Point2D[]
}
type StairEntry = {
stair: StairNode
segments: Array<{ polygon: Point2D[] }>
}
type WallEntry = {
wall: WallNode
polygon: Point2D[]
}
type SlabEntry = {
slab: SlabNode
polygon: Point2D[]
holes: Point2D[][]
}
type FloorplanSelectionToolContext = {
point: Point2D
phase: 'site' | 'structure' | 'furnish'
isItemContextActive: boolean
items: ItemEntry[]
openings: OpeningPolygonEntry[]
stairs: StairEntry[]
walls: WallEntry[]
slabs: SlabEntry[]
openingHitTolerance: number
wallHitTolerance: number
getOpeningCenterLine: (polygon: Point2D[]) => { start: Point2D; end: Point2D } | null
}
function getItemHitId(context: FloorplanSelectionToolContext) {
if (!context.isItemContextActive) {
return null
}
const itemHit = context.items.find(({ polygon }) => isPointInsidePolygon(context.point, polygon))
return itemHit?.item.id ?? null
}
export function getFloorplanHitNodeId(context: FloorplanSelectionToolContext) {
if (context.phase === 'structure') {
const openingHit = context.openings.find(({ polygon }) => {
if (isPointInsidePolygon(context.point, polygon)) {
return true
}
const centerLine = context.getOpeningCenterLine(polygon)
if (!centerLine) {
return false
}
return (
getDistanceToWallSegment(
context.point,
[centerLine.start.x, centerLine.start.y],
[centerLine.end.x, centerLine.end.y],
) <= context.openingHitTolerance
)
})
if (openingHit) {
return openingHit.opening.id
}
const stairHit = context.stairs.find(({ segments }) =>
segments.some(({ polygon }) => isPointInsidePolygon(context.point, polygon)),
)
if (stairHit) {
return stairHit.stair.id
}
const wallHit = context.walls.find(
({ wall, polygon }) =>
isPointInsidePolygon(context.point, polygon) ||
getDistanceToWallSegment(context.point, wall.start, wall.end) <= context.wallHitTolerance,
)
if (wallHit) {
return wallHit.wall.id
}
const slabHit = context.slabs.find(({ polygon, holes }) =>
isPointInsidePolygonWithHoles(context.point, polygon, holes),
)
if (slabHit) {
return slabHit.slab.id
}
}
return getItemHitId(context)
}
type FloorplanSelectionBoundsContext = {
bounds: FloorplanSelectionBounds
phase: 'site' | 'structure' | 'furnish'
isItemContextActive: boolean
items: ItemEntry[]
walls: WallEntry[]
openings: OpeningPolygonEntry[]
slabs: SlabEntry[]
stairs: StairEntry[]
}
export function getFloorplanSelectionIdsInBounds({
bounds,
phase,
isItemContextActive,
items,
walls,
openings,
slabs,
stairs,
}: FloorplanSelectionBoundsContext) {
const itemIds = isItemContextActive
? items
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
.map(({ item }) => item.id)
: []
if (phase !== 'structure') {
return itemIds
}
const wallIds = walls
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
.map(({ wall }) => wall.id)
const openingIds = openings
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
.map(({ opening }) => opening.id)
const slabIds = slabs
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
.map(({ slab }) => slab.id)
const stairIds = stairs
.filter(({ segments }) =>
segments.some(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds)),
)
.map(({ stair }) => stair.id)
return Array.from(new Set([...itemIds, ...wallIds, ...openingIds, ...slabIds, ...stairIds]))
}