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:
@@ -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) ?? []);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user