Refactor floorplan wall visuals and shared plan helpers
This commit is contained in:
@@ -96,6 +96,44 @@ export {
|
|||||||
pointToKey,
|
pointToKey,
|
||||||
type WallMiterData,
|
type WallMiterData,
|
||||||
} from './systems/wall/wall-mitering'
|
} 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 { WallSystem } from './systems/wall/wall-system'
|
||||||
export { WindowSystem } from './systems/window/window-system'
|
export { WindowSystem } from './systems/window/window-system'
|
||||||
export type { SceneGraph } from './utils/clone-scene-graph'
|
export type { SceneGraph } from './utils/clone-scene-graph'
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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 AnyNodeId,
|
||||||
type BuildingNode,
|
type BuildingNode,
|
||||||
type CeilingNode,
|
type CeilingNode,
|
||||||
|
buildFloorplanItemEntry,
|
||||||
|
buildFloorplanStairEntry as buildSharedFloorplanStairEntry,
|
||||||
calculateLevelMiters,
|
calculateLevelMiters,
|
||||||
|
collectLevelDescendants as collectSharedLevelDescendants,
|
||||||
DoorNode,
|
DoorNode,
|
||||||
emitter,
|
emitter,
|
||||||
|
getFloorplanWall as getSharedFloorplanWall,
|
||||||
type GridEvent,
|
type GridEvent,
|
||||||
type GuideNode,
|
type GuideNode,
|
||||||
getScaledDimensions,
|
|
||||||
getWallChordFrame,
|
getWallChordFrame,
|
||||||
getWallCurveLength,
|
getWallCurveLength,
|
||||||
getWallMidpointHandlePoint,
|
getWallMidpointHandlePoint,
|
||||||
getWallPlanFootprint,
|
getWallPlanFootprint,
|
||||||
|
type FloorplanNodeTransform as SharedFloorplanNodeTransform,
|
||||||
type ItemNode,
|
type ItemNode,
|
||||||
ItemNode as ItemNodeSchema,
|
ItemNode as ItemNodeSchema,
|
||||||
isCurvedWall,
|
isCurvedWall,
|
||||||
@@ -23,6 +27,7 @@ import {
|
|||||||
loadAssetUrl,
|
loadAssetUrl,
|
||||||
normalizeWallCurveOffset,
|
normalizeWallCurveOffset,
|
||||||
type Point2D,
|
type Point2D,
|
||||||
|
rotatePlanVector as rotateSharedPlanVector,
|
||||||
type SiteNode,
|
type SiteNode,
|
||||||
SlabNode,
|
SlabNode,
|
||||||
type StairNode,
|
type StairNode,
|
||||||
@@ -50,9 +55,17 @@ import {
|
|||||||
} from 'react'
|
} from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
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 { sfxEmitter } from '../../lib/sfx-bus'
|
||||||
import { cn } from '../../lib/utils'
|
import { cn } from '../../lib/utils'
|
||||||
import useEditor, { type FloorplanSelectionTool } from '../../store/use-editor'
|
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 { snapToHalf } from '../tools/item/placement-math'
|
||||||
import {
|
import {
|
||||||
DEFAULT_STAIR_ATTACHMENT_SIDE,
|
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_MARQUEE_DRAG_THRESHOLD_PX = 4
|
||||||
const FLOORPLAN_MEASUREMENT_OFFSET = 0.46
|
const FLOORPLAN_MEASUREMENT_OFFSET = 0.46
|
||||||
const FLOORPLAN_MEASUREMENT_EXTENSION_OVERSHOOT = 0.08
|
const FLOORPLAN_MEASUREMENT_EXTENSION_OVERSHOOT = 0.08
|
||||||
const FLOORPLAN_MEASUREMENT_LINE_WIDTH = 1.2
|
const FLOORPLAN_MEASUREMENT_LINE_WIDTH = 1.35
|
||||||
const FLOORPLAN_MEASUREMENT_LINE_OUTLINE_WIDTH = 2.8
|
const FLOORPLAN_MEASUREMENT_LINE_OUTLINE_WIDTH = 0
|
||||||
const FLOORPLAN_MEASUREMENT_LINE_OPACITY = 0.72
|
const FLOORPLAN_MEASUREMENT_LINE_OPACITY = 0.95
|
||||||
const FLOORPLAN_MEASUREMENT_LINE_OUTLINE_OPACITY = 0.9
|
const FLOORPLAN_MEASUREMENT_LINE_OUTLINE_OPACITY = 0
|
||||||
const FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE = 0.15
|
const FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE = 0.15
|
||||||
const FLOORPLAN_MEASUREMENT_LABEL_OPACITY = 0.82
|
const FLOORPLAN_MEASUREMENT_LABEL_OPACITY = 0.98
|
||||||
const FLOORPLAN_MEASUREMENT_LABEL_STROKE_WIDTH = 0.05
|
const FLOORPLAN_MEASUREMENT_LABEL_STROKE_WIDTH = 0
|
||||||
const FLOORPLAN_MEASUREMENT_LABEL_GAP = 0.56
|
const FLOORPLAN_MEASUREMENT_LABEL_GAP = 0.56
|
||||||
const FLOORPLAN_MEASUREMENT_LABEL_LINE_PADDING = 0.14
|
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_HORIZONTAL_PADDING = 60
|
||||||
const FLOORPLAN_ACTION_MENU_MIN_ANCHOR_Y = 56
|
const FLOORPLAN_ACTION_MENU_MIN_ANCHOR_Y = 56
|
||||||
const FLOORPLAN_ACTION_MENU_OFFSET_Y = 10
|
const FLOORPLAN_ACTION_MENU_OFFSET_Y = 10
|
||||||
@@ -417,6 +432,8 @@ type FloorplanPalette = {
|
|||||||
selectedSlabFill: string
|
selectedSlabFill: string
|
||||||
wallFill: string
|
wallFill: string
|
||||||
wallStroke: string
|
wallStroke: string
|
||||||
|
wallInnerStroke: string
|
||||||
|
wallShadow: string
|
||||||
wallHoverStroke: string
|
wallHoverStroke: string
|
||||||
deleteFill: string
|
deleteFill: string
|
||||||
deleteStroke: string
|
deleteStroke: string
|
||||||
@@ -437,6 +454,9 @@ type FloorplanPalette = {
|
|||||||
endpointHandleHoverStroke: string
|
endpointHandleHoverStroke: string
|
||||||
endpointHandleActiveFill: string
|
endpointHandleActiveFill: string
|
||||||
endpointHandleActiveStroke: string
|
endpointHandleActiveStroke: string
|
||||||
|
curveHandleFill: string
|
||||||
|
curveHandleStroke: string
|
||||||
|
curveHandleHoverStroke: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const resizeCursorByDirection: Record<ResizeDirection, 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] {
|
function rotatePlanVector(x: number, y: number, rotation: number): [number, number] {
|
||||||
const cos = Math.cos(rotation)
|
return rotateSharedPlanVector(x, y, rotation)
|
||||||
const sin = Math.sin(rotation)
|
|
||||||
return [x * cos + y * sin, -x * sin + y * cos]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPolygonBounds(points: Point2D[]) {
|
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
|
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 {
|
function getWallHoverSidePaths(polygon: Point2D[], wall: WallNode): [string, string] | null {
|
||||||
if (polygon.length < 4) {
|
if (polygon.length < 4) {
|
||||||
return null
|
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 startRight = polygon[0]
|
||||||
const endRight = polygon[1]
|
const endRight = polygon[1]
|
||||||
const hasEndCenterPoint = pointMatchesWallPlanPoint(polygon[2], wall.end)
|
const hasEndCenterPoint = pointMatchesWallPlanPoint(polygon[2], wall.end)
|
||||||
@@ -1972,10 +2015,10 @@ function getWallHoverSidePaths(polygon: Point2D[], wall: WallNode): [string, str
|
|||||||
const svgStartLeft = toSvgPoint(startLeft)
|
const svgStartLeft = toSvgPoint(startLeft)
|
||||||
const svgEndLeft = toSvgPoint(endLeft)
|
const svgEndLeft = toSvgPoint(endLeft)
|
||||||
|
|
||||||
return [
|
const rightSidePath = `M ${svgStartRight.x} ${svgStartRight.y} L ${svgEndRight.x} ${svgEndRight.y}`
|
||||||
`M ${svgStartRight.x} ${svgStartRight.y} L ${svgEndRight.x} ${svgEndRight.y}`,
|
const leftSidePath = `M ${svgStartLeft.x} ${svgStartLeft.y} L ${svgEndLeft.x} ${svgEndLeft.y}`
|
||||||
`M ${svgStartLeft.x} ${svgStartLeft.y} L ${svgEndLeft.x} ${svgEndLeft.y}`,
|
|
||||||
]
|
return [rightSidePath, leftSidePath]
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDraftWall(levelId: string, start: WallPlanPoint, end: WallPlanPoint): WallNode {
|
function buildDraftWall(levelId: string, start: WallPlanPoint, end: WallPlanPoint): WallNode {
|
||||||
@@ -2149,38 +2192,26 @@ function FloorplanMeasurementLine({
|
|||||||
palette,
|
palette,
|
||||||
segment,
|
segment,
|
||||||
isSelected,
|
isSelected,
|
||||||
|
dashed = false,
|
||||||
}: {
|
}: {
|
||||||
palette: FloorplanPalette
|
palette: FloorplanPalette
|
||||||
segment: { x1: number; y1: number; x2: number; y2: number }
|
segment: { x1: number; y1: number; x2: number; y2: number }
|
||||||
isSelected?: boolean
|
isSelected?: boolean
|
||||||
|
dashed?: boolean
|
||||||
}) {
|
}) {
|
||||||
const lineOpacity = isSelected
|
const lineOpacity = isSelected
|
||||||
? FLOORPLAN_MEASUREMENT_LINE_OPACITY
|
? FLOORPLAN_MEASUREMENT_LINE_OPACITY
|
||||||
: FLOORPLAN_MEASUREMENT_LINE_OPACITY * 0.4
|
: FLOORPLAN_MEASUREMENT_LINE_OPACITY * 0.4
|
||||||
const outlineOpacity = isSelected
|
|
||||||
? FLOORPLAN_MEASUREMENT_LINE_OUTLINE_OPACITY
|
|
||||||
: FLOORPLAN_MEASUREMENT_LINE_OUTLINE_OPACITY * 0.4
|
|
||||||
|
|
||||||
return (
|
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
|
<line
|
||||||
shapeRendering="geometricPrecision"
|
shapeRendering="geometricPrecision"
|
||||||
stroke={palette.measurementStroke}
|
stroke={palette.measurementStroke}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeOpacity={lineOpacity}
|
strokeOpacity={lineOpacity}
|
||||||
strokeWidth={FLOORPLAN_MEASUREMENT_LINE_WIDTH}
|
strokeWidth={FLOORPLAN_MEASUREMENT_LINE_WIDTH}
|
||||||
|
strokeDasharray={dashed ? FLOORPLAN_MEASUREMENT_EXTENSION_DASH : undefined}
|
||||||
vectorEffect="non-scaling-stroke"
|
vectorEffect="non-scaling-stroke"
|
||||||
x1={segment.x1}
|
x1={segment.x1}
|
||||||
x2={segment.x2}
|
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(
|
function getWallMeasurementOverlay(
|
||||||
wall: WallNode,
|
wall: WallNode,
|
||||||
centerX: number,
|
centerX: number,
|
||||||
@@ -2933,6 +2998,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
|||||||
selectedIdSet,
|
selectedIdSet,
|
||||||
slabPolygons,
|
slabPolygons,
|
||||||
wallPolygons,
|
wallPolygons,
|
||||||
|
wallSelectionHatchId,
|
||||||
unit,
|
unit,
|
||||||
}: {
|
}: {
|
||||||
canFocusGeometry: boolean
|
canFocusGeometry: boolean
|
||||||
@@ -2958,6 +3024,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
|||||||
selectedIdSet: ReadonlySet<string>
|
selectedIdSet: ReadonlySet<string>
|
||||||
slabPolygons: SlabPolygonEntry[]
|
slabPolygons: SlabPolygonEntry[]
|
||||||
wallPolygons: WallPolygonEntry[]
|
wallPolygons: WallPolygonEntry[]
|
||||||
|
wallSelectionHatchId: string
|
||||||
unit: 'metric' | 'imperial'
|
unit: 'metric' | 'imperial'
|
||||||
}) {
|
}) {
|
||||||
let minX = Number.POSITIVE_INFINITY,
|
let minX = Number.POSITIVE_INFINITY,
|
||||||
@@ -2977,7 +3044,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
|||||||
if (measurement) {
|
if (measurement) {
|
||||||
measurement.isSelected = selectedIdSet.has(wall.id)
|
measurement.isSelected = selectedIdSet.has(wall.id)
|
||||||
}
|
}
|
||||||
return measurement ? [measurement] : []
|
return measurement?.isSelected ? [measurement] : []
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -3069,13 +3136,14 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
|||||||
const isHighlighted = highlightedIdSet.has(wall.id)
|
const isHighlighted = highlightedIdSet.has(wall.id)
|
||||||
const isHovered = canSelectGeometry && hoveredWallId === wall.id
|
const isHovered = canSelectGeometry && hoveredWallId === wall.id
|
||||||
const isDeleteHovered = isDeleteMode && isHovered
|
const isDeleteHovered = isDeleteMode && isHovered
|
||||||
|
const showSelectedWallChrome = isSelected || isHighlighted
|
||||||
const hoverStroke = isDeleteHovered
|
const hoverStroke = isDeleteHovered
|
||||||
? palette.deleteWallHoverStroke
|
? palette.deleteWallHoverStroke
|
||||||
: isHighlighted
|
: showSelectedWallChrome
|
||||||
? palette.selectedStroke
|
? palette.selectedStroke
|
||||||
: palette.wallHoverStroke
|
: palette.wallHoverStroke
|
||||||
const hoverGlowOpacity = isDeleteHovered ? 0.14 : isHighlighted ? 0.22 : 0.16
|
const hoverGlowOpacity = isDeleteHovered ? 0.14 : showSelectedWallChrome ? 0.24 : 0.16
|
||||||
const hoverRingOpacity = isDeleteHovered ? 0.38 : isHighlighted ? 0.6 : 0.48
|
const hoverRingOpacity = isDeleteHovered ? 0.38 : showSelectedWallChrome ? 0.62 : 0.48
|
||||||
const hoverSidePaths = getWallHoverSidePaths(polygon, wall)
|
const hoverSidePaths = getWallHoverSidePaths(polygon, wall)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -3120,6 +3188,21 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
|||||||
vectorEffect="non-scaling-stroke"
|
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 && (
|
{canSelectGeometry && (
|
||||||
<line
|
<line
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
@@ -3142,12 +3225,22 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
|||||||
y2={toSvgY(wall.end[1])}
|
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
|
<polygon
|
||||||
fill={
|
fill={
|
||||||
isDeleteHovered
|
isDeleteHovered
|
||||||
? palette.deleteWallFill
|
? palette.deleteWallFill
|
||||||
: isHighlighted
|
|
||||||
? palette.selectedFill
|
|
||||||
: palette.wallFill
|
: palette.wallFill
|
||||||
}
|
}
|
||||||
onClick={
|
onClick={
|
||||||
@@ -3168,13 +3261,59 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
|||||||
}
|
}
|
||||||
points={points}
|
points={points}
|
||||||
stroke={
|
stroke={
|
||||||
isDeleteHovered ? palette.deleteStroke : isHighlighted ? 'none' : palette.wallStroke
|
isDeleteHovered
|
||||||
|
? palette.deleteStroke
|
||||||
|
: showSelectedWallChrome
|
||||||
|
? palette.selectedStroke
|
||||||
|
: palette.wallStroke
|
||||||
}
|
}
|
||||||
strokeOpacity={1}
|
strokeOpacity={1}
|
||||||
strokeWidth="0.06"
|
strokeWidth={showSelectedWallChrome ? '1.05' : '0.9'}
|
||||||
style={{ cursor: EDITOR_CURSOR }}
|
style={{ cursor: EDITOR_CURSOR }}
|
||||||
vectorEffect="non-scaling-stroke"
|
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>
|
</g>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -3495,6 +3634,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
|||||||
isSelected={measurement.isSelected}
|
isSelected={measurement.isSelected}
|
||||||
palette={palette}
|
palette={palette}
|
||||||
segment={measurement.extensionStart}
|
segment={measurement.extensionStart}
|
||||||
|
dashed
|
||||||
/>
|
/>
|
||||||
<FloorplanMeasurementLine
|
<FloorplanMeasurementLine
|
||||||
isSelected={measurement.isSelected}
|
isSelected={measurement.isSelected}
|
||||||
@@ -3510,6 +3650,21 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
|||||||
isSelected={measurement.isSelected}
|
isSelected={measurement.isSelected}
|
||||||
palette={palette}
|
palette={palette}
|
||||||
segment={measurement.extensionEnd}
|
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
|
<text
|
||||||
dominantBaseline="central"
|
dominantBaseline="central"
|
||||||
@@ -3522,12 +3677,6 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
|||||||
fontFamily="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace"
|
fontFamily="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace"
|
||||||
fontSize={FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE}
|
fontSize={FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE}
|
||||||
fontWeight="600"
|
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"
|
textAnchor="middle"
|
||||||
transform={`rotate(${measurement.labelAngleDeg} ${measurement.labelX} ${measurement.labelY}) translate(0, -0.04)`}
|
transform={`rotate(${measurement.labelAngleDeg} ${measurement.labelX} ${measurement.labelY}) translate(0, -0.04)`}
|
||||||
x={measurement.labelX}
|
x={measurement.labelX}
|
||||||
@@ -4406,10 +4555,8 @@ const FloorplanWallCurveHandleLayer = memo(function FloorplanWallCurveHandleLaye
|
|||||||
{curveHandles.map(({ wall, point, isActive }) => {
|
{curveHandles.map(({ wall, point, isActive }) => {
|
||||||
const handleId = `curve:${wall.id}`
|
const handleId = `curve:${wall.id}`
|
||||||
const isHovered = hoveredHandleId === handleId
|
const isHovered = hoveredHandleId === handleId
|
||||||
const stroke = isActive ? palette.endpointHandleActiveStroke : palette.endpointHandleStroke
|
const stroke = palette.curveHandleStroke
|
||||||
const hoverStroke = isActive
|
const hoverStroke = palette.curveHandleHoverStroke
|
||||||
? palette.endpointHandleActiveStroke
|
|
||||||
: palette.endpointHandleHoverStroke
|
|
||||||
const svgPoint = toSvgPlanPoint(point)
|
const svgPoint = toSvgPlanPoint(point)
|
||||||
const radius = isActive ? 0.16 : 0.14
|
const radius = isActive ? 0.16 : 0.14
|
||||||
|
|
||||||
@@ -4440,7 +4587,7 @@ const FloorplanWallCurveHandleLayer = memo(function FloorplanWallCurveHandleLaye
|
|||||||
<circle
|
<circle
|
||||||
cx={svgPoint.x}
|
cx={svgPoint.x}
|
||||||
cy={svgPoint.y}
|
cy={svgPoint.y}
|
||||||
fill={isActive ? palette.endpointHandleActiveFill : palette.endpointHandleFill}
|
fill={palette.curveHandleFill}
|
||||||
fillOpacity={0.96}
|
fillOpacity={0.96}
|
||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
r={radius}
|
r={radius}
|
||||||
@@ -4453,7 +4600,7 @@ const FloorplanWallCurveHandleLayer = memo(function FloorplanWallCurveHandleLaye
|
|||||||
cy={svgPoint.y}
|
cy={svgPoint.y}
|
||||||
fill={stroke}
|
fill={stroke}
|
||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
r={0.045}
|
r={0.05}
|
||||||
vectorEffect="non-scaling-stroke"
|
vectorEffect="non-scaling-stroke"
|
||||||
/>
|
/>
|
||||||
<circle
|
<circle
|
||||||
@@ -5147,7 +5294,7 @@ export function FloorplanPanel() {
|
|||||||
return [] as AnyNode[]
|
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],
|
[levelDescendantNodes],
|
||||||
)
|
)
|
||||||
const floorplanItemEntries = useMemo(() => {
|
const floorplanItemEntries = useMemo(() => {
|
||||||
const transformCache = new Map<string, FloorplanNodeTransform | null>()
|
const transformCache = new Map<string, SharedFloorplanNodeTransform | null>()
|
||||||
|
|
||||||
return floorplanItems.flatMap((item) => {
|
return floorplanItems.flatMap((item) => {
|
||||||
const transform = getItemFloorplanTransform(item, levelDescendantNodeById, transformCache)
|
const entry = buildFloorplanItemEntry(item, levelDescendantNodeById, transformCache)
|
||||||
if (!transform) {
|
if (!entry) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const [width, , depth] = getScaledDimensions(item)
|
|
||||||
const polygon = getRotatedRectanglePolygon(
|
|
||||||
transform.position,
|
|
||||||
width,
|
|
||||||
depth,
|
|
||||||
transform.rotation,
|
|
||||||
)
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
item,
|
item: entry.item,
|
||||||
points: formatPolygonPoints(polygon),
|
points: formatPolygonPoints(entry.polygon),
|
||||||
polygon,
|
polygon: entry.polygon,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
@@ -5579,8 +5718,25 @@ export function FloorplanPanel() {
|
|||||||
(node): node is StairSegmentNode =>
|
(node): node is StairSegmentNode =>
|
||||||
node?.type === 'stair-segment' && node.visible !== false,
|
node?.type === 'stair-segment' && node.visible !== false,
|
||||||
)
|
)
|
||||||
const entry = buildFloorplanStairEntry(displayStair, segments)
|
const entry = buildSharedFloorplanStairEntry(displayStair, segments)
|
||||||
return entry ? [entry] : []
|
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,
|
cursorPoint,
|
||||||
@@ -5704,7 +5860,23 @@ export function FloorplanPanel() {
|
|||||||
metadata: { isTransient: true, isFloorplanPreview: true },
|
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,
|
floorplanPreviewStairSegment,
|
||||||
isStairBuildActive,
|
isStairBuildActive,
|
||||||
@@ -6003,7 +6175,7 @@ export function FloorplanPanel() {
|
|||||||
return null
|
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.
|
// Keep the live draft preview cheap; full level-wide mitering here runs on every mouse move.
|
||||||
return getWallPlanFootprint(draftWall, EMPTY_WALL_MITER_DATA)
|
return getWallPlanFootprint(draftWall, EMPTY_WALL_MITER_DATA)
|
||||||
}, [draftEnd, draftStart, levelId])
|
}, [draftEnd, draftStart, levelId])
|
||||||
@@ -6375,14 +6547,16 @@ export function FloorplanPanel() {
|
|||||||
slabStroke: '#71717a',
|
slabStroke: '#71717a',
|
||||||
selectedSlabFill: '#b7b5f7',
|
selectedSlabFill: '#b7b5f7',
|
||||||
wallFill: '#fafafa',
|
wallFill: '#fafafa',
|
||||||
wallStroke: '#38bdf8',
|
wallStroke: '#0f172a',
|
||||||
wallHoverStroke: '#a1a1aa',
|
wallInnerStroke: 'rgba(51, 65, 85, 0.72)',
|
||||||
|
wallShadow: 'rgba(15, 23, 42, 0.12)',
|
||||||
|
wallHoverStroke: '#60a5fa',
|
||||||
deleteFill: '#f87171',
|
deleteFill: '#f87171',
|
||||||
deleteStroke: '#ef4444',
|
deleteStroke: '#ef4444',
|
||||||
deleteWallFill: '#ef4444',
|
deleteWallFill: '#ef4444',
|
||||||
deleteWallHoverStroke: '#fca5a5',
|
deleteWallHoverStroke: '#fca5a5',
|
||||||
selectedFill: '#8381ed',
|
selectedFill: '#fafafa',
|
||||||
selectedStroke: '#8381ed',
|
selectedStroke: '#3b82f6',
|
||||||
draftFill: '#818cf8',
|
draftFill: '#818cf8',
|
||||||
draftStroke: '#c7d2fe',
|
draftStroke: '#c7d2fe',
|
||||||
measurementStroke: '#cbd5e1',
|
measurementStroke: '#cbd5e1',
|
||||||
@@ -6391,11 +6565,14 @@ export function FloorplanPanel() {
|
|||||||
anchor: '#818cf8',
|
anchor: '#818cf8',
|
||||||
openingFill: '#0a0e1b',
|
openingFill: '#0a0e1b',
|
||||||
openingStroke: '#fafafa',
|
openingStroke: '#fafafa',
|
||||||
endpointHandleFill: '#09090b',
|
endpointHandleFill: '#fff7ed',
|
||||||
endpointHandleStroke: '#a1a1aa',
|
endpointHandleStroke: '#c2410c',
|
||||||
endpointHandleHoverStroke: '#d4d4d8',
|
endpointHandleHoverStroke: '#fb923c',
|
||||||
endpointHandleActiveFill: '#8381ed',
|
endpointHandleActiveFill: '#fff7ed',
|
||||||
endpointHandleActiveStroke: '#8381ed',
|
endpointHandleActiveStroke: '#f97316',
|
||||||
|
curveHandleFill: '#ccfbf1',
|
||||||
|
curveHandleStroke: '#0f766e',
|
||||||
|
curveHandleHoverStroke: '#14b8a6',
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
surface: '#ffffff',
|
surface: '#ffffff',
|
||||||
@@ -6406,15 +6583,17 @@ export function FloorplanPanel() {
|
|||||||
slabFill: '#c4c4cc',
|
slabFill: '#c4c4cc',
|
||||||
slabStroke: '#52525b',
|
slabStroke: '#52525b',
|
||||||
selectedSlabFill: '#b7b5f7',
|
selectedSlabFill: '#b7b5f7',
|
||||||
wallFill: '#171717',
|
wallFill: '#ffffff',
|
||||||
wallStroke: '#0284c7',
|
wallStroke: '#0f172a',
|
||||||
wallHoverStroke: '#71717a',
|
wallInnerStroke: 'rgba(71, 85, 105, 0.58)',
|
||||||
|
wallShadow: 'rgba(15, 23, 42, 0.1)',
|
||||||
|
wallHoverStroke: '#60a5fa',
|
||||||
deleteFill: '#fca5a5',
|
deleteFill: '#fca5a5',
|
||||||
deleteStroke: '#dc2626',
|
deleteStroke: '#dc2626',
|
||||||
deleteWallFill: '#ef4444',
|
deleteWallFill: '#ef4444',
|
||||||
deleteWallHoverStroke: '#f87171',
|
deleteWallHoverStroke: '#f87171',
|
||||||
selectedFill: '#8381ed',
|
selectedFill: '#ffffff',
|
||||||
selectedStroke: '#8381ed',
|
selectedStroke: '#3b82f6',
|
||||||
draftFill: '#6366f1',
|
draftFill: '#6366f1',
|
||||||
draftStroke: '#4338ca',
|
draftStroke: '#4338ca',
|
||||||
measurementStroke: '#334155',
|
measurementStroke: '#334155',
|
||||||
@@ -6423,14 +6602,18 @@ export function FloorplanPanel() {
|
|||||||
anchor: '#4338ca',
|
anchor: '#4338ca',
|
||||||
openingFill: '#ffffff',
|
openingFill: '#ffffff',
|
||||||
openingStroke: '#171717',
|
openingStroke: '#171717',
|
||||||
endpointHandleFill: '#ffffff',
|
endpointHandleFill: '#fff7ed',
|
||||||
endpointHandleStroke: '#71717a',
|
endpointHandleStroke: '#c2410c',
|
||||||
endpointHandleHoverStroke: '#52525b',
|
endpointHandleHoverStroke: '#fb923c',
|
||||||
endpointHandleActiveFill: '#8381ed',
|
endpointHandleActiveFill: '#fff7ed',
|
||||||
endpointHandleActiveStroke: '#8381ed',
|
endpointHandleActiveStroke: '#f97316',
|
||||||
|
curveHandleFill: '#ccfbf1',
|
||||||
|
curveHandleStroke: '#0f766e',
|
||||||
|
curveHandleHoverStroke: '#14b8a6',
|
||||||
},
|
},
|
||||||
[theme],
|
[theme],
|
||||||
)
|
)
|
||||||
|
const wallSelectionHatchId = useMemo(() => `floorplan-wall-selection-hatch-${theme}`, [theme])
|
||||||
const gridSteps = useMemo(
|
const gridSteps = useMemo(
|
||||||
() => getVisibleGridSteps(viewBox.width, surfaceSize.width),
|
() => getVisibleGridSteps(viewBox.width, surfaceSize.width),
|
||||||
[surfaceSize.width, viewBox.width],
|
[surfaceSize.width, viewBox.width],
|
||||||
@@ -8087,66 +8270,19 @@ export function FloorplanPanel() {
|
|||||||
const getFloorplanHitIdAtPoint = useCallback(
|
const getFloorplanHitIdAtPoint = useCallback(
|
||||||
(planPoint: WallPlanPoint) => {
|
(planPoint: WallPlanPoint) => {
|
||||||
const point = toPoint2D(planPoint)
|
const point = toPoint2D(planPoint)
|
||||||
|
return getFloorplanHitNodeId({
|
||||||
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,
|
point,
|
||||||
[centerLine.start.x, centerLine.start.y],
|
phase,
|
||||||
[centerLine.end.x, centerLine.end.y],
|
isItemContextActive: isFloorplanItemContextActive,
|
||||||
) <= floorplanOpeningHitTolerance
|
items: floorplanItemEntries,
|
||||||
)
|
openings: openingsPolygons,
|
||||||
|
stairs: floorplanStairEntries,
|
||||||
|
walls: displayWallPolygons,
|
||||||
|
slabs: displaySlabPolygons,
|
||||||
|
openingHitTolerance: floorplanOpeningHitTolerance,
|
||||||
|
wallHitTolerance: floorplanWallHitTolerance,
|
||||||
|
getOpeningCenterLine,
|
||||||
})
|
})
|
||||||
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()
|
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
displaySlabPolygons,
|
displaySlabPolygons,
|
||||||
@@ -8162,34 +8298,17 @@ export function FloorplanPanel() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const getFloorplanSelectionIdsInBounds = useCallback(
|
const getFloorplanSelectionIdsInBounds = useCallback(
|
||||||
(bounds: FloorplanSelectionBounds) => {
|
(bounds: FloorplanSelectionBounds) =>
|
||||||
const itemIds = isFloorplanItemContextActive
|
getSelectionIdsInBoundsFromTool({
|
||||||
? floorplanItemEntries
|
bounds,
|
||||||
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
|
phase,
|
||||||
.map(({ item }) => item.id)
|
isItemContextActive: isFloorplanItemContextActive,
|
||||||
: []
|
items: floorplanItemEntries,
|
||||||
|
walls: displayWallPolygons,
|
||||||
if (phase !== 'structure') {
|
openings: openingsPolygons,
|
||||||
return itemIds
|
slabs: displaySlabPolygons,
|
||||||
}
|
stairs: floorplanStairEntries,
|
||||||
|
}),
|
||||||
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]))
|
|
||||||
},
|
|
||||||
[
|
[
|
||||||
displaySlabPolygons,
|
displaySlabPolygons,
|
||||||
displayWallPolygons,
|
displayWallPolygons,
|
||||||
@@ -9835,11 +9954,14 @@ export function FloorplanPanel() {
|
|||||||
onDuplicateSelected={handleDuplicateFloorplanSelection}
|
onDuplicateSelected={handleDuplicateFloorplanSelection}
|
||||||
/>
|
/>
|
||||||
<div className="relative min-h-0 flex-1" ref={viewportHostRef}>
|
<div className="relative min-h-0 flex-1" ref={viewportHostRef}>
|
||||||
<FloorplanCursorIndicatorOverlay
|
<Editor2dFloorplanCursorIndicatorOverlay
|
||||||
cursorAnchorPosition={floorplanCursorAnchorPosition}
|
cursorAnchorPosition={floorplanCursorAnchorPosition}
|
||||||
cursorColor={floorplanCursorColor}
|
cursorColor={floorplanCursorColor}
|
||||||
cursorPosition={floorplanCursorPosition}
|
cursorPosition={floorplanCursorPosition}
|
||||||
floorplanSelectionTool={floorplanSelectionTool}
|
floorplanSelectionTool={floorplanSelectionTool}
|
||||||
|
indicatorBadgeOffsetX={FLOORPLAN_CURSOR_BADGE_OFFSET_X}
|
||||||
|
indicatorBadgeOffsetY={FLOORPLAN_CURSOR_BADGE_OFFSET_Y}
|
||||||
|
indicatorLineHeight={FLOORPLAN_CURSOR_INDICATOR_LINE_HEIGHT}
|
||||||
isPanning={isPanning}
|
isPanning={isPanning}
|
||||||
movingOpeningType={movingOpeningType}
|
movingOpeningType={movingOpeningType}
|
||||||
/>
|
/>
|
||||||
@@ -9851,7 +9973,7 @@ export function FloorplanPanel() {
|
|||||||
rotationModifierPressed={rotationModifierPressed}
|
rotationModifierPressed={rotationModifierPressed}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<FloorplanActionMenuLayer
|
<Editor2dFloorplanActionMenuLayer
|
||||||
ceiling={{
|
ceiling={{
|
||||||
position: selectedCeilingActionMenuPosition,
|
position: selectedCeilingActionMenuPosition,
|
||||||
onDelete: handleSelectedCeilingDelete,
|
onDelete: handleSelectedCeilingDelete,
|
||||||
@@ -9885,6 +10007,7 @@ export function FloorplanPanel() {
|
|||||||
onDelete: handleSelectedWallDelete,
|
onDelete: handleSelectedWallDelete,
|
||||||
onMove: handleSelectedWallMove,
|
onMove: handleSelectedWallMove,
|
||||||
}}
|
}}
|
||||||
|
offsetY={FLOORPLAN_ACTION_MENU_OFFSET_Y}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!levelNode || levelNode.type !== 'level' ? (
|
{!levelNode || levelNode.type !== 'level' ? (
|
||||||
@@ -9906,6 +10029,26 @@ export function FloorplanPanel() {
|
|||||||
style={{ cursor: EDITOR_CURSOR }}
|
style={{ cursor: EDITOR_CURSOR }}
|
||||||
viewBox={`${viewBox.minX} ${viewBox.minY} ${viewBox.width} ${viewBox.height}`}
|
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
|
<rect
|
||||||
fill={palette.surface}
|
fill={palette.surface}
|
||||||
height={viewBox.height}
|
height={viewBox.height}
|
||||||
@@ -9959,6 +10102,7 @@ export function FloorplanPanel() {
|
|||||||
slabPolygons={displaySlabPolygons}
|
slabPolygons={displaySlabPolygons}
|
||||||
unit={unit}
|
unit={unit}
|
||||||
wallPolygons={displayWallPolygons}
|
wallPolygons={displayWallPolygons}
|
||||||
|
wallSelectionHatchId={wallSelectionHatchId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FloorplanZoneLayer
|
<FloorplanZoneLayer
|
||||||
@@ -10047,96 +10191,27 @@ export function FloorplanPanel() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{visibleSvgMarqueeBounds && (
|
<FloorplanMarqueeLayer
|
||||||
<>
|
bounds={visibleSvgMarqueeBounds}
|
||||||
<rect
|
cursorColor={palette.cursor}
|
||||||
fill={palette.cursor}
|
glowWidth={FLOORPLAN_MARQUEE_GLOW_WIDTH}
|
||||||
fillOpacity={0.12}
|
outlineWidth={FLOORPLAN_MARQUEE_OUTLINE_WIDTH}
|
||||||
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}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{draftPolygon && (
|
<FloorplanDraftLayer
|
||||||
<polygon
|
anchorFill={palette.anchor}
|
||||||
fill={palette.draftFill}
|
draftAnchorPoints={activePolygonDraftPoints.map((point, index) => ({
|
||||||
fillOpacity={0.35}
|
x: toSvgX(point[0]),
|
||||||
points={draftPolygonPoints ?? undefined}
|
y: toSvgY(point[1]),
|
||||||
stroke={palette.draftStroke}
|
isPrimary: index === 0,
|
||||||
strokeDasharray="0.24 0.12"
|
}))}
|
||||||
strokeWidth="0.07"
|
draftFill={palette.draftFill}
|
||||||
vectorEffect="non-scaling-stroke"
|
draftPolygonPoints={draftPolygonPoints}
|
||||||
|
draftStroke={palette.draftStroke}
|
||||||
|
polygonDraftClosingSegment={polygonDraftClosingSegment}
|
||||||
|
polygonDraftPolygonPoints={polygonDraftPolygonPoints}
|
||||||
|
polygonDraftPolylinePoints={polygonDraftPolylinePoints}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{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"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<FloorplanWallEndpointLayer
|
<FloorplanWallEndpointLayer
|
||||||
endpointHandles={wallEndpointHandles}
|
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]))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user