feat(CAD): 337-5 라 옮기기 무리 2 단계 — COPY · MIRROR · STRETCH · ARRAY · PEDIT 고르기

- COPY: 기준점 [변위(D)/모드(O)/다중(M)] · 두 번째 점 [배열(A)] → [배열(A)/종료(E)/명령 취소(U)] <종료> 되풀이 · 첫 빈 Enter = 변위 · COPYMODE 기억 · A 항목 수 · F 맞춤
- MIRROR: 대칭선 두 점 → 원본 객체를 지우시겠습니까? [예(Y)/아니오(N)] <아니오> · Esc = 안 만듦
- STRETCH: MOVE 틀 + 걸침 창 안 꼭짓점만(선 · 폴리선 토막 · 사각형) · 창 안 통째 · 하나씩 고른 것 = 이동 · STRETCH.DISP
- ARRAY: 유형 [직사각형(R)/원형(PO)] <직사각형>(ARRAYTYPE) → 편집 물음 되풀이(COU · S · COL · R · X / I · A · F · ROT · X) · 미리보기 4 × 3 · 6 개 360° · 처음 간격 범위 1.5 배 · Esc = 안 만듦(N17 없어짐)
- PEDIT: 공용 고르기 상태(객체 선택 · 빈 Enter · Esc = 끝 · 묶은 뒤 끝)
- 시험 transform-acad 29 + ALL · L · P 1(선택 도구 처리 뒤 돎)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
This commit is contained in:
2026-10-16 00:25:20 +09:00
co-authored by Claude Opus 5.5
parent d35bdfa69d
commit 1d5cdff0a5
6 changed files with 1260 additions and 799 deletions
@@ -1,37 +1,80 @@
/** 대칭·정렬·간격띄우기·신축·길이조정 (조사표 2절) */
import { Point } from '@flatten-js/core';
import { Box, Point, Polygon } from '@flatten-js/core';
import { toast } from 'react-toastify';
import { type AnyActorRef, assign } from 'xstate';
import { OPT } from '../../commands/prompt';
import type { Entity } from '../../entities/Entity';
import { LineEntity } from '../../entities/LineEntity';
import { addEntities, deleteEntities, getActiveLayerId, setSelectedEntityIds } from '../../state';
import { PolyLineEntity } from '../../entities/PolyLineEntity';
import { RectangleEntity } from '../../entities/RectangleEntity';
import {
addEntities,
deleteEntities,
getActiveLayerId,
getEntities,
setEntities,
setSelectedEntityIds,
} from '../../state';
import { Tool } from '../../tools';
import { createSequenceTool } from '../factories/sequence-tool';
import { createDisplaceTool } from '../move-tool';
import { SelectState } from '../select-tool';
import type { MouseClickEvent, StateEvent } from '../tool.types';
import { SELECT_CHILD } from '../transform.helpers';
import { copyStyle, lengthenLine, offsetEntity } from './modify.helpers';
/** 원본 특성을 유지한 복사본 */
const cloneStyled = (entity: Entity): Entity => copyStyle(entity, entity.clone());
/** 대칭선 — 두 점이 같으면 없음 */
const mirrorAxis = (first: Point, second: Point): LineEntity | null =>
first.distanceTo(second)[0] < 1e-9 ? null : new LineEntity(getActiveLayerId(), first, second);
const mirrored = (originals: Entity[], axis: LineEntity): Entity[] =>
originals.map((entity) => {
const copy = cloneStyled(entity);
copy.mirror(axis);
return copy;
});
/**
* MIRROR(337 라 · 설계 1-3) — 「객체 선택:」 → 「대칭선의 첫 번째 점 지정:」 → 「대칭선의 두 번째 점 지정:」 →
* 「원본 객체를 지우시겠습니까? [예(Y)/아니오(N)] <아니오>:」 · Esc = 안 만듦 · 한 번 뒤 끝.
*/
export const mirrorToolStateMachine = createSequenceTool({
tool: Tool.MIRROR,
steps: [
{ kind: 'selection', instructions: '대칭 복사할 객체를 선택한 뒤 ENTER.' },
{ kind: 'point', instructions: '대칭축의 첫 점을 지정하십시오.' },
{ kind: 'point', instructions: '대칭축의 두 번째 점을 지정하십시오.' },
{ kind: 'selection' },
{ kind: 'point', id: 'first', prompt: { text: '대칭선의 첫 번째 점 지정' } },
{ kind: 'point', id: 'second', prompt: { text: '대칭선의 두 번째 점 지정' } },
{
kind: 'keyword',
id: 'erase',
prompt: {
text: '원본 객체를 지우시겠습니까?',
options: [OPT.YES, OPT.NO],
default: { kind: 'option', key: OPT.NO.key },
},
},
],
preview: (input) => {
const points = input.points();
if (points.length !== 1) return [];
return [new LineEntity(getActiveLayerId(), points[0], input.cursor)];
const [first, second] = input.points();
const axis = first ? mirrorAxis(first, second ?? input.cursor) : null;
if (!axis) return [];
return [...(second ? [] : [axis]), ...mirrored(input.entities(0), axis)];
},
commit: (input) => {
const [first, second] = input.points();
const axis = new LineEntity(getActiveLayerId(), first, second);
const mirrored = input.entities(0).map((entity) => {
const copy = cloneStyled(entity);
copy.mirror(axis);
return copy;
});
if (mirrored.length) addEntities(mirrored, true);
const axis = mirrorAxis(first, second);
const originals = input.entities(0);
if (!axis || !originals.length) return;
const copies = mirrored(originals, axis);
if (input.get('erase') === OPT.YES.key) {
const gone = new Set(originals.map((entity) => entity.id));
setEntities([...getEntities().filter((entity) => !gone.has(entity.id)), ...copies], true);
} else {
addEntities(copies, true);
}
setSelectedEntityIds([]);
},
});
@@ -84,44 +127,85 @@ export const offsetToolStateMachine = createSequenceTool({
},
});
export const stretchToolStateMachine = createSequenceTool({
tool: Tool.STRETCH,
steps: [
{ kind: 'selection', instructions: '신축할 객체를 선택한 뒤 ENTER.' },
{ kind: 'point', instructions: '기준점을 지정하십시오.' },
{ kind: 'point', instructions: '이동할 위치를 지정하십시오.' },
],
commit: (input) => {
const [base, destination] = input.points();
const dx = destination.x - base.x;
const dy = destination.y - base.y;
const replaced: Entity[] = [];
const removed: Entity[] = [];
/** 바꾼 것을 원본 자리에 — id · 묶음 · 꼴 그대로 */
const inPlaceOf = <T extends Entity>(source: Entity, made: T): T => {
copyStyle(source, made);
made.id = source.id;
made.groupId = source.groupId;
made.opacity = source.opacity;
return made;
};
for (const entity of input.entities(0)) {
const shape = entity.getShape();
// 선은 기준점에 가까운 끝점만 끌어당긴다. 그 밖의 객체는 통째로 옮긴다.
if (shape && 'start' in shape && 'end' in shape) {
const start = (shape as { start: Point }).start;
const end = (shape as { end: Point }).end;
const moveStart = start.distanceTo(base)[0] < end.distanceTo(base)[0];
const newLine = new LineEntity(
entity.layerId,
moveStart ? new Point(start.x + dx, start.y + dy) : start,
moveStart ? end : new Point(end.x + dx, end.y + dy)
);
replaced.push(copyStyle(entity, newLine));
removed.push(entity);
} else {
const copy = cloneStyled(entity);
copy.move(dx, dy);
replaced.push(copy);
removed.push(entity);
}
}
if (removed.length) deleteEntities(removed, false);
addEntities(replaced, true);
setSelectedEntityIds([]);
/**
* 걸침 창 신축 — 창 안 꼭짓점만 (dx, dy) · 창 안에 통째 든 것 · 하나씩 집은 것(창 밖) = 이동 · 그 밖 = 그대로(null).
* ponytail: 선 · 폴리선(선 토막) · 사각형만 꼭짓점 신축 — 원 · 호 · 글은 걸치면 그대로(오토캐드는 중심 · 끝점 기준).
*/
function stretchEntity(entity: Entity, box: Box | null, dx: number, dy: number): Entity | null {
const inside = (point: Point) =>
!!box &&
point.x >= box.xmin &&
point.x <= box.xmax &&
point.y >= box.ymin &&
point.y <= box.ymax;
const shift = (point: Point) => (inside(point) ? new Point(point.x + dx, point.y + dy) : point);
if (!box || entity.isContainedInBox(box) || !entity.intersectsWithBox(box)) {
const copy = cloneStyled(entity);
copy.move(dx, dy);
return inPlaceOf(entity, copy);
}
if (entity instanceof LineEntity) {
const start = entity.getStartPoint();
const end = entity.getEndPoint();
if (!inside(start) && !inside(end)) return null;
return inPlaceOf(entity, new LineEntity(entity.layerId, shift(start), shift(end)));
}
if (entity instanceof PolyLineEntity) {
let changed = false;
const parts = entity.getEntities().map((part) => {
if (!(part instanceof LineEntity)) return cloneStyled(part);
const start = part.getStartPoint();
const end = part.getEndPoint();
if (inside(start) || inside(end)) changed = true;
return copyStyle(part, new LineEntity(part.layerId, shift(start), shift(end)));
});
return changed ? inPlaceOf(entity, new PolyLineEntity(entity.layerId, parts)) : null;
}
if (entity instanceof RectangleEntity) {
const corners = (entity.getShape() as Polygon).vertices;
if (!corners.some(inside)) return null;
return inPlaceOf(entity, new RectangleEntity(entity.layerId, new Polygon(corners.map(shift))));
}
return null;
}
/** 고르기 중 마지막 걸침 창 — 선택 도구가 두 번째 구석을 기다릴 때의 누름 */
const watchWindow = assign(({ event, self }: { event: StateEvent; self: AnyActorRef }) => {
const snapshot = self.getSnapshot().children[SELECT_CHILD]?.getSnapshot();
const start = snapshot?.context?.startPoint as Point | null | undefined;
if (snapshot?.value !== SelectState.WAITING_FOR_SECOND_SELECT_POINT || !start) return {};
return { window: [start, (event as MouseClickEvent).worldMouseLocation] as [Point, Point] };
});
/**
* STRETCH(337 라 · 설계 1-3) — 기록 「걸치기 윈도우 또는 걸치기 폴리곤만큼 신축할 객체 선택...」 → 「객체 선택:」 →
* MOVE 와 같은 기준점 [변위(D)] · 두 번째 점 · 변위 기억(STRETCH.DISP) · 걸침 창 안 꼭짓점만 옮김.
*/
export const stretchToolStateMachine = createDisplaceTool({
tool: Tool.STRETCH,
remember: 'STRETCH.DISP',
intro: '걸치기 윈도우 또는 걸치기 폴리곤만큼 신축할 객체 선택...',
watch: watchWindow,
change: (context, dx, dy) => {
const window = context.window;
const box = window
? new Box(
Math.min(window[0].x, window[1].x),
Math.min(window[0].y, window[1].y),
Math.max(window[0].x, window[1].x),
Math.max(window[0].y, window[1].y)
)
: null;
return context.originals.flatMap((entity) => stretchEntity(entity, box, dx, dy) ?? []);
},
});