From a69591670d8b362be10f9365217ad2681a73900d Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 22 Apr 2026 15:34:22 +0530 Subject: [PATCH 01/12] Refactor floorplan wall visuals and shared plan helpers --- packages/core/src/index.ts | 38 ++ packages/core/src/plan/geometry.ts | 263 ++++++++ packages/core/src/plan/items.ts | 139 ++++ packages/core/src/plan/stairs.ts | 403 ++++++++++++ packages/core/src/plan/types.ts | 51 ++ packages/core/src/plan/walls.ts | 23 + .../editor-2d/floorplan-action-menu-layer.tsx | 76 +++ .../floorplan-cursor-indicator-overlay.tsx | 160 +++++ .../renderers/floorplan-draft-layer.tsx | 92 +++ .../renderers/floorplan-marquee-layer.tsx | 58 ++ .../src/components/editor/floorplan-panel.tsx | 609 ++++++++++-------- .../tools/floorplan/selection-tool.ts | 160 +++++ 12 files changed, 1805 insertions(+), 267 deletions(-) create mode 100644 packages/core/src/plan/geometry.ts create mode 100644 packages/core/src/plan/items.ts create mode 100644 packages/core/src/plan/stairs.ts create mode 100644 packages/core/src/plan/types.ts create mode 100644 packages/core/src/plan/walls.ts create mode 100644 packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx create mode 100644 packages/editor/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx create mode 100644 packages/editor/src/components/editor-2d/renderers/floorplan-draft-layer.tsx create mode 100644 packages/editor/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx create mode 100644 packages/editor/src/components/tools/floorplan/selection-tool.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8c7778ba2..6e30a35fd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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' diff --git a/packages/core/src/plan/geometry.ts b/packages/core/src/plan/geometry.ts new file mode 100644 index 000000000..23255b28f --- /dev/null +++ b/packages/core/src/plan/geometry.ts @@ -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 +} diff --git a/packages/core/src/plan/items.ts b/packages/core/src/plan/items.ts new file mode 100644 index 000000000..73e722643 --- /dev/null +++ b/packages/core/src/plan/items.ts @@ -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, +): 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, +): 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) + : 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, +): 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), + } +} diff --git a/packages/core/src/plan/stairs.ts b/packages/core/src/plan/stairs.ts new file mode 100644 index 000000000..eb5624a7e --- /dev/null +++ b/packages/core/src/plan/stairs.ts @@ -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, + } +} diff --git a/packages/core/src/plan/types.ts b/packages/core/src/plan/types.ts new file mode 100644 index 000000000..5c886ec3c --- /dev/null +++ b/packages/core/src/plan/types.ts @@ -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 diff --git a/packages/core/src/plan/walls.ts b/packages/core/src/plan/walls.ts new file mode 100644 index 000000000..c44180d7c --- /dev/null +++ b/packages/core/src/plan/walls.ts @@ -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), + } +} diff --git a/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx new file mode 100644 index 000000000..45576ccbc --- /dev/null +++ b/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx @@ -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) => 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 ? ( +
+ event.stopPropagation()} + onPointerUp={(event) => event.stopPropagation()} + /> +
+ ) : null, + )} + + ) +}) diff --git a/packages/editor/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx new file mode 100644 index 000000000..152eca79f --- /dev/null +++ b/packages/editor/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx @@ -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(() => { + 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 ( +