import { type Circle, Point, Segment } from '@flatten-js/core'; import { compact } from 'es-toolkit'; import { ArcEntity } from '../entities/ArcEntity'; import type { CircleEntity } from '../entities/CircleEntity'; import type { Entity } from '../entities/Entity'; import { LineEntity } from '../entities/LineEntity'; import { findClosestEntity } from '../helpers/find-closest-entity'; import { findNeighboringPointsOnArc } from '../helpers/find-neighboring-points-on-arc'; import { findNeighboringPointsOnCircle } from '../helpers/find-neighboring-points-on-circle'; import { findNeighboringPointsOnLine } from '../helpers/find-neighboring-points-on-line'; import { sampleEntityPoints } from '../helpers/geometry/sample-entity'; import { intersectLines } from '../helpers/geometry/shape-points'; import { getAngleWithXAxis } from '../helpers/get-angle-with-x-axis.ts'; import { isPointEqual } from '../helpers/is-point-equal'; import { pickRadius } from '../helpers/pick-radius'; import { queryEntitiesNearPoint } from '../helpers/spatial-index'; import { addEntities, deleteEntities, getActiveLayerId, getLayerById } from '../state'; import { copyStyle, isBetween } from './modify/modify.helpers'; /** 누른 자리 집기 반경 안 가장 가까운 객체 — 잠금 도면층 뺌(순서 엔진과 같은 셈) */ export function pickEntityAt(point: Point): Entity | null { const radius = pickRadius(); const candidates = queryEntitiesNearPoint(point.x, point.y, radius).filter( (entity) => !getLayerById(entity.layerId)?.isLocked ); const { distance, entity } = findClosestEntity(point, candidates); return entity && distance <= radius ? entity : null; } /** 점을 객체 위로 내림(가장 가까운 자리) */ export const closestPointOn = (entity: Entity, point: Point): Point => findClosestEntity(point, [entity]).segment?.start ?? point; /** EXTEND 빠른 모드 — 누른 쪽 끝을 그 방향 가장 가까운 경계(다른 모든 객체)까지 · 선만 */ export function extendLineEnd(line: Entity, pick: Point, boundaries: Entity[]): Entity | null { const shape = line.getShape(); if (!(shape instanceof Segment)) return null; const fromStart = pick.distanceTo(shape.start)[0] < pick.distanceTo(shape.end)[0]; const tip = fromStart ? shape.start : shape.end; const tail = fromStart ? shape.end : shape.start; const length = tail.distanceTo(tip)[0]; if (!length) return null; const dx = (tip.x - tail.x) / length; const dy = (tip.y - tail.y) / length; let best = Number.POSITIVE_INFINITY; let hit: Point | null = null; for (const boundary of boundaries) { if (boundary.id === line.id) continue; const points = sampleEntityPoints(boundary); for (let index = 1; index < points.length; index++) { const crossing = intersectLines(tail, tip, points[index - 1], points[index]); if (!crossing || !isBetween(crossing, points[index - 1], points[index])) continue; const ahead = (crossing.x - tip.x) * dx + (crossing.y - tip.y) * dy; if (ahead > 1e-9 && ahead < best) { best = ahead; hit = crossing; } } } if (!hit) return null; return copyStyle( line, new LineEntity(line.layerId, fromStart ? hit : tail, fromStart ? tail : hit) ); } export function getAllIntersectionPoints(entity: Entity, entities: Entity[]): Point[] { // TODO see if we need to make this list unique return compact( entities .filter((otherEntity) => otherEntity.id !== entity.id) .flatMap((otherEntity) => { if (entity.id === otherEntity.id) { return null; } return entity.getIntersections(otherEntity); }) ); } export function eraseLineSegment( line: LineEntity, clickedPointOnShape: Point, intersections: Point[] ): void { const segment = line.getShape() as Segment; const [firstCutPoint, secondCutPoint] = findNeighboringPointsOnLine( clickedPointOnShape, segment.start, segment.end, intersections ); const cutLines: Entity[] = line.cutAtPoints([firstCutPoint, secondCutPoint]); // Remove the segment that has the clickedPointOnShape point on it — 남는 토막은 원래 층 · 꼴(N9) const remainingLines = cutLines .filter((piece) => !piece.containsPointOnShape(clickedPointOnShape)) .map((piece) => copyStyle(line, piece)); deleteEntities([line], false); // Helper functions should never trigger an undo state, since they can be called multiple times during one user operation addEntities(remainingLines, false); } export function eraseCircleSegment( circle: CircleEntity, clickedPointOnShape: Point, intersections: Point[] ): void { if (intersections.length === 0) { deleteEntities([circle], false); return; } const [firstCutPoint, secondCutPoint] = findNeighboringPointsOnCircle( clickedPointOnShape, circle, intersections ); const circleShape = circle.getShape() as Circle; const center = circleShape.center; const angles = [firstCutPoint, secondCutPoint, clickedPointOnShape].map((p) => getAngleWithXAxis(new Point(center.x, center.y), new Point(p.x, p.y)) ); const [startAngle, endAngle] = isAngleBetween(angles[2], angles[0], angles[1]) ? [angles[1], angles[0]] : [angles[0], angles[1]]; const newArc = new ArcEntity( getActiveLayerId(), center, circleShape.r, startAngle, endAngle, true ); Object.assign(newArc, { lineColor: circle.lineColor, lineWidth: circle.lineWidth, }); deleteEntities([circle], false); // Helper functions should never trigger an undo state, since they can be called multiple times during one user operation addEntities([newArc], false); } function isAngleBetween(angle: number, start: number, end: number): boolean { const twoPi = 2 * Math.PI; return (angle - start + twoPi) % twoPi <= (end - start + twoPi) % twoPi; } export function eraseArcSegment( arc: ArcEntity, clickedPointOnShape: Point, intersections: Point[] ): void { const [first, second] = findNeighboringPointsOnArc(clickedPointOnShape, arc, intersections); if (isPointEqual(first, second)) { deleteEntities([arc], true); return; } const newArcs = arc .cutAtPoints([first, second]) .filter((cutArc) => !cutArc.containsPointOnShape(clickedPointOnShape)); for (const newArc of newArcs) { Object.assign(newArc, { lineColor: arc.lineColor, lineWidth: arc.lineWidth, }); addEntities([newArc], false); } // Helper functions should never trigger an undo state, since they can be called multiple times during one user operation deleteEntities([arc], false); }