Files
Aislo/B07_DesignDetail/openwebcad/src/tools/modify/transform-tools.ts
T
eomsangdonandClaude Opus 5.5 1d5cdff0a5 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
2026-10-16 00:25:20 +09:00

233 lines
9.0 KiB
TypeScript

/** 대칭·정렬·간격띄우기·신축·길이조정 (조사표 2절) */
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 { 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' },
{ 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 [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 = 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([]);
},
});
export const alignToolStateMachine = createSequenceTool({
tool: Tool.ALIGN,
steps: [
{ kind: 'selection', instructions: '정렬할 객체를 선택한 뒤 ENTER.' },
{ kind: 'point', instructions: '첫 번째 원본점을 지정하십시오.' },
{ kind: 'point', instructions: '첫 번째 대상점을 지정하십시오.' },
{ kind: 'point', instructions: '두 번째 원본점을 지정하십시오.' },
{ kind: 'point', instructions: '두 번째 대상점을 지정하십시오.' },
],
commit: (input) => {
const [source1, target1, source2, target2] = input.points();
const sourceAngle = Math.atan2(source2.y - source1.y, source2.x - source1.x);
const targetAngle = Math.atan2(target2.y - target1.y, target2.x - target1.x);
const rotation = targetAngle - sourceAngle;
// 원본을 그대로 두고 사본을 변환해 교체한다 (실행취소가 원본을 되살릴 수 있어야 한다)
const originals = input.entities(0);
const aligned = originals.map((entity) => {
const copy = cloneStyled(entity);
copy.move(target1.x - source1.x, target1.y - source1.y);
copy.rotate(target1, rotation);
return copy;
});
if (originals.length) {
deleteEntities(originals, false);
addEntities(aligned, true);
}
setSelectedEntityIds([]);
},
});
export const offsetToolStateMachine = createSequenceTool({
tool: Tool.OFFSET,
steps: [
{ kind: 'number', instructions: '간격띄우기 거리를 입력하십시오 <1>.', defaultValue: 1 },
{ kind: 'entity', instructions: '간격띄우기할 객체를 선택하십시오.' },
{ kind: 'point', instructions: '간격을 띄울 방향의 점을 지정하십시오.' },
],
commit: (input) => {
const result = offsetEntity(input.entity(1), input.number(0), input.point(2));
if (!result) {
toast.warn('이 객체는 지정한 거리로 간격을 띄울 수 없습니다.');
return;
}
addEntities([result], true);
},
});
/** 바꾼 것을 원본 자리에 — 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;
};
/**
* 걸침 창 신축 — 창 안 꼭짓점만 (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) ?? []);
},
});
export const lengthenToolStateMachine = createSequenceTool({
tool: Tool.LENGTHEN,
steps: [
{ kind: 'entity', instructions: '길이를 바꿀 선을 늘릴 쪽 끝 근처에서 선택하십시오.' },
{
kind: 'number',
instructions: '증분 길이를 입력하십시오 (음수는 단축) <10>.',
defaultValue: 10,
},
],
commit: (input) => {
const entity = input.entity(0);
const changed = lengthenLine(entity, input.number(1), input.pick(0));
if (!changed) {
toast.warn('선만 길이를 조정할 수 있습니다.');
return;
}
deleteEntities([entity], false);
addEntities([changed], true);
},
});