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:
2026-08-30 01:34:13 +09:00
parent 60bbe89edb
commit ea9d9685be
88 changed files with 8052 additions and 850 deletions
@@ -0,0 +1,229 @@
/**
* 수정 명령의 객체 조작 — 간격띄우기·연장·길이조정·분해·결합·중복정리.
* 기하 계산은 helpers/geometry의 순수 함수를 쓰고, 여기서는 엔티티로 바꾼다.
*/
import { Circle, Point, Segment } from '@flatten-js/core';
import { ArcEntity } from '../../entities/ArcEntity';
import { CircleEntity } from '../../entities/CircleEntity';
import { type Entity, EntityName } from '../../entities/Entity';
import type { HatchEntity } from '../../entities/HatchEntity';
import { LineEntity } from '../../entities/LineEntity';
import { PolyLineEntity } from '../../entities/PolyLineEntity';
import type { RectangleEntity } from '../../entities/RectangleEntity';
import { dedupeConsecutive, sampleEntityPoints } from '../../helpers/geometry/sample-entity';
import { intersectLines, offsetPolylinePoints } from '../../helpers/geometry/shape-points';
import { polygonToSegments } from '../../helpers/polygon-to-segments';
import { getActiveLayerId } from '../../state';
/** 원본 객체의 표시 특성을 새 객체에 옮긴다 */
export function copyStyle<T extends Entity>(source: Entity, target: T): T {
target.lineColor = source.lineColor;
target.lineWidth = source.lineWidth;
target.lineDash = source.lineDash;
target.layerId = source.layerId;
return target;
}
const makeLine = (source: Entity, start: Point, end: Point): LineEntity =>
copyStyle(source, new LineEntity(source.layerId || getActiveLayerId(), start, end));
const makePolyLine = (source: Entity, points: Point[]): PolyLineEntity | null => {
if (points.length < 2) return null;
const segments: Entity[] = [];
for (let index = 1; index < points.length; index++) {
segments.push(makeLine(source, points[index - 1], points[index]));
}
return copyStyle(source, new PolyLineEntity(source.layerId || getActiveLayerId(), segments));
};
/** 점이 선의 어느 쪽에 있는지 (+1 왼쪽, -1 오른쪽) */
function sideOfLine(start: Point, end: Point, point: Point): number {
const cross = (end.x - start.x) * (point.y - start.y) - (end.y - start.y) * (point.x - start.x);
return cross >= 0 ? 1 : -1;
}
/** OFFSET — 객체를 distance만큼 sidePoint 쪽으로 민 새 객체 */
export function offsetEntity(entity: Entity, distance: number, sidePoint: Point): Entity | null {
const shape = entity.getShape();
if (shape instanceof Segment) {
const side = sideOfLine(shape.start, shape.end, sidePoint);
const points = offsetPolylinePoints([shape.start, shape.end], distance * side);
return makeLine(entity, points[0], points[points.length - 1]);
}
if (shape instanceof Circle) {
const outward = sidePoint.distanceTo(shape.center)[0] > Number(shape.r);
const radius = Number(shape.r) + (outward ? distance : -distance);
if (radius <= 0) return null;
return copyStyle(entity, new CircleEntity(entity.layerId, shape.center, radius));
}
if (entity.getType() === EntityName.Arc) {
const arcShape = entity.getShape() as unknown as {
center: Point;
r: number;
startAngle: number;
endAngle: number;
counterClockwise: boolean;
};
const outward = sidePoint.distanceTo(arcShape.center)[0] > Number(arcShape.r);
const radius = Number(arcShape.r) + (outward ? distance : -distance);
if (radius <= 0) return null;
return copyStyle(
entity,
new ArcEntity(
entity.layerId,
arcShape.center,
radius,
arcShape.startAngle,
arcShape.endAngle,
arcShape.counterClockwise
)
);
}
// 폴리선·사각형·해치 등은 점렬을 밀어서 만든다
const points = sampleEntityPoints(entity);
if (points.length < 2) return null;
const side = sideOfLine(points[0], points[1], sidePoint);
return makePolyLine(entity, offsetPolylinePoints(points, distance * side));
}
/** EXTEND — 대상 선을 경계 객체와 만나는 곳까지 늘린다 */
export function extendLineToBoundary(target: Entity, boundary: Entity): Entity | null {
const shape = target.getShape();
if (!(shape instanceof Segment)) return null;
const boundaryPoints = sampleEntityPoints(boundary);
if (boundaryPoints.length < 2) return null;
let best: { point: Point; distance: number; fromStart: boolean } | null = null;
for (let index = 1; index < boundaryPoints.length; index++) {
const crossing = intersectLines(
shape.start,
shape.end,
boundaryPoints[index - 1],
boundaryPoints[index]
);
if (!crossing) continue;
// 교점이 경계 선분 안에 있어야 한다
if (!isBetween(crossing, boundaryPoints[index - 1], boundaryPoints[index])) continue;
const fromEnd = shape.end.distanceTo(crossing)[0];
const fromStart = shape.start.distanceTo(crossing)[0];
const useStart = fromStart < fromEnd;
const distance = Math.min(fromStart, fromEnd);
if (!best || distance < best.distance) {
best = { point: crossing, distance, fromStart: useStart };
}
}
if (!best) return null;
return best.fromStart
? makeLine(target, best.point, shape.end)
: makeLine(target, shape.start, best.point);
}
/** 점이 두 점 사이 선분 위(오차 허용)에 있는가 */
export function isBetween(point: Point, start: Point, end: Point, tolerance = 1e-6): boolean {
const minX = Math.min(start.x, end.x) - tolerance;
const maxX = Math.max(start.x, end.x) + tolerance;
const minY = Math.min(start.y, end.y) - tolerance;
const maxY = Math.max(start.y, end.y) + tolerance;
return point.x >= minX && point.x <= maxX && point.y >= minY && point.y <= maxY;
}
/** LENGTHEN — 선의 끝을 delta만큼 늘리거나(양수) 줄인다(음수) */
export function lengthenLine(entity: Entity, delta: number, nearPoint: Point): Entity | null {
const shape = entity.getShape();
if (!(shape instanceof Segment)) return null;
const atStart = shape.start.distanceTo(nearPoint)[0] < shape.end.distanceTo(nearPoint)[0];
const length = shape.start.distanceTo(shape.end)[0] || 1;
const dx = (shape.end.x - shape.start.x) / length;
const dy = (shape.end.y - shape.start.y) / length;
if (atStart) {
return makeLine(
entity,
new Point(shape.start.x - dx * delta, shape.start.y - dy * delta),
shape.end
);
}
return makeLine(entity, shape.start, new Point(shape.end.x + dx * delta, shape.end.y + dy * delta));
}
/** EXPLODE — 복합 객체를 구성요소로 나눈다. 나눌 게 없으면 빈 배열 */
export function explodeEntity(entity: Entity): Entity[] {
if (entity.getType() === EntityName.PolyLine) {
return (entity as PolyLineEntity).getEntities().map((child) => copyStyle(entity, child));
}
if (entity.getType() === EntityName.Rectangle) {
const polygon = (entity as RectangleEntity).getShape();
if (!polygon) return [];
return polygonToSegments(polygon as never).map((segment) =>
makeLine(entity, segment.start, segment.end)
);
}
if (entity.getType() === EntityName.Hatch) {
const boundary = makePolyLine(entity, (entity as HatchEntity).getPoints());
return boundary ? [boundary] : [];
}
return [];
}
/** JOIN — 끝점이 맞닿는 객체들을 하나의 폴리선으로 잇는다 */
export function joinEntities(entities: Entity[], tolerance = 1e-3): PolyLineEntity | null {
const chains = entities
.map((entity) => sampleEntityPoints(entity))
.filter((points) => points.length >= 2);
if (chains.length < 2) return null;
const near = (a: Point, b: Point) =>
Math.abs(a.x - b.x) <= tolerance && Math.abs(a.y - b.y) <= tolerance;
const remaining = [...chains];
let joined = remaining.shift() as Point[];
let progress = true;
while (remaining.length && progress) {
progress = false;
const head = joined[0];
const tail = joined[joined.length - 1];
for (let index = 0; index < remaining.length; index++) {
const chain = remaining[index];
const start = chain[0];
const end = chain[chain.length - 1];
if (near(start, tail)) joined = [...joined, ...chain.slice(1)];
else if (near(end, tail)) joined = [...joined, ...[...chain].reverse().slice(1)];
else if (near(end, head)) joined = [...chain.slice(0, -1), ...joined];
else if (near(start, head)) joined = [...[...chain].reverse().slice(0, -1), ...joined];
else continue;
remaining.splice(index, 1);
progress = true;
break;
}
}
if (remaining.length === chains.length - 1) return null; // 하나도 못 이었다
return makePolyLine(entities[0], dedupeConsecutive(joined));
}
/** REVERSE — 방향을 뒤집은 새 객체 */
export function reverseEntity(entity: Entity): Entity | null {
const shape = entity.getShape();
if (shape instanceof Segment) return makeLine(entity, shape.end, shape.start);
const points = sampleEntityPoints(entity);
if (points.length < 2) return null;
return makePolyLine(entity, [...points].reverse());
}
/** OVERKILL — 형상이 같은 객체의 중복분을 골라낸다 */
export function findDuplicateEntities(entities: Entity[], precision = 4): Entity[] {
const seen = new Set<string>();
const duplicates: Entity[] = [];
for (const entity of entities) {
const signature = `${entity.getType()}|${sampleEntityPoints(entity)
.map((point) => `${point.x.toFixed(precision)},${point.y.toFixed(precision)}`)
.join(';')}`;
if (seen.has(signature)) duplicates.push(entity);
else seen.add(signature);
}
return duplicates;
}