264 lines
7.2 KiB
TypeScript
264 lines
7.2 KiB
TypeScript
import type { Point2D } from '@pascal-app/core'
|
|
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
|
|
}
|