feat(B07): CAD를 AutoCAD 명령 체계로 재구성한다 (조사표 1·2·3·5절)
저장소 사고로 잃은 4개 커밋(38eb7cd4·b8a11756·281528bf·f60b488d)의 작업물을 하나로 다시 담았다. 내용은 동일하다. ■ 구조 - commands/: 명령 정의(id·AutoCAD 별칭·글리프·도구/즉시실행)를 단일 소스로 두고 리본·명령행·단축키가 모두 이 레지스트리를 읽는다. tools/tool.consts.ts 폐지. - ribbon/: 탭→패널→명령 데이터(ribbon.config.ts)와 범용 렌더러 분리. AutoCAD 배치(홈·삽입·주석·뷰·출력)와 패널 확장(▾)을 따른다. - tools/factories/sequence-tool.ts: 점·숫자·문자·객체·선택 단계를 선언하면 xstate 머신을 만들어 주는 공장. 명령당 170줄 보일러플레이트 제거. - helpers/geometry/: 3점 호·정다각형·타원·스플라인·구름형·평행이동·해치 스캔선· 점렬 샘플링 등 상태 없는 순수 함수. - Toolbar 483줄을 QuickAccessBar/Ribbon/InspectorPanel/StatusBar/CommandLine/ ViewControls/PropertiesEditor/QuickProperties로 분해. ■ 명령 (조사표 기준) - 1절 그리기 21건, 2절 수정 27건, 3절 도면층·특성·그룹·유틸리티 39건 전부 반영. - 5절 주석 34건 중 26건 반영(문자·치수 16종·지시선·표·구름형·주석 축척). - HatchEntity 추가(solid·pattern·cross·gradient) + JSON 왕복, Layer에 색·선가중치· 선종류·동결·투명도 필드 추가, 그리기 루프가 동결·숨김·투명도를 반영. ■ 화면 실측에서 고친 결함 - 명령행 포커스 상태에서 ENTER가 도구로 가지 않던 문제 - 명령행 문자 입력이 접두사가 같은 명령으로 실행되던 문제 - 명령이 끝나도 입력을 계속 먹던 문제(점 입력 명령만 반복, 나머지는 선택 도구 복귀) - 시퀀스 단계 인덱스 오사용 9건 + 단계 값 종류 검사 추가 - 해치 내부를 클릭해도 선택되지 않던 문제 미반영 8건(맞춤법 검사·꺾기 치수·치수 끊기/재연관/검사 치수·기하공차·지시선 수집· 축척 리스트 편집)은 조사표 `반영` 열과 PLAN.md에 사유를 적었다.
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 내부 클립보드 — 잘라내기·복사·붙여넣기(기준점 포함).
|
||||
* 시스템 클립보드는 도면 객체를 담을 수 없어 앱 안에서만 오간다.
|
||||
*/
|
||||
import { Point } from '@flatten-js/core';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
import { getBoundingBoxOfMultipleEntities } from './get-bounding-box-of-multiple-entities';
|
||||
|
||||
interface ClipboardContent {
|
||||
entities: Entity[];
|
||||
/** 붙여넣기 기준점 — 지정하지 않으면 선택 영역의 좌하단 */
|
||||
basePoint: Point;
|
||||
}
|
||||
|
||||
let clipboard: ClipboardContent | null = null;
|
||||
|
||||
export const hasClipboardContent = (): boolean => !!clipboard?.entities.length;
|
||||
|
||||
export function copyToClipboard(entities: Entity[], basePoint?: Point): number {
|
||||
if (!entities.length) return 0;
|
||||
const box = getBoundingBoxOfMultipleEntities(entities);
|
||||
clipboard = {
|
||||
entities: entities.map((entity) => {
|
||||
const copy = entity.clone();
|
||||
copy.lineColor = entity.lineColor;
|
||||
copy.lineWidth = entity.lineWidth;
|
||||
copy.lineDash = entity.lineDash;
|
||||
copy.layerId = entity.layerId;
|
||||
return copy;
|
||||
}),
|
||||
basePoint: basePoint ?? new Point(box.minX, box.minY),
|
||||
};
|
||||
return clipboard.entities.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 클립보드 내용을 새 객체로 꺼낸다.
|
||||
* target을 주면 기준점이 그 위치에 오도록 옮기고, 없으면 원래 좌표 그대로 둔다.
|
||||
*/
|
||||
export function pasteFromClipboard(target?: Point): Entity[] {
|
||||
if (!clipboard) return [];
|
||||
return clipboard.entities.map((entity) => {
|
||||
const copy = entity.clone();
|
||||
copy.lineColor = entity.lineColor;
|
||||
copy.lineWidth = entity.lineWidth;
|
||||
copy.lineDash = entity.lineDash;
|
||||
copy.layerId = entity.layerId;
|
||||
if (target) {
|
||||
copy.move(target.x - (clipboard as ClipboardContent).basePoint.x, target.y - (clipboard as ClipboardContent).basePoint.y);
|
||||
}
|
||||
return copy;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 화면 검증용 상태 조회 창구 — 브라우저 콘솔·자동화 스크립트가
|
||||
* window.__aisloCad로 현재 객체·도면층·선택을 읽는다. 앱 동작에는 관여하지 않는다.
|
||||
*/
|
||||
import { getEntities, getLayers, getSelectedEntityIds } from '../state';
|
||||
import { isEntityHidden } from './visibility';
|
||||
|
||||
export function registerCadDebugHook(): void {
|
||||
(window as unknown as Record<string, unknown>).__aisloCad = {
|
||||
entities: () =>
|
||||
getEntities().map((entity) => {
|
||||
const box = entity.getBoundingBox();
|
||||
return {
|
||||
id: entity.id,
|
||||
type: entity.getType(),
|
||||
layerId: entity.layerId,
|
||||
groupId: entity.groupId ?? null,
|
||||
opacity: entity.opacity ?? null,
|
||||
hidden: isEntityHidden(entity),
|
||||
color: entity.lineColor,
|
||||
box: [
|
||||
Math.round(box.xmin),
|
||||
Math.round(box.ymin),
|
||||
Math.round(box.xmax),
|
||||
Math.round(box.ymax),
|
||||
],
|
||||
};
|
||||
}),
|
||||
layers: () => getLayers(),
|
||||
selection: () => getSelectedEntityIds(),
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type {DrawController} from '../drawControllers/DrawController';
|
||||
import type {ScreenCanvasDrawController} from '../drawControllers/screenCanvas.drawController';
|
||||
import type {Entity} from '../entities/Entity';
|
||||
import {getLayerById, isEntityHighlighted, isEntitySelected} from '../state';
|
||||
import {isEntityHidden} from './visibility';
|
||||
import {toast} from 'react-toastify';
|
||||
|
||||
export function drawEntities(drawController: DrawController, entities: Entity[]) {
|
||||
@@ -15,9 +16,13 @@ export function drawEntities(drawController: DrawController, entities: Entity[])
|
||||
console.error('Failed to find layer for entity: ', entity);
|
||||
continue;
|
||||
}
|
||||
if (!layer?.isVisible) {
|
||||
continue; // Layer not visible, skip drawing
|
||||
if (!layer?.isVisible || layer.isFrozen) {
|
||||
continue; // 꺼졌거나 동결된 도면층은 그리지 않는다
|
||||
}
|
||||
if (isEntityHidden(entity)) {
|
||||
continue; // ISOLATEOBJECTS·HIDEOBJECTS로 숨긴 객체
|
||||
}
|
||||
drawController.setOpacity(entity.opacity ?? layer.opacity ?? 1);
|
||||
drawController.setLineStyles(
|
||||
isEntityHighlighted(entity),
|
||||
isEntitySelected(entity),
|
||||
@@ -26,6 +31,7 @@ export function drawEntities(drawController: DrawController, entities: Entity[])
|
||||
[]
|
||||
);
|
||||
entity.draw(drawController);
|
||||
drawController.setOpacity(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* GROUP으로 묶인 객체를 함께 선택하기 위한 확장.
|
||||
* 선택 도구가 부르는 자리라 도구 모듈을 import하지 않는다 (순환 참조 방지).
|
||||
*/
|
||||
import { getEntities } from '../state';
|
||||
|
||||
export function expandSelectionWithGroups(entityIds: string[]): string[] {
|
||||
const entities = getEntities();
|
||||
const ids = new Set(entityIds);
|
||||
const selectedGroups = new Set(
|
||||
entities
|
||||
.filter((entity) => ids.has(entity.id) && entity.groupId)
|
||||
.map((entity) => entity.groupId as string)
|
||||
);
|
||||
if (!selectedGroups.size) return entityIds;
|
||||
|
||||
const expanded = new Set(entityIds);
|
||||
for (const entity of entities) {
|
||||
if (entity.groupId && selectedGroups.has(entity.groupId)) expanded.add(entity.id);
|
||||
}
|
||||
return [...expanded];
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 선택한 객체들을 하나의 닫힌 경계(점렬)로 잇는다.
|
||||
* HATCH·BOUNDARY·REGION·WIPEOUT이 공유한다.
|
||||
*/
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { dedupeConsecutive, sampleEntityPoints } from './sample-entity';
|
||||
|
||||
const DEFAULT_TOLERANCE = 1e-3;
|
||||
|
||||
const near = (a: Point, b: Point, tolerance: number): boolean =>
|
||||
Math.abs(a.x - b.x) <= tolerance && Math.abs(a.y - b.y) <= tolerance;
|
||||
|
||||
/**
|
||||
* 끝점이 맞닿는 순서대로 이어 붙여 경계를 만든다.
|
||||
* 이미 닫힌 객체(원·사각형) 하나만 골랐다면 그 점렬을 그대로 돌려준다.
|
||||
*/
|
||||
export function entitiesToLoop(entities: Entity[], tolerance = DEFAULT_TOLERANCE): Point[] {
|
||||
const chains = entities
|
||||
.map((entity) => sampleEntityPoints(entity))
|
||||
.filter((points) => points.length >= 2);
|
||||
if (!chains.length) return [];
|
||||
if (chains.length === 1) return closeLoop(chains[0], tolerance);
|
||||
|
||||
const remaining = [...chains];
|
||||
let loop = remaining.shift() as Point[];
|
||||
|
||||
while (remaining.length) {
|
||||
const tail = loop[loop.length - 1];
|
||||
const index = remaining.findIndex(
|
||||
(chain) =>
|
||||
near(chain[0], tail, tolerance) || near(chain[chain.length - 1], tail, tolerance)
|
||||
);
|
||||
if (index === -1) break; // 끊긴 경계 — 여기까지만 잇는다
|
||||
const [chain] = remaining.splice(index, 1);
|
||||
const ordered = near(chain[0], tail, tolerance) ? chain : [...chain].reverse();
|
||||
loop = dedupeConsecutive([...loop, ...ordered]);
|
||||
}
|
||||
|
||||
return closeLoop(loop, tolerance);
|
||||
}
|
||||
|
||||
/** 시작점과 끝점이 떨어져 있으면 닫는다 */
|
||||
export function closeLoop(points: Point[], tolerance = DEFAULT_TOLERANCE): Point[] {
|
||||
const cleaned = dedupeConsecutive(points);
|
||||
if (cleaned.length < 3) return cleaned;
|
||||
const first = cleaned[0];
|
||||
const last = cleaned[cleaned.length - 1];
|
||||
return near(first, last, tolerance) ? cleaned : [...cleaned, first.clone()];
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/** 해치·그라데이션이 쓰는 스캔선 계산 — 상태 없는 순수 함수. */
|
||||
import { Point } from '@flatten-js/core';
|
||||
|
||||
export type Span = [Point, Point];
|
||||
|
||||
const rotate = (point: Point, cos: number, sin: number): Point =>
|
||||
new Point(point.x * cos - point.y * sin, point.x * sin + point.y * cos);
|
||||
|
||||
/**
|
||||
* 닫힌 다각형 내부를 angle 방향 평행선으로 채울 때의 선분 목록.
|
||||
* 오목한 다각형도 스캔선 교차를 짝지어 처리한다.
|
||||
*/
|
||||
export function hatchSpans(polygon: Point[], angleRad: number, spacing: number): Span[] {
|
||||
if (polygon.length < 3 || spacing <= 0) return [];
|
||||
|
||||
const cos = Math.cos(-angleRad);
|
||||
const sin = Math.sin(-angleRad);
|
||||
const rotated = polygon.map((point) => rotate(point, cos, sin));
|
||||
|
||||
const minY = Math.min(...rotated.map((point) => point.y));
|
||||
const maxY = Math.max(...rotated.map((point) => point.y));
|
||||
if (!Number.isFinite(minY) || !Number.isFinite(maxY)) return [];
|
||||
|
||||
// 스캔선 수 상한 — 축척이 클 때 무한 루프처럼 보이는 것을 막는다
|
||||
const lineCount = Math.floor((maxY - minY) / spacing);
|
||||
if (lineCount > 5000) return [];
|
||||
|
||||
const backCos = Math.cos(angleRad);
|
||||
const backSin = Math.sin(angleRad);
|
||||
const spans: Span[] = [];
|
||||
|
||||
for (let y = minY + spacing / 2; y < maxY; y += spacing) {
|
||||
const crossings: number[] = [];
|
||||
for (let index = 0; index < rotated.length; index++) {
|
||||
const start = rotated[index];
|
||||
const end = rotated[(index + 1) % rotated.length];
|
||||
if (start.y === end.y) continue;
|
||||
const low = Math.min(start.y, end.y);
|
||||
const high = Math.max(start.y, end.y);
|
||||
if (y < low || y >= high) continue;
|
||||
const ratio = (y - start.y) / (end.y - start.y);
|
||||
crossings.push(start.x + (end.x - start.x) * ratio);
|
||||
}
|
||||
crossings.sort((a, b) => a - b);
|
||||
for (let index = 0; index + 1 < crossings.length; index += 2) {
|
||||
spans.push([
|
||||
rotate(new Point(crossings[index], y), backCos, backSin),
|
||||
rotate(new Point(crossings[index + 1], y), backCos, backSin),
|
||||
]);
|
||||
}
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
/** 점이 닫힌 다각형 안에 있는가 (홀수 교차 판정) */
|
||||
export function isPointInPolygon(polygon: Point[], point: Point): boolean {
|
||||
let inside = false;
|
||||
for (let index = 0, previous = polygon.length - 1; index < polygon.length; previous = index++) {
|
||||
const a = polygon[index];
|
||||
const b = polygon[previous];
|
||||
const crosses = a.y > point.y !== b.y > point.y;
|
||||
if (crosses && point.x < ((b.x - a.x) * (point.y - a.y)) / (b.y - a.y) + a.x) {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 엔티티를 점렬(폴리라인)로 펴는 순수 함수들.
|
||||
* 등분(DIVIDE)·길이분할(MEASURE)·경계(BOUNDARY)·해치가 모두 이 표현을 공유한다.
|
||||
*/
|
||||
import { Arc, Circle, Point, Polygon, Segment } from '@flatten-js/core';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { EntityName } from '../../entities/Entity';
|
||||
import type { PolyLineEntity } from '../../entities/PolyLineEntity';
|
||||
import { polygonToSegments } from '../polygon-to-segments';
|
||||
|
||||
const CURVE_SEGMENTS = 64;
|
||||
|
||||
/** 두 점이 사실상 같은 위치인지 (좌표 오차 허용) */
|
||||
const samePoint = (a: Point, b: Point, tolerance = 1e-6): boolean =>
|
||||
Math.abs(a.x - b.x) <= tolerance && Math.abs(a.y - b.y) <= tolerance;
|
||||
|
||||
function sampleArc(arc: Arc, segments: number): Point[] {
|
||||
const points: Point[] = [];
|
||||
const sweep = arc.sweep * (arc.counterClockwise ? 1 : -1);
|
||||
// flatten-js 타입 선언이 반지름을 Number 객체로 잡아 두어 숫자로 되돌린다
|
||||
const radius = Number(arc.r);
|
||||
for (let index = 0; index <= segments; index++) {
|
||||
const angle = arc.startAngle + (sweep * index) / segments;
|
||||
points.push(
|
||||
new Point(arc.center.x + radius * Math.cos(angle), arc.center.y + radius * Math.sin(angle))
|
||||
);
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
function sampleCircle(circle: Circle, segments: number): Point[] {
|
||||
const points: Point[] = [];
|
||||
for (let index = 0; index <= segments; index++) {
|
||||
const angle = (2 * Math.PI * index) / segments;
|
||||
points.push(
|
||||
new Point(circle.center.x + circle.r * Math.cos(angle), circle.center.y + circle.r * Math.sin(angle))
|
||||
);
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
/** 이어지는 중복점을 제거한다 (폴리선 이음매에서 생긴다) */
|
||||
export function dedupeConsecutive(points: Point[]): Point[] {
|
||||
return points.filter((point, index) => index === 0 || !samePoint(point, points[index - 1]));
|
||||
}
|
||||
|
||||
/** 엔티티 하나를 점렬로 편다. 곡선은 curveSegments 등분해 근사한다. */
|
||||
export function sampleEntityPoints(entity: Entity, curveSegments = CURVE_SEGMENTS): Point[] {
|
||||
if (entity.getType() === EntityName.PolyLine) {
|
||||
const children = (entity as PolyLineEntity).getEntities();
|
||||
return dedupeConsecutive(
|
||||
children.flatMap((child) => sampleEntityPoints(child, curveSegments))
|
||||
);
|
||||
}
|
||||
|
||||
const shape = entity.getShape();
|
||||
if (shape instanceof Segment) return [shape.start, shape.end];
|
||||
if (shape instanceof Arc) return sampleArc(shape, curveSegments);
|
||||
if (shape instanceof Circle) return sampleCircle(shape, curveSegments);
|
||||
if (shape instanceof Polygon) {
|
||||
const segments = polygonToSegments(shape);
|
||||
return dedupeConsecutive([
|
||||
...segments.map((segment) => segment.start),
|
||||
...(segments.length ? [segments[segments.length - 1].end] : []),
|
||||
]);
|
||||
}
|
||||
if (shape instanceof Point) return [shape];
|
||||
return [];
|
||||
}
|
||||
|
||||
/** 점렬의 누적 길이 */
|
||||
export function polylineLengths(points: Point[]): number[] {
|
||||
const lengths = [0];
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
lengths.push(lengths[index - 1] + points[index - 1].distanceTo(points[index])[0]);
|
||||
}
|
||||
return lengths;
|
||||
}
|
||||
|
||||
export const polylineLength = (points: Point[]): number => {
|
||||
const lengths = polylineLengths(points);
|
||||
return lengths[lengths.length - 1] ?? 0;
|
||||
};
|
||||
|
||||
/** 시작점에서 distance만큼 진행한 위치 (길이를 넘으면 null) */
|
||||
export function pointAtDistance(points: Point[], distance: number): Point | null {
|
||||
if (points.length < 2) return null;
|
||||
const lengths = polylineLengths(points);
|
||||
const total = lengths[lengths.length - 1];
|
||||
if (distance < 0 || distance > total) return null;
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
if (lengths[index] >= distance) {
|
||||
const segmentLength = lengths[index] - lengths[index - 1];
|
||||
const ratio = segmentLength === 0 ? 0 : (distance - lengths[index - 1]) / segmentLength;
|
||||
const start = points[index - 1];
|
||||
const end = points[index];
|
||||
return new Point(start.x + (end.x - start.x) * ratio, start.y + (end.y - start.y) * ratio);
|
||||
}
|
||||
}
|
||||
return points[points.length - 1];
|
||||
}
|
||||
|
||||
/** DIVIDE — 객체를 count 등분하는 내부 점 (count-1개) */
|
||||
export function dividePoints(points: Point[], count: number): Point[] {
|
||||
if (count < 2) return [];
|
||||
const total = polylineLength(points);
|
||||
const result: Point[] = [];
|
||||
for (let index = 1; index < count; index++) {
|
||||
const point = pointAtDistance(points, (total * index) / count);
|
||||
if (point) result.push(point);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** MEASURE — 시작점에서 spacing 간격마다 찍는 점 */
|
||||
export function measurePoints(points: Point[], spacing: number): Point[] {
|
||||
if (spacing <= 0) return [];
|
||||
const total = polylineLength(points);
|
||||
const result: Point[] = [];
|
||||
for (let distance = spacing; distance <= total + 1e-9; distance += spacing) {
|
||||
const point = pointAtDistance(points, Math.min(distance, total));
|
||||
if (point) result.push(point);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* 점 몇 개로 정의되는 도형의 좌표 계산 — 순수 함수.
|
||||
* 정다각형·타원·스플라인·구름형처럼 폴리선으로 근사해 그리는 도형이 여기서 나온다.
|
||||
*/
|
||||
import { Point } from '@flatten-js/core';
|
||||
|
||||
export interface ArcDefinition {
|
||||
center: Point;
|
||||
radius: number;
|
||||
startAngle: number;
|
||||
endAngle: number;
|
||||
counterClockwise: boolean;
|
||||
}
|
||||
|
||||
/** 3점(시작·통과·끝)을 지나는 호. 세 점이 일직선이면 null */
|
||||
export function arcThroughThreePoints(
|
||||
start: Point,
|
||||
through: Point,
|
||||
end: Point
|
||||
): ArcDefinition | null {
|
||||
const ax = start.x;
|
||||
const ay = start.y;
|
||||
const bx = through.x;
|
||||
const by = through.y;
|
||||
const cx = end.x;
|
||||
const cy = end.y;
|
||||
|
||||
const d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
|
||||
if (Math.abs(d) < 1e-9) return null; // 일직선
|
||||
|
||||
const ux =
|
||||
((ax * ax + ay * ay) * (by - cy) +
|
||||
(bx * bx + by * by) * (cy - ay) +
|
||||
(cx * cx + cy * cy) * (ay - by)) /
|
||||
d;
|
||||
const uy =
|
||||
((ax * ax + ay * ay) * (cx - bx) +
|
||||
(bx * bx + by * by) * (ax - cx) +
|
||||
(cx * cx + cy * cy) * (bx - ax)) /
|
||||
d;
|
||||
|
||||
const center = new Point(ux, uy);
|
||||
const radius = center.distanceTo(start)[0];
|
||||
const startAngle = Math.atan2(ay - uy, ax - ux);
|
||||
const throughAngle = Math.atan2(by - uy, bx - ux);
|
||||
const endAngle = Math.atan2(cy - uy, cx - ux);
|
||||
|
||||
// 통과점이 시작→끝 사이에 오도록 회전 방향을 고른다
|
||||
const counterClockwise = isAngleBetween(throughAngle, startAngle, endAngle, true);
|
||||
return { center, radius, startAngle, endAngle, counterClockwise };
|
||||
}
|
||||
|
||||
/** 반시계(또는 시계) 방향으로 start에서 end로 갈 때 angle을 지나는가 */
|
||||
export function isAngleBetween(
|
||||
angle: number,
|
||||
start: number,
|
||||
end: number,
|
||||
counterClockwise: boolean
|
||||
): boolean {
|
||||
const normalize = (value: number) => ((value % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
|
||||
const sweep = counterClockwise ? normalize(end - start) : normalize(start - end);
|
||||
const offset = counterClockwise ? normalize(angle - start) : normalize(start - angle);
|
||||
return offset <= sweep;
|
||||
}
|
||||
|
||||
/** 정다각형 — 중심과 첫 꼭짓점, 변 수 */
|
||||
export function regularPolygonPoints(center: Point, vertex: Point, sides: number): Point[] {
|
||||
const count = Math.max(3, Math.round(sides));
|
||||
const radius = center.distanceTo(vertex)[0];
|
||||
const startAngle = Math.atan2(vertex.y - center.y, vertex.x - center.x);
|
||||
const points: Point[] = [];
|
||||
for (let index = 0; index < count; index++) {
|
||||
const angle = startAngle + (2 * Math.PI * index) / count;
|
||||
points.push(new Point(center.x + radius * Math.cos(angle), center.y + radius * Math.sin(angle)));
|
||||
}
|
||||
points.push(points[0].clone());
|
||||
return points;
|
||||
}
|
||||
|
||||
/** 타원 — 중심, 장축 끝점, 단축 반지름. 폴리선으로 근사한다 */
|
||||
export function ellipsePoints(
|
||||
center: Point,
|
||||
majorPoint: Point,
|
||||
minorRadius: number,
|
||||
segments = 72
|
||||
): Point[] {
|
||||
const majorRadius = center.distanceTo(majorPoint)[0];
|
||||
const rotation = Math.atan2(majorPoint.y - center.y, majorPoint.x - center.x);
|
||||
const cos = Math.cos(rotation);
|
||||
const sin = Math.sin(rotation);
|
||||
const points: Point[] = [];
|
||||
for (let index = 0; index <= segments; index++) {
|
||||
const angle = (2 * Math.PI * index) / segments;
|
||||
const x = majorRadius * Math.cos(angle);
|
||||
const y = minorRadius * Math.sin(angle);
|
||||
points.push(new Point(center.x + x * cos - y * sin, center.y + x * sin + y * cos));
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
/** 조정점을 지나는 부드러운 곡선 (Catmull-Rom → 폴리선) */
|
||||
export function splinePoints(controlPoints: Point[], segmentsPerSpan = 12): Point[] {
|
||||
if (controlPoints.length < 3) return [...controlPoints];
|
||||
const extended = [
|
||||
controlPoints[0],
|
||||
...controlPoints,
|
||||
controlPoints[controlPoints.length - 1],
|
||||
];
|
||||
const result: Point[] = [];
|
||||
for (let index = 1; index < extended.length - 2; index++) {
|
||||
const p0 = extended[index - 1];
|
||||
const p1 = extended[index];
|
||||
const p2 = extended[index + 1];
|
||||
const p3 = extended[index + 2];
|
||||
for (let step = 0; step < segmentsPerSpan; step++) {
|
||||
const t = step / segmentsPerSpan;
|
||||
result.push(catmullRom(p0, p1, p2, p3, t));
|
||||
}
|
||||
}
|
||||
result.push(controlPoints[controlPoints.length - 1]);
|
||||
return result;
|
||||
}
|
||||
|
||||
function catmullRom(p0: Point, p1: Point, p2: Point, p3: Point, t: number): Point {
|
||||
const t2 = t * t;
|
||||
const t3 = t2 * t;
|
||||
const axis = (a: number, b: number, c: number, d: number) =>
|
||||
0.5 * (2 * b + (c - a) * t + (2 * a - 5 * b + 4 * c - d) * t2 + (3 * b - 3 * c + d - a) * t3);
|
||||
return new Point(axis(p0.x, p1.x, p2.x, p3.x), axis(p0.y, p1.y, p2.y, p3.y));
|
||||
}
|
||||
|
||||
/** 구름형 리비전 — 경로를 따라 반원 스캘럽을 이어붙인 점렬 */
|
||||
export function revisionCloudPoints(path: Point[], arcRadius: number): Point[] {
|
||||
if (path.length < 2 || arcRadius <= 0) return [...path];
|
||||
const result: Point[] = [];
|
||||
for (let index = 1; index < path.length; index++) {
|
||||
const start = path[index - 1];
|
||||
const end = path[index];
|
||||
const length = start.distanceTo(end)[0];
|
||||
const bulges = Math.max(1, Math.round(length / (arcRadius * 2)));
|
||||
for (let bulge = 0; bulge < bulges; bulge++) {
|
||||
const from = lerp(start, end, bulge / bulges);
|
||||
const to = lerp(start, end, (bulge + 1) / bulges);
|
||||
result.push(...halfArcPoints(from, to));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const lerp = (a: Point, b: Point, t: number): Point =>
|
||||
new Point(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t);
|
||||
|
||||
/** 두 점을 지름으로 하는 반원 (구름형 한 칸) */
|
||||
function halfArcPoints(from: Point, to: Point, segments = 8): Point[] {
|
||||
const center = lerp(from, to, 0.5);
|
||||
const radius = from.distanceTo(to)[0] / 2;
|
||||
const baseAngle = Math.atan2(to.y - from.y, to.x - from.x);
|
||||
const points: Point[] = [];
|
||||
for (let index = 0; index <= segments; index++) {
|
||||
const angle = baseAngle + Math.PI - (Math.PI * index) / segments;
|
||||
points.push(new Point(center.x + radius * Math.cos(angle), center.y + radius * Math.sin(angle)));
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
/** 폴리선을 distance만큼 나란히 민 점렬 (양수: 진행 방향 왼쪽) */
|
||||
export function offsetPolylinePoints(points: Point[], distance: number): Point[] {
|
||||
if (points.length < 2 || distance === 0) return [...points];
|
||||
const offsetLines: { start: Point; end: Point }[] = [];
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
const start = points[index - 1];
|
||||
const end = points[index];
|
||||
const dx = end.x - start.x;
|
||||
const dy = end.y - start.y;
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (length < 1e-9) continue;
|
||||
const nx = (-dy / length) * distance;
|
||||
const ny = (dx / length) * distance;
|
||||
offsetLines.push({
|
||||
start: new Point(start.x + nx, start.y + ny),
|
||||
end: new Point(end.x + nx, end.y + ny),
|
||||
});
|
||||
}
|
||||
if (!offsetLines.length) return [...points];
|
||||
|
||||
const result: Point[] = [offsetLines[0].start];
|
||||
for (let index = 1; index < offsetLines.length; index++) {
|
||||
const previous = offsetLines[index - 1];
|
||||
const current = offsetLines[index];
|
||||
const joint = intersectLines(previous.start, previous.end, current.start, current.end);
|
||||
result.push(joint ?? current.start);
|
||||
}
|
||||
result.push(offsetLines[offsetLines.length - 1].end);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 두 직선(무한 연장)의 교점. 평행이면 null */
|
||||
export function intersectLines(a1: Point, a2: Point, b1: Point, b2: Point): Point | null {
|
||||
const d1x = a2.x - a1.x;
|
||||
const d1y = a2.y - a1.y;
|
||||
const d2x = b2.x - b1.x;
|
||||
const d2y = b2.y - b1.y;
|
||||
const denominator = d1x * d2y - d1y * d2x;
|
||||
if (Math.abs(denominator) < 1e-12) return null;
|
||||
const t = ((b1.x - a1.x) * d2y - (b1.y - a1.y) * d2x) / denominator;
|
||||
return new Point(a1.x + d1x * t, a1.y + d1y * t);
|
||||
}
|
||||
+3
@@ -2,6 +2,7 @@ import {compact} from 'es-toolkit';
|
||||
import {ArcEntity, type ArcJsonData} from '../../entities/ArcEntity';
|
||||
import {CircleEntity, type CircleJsonData} from '../../entities/CircleEntity';
|
||||
import {type Entity, EntityName, type JsonEntity} from '../../entities/Entity';
|
||||
import {HatchEntity, type HatchJsonData} from '../../entities/HatchEntity';
|
||||
import {ImageEntity, type ImageJsonData} from '../../entities/ImageEntity.ts';
|
||||
import {LineEntity, type LineJsonData} from '../../entities/LineEntity';
|
||||
import {MeasurementEntity, type MeasurementJsonData} from '../../entities/MeasurementEntity.ts';
|
||||
@@ -60,6 +61,8 @@ export async function getEntitiesAndLayersFromJsonObject(
|
||||
return ImageEntity.fromJson(entity as JsonEntity<ImageJsonData>);
|
||||
case EntityName.PolyLine:
|
||||
return PolyLineEntity.fromJson(entity as JsonEntity<PolyLineJsonData>);
|
||||
case EntityName.Hatch:
|
||||
return HatchEntity.fromJson(entity as JsonEntity<HatchJsonData>);
|
||||
|
||||
default:
|
||||
throw new Error(`Invalid entity type: ${entity.type}`);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 도면층 상태 기록 — 이전 상태(LAYERP)와 이름 붙인 상태(LAYERSTATE).
|
||||
* 도면층 목록 자체를 통째로 복사해 둔다 (개수가 적어 비용이 무시할 만하다).
|
||||
*/
|
||||
import type { Layer } from '../App.types';
|
||||
|
||||
const HISTORY_LIMIT = 20;
|
||||
|
||||
const history: Layer[][] = [];
|
||||
const namedStates = new Map<string, Layer[]>();
|
||||
|
||||
const copy = (layers: Layer[]): Layer[] => layers.map((layer) => ({ ...layer }));
|
||||
|
||||
/** 도면층을 바꾸기 직전에 부른다 */
|
||||
export const pushLayerHistory = (layers: Layer[]): void => {
|
||||
history.push(copy(layers));
|
||||
if (history.length > HISTORY_LIMIT) history.shift();
|
||||
};
|
||||
|
||||
/** 직전 상태를 꺼낸다 (없으면 null) */
|
||||
export const popLayerHistory = (): Layer[] | null => history.pop() ?? null;
|
||||
|
||||
export const saveLayerState = (name: string, layers: Layer[]): void => {
|
||||
namedStates.set(name.trim().toUpperCase(), copy(layers));
|
||||
};
|
||||
|
||||
export const restoreLayerState = (name: string): Layer[] | null => {
|
||||
const saved = namedStates.get(name.trim().toUpperCase());
|
||||
return saved ? copy(saved) : null;
|
||||
};
|
||||
|
||||
export const getLayerStateNames = (): string[] => [...namedStates.keys()];
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 객체 분리·숨기기 상태 (ISOLATEOBJECTS·HIDEOBJECTS·UNISOLATEOBJECTS).
|
||||
* 도면층 가시성과 달리 객체 단위이며 저장하지 않는 일시 상태다.
|
||||
*/
|
||||
import { HtmlEvent } from '../App.types';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
import { bumpSceneVersion } from './scene-version';
|
||||
|
||||
let hiddenEntityIds = new Set<string>();
|
||||
|
||||
export const isEntityHidden = (entity: Entity): boolean => hiddenEntityIds.has(entity.id);
|
||||
export const getHiddenEntityCount = (): number => hiddenEntityIds.size;
|
||||
|
||||
function apply(ids: Set<string>): void {
|
||||
hiddenEntityIds = ids;
|
||||
bumpSceneVersion();
|
||||
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
|
||||
}
|
||||
|
||||
/** 지정 객체를 숨긴다 */
|
||||
export const hideEntities = (entities: Entity[]): void => {
|
||||
const next = new Set(hiddenEntityIds);
|
||||
for (const entity of entities) next.add(entity.id);
|
||||
apply(next);
|
||||
};
|
||||
|
||||
/** 지정 객체만 남기고 나머지를 숨긴다 */
|
||||
export const isolateEntities = (entities: Entity[], allEntities: Entity[]): void => {
|
||||
const keep = new Set(entities.map((entity) => entity.id));
|
||||
apply(new Set(allEntities.filter((entity) => !keep.has(entity.id)).map((entity) => entity.id)));
|
||||
};
|
||||
|
||||
/** 숨김 해제 */
|
||||
export const showAllEntities = (): void => apply(new Set());
|
||||
Reference in New Issue
Block a user