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,66 @@
|
||||
/** 치수·지시선이 함께 쓰는 조각 만들기 (화살표·치수 문자·직전 치수 기억) */
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { getDimArrowSize, getDimDecimals, getDimTextHeight } from '../../commands/dim-settings';
|
||||
import { ArrowHeadEntity } from '../../entities/ArrowHeadEntity';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import type { MeasurementEntity } from '../../entities/MeasurementEntity';
|
||||
import { getActiveLayerId, getActiveLineColor, getScreenCanvasDrawController } from '../../state';
|
||||
import { textEntity } from '../factories/entity-factory';
|
||||
|
||||
/** 화면 px 기준 상수를 도면 단위로 바꾼다 (줌이 달라도 크기가 일정하게 보인다) */
|
||||
export function worldFactor(): number {
|
||||
try {
|
||||
return getScreenCanvasDrawController().getScreenScale() || 1;
|
||||
} catch {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** tip을 꼭짓점으로 하고 from 방향에서 들어오는 화살표 */
|
||||
export function arrowHead(tip: Point, from: Point, size = getDimArrowSize()): ArrowHeadEntity {
|
||||
const length = size / worldFactor();
|
||||
const dx = tip.x - from.x;
|
||||
const dy = tip.y - from.y;
|
||||
const distance = Math.hypot(dx, dy) || 1;
|
||||
const ux = dx / distance;
|
||||
const uy = dy / distance;
|
||||
const baseX = tip.x - ux * length;
|
||||
const baseY = tip.y - uy * length;
|
||||
const halfWidth = length * 0.35;
|
||||
|
||||
const head = new ArrowHeadEntity(
|
||||
getActiveLayerId(),
|
||||
tip,
|
||||
new Point(baseX - uy * halfWidth, baseY + ux * halfWidth),
|
||||
new Point(baseX + uy * halfWidth, baseY - ux * halfWidth)
|
||||
);
|
||||
head.lineColor = getActiveLineColor();
|
||||
head.fillColor = getActiveLineColor();
|
||||
return head;
|
||||
}
|
||||
|
||||
/** 치수 문자 — 설정한 소수 자릿수·문자 높이를 따른다 */
|
||||
export function dimensionText(value: number, position: Point, prefix = '', suffix = ''): Entity {
|
||||
const label = `${prefix}${value.toFixed(getDimDecimals())}${suffix}`;
|
||||
return textEntity(label, position, {
|
||||
fontSize: getDimTextHeight() / worldFactor(),
|
||||
textAlign: 'center',
|
||||
});
|
||||
}
|
||||
|
||||
/** 문자 그대로 찍는 주석 (좌표·각도처럼 단위가 다른 값) */
|
||||
export function annotationText(label: string, position: Point): Entity {
|
||||
return textEntity(label, position, {
|
||||
fontSize: getDimTextHeight() / worldFactor(),
|
||||
textAlign: 'center',
|
||||
});
|
||||
}
|
||||
|
||||
/** DIMBASELINE·DIMCONTINUE가 이어 그릴 직전 치수 */
|
||||
let lastDimension: MeasurementEntity | null = null;
|
||||
|
||||
export const rememberDimension = (dimension: MeasurementEntity): void => {
|
||||
lastDimension = dimension;
|
||||
};
|
||||
|
||||
export const getLastDimension = (): MeasurementEntity | null => lastDimension;
|
||||
@@ -0,0 +1,256 @@
|
||||
/** 반지름·지름·각도·호길이·세로좌표 치수와 중심 표식 (조사표 5절) */
|
||||
import { Arc, Circle, Point } from '@flatten-js/core';
|
||||
import { toast } from 'react-toastify';
|
||||
import { getDimDecimals } from '../../commands/dim-settings';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { MeasurementEntity } from '../../entities/MeasurementEntity';
|
||||
import { polylineLength, sampleEntityPoints } from '../../helpers/geometry/sample-entity';
|
||||
import { addEntities, getEntities, setEntities, setSelectedEntityIds } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { arcEntity, lineEntity } from '../factories/entity-factory';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
import { annotationText, arrowHead, dimensionText } from './annotation.helpers';
|
||||
|
||||
interface CircularShape {
|
||||
center: Point;
|
||||
radius: number;
|
||||
}
|
||||
|
||||
/** 원·호에서 중심과 반지름을 꺼낸다 */
|
||||
function circularOf(entity: Entity): CircularShape | null {
|
||||
const shape = entity.getShape();
|
||||
if (shape instanceof Circle) return { center: shape.center, radius: Number(shape.r) };
|
||||
if (shape instanceof Arc) return { center: shape.center, radius: Number(shape.r) };
|
||||
return null;
|
||||
}
|
||||
|
||||
function radialDimension(entity: Entity, textPoint: Point, diameter: boolean): Entity[] | null {
|
||||
const circular = circularOf(entity);
|
||||
if (!circular) return null;
|
||||
|
||||
const angle = Math.atan2(textPoint.y - circular.center.y, textPoint.x - circular.center.x);
|
||||
const edge = new Point(
|
||||
circular.center.x + circular.radius * Math.cos(angle),
|
||||
circular.center.y + circular.radius * Math.sin(angle)
|
||||
);
|
||||
const start = diameter
|
||||
? new Point(
|
||||
circular.center.x - circular.radius * Math.cos(angle),
|
||||
circular.center.y - circular.radius * Math.sin(angle)
|
||||
)
|
||||
: circular.center;
|
||||
|
||||
return [
|
||||
lineEntity(start, textPoint),
|
||||
arrowHead(edge, circular.center),
|
||||
dimensionText(
|
||||
diameter ? circular.radius * 2 : circular.radius,
|
||||
textPoint,
|
||||
diameter ? 'Ø' : 'R'
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export const dimRadiusToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DIMRADIUS,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '반지름을 기입할 원 또는 호를 선택하십시오.' },
|
||||
{ kind: 'point', instructions: '치수 문자 위치를 지정하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const parts = radialDimension(input.entity(0), input.point(1), false);
|
||||
if (!parts) {
|
||||
toast.warn('원 또는 호를 선택하십시오.');
|
||||
return;
|
||||
}
|
||||
addEntities(parts, true);
|
||||
},
|
||||
});
|
||||
|
||||
export const dimDiameterToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DIMDIAMETER,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '지름을 기입할 원 또는 호를 선택하십시오.' },
|
||||
{ kind: 'point', instructions: '치수 문자 위치를 지정하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const parts = radialDimension(input.entity(0), input.point(1), true);
|
||||
if (!parts) {
|
||||
toast.warn('원 또는 호를 선택하십시오.');
|
||||
return;
|
||||
}
|
||||
addEntities(parts, true);
|
||||
},
|
||||
});
|
||||
|
||||
export const dimAngularToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DIMANGULAR,
|
||||
steps: [
|
||||
{ kind: 'point', instructions: '각의 꼭짓점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '첫 번째 변 위의 점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '두 번째 변 위의 점을 지정하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const [vertex, first, second] = input.points();
|
||||
const radius = Math.min(vertex.distanceTo(first)[0], vertex.distanceTo(second)[0]) * 0.6;
|
||||
if (radius <= 0) return;
|
||||
|
||||
const startAngle = Math.atan2(first.y - vertex.y, first.x - vertex.x);
|
||||
const endAngle = Math.atan2(second.y - vertex.y, second.x - vertex.x);
|
||||
const sweep = ((endAngle - startAngle + 2 * Math.PI) % (2 * Math.PI));
|
||||
const midAngle = startAngle + sweep / 2;
|
||||
const degrees = (sweep * 180) / Math.PI;
|
||||
|
||||
addEntities(
|
||||
[
|
||||
lineEntity(vertex, first),
|
||||
lineEntity(vertex, second),
|
||||
arcEntity({
|
||||
center: vertex,
|
||||
radius,
|
||||
startAngle,
|
||||
endAngle,
|
||||
counterClockwise: true,
|
||||
}),
|
||||
annotationText(
|
||||
`${degrees.toFixed(getDimDecimals())}°`,
|
||||
new Point(
|
||||
vertex.x + radius * 1.25 * Math.cos(midAngle),
|
||||
vertex.y + radius * 1.25 * Math.sin(midAngle)
|
||||
)
|
||||
),
|
||||
],
|
||||
true
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const dimArcToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DIMARC,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '호 길이를 기입할 호를 선택하십시오.' },
|
||||
{ kind: 'point', instructions: '치수 문자 위치를 지정하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const entity = input.entity(0);
|
||||
const circular = circularOf(entity);
|
||||
if (!circular) {
|
||||
toast.warn('호를 선택하십시오.');
|
||||
return;
|
||||
}
|
||||
const length = polylineLength(sampleEntityPoints(entity));
|
||||
addEntities([dimensionText(length, input.point(1), '⌒ ')], true);
|
||||
},
|
||||
});
|
||||
|
||||
export const dimOrdinateToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DIMORDINATE,
|
||||
steps: [
|
||||
{ kind: 'point', instructions: '좌표를 기입할 피처 위치를 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '지시선 끝점을 지정하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const [feature, leaderEnd] = input.points();
|
||||
// 지시선이 세로로 길면 X좌표를, 가로로 길면 Y좌표를 기입한다 (AutoCAD와 같다)
|
||||
const vertical = Math.abs(leaderEnd.y - feature.y) >= Math.abs(leaderEnd.x - feature.x);
|
||||
const value = vertical ? feature.x : feature.y;
|
||||
addEntities(
|
||||
[
|
||||
lineEntity(feature, leaderEnd),
|
||||
annotationText(
|
||||
`${vertical ? 'X' : 'Y'} ${value.toFixed(getDimDecimals())}`,
|
||||
new Point(leaderEnd.x, leaderEnd.y)
|
||||
),
|
||||
],
|
||||
true
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const centerMarkToolStateMachine = createSequenceTool({
|
||||
tool: Tool.CENTERMARK,
|
||||
steps: [{ kind: 'entity', instructions: '중심 표식을 넣을 원 또는 호를 선택하십시오.' }],
|
||||
commit: (input) => {
|
||||
const circular = circularOf(input.entity(0));
|
||||
if (!circular) {
|
||||
toast.warn('원 또는 호를 선택하십시오.');
|
||||
return;
|
||||
}
|
||||
const size = circular.radius * 0.15;
|
||||
addEntities(
|
||||
[
|
||||
lineEntity(
|
||||
new Point(circular.center.x - size, circular.center.y),
|
||||
new Point(circular.center.x + size, circular.center.y)
|
||||
),
|
||||
lineEntity(
|
||||
new Point(circular.center.x, circular.center.y - size),
|
||||
new Point(circular.center.x, circular.center.y + size)
|
||||
),
|
||||
],
|
||||
true
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const centerLineToolStateMachine = createSequenceTool({
|
||||
tool: Tool.CENTERLINE,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '첫 번째 선을 선택하십시오.' },
|
||||
{ kind: 'entity', instructions: '두 번째 선을 선택하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const first = sampleEntityPoints(input.entity(0));
|
||||
const second = sampleEntityPoints(input.entity(1));
|
||||
if (first.length < 2 || second.length < 2) {
|
||||
toast.warn('두 선을 선택하십시오.');
|
||||
return;
|
||||
}
|
||||
const middle = (a: Point, b: Point) => new Point((a.x + b.x) / 2, (a.y + b.y) / 2);
|
||||
const line = lineEntity(
|
||||
middle(first[0], second[0]),
|
||||
middle(first[first.length - 1], second[second.length - 1])
|
||||
);
|
||||
line.lineDash = [12, 4, 2, 4];
|
||||
addEntities([line], true);
|
||||
},
|
||||
});
|
||||
|
||||
/** DIMSPACE — 선택한 치수들의 치수선 간격을 고르게 맞춘다 */
|
||||
export const dimSpaceToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DIMSPACE,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'selection', instructions: '간격을 맞출 치수를 선택한 뒤 ENTER.' },
|
||||
{ kind: 'number', instructions: '치수선 간격을 입력하십시오 <10>.', defaultValue: 10 },
|
||||
],
|
||||
commit: (input) => {
|
||||
const dimensions = input
|
||||
.entities(0)
|
||||
.filter((entity): entity is MeasurementEntity => entity instanceof MeasurementEntity);
|
||||
if (dimensions.length < 2) {
|
||||
toast.warn('치수를 두 개 이상 선택하십시오.');
|
||||
return;
|
||||
}
|
||||
const spacing = input.number(1);
|
||||
const base = dimensions[0];
|
||||
const baseStart = base.getStartPoint();
|
||||
const baseOffset = base.getOffsetPoint();
|
||||
const direction = new Point(baseOffset.x - baseStart.x, baseOffset.y - baseStart.y);
|
||||
const length = Math.hypot(direction.x, direction.y) || 1;
|
||||
|
||||
dimensions.forEach((dimension, index) => {
|
||||
if (index === 0) return;
|
||||
const start = dimension.getStartPoint();
|
||||
dimension.setOffsetPoint(
|
||||
new Point(
|
||||
start.x + (direction.x / length) * (length + spacing * index),
|
||||
start.y + (direction.y / length) * (length + spacing * index)
|
||||
)
|
||||
);
|
||||
});
|
||||
setEntities([...getEntities()], true);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
/** 선형 치수 계열 — 선형·정렬·기준선·연속·빠른 치수·자동 치수 (조사표 5절) */
|
||||
import { Circle, Point } from '@flatten-js/core';
|
||||
import { toast } from 'react-toastify';
|
||||
import { EntityName } from '../../entities/Entity';
|
||||
import { MeasurementEntity } from '../../entities/MeasurementEntity';
|
||||
import { sampleEntityPoints } from '../../helpers/geometry/sample-entity';
|
||||
import { addEntities, getActiveLayerId, setSelectedEntityIds } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { styled } from '../factories/entity-factory';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
import { getLastDimension, rememberDimension } from './annotation.helpers';
|
||||
|
||||
/** 치수 객체 하나 만들기 — 현재 선 특성을 입히고 직전 치수로 기억한다 */
|
||||
function makeDimension(start: Point, end: Point, offset: Point): MeasurementEntity {
|
||||
const dimension = styled(new MeasurementEntity(getActiveLayerId(), start, end, offset));
|
||||
rememberDimension(dimension);
|
||||
return dimension;
|
||||
}
|
||||
|
||||
/**
|
||||
* 선형 치수 — 치수선을 놓은 방향으로 수평/수직을 고른다.
|
||||
* 위·아래에 놓으면 가로 거리, 좌·우에 놓으면 세로 거리를 잰다 (AutoCAD와 같다).
|
||||
*/
|
||||
function projectForLinear(start: Point, end: Point, offset: Point): [Point, Point] {
|
||||
const horizontalSpan = Math.abs(end.x - start.x);
|
||||
const verticalSpan = Math.abs(end.y - start.y);
|
||||
const offsetIsVertical =
|
||||
Math.abs(offset.y - (start.y + end.y) / 2) >= Math.abs(offset.x - (start.x + end.x) / 2);
|
||||
if (offsetIsVertical || horizontalSpan >= verticalSpan) {
|
||||
return [start, new Point(end.x, start.y)];
|
||||
}
|
||||
return [start, new Point(start.x, end.y)];
|
||||
}
|
||||
|
||||
export const dimLinearToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DIMLINEAR,
|
||||
steps: [
|
||||
{ kind: 'point', instructions: '첫 번째 치수보조선 원점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '두 번째 치수보조선 원점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '치수선 위치를 지정하십시오.' },
|
||||
],
|
||||
preview: (input) => {
|
||||
const points = input.points();
|
||||
if (points.length !== 2) return [];
|
||||
const [start, end] = projectForLinear(points[0], points[1], input.cursor);
|
||||
return [new MeasurementEntity(getActiveLayerId(), start, end, input.cursor)];
|
||||
},
|
||||
commit: (input) => {
|
||||
const [first, second, offset] = input.points();
|
||||
const [start, end] = projectForLinear(first, second, offset);
|
||||
addEntities([makeDimension(start, end, offset)], true);
|
||||
},
|
||||
});
|
||||
|
||||
export const dimAlignedToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DIMALIGNED,
|
||||
steps: [
|
||||
{ kind: 'point', instructions: '첫 번째 치수보조선 원점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '두 번째 치수보조선 원점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '치수선 위치를 지정하십시오.' },
|
||||
],
|
||||
preview: (input) => {
|
||||
const points = input.points();
|
||||
if (points.length !== 2) return [];
|
||||
return [new MeasurementEntity(getActiveLayerId(), points[0], points[1], input.cursor)];
|
||||
},
|
||||
commit: (input) => {
|
||||
const [start, end, offset] = input.points();
|
||||
addEntities([makeDimension(start, end, offset)], true);
|
||||
},
|
||||
});
|
||||
|
||||
export const dimBaselineToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DIMBASELINE,
|
||||
steps: [{ kind: 'point', instructions: '다음 치수보조선 원점을 지정하십시오.' }],
|
||||
commit: (input) => {
|
||||
const previous = getLastDimension();
|
||||
if (!previous) {
|
||||
toast.warn('먼저 치수를 하나 작성하십시오.');
|
||||
return;
|
||||
}
|
||||
const start = previous.getStartPoint();
|
||||
const offset = previous.getOffsetPoint();
|
||||
// 기준선 치수는 같은 시작점에서 재고, 치수선을 한 칸 더 띄운다
|
||||
const spacing = offset.distanceTo(start)[0] * 0.35 || 10;
|
||||
const direction = new Point(offset.x - start.x, offset.y - start.y);
|
||||
const length = Math.hypot(direction.x, direction.y) || 1;
|
||||
const nextOffset = new Point(
|
||||
offset.x + (direction.x / length) * spacing,
|
||||
offset.y + (direction.y / length) * spacing
|
||||
);
|
||||
addEntities([makeDimension(start, input.point(0), nextOffset)], true);
|
||||
},
|
||||
});
|
||||
|
||||
export const dimContinueToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DIMCONTINUE,
|
||||
steps: [{ kind: 'point', instructions: '다음 치수보조선 원점을 지정하십시오.' }],
|
||||
commit: (input) => {
|
||||
const previous = getLastDimension();
|
||||
if (!previous) {
|
||||
toast.warn('먼저 치수를 하나 작성하십시오.');
|
||||
return;
|
||||
}
|
||||
addEntities(
|
||||
[makeDimension(previous.getEndPoint(), input.point(0), previous.getOffsetPoint())],
|
||||
true
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const qDimToolStateMachine = createSequenceTool({
|
||||
tool: Tool.QDIM,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'selection', instructions: '치수를 넣을 객체를 선택한 뒤 ENTER.' },
|
||||
{ kind: 'point', instructions: '치수선 위치를 지정하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const offset = input.point(1);
|
||||
const dimensions = input
|
||||
.entities(0)
|
||||
.map((entity) => {
|
||||
const points = sampleEntityPoints(entity);
|
||||
if (points.length < 2) return null;
|
||||
return makeDimension(points[0], points[points.length - 1], offset);
|
||||
})
|
||||
.filter((dimension): dimension is MeasurementEntity => !!dimension);
|
||||
if (!dimensions.length) {
|
||||
toast.warn('치수를 넣을 수 있는 객체가 없습니다.');
|
||||
return;
|
||||
}
|
||||
addEntities(dimensions, true);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
|
||||
/** DIM — 선택한 객체 종류에 맞는 치수를 자동으로 고른다 */
|
||||
export const dimAutoToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DIM,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '치수를 넣을 객체를 선택하십시오.' },
|
||||
{ kind: 'point', instructions: '치수선 위치를 지정하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const entity = input.entity(0);
|
||||
const offset = input.point(1);
|
||||
const shape = entity.getShape();
|
||||
|
||||
if (shape instanceof Circle || entity.getType() === EntityName.Arc) {
|
||||
// 원·호는 반지름 치수 명령이 더 알맞다
|
||||
toast.info('원·호에는 DIMRADIUS 또는 DIMDIAMETER를 사용하십시오.');
|
||||
return;
|
||||
}
|
||||
const points = sampleEntityPoints(entity);
|
||||
if (points.length < 2) {
|
||||
toast.warn('치수를 넣을 수 없는 객체입니다.');
|
||||
return;
|
||||
}
|
||||
addEntities([makeDimension(points[0], points[points.length - 1], offset)], true);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
/** 지시선·표·구름형·스타일 설정 (조사표 5절 지시선·표·표식·주석 축척) */
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { toast } from 'react-toastify';
|
||||
import {
|
||||
getAnnotationScale,
|
||||
getDimTextHeight,
|
||||
getTableColumnWidth,
|
||||
getTableRowHeight,
|
||||
setAnnotationScale,
|
||||
setDimStyle,
|
||||
setTableStyle,
|
||||
} from '../../commands/dim-settings';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { EntityName } from '../../entities/Entity';
|
||||
import type { TextEntity } from '../../entities/TextEntity';
|
||||
import { revisionCloudPoints } from '../../helpers/geometry/shape-points';
|
||||
import { addEntities, getEntities, setEntities, setSelectedEntityIds } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { lineEntity, polyLineEntity } from '../factories/entity-factory';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
import { annotationText, arrowHead, worldFactor } from './annotation.helpers';
|
||||
|
||||
export const mleaderToolStateMachine = createSequenceTool({
|
||||
tool: Tool.MLEADER,
|
||||
steps: [
|
||||
{ kind: 'point', instructions: '지시선 화살표 위치를 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '지시선 꺾임점을 지정하십시오.' },
|
||||
{ kind: 'text', instructions: '지시선 문자를 입력하십시오.' },
|
||||
],
|
||||
preview: (input) => {
|
||||
const points = input.points();
|
||||
if (points.length !== 1) return [];
|
||||
return [lineEntity(points[0], input.cursor), arrowHead(points[0], input.cursor)];
|
||||
},
|
||||
commit: (input) => {
|
||||
const [tip, knee] = input.points();
|
||||
const textHeight = getDimTextHeight() / worldFactor();
|
||||
// 꺾임점에서 문자 쪽으로 짧은 가로선을 하나 더 뽑는다 (AutoCAD 지시선 모양)
|
||||
const landingLength = textHeight * 2;
|
||||
const toRight = knee.x >= tip.x;
|
||||
const landingEnd = new Point(knee.x + (toRight ? landingLength : -landingLength), knee.y);
|
||||
const label = annotationText(input.text(2), new Point(landingEnd.x, landingEnd.y + textHeight * 0.4));
|
||||
const groupId = crypto.randomUUID();
|
||||
const parts: Entity[] = [
|
||||
lineEntity(tip, knee),
|
||||
lineEntity(knee, landingEnd),
|
||||
arrowHead(tip, knee),
|
||||
label,
|
||||
];
|
||||
for (const part of parts) part.groupId = groupId;
|
||||
addEntities(parts, true);
|
||||
},
|
||||
});
|
||||
|
||||
export const mleaderStyleToolStateMachine = createSequenceTool({
|
||||
tool: Tool.MLEADERSTYLE,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'number', instructions: '지시선 문자 높이를 입력하십시오 <16>.', defaultValue: 16 },
|
||||
{ kind: 'number', instructions: '화살표 크기를 입력하십시오 <20>.', defaultValue: 20 },
|
||||
],
|
||||
commit: (input) => {
|
||||
setDimStyle(input.number(0), input.number(1), -1);
|
||||
toast.success(`지시선 스타일: 문자 ${input.number(0)} · 화살표 ${input.number(1)}`);
|
||||
},
|
||||
});
|
||||
|
||||
/** 지시선 정렬 — 선택한 지시선 문자의 X 위치를 첫 문자에 맞춘다 */
|
||||
export const mleaderAlignToolStateMachine = createSequenceTool({
|
||||
tool: Tool.MLEADERALIGN,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '정렬할 지시선 문자를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
const texts = input
|
||||
.entities(0)
|
||||
.filter((entity) => entity.getType() === EntityName.Text) as TextEntity[];
|
||||
if (texts.length < 2) {
|
||||
toast.warn('지시선 문자를 두 개 이상 선택하십시오.');
|
||||
return;
|
||||
}
|
||||
const targetX = texts[0].getBoundingBox().xmin;
|
||||
for (const text of texts.slice(1)) {
|
||||
text.move(targetX - text.getBoundingBox().xmin, 0);
|
||||
}
|
||||
setEntities([...getEntities()], true);
|
||||
setSelectedEntityIds([]);
|
||||
toast.success(`${texts.length}개 지시선 문자를 정렬했습니다.`);
|
||||
},
|
||||
});
|
||||
|
||||
export const dimStyleToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DIMSTYLE,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'number', instructions: '치수 문자 높이를 입력하십시오 <16>.', defaultValue: 16 },
|
||||
{ kind: 'number', instructions: '화살표 크기를 입력하십시오 <20>.', defaultValue: 20 },
|
||||
{ kind: 'number', instructions: '소수 자릿수를 입력하십시오 <2>.', defaultValue: 2 },
|
||||
],
|
||||
commit: (input) => {
|
||||
setDimStyle(input.number(0), input.number(1), input.number(2));
|
||||
setEntities([...getEntities()], false);
|
||||
toast.success('치수 스타일을 바꿨습니다.');
|
||||
},
|
||||
});
|
||||
|
||||
/** 치수 업데이트 — 스타일을 바꾼 뒤 화면을 다시 그린다 */
|
||||
export function updateDimensions(): string {
|
||||
setEntities([...getEntities()], false);
|
||||
toast.success('치수를 현재 스타일로 갱신했습니다.');
|
||||
return '치수 업데이트';
|
||||
}
|
||||
|
||||
export const annotationScaleToolStateMachine = createSequenceTool({
|
||||
tool: Tool.ANNOSCALE,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'number', instructions: '주석 축척 배율을 입력하십시오 <1>.', defaultValue: 1 }],
|
||||
commit: (input) => {
|
||||
setAnnotationScale(input.number(0));
|
||||
setEntities([...getEntities()], false);
|
||||
toast.success(`주석 축척 ${getAnnotationScale()}배`);
|
||||
},
|
||||
});
|
||||
|
||||
export const tableStyleToolStateMachine = createSequenceTool({
|
||||
tool: Tool.TABLESTYLE,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'number', instructions: '열 너비를 입력하십시오 <40>.', defaultValue: 40 },
|
||||
{ kind: 'number', instructions: '행 높이를 입력하십시오 <10>.', defaultValue: 10 },
|
||||
],
|
||||
commit: (input) => {
|
||||
setTableStyle(input.number(0), input.number(1));
|
||||
toast.success(`표 스타일: 열 ${input.number(0)} · 행 ${input.number(1)}`);
|
||||
},
|
||||
});
|
||||
|
||||
export const tableToolStateMachine = createSequenceTool({
|
||||
tool: Tool.TABLE,
|
||||
steps: [
|
||||
{ kind: 'number', instructions: '열 수를 입력하십시오 <3>.', defaultValue: 3 },
|
||||
{ kind: 'number', instructions: '행 수를 입력하십시오 <3>.', defaultValue: 3 },
|
||||
{ kind: 'point', instructions: '표의 좌측 상단 삽입점을 지정하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const columns = Math.max(1, Math.round(input.number(0)));
|
||||
const rows = Math.max(1, Math.round(input.number(1)));
|
||||
const origin = input.point(2);
|
||||
const width = getTableColumnWidth();
|
||||
const height = getTableRowHeight();
|
||||
const groupId = crypto.randomUUID();
|
||||
const parts: Entity[] = [];
|
||||
|
||||
for (let column = 0; column <= columns; column++) {
|
||||
parts.push(
|
||||
lineEntity(
|
||||
new Point(origin.x + column * width, origin.y),
|
||||
new Point(origin.x + column * width, origin.y - rows * height)
|
||||
)
|
||||
);
|
||||
}
|
||||
for (let row = 0; row <= rows; row++) {
|
||||
parts.push(
|
||||
lineEntity(
|
||||
new Point(origin.x, origin.y - row * height),
|
||||
new Point(origin.x + columns * width, origin.y - row * height)
|
||||
)
|
||||
);
|
||||
}
|
||||
for (const part of parts) part.groupId = groupId;
|
||||
addEntities(parts, true);
|
||||
toast.info('표를 만들었습니다. 칸 내용은 문자(TEXT) 명령으로 채우십시오.');
|
||||
},
|
||||
});
|
||||
|
||||
export const revCloudToolStateMachine = createSequenceTool({
|
||||
tool: Tool.REVCLOUD,
|
||||
steps: [
|
||||
{ kind: 'point', instructions: '구름형 경로의 첫 점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '다음 점을 지정하십시오. ENTER로 닫습니다.' },
|
||||
],
|
||||
repeatLastStep: true,
|
||||
preview: (input) => {
|
||||
const preview = polyLineEntity([...input.points(), input.cursor]);
|
||||
return preview ? [preview] : [];
|
||||
},
|
||||
commit: (input) => {
|
||||
const points = input.points();
|
||||
if (points.length < 2) return;
|
||||
const closed = [...points, points[0].clone()];
|
||||
const box = closed.reduce(
|
||||
(size, point) => ({
|
||||
minX: Math.min(size.minX, point.x),
|
||||
minY: Math.min(size.minY, point.y),
|
||||
maxX: Math.max(size.maxX, point.x),
|
||||
maxY: Math.max(size.maxY, point.y),
|
||||
}),
|
||||
{
|
||||
minX: Number.POSITIVE_INFINITY,
|
||||
minY: Number.POSITIVE_INFINITY,
|
||||
maxX: Number.NEGATIVE_INFINITY,
|
||||
maxY: Number.NEGATIVE_INFINITY,
|
||||
}
|
||||
);
|
||||
// 스캘럽 크기는 구름 크기에 비례시켜 어떤 축척에서도 비슷하게 보이게 한다
|
||||
const arcRadius = Math.max(Math.hypot(box.maxX - box.minX, box.maxY - box.minY) / 40, 1e-6);
|
||||
const cloud = polyLineEntity(revisionCloudPoints(closed, arcRadius));
|
||||
if (cloud) addEntities([cloud], true);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/** 문자 명령 — 여러 줄 문자·단일 행 문자·편집·찾기 (조사표 5절 문자 패널) */
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { toast } from 'react-toastify';
|
||||
import { getAnnotationScale } from '../../commands/dim-settings';
|
||||
import { EntityName } from '../../entities/Entity';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import type { TextEntity } from '../../entities/TextEntity';
|
||||
import { addEntities, getActiveTextStyle, getEntities, setEntities } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { textEntity } from '../factories/entity-factory';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
|
||||
/** AutoCAD 여러 줄 문자의 줄바꿈 표기(\P)와 \n을 모두 받는다 */
|
||||
const splitLines = (text: string): string[] =>
|
||||
text
|
||||
.replace(/\\P/gi, '\n')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
function buildTextLines(lines: string[], basePoint: Point): Entity[] {
|
||||
const lineHeight = getActiveTextStyle().fontSize * getAnnotationScale() * 1.35;
|
||||
const groupId = lines.length > 1 ? crypto.randomUUID() : undefined;
|
||||
return lines.map((line, index) => {
|
||||
const entity = textEntity(line, new Point(basePoint.x, basePoint.y - lineHeight * index), {
|
||||
textAlign: 'left',
|
||||
fontSize: getActiveTextStyle().fontSize * getAnnotationScale(),
|
||||
});
|
||||
entity.groupId = groupId;
|
||||
return entity;
|
||||
});
|
||||
}
|
||||
|
||||
export const textToolStateMachine = createSequenceTool({
|
||||
tool: Tool.TEXT,
|
||||
steps: [
|
||||
{ kind: 'point', instructions: '문자의 시작점을 지정하십시오.' },
|
||||
{ kind: 'text', instructions: '문자를 입력하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const lines = splitLines(input.text(1));
|
||||
if (!lines.length) return;
|
||||
addEntities(buildTextLines(lines.slice(0, 1), input.point(0)), true);
|
||||
},
|
||||
});
|
||||
|
||||
export const mtextToolStateMachine = createSequenceTool({
|
||||
tool: Tool.MTEXT,
|
||||
steps: [
|
||||
{ kind: 'point', instructions: '여러 줄 문자의 첫 코너를 지정하십시오.' },
|
||||
{ kind: 'text', instructions: '문자를 입력하십시오 (줄바꿈은 \\P).' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const lines = splitLines(input.text(1));
|
||||
if (!lines.length) return;
|
||||
addEntities(buildTextLines(lines, input.point(0)), true);
|
||||
},
|
||||
});
|
||||
|
||||
export const textEditToolStateMachine = createSequenceTool({
|
||||
tool: Tool.TEXTEDIT,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '편집할 문자를 선택하십시오.' },
|
||||
{ kind: 'text', instructions: '새 문자를 입력하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const entity = input.entity(0);
|
||||
if (entity.getType() !== EntityName.Text) {
|
||||
toast.warn('문자 객체를 선택하십시오.');
|
||||
return;
|
||||
}
|
||||
(entity as TextEntity).setLabel(input.text(1));
|
||||
setEntities([...getEntities()], true);
|
||||
},
|
||||
});
|
||||
|
||||
export const findToolStateMachine = createSequenceTool({
|
||||
tool: Tool.FIND,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'text', instructions: '찾을 문자열을 입력하십시오.' },
|
||||
{ kind: 'text', instructions: '바꿀 문자열을 입력하십시오 (그대로 두려면 ENTER).', defaultValue: '' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const needle = input.text(0);
|
||||
const replacement = input.text(1);
|
||||
let found = 0;
|
||||
let replaced = 0;
|
||||
for (const entity of getEntities()) {
|
||||
if (entity.getType() !== EntityName.Text) continue;
|
||||
const text = entity as TextEntity;
|
||||
if (!text.getLabel().includes(needle)) continue;
|
||||
found += 1;
|
||||
if (replacement) {
|
||||
text.setLabel(text.getLabel().split(needle).join(replacement));
|
||||
replaced += 1;
|
||||
}
|
||||
}
|
||||
if (replaced) setEntities([...getEntities()], true);
|
||||
toast.info(replaced ? `${replaced}개 문자를 바꿨습니다.` : `${found}개 문자를 찾았습니다.`);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
/** 그리기 명령 — 폴리선·호·다각형·타원·스플라인·점 (조사표 1절) */
|
||||
import { CircleEntity } from '../../entities/CircleEntity';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import {
|
||||
arcThroughThreePoints,
|
||||
ellipsePoints,
|
||||
regularPolygonPoints,
|
||||
splinePoints,
|
||||
} from '../../helpers/geometry/shape-points';
|
||||
import { addEntities, getActiveLayerId } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { arcEntity, lineEntity, pointEntity, polyLineEntity, styled } from '../factories/entity-factory';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
|
||||
export const plineToolStateMachine = createSequenceTool({
|
||||
tool: Tool.PLINE,
|
||||
steps: [
|
||||
{ kind: 'point', instructions: '폴리선의 시작점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '다음 점을 지정하십시오. ENTER로 종료합니다.' },
|
||||
],
|
||||
repeatLastStep: true,
|
||||
preview: (input) => {
|
||||
const preview = polyLineEntity([...input.points(), input.cursor]);
|
||||
return preview ? [preview] : [];
|
||||
},
|
||||
commit: (input) => {
|
||||
const polyline = polyLineEntity(input.points());
|
||||
if (polyline) addEntities([polyline], true);
|
||||
},
|
||||
});
|
||||
|
||||
export const arcToolStateMachine = createSequenceTool({
|
||||
tool: Tool.ARC,
|
||||
steps: [
|
||||
{ kind: 'point', instructions: '호의 시작점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '호가 지나갈 두 번째 점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '호의 끝점을 지정하십시오.' },
|
||||
],
|
||||
preview: (input) => {
|
||||
const points = input.points();
|
||||
if (points.length === 1) return [lineEntity(points[0], input.cursor)];
|
||||
if (points.length === 2) {
|
||||
const definition = arcThroughThreePoints(points[0], points[1], input.cursor);
|
||||
return definition ? [arcEntity(definition)] : [lineEntity(points[0], input.cursor)];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
commit: (input) => {
|
||||
const [start, through, end] = input.points();
|
||||
const definition = arcThroughThreePoints(start, through, end);
|
||||
// 세 점이 일직선이면 호가 성립하지 않으므로 선으로 대체한다
|
||||
addEntities([definition ? arcEntity(definition) : lineEntity(start, end)], true);
|
||||
},
|
||||
});
|
||||
|
||||
export const polygonToolStateMachine = createSequenceTool({
|
||||
tool: Tool.POLYGON,
|
||||
steps: [
|
||||
{ kind: 'number', instructions: '면의 수를 입력하십시오 <6>.', defaultValue: 6 },
|
||||
{ kind: 'point', instructions: '다각형의 중심을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '꼭짓점을 지정하십시오 (중심에서의 반지름).' },
|
||||
],
|
||||
preview: (input) => {
|
||||
const points = input.points();
|
||||
if (points.length !== 1) return [];
|
||||
const polygon = polyLineEntity(regularPolygonPoints(points[0], input.cursor, input.number(0)));
|
||||
return polygon ? [polygon] : [];
|
||||
},
|
||||
commit: (input) => {
|
||||
const [center, vertex] = input.points();
|
||||
const polygon = polyLineEntity(regularPolygonPoints(center, vertex, input.number(0)));
|
||||
if (polygon) addEntities([polygon], true);
|
||||
},
|
||||
});
|
||||
|
||||
export const ellipseToolStateMachine = createSequenceTool({
|
||||
tool: Tool.ELLIPSE,
|
||||
steps: [
|
||||
{ kind: 'point', instructions: '타원의 중심을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '장축의 끝점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '단축 거리를 지정하십시오.' },
|
||||
],
|
||||
preview: (input) => {
|
||||
const points = input.points();
|
||||
if (points.length === 1) return [lineEntity(points[0], input.cursor)];
|
||||
if (points.length === 2) {
|
||||
const minor = points[0].distanceTo(input.cursor)[0];
|
||||
const ellipse = polyLineEntity(ellipsePoints(points[0], points[1], minor));
|
||||
return ellipse ? [ellipse] : [];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
commit: (input) => {
|
||||
const [center, majorPoint, minorPoint] = input.points();
|
||||
const minorRadius = center.distanceTo(minorPoint)[0];
|
||||
const ellipse = polyLineEntity(ellipsePoints(center, majorPoint, minorRadius));
|
||||
if (ellipse) addEntities([ellipse], true);
|
||||
},
|
||||
});
|
||||
|
||||
export const splineToolStateMachine = createSequenceTool({
|
||||
tool: Tool.SPLINE,
|
||||
steps: [
|
||||
{ kind: 'point', instructions: '스플라인의 첫 점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '다음 점을 지정하십시오. ENTER로 종료합니다.' },
|
||||
],
|
||||
repeatLastStep: true,
|
||||
preview: (input) => {
|
||||
const preview = polyLineEntity(splinePoints([...input.points(), input.cursor]));
|
||||
return preview ? [preview] : [];
|
||||
},
|
||||
commit: (input) => {
|
||||
const spline = polyLineEntity(splinePoints(input.points()));
|
||||
if (spline) addEntities([spline], true);
|
||||
},
|
||||
});
|
||||
|
||||
export const pointToolStateMachine = createSequenceTool({
|
||||
tool: Tool.POINT,
|
||||
steps: [{ kind: 'point', instructions: '점의 위치를 지정하십시오.' }],
|
||||
preview: (input) => [pointEntity(input.cursor)],
|
||||
commit: (input) => {
|
||||
addEntities([pointEntity(input.point(0))], true);
|
||||
},
|
||||
});
|
||||
|
||||
/** 도넛 — 안쪽·바깥쪽 지름을 받아 동심원 두 개를 놓는다 */
|
||||
export const donutToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DONUT,
|
||||
steps: [
|
||||
{ kind: 'number', instructions: '도넛의 내부 지름을 입력하십시오 <1>.', defaultValue: 1 },
|
||||
{ kind: 'number', instructions: '도넛의 외부 지름을 입력하십시오 <2>.', defaultValue: 2 },
|
||||
{ kind: 'point', instructions: '도넛의 중심을 지정하십시오.' },
|
||||
],
|
||||
preview: (input) => donutEntities(input.number(0), input.number(1), input.cursor),
|
||||
commit: (input) => {
|
||||
addEntities(donutEntities(input.number(0), input.number(1), input.point(2)), true);
|
||||
},
|
||||
});
|
||||
|
||||
function donutEntities(innerDiameter: number, outerDiameter: number, center: Parameters<typeof pointEntity>[0]): Entity[] {
|
||||
const circles: Entity[] = [];
|
||||
for (const diameter of [innerDiameter, outerDiameter]) {
|
||||
if (diameter > 0) {
|
||||
circles.push(styled(new CircleEntity(getActiveLayerId(), center, diameter / 2)));
|
||||
}
|
||||
}
|
||||
return circles;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/** 구성선·광선·다중선·와이프아웃 (조사표 1절 후반) */
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { getMlineElements, getMlineSpacing, setMlineStyle } from '../../commands/draw-settings';
|
||||
import { offsetPolylinePoints } from '../../helpers/geometry/shape-points';
|
||||
import { addEntities, getScreenCanvasDrawController } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { hatchEntity, lineEntity, polyLineEntity } from '../factories/entity-factory';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
|
||||
/**
|
||||
* 구성선은 원래 무한하지만, 무한 선은 경계상자를 망가뜨려 [범위 줌]을 못 쓰게 만든다.
|
||||
* 현재 화면 대각선의 20배로 그어 화면 안에서는 무한선처럼 보이게 한다.
|
||||
*/
|
||||
function constructionLength(): number {
|
||||
const controller = getScreenCanvasDrawController();
|
||||
const size = controller.getCanvasSize();
|
||||
const scale = controller.getScreenScale() || 1;
|
||||
return (Math.hypot(size.x, size.y) / scale) * 20;
|
||||
}
|
||||
|
||||
function extendFrom(base: Point, through: Point, bothWays: boolean): [Point, Point] {
|
||||
const dx = through.x - base.x;
|
||||
const dy = through.y - base.y;
|
||||
const length = Math.hypot(dx, dy) || 1;
|
||||
const reach = constructionLength();
|
||||
const forward = new Point(base.x + (dx / length) * reach, base.y + (dy / length) * reach);
|
||||
const backward = bothWays
|
||||
? new Point(base.x - (dx / length) * reach, base.y - (dy / length) * reach)
|
||||
: base;
|
||||
return [backward, forward];
|
||||
}
|
||||
|
||||
export const xlineToolStateMachine = createSequenceTool({
|
||||
tool: Tool.XLINE,
|
||||
steps: [
|
||||
{ kind: 'point', instructions: '구성선이 지날 점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '방향을 지정하십시오.' },
|
||||
],
|
||||
preview: (input) => {
|
||||
const points = input.points();
|
||||
if (points.length !== 1) return [];
|
||||
const [start, end] = extendFrom(points[0], input.cursor, true);
|
||||
return [lineEntity(start, end)];
|
||||
},
|
||||
commit: (input) => {
|
||||
const [base, through] = input.points();
|
||||
const [start, end] = extendFrom(base, through, true);
|
||||
addEntities([lineEntity(start, end)], true);
|
||||
},
|
||||
});
|
||||
|
||||
export const rayToolStateMachine = createSequenceTool({
|
||||
tool: Tool.RAY,
|
||||
steps: [
|
||||
{ kind: 'point', instructions: '광선의 시작점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '통과점을 지정하십시오.' },
|
||||
],
|
||||
preview: (input) => {
|
||||
const points = input.points();
|
||||
if (points.length !== 1) return [];
|
||||
const [start, end] = extendFrom(points[0], input.cursor, false);
|
||||
return [lineEntity(start, end)];
|
||||
},
|
||||
commit: (input) => {
|
||||
const [base, through] = input.points();
|
||||
const [start, end] = extendFrom(base, through, false);
|
||||
addEntities([lineEntity(start, end)], true);
|
||||
},
|
||||
});
|
||||
|
||||
/** 다중선 — 중심선을 기준으로 MLSTYLE의 요소 수·간격만큼 평행선을 만든다 */
|
||||
function mlineEntities(points: Point[]): Entity[] {
|
||||
if (points.length < 2) return [];
|
||||
const elements = getMlineElements();
|
||||
const spacing = getMlineSpacing();
|
||||
const result: Entity[] = [];
|
||||
for (let index = 0; index < elements; index++) {
|
||||
const offset = (index - (elements - 1) / 2) * spacing;
|
||||
const line = polyLineEntity(offsetPolylinePoints(points, offset));
|
||||
if (line) result.push(line);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export const mlineToolStateMachine = createSequenceTool({
|
||||
tool: Tool.MLINE,
|
||||
steps: [
|
||||
{ kind: 'point', instructions: '다중선의 시작점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '다음 점을 지정하십시오. ENTER로 종료합니다.' },
|
||||
],
|
||||
repeatLastStep: true,
|
||||
preview: (input) => mlineEntities([...input.points(), input.cursor]),
|
||||
commit: (input) => {
|
||||
const entities = mlineEntities(input.points());
|
||||
if (entities.length) addEntities(entities, true);
|
||||
},
|
||||
});
|
||||
|
||||
export const mlstyleToolStateMachine = createSequenceTool({
|
||||
tool: Tool.MLSTYLE,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'number', instructions: '다중선 요소 수를 입력하십시오 <2>.', defaultValue: 2 },
|
||||
{ kind: 'number', instructions: '요소 간격을 입력하십시오 <1>.', defaultValue: 1 },
|
||||
],
|
||||
commit: (input) => {
|
||||
setMlineStyle(input.number(0), input.number(1));
|
||||
toast.success(`다중선 스타일: ${getMlineElements()}줄, 간격 ${getMlineSpacing()}`);
|
||||
},
|
||||
});
|
||||
|
||||
/** 캔버스 배경색 — 와이프아웃은 이 색으로 뒤 객체를 가린다 */
|
||||
function canvasBackgroundColor(): string {
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue('--cad-canvas').trim();
|
||||
return value || '#1e1e1e';
|
||||
}
|
||||
|
||||
export const wipeoutToolStateMachine = createSequenceTool({
|
||||
tool: Tool.WIPEOUT,
|
||||
steps: [
|
||||
{ kind: 'point', instructions: '와이프아웃 경계의 첫 점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '다음 점을 지정하십시오. ENTER로 닫습니다.' },
|
||||
],
|
||||
repeatLastStep: true,
|
||||
preview: (input) => {
|
||||
const preview = polyLineEntity([...input.points(), input.cursor]);
|
||||
return preview ? [preview] : [];
|
||||
},
|
||||
commit: (input) => {
|
||||
const points = input.points();
|
||||
if (points.length < 3) return;
|
||||
const mask = hatchEntity([...points, points[0].clone()], {
|
||||
style: 'solid',
|
||||
color: canvasBackgroundColor(),
|
||||
});
|
||||
mask.lineColor = canvasBackgroundColor();
|
||||
// 가리개는 뒤 객체를 덮어야 하므로 가장 나중에 그려지도록 목록 끝에 넣는다
|
||||
addEntities([mask], true);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/** 등분(DIVIDE)·길이분할(MEASURE) — 객체를 자르지 않고 점만 놓는다 */
|
||||
import { toast } from 'react-toastify';
|
||||
import { dividePoints, measurePoints, sampleEntityPoints } from '../../helpers/geometry/sample-entity';
|
||||
import { addEntities } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { pointEntity } from '../factories/entity-factory';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
|
||||
export const divideToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DIVIDE,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '등분할 객체를 선택하십시오.' },
|
||||
{ kind: 'number', instructions: '세그먼트 수를 입력하십시오 <4>.', defaultValue: 4 },
|
||||
],
|
||||
commit: (input) => {
|
||||
const points = dividePoints(sampleEntityPoints(input.entity(0)), input.number(1));
|
||||
if (!points.length) {
|
||||
toast.warn('등분할 수 없는 객체입니다.');
|
||||
return;
|
||||
}
|
||||
addEntities(points.map(pointEntity), true);
|
||||
},
|
||||
});
|
||||
|
||||
export const measureLengthToolStateMachine = createSequenceTool({
|
||||
tool: Tool.MEASURE_LENGTH,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '길이로 분할할 객체를 선택하십시오.' },
|
||||
{ kind: 'number', instructions: '세그먼트 길이를 입력하십시오 <10>.', defaultValue: 10 },
|
||||
],
|
||||
commit: (input) => {
|
||||
const points = measurePoints(sampleEntityPoints(input.entity(0)), input.number(1));
|
||||
if (!points.length) {
|
||||
toast.warn('지정한 길이로 나눌 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
addEntities(points.map(pointEntity), true);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/** 해치·그라데이션·경계·영역 — 선택한 객체가 이루는 닫힌 경계를 채우거나 뽑는다 */
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import { toast } from 'react-toastify';
|
||||
import {
|
||||
autoHatchSpacing,
|
||||
getHatchAngle,
|
||||
getHatchSpacing,
|
||||
getHatchStyle,
|
||||
} from '../../commands/draw-settings';
|
||||
import { entitiesToLoop } from '../../helpers/geometry/entity-loop';
|
||||
import { addEntities, getActiveLineColor, setSelectedEntityIds } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { hatchEntity, polyLineEntity } from '../factories/entity-factory';
|
||||
import { createSequenceTool, type SequenceInput } from '../factories/sequence-tool';
|
||||
|
||||
/** 선택 객체에서 닫힌 경계를 얻는다. 못 얻으면 안내 후 빈 배열 */
|
||||
function loopFromSelection(input: SequenceInput): Point[] {
|
||||
const loop = entitiesToLoop(input.entities(0));
|
||||
if (loop.length < 3) {
|
||||
toast.warn('닫힌 경계를 만들 객체를 선택하십시오.');
|
||||
return [];
|
||||
}
|
||||
return loop;
|
||||
}
|
||||
|
||||
function boundingSize(points: Point[]): { width: number; height: number } {
|
||||
const xs = points.map((point) => point.x);
|
||||
const ys = points.map((point) => point.y);
|
||||
return { width: Math.max(...xs) - Math.min(...xs), height: Math.max(...ys) - Math.min(...ys) };
|
||||
}
|
||||
|
||||
export const hatchToolStateMachine = createSequenceTool({
|
||||
tool: Tool.HATCH,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'selection', instructions: '해치를 채울 경계 객체를 선택한 뒤 ENTER를 누르십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const loop = loopFromSelection(input);
|
||||
if (!loop.length) return;
|
||||
const { width, height } = boundingSize(loop);
|
||||
addEntities(
|
||||
[
|
||||
hatchEntity(loop, {
|
||||
style: getHatchStyle(),
|
||||
color: getActiveLineColor(),
|
||||
spacing: getHatchSpacing() ?? autoHatchSpacing(width, height),
|
||||
angle: getHatchAngle(),
|
||||
}),
|
||||
],
|
||||
true
|
||||
);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
|
||||
export const gradientToolStateMachine = createSequenceTool({
|
||||
tool: Tool.GRADIENT,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'selection', instructions: '그라데이션을 넣을 경계 객체를 선택한 뒤 ENTER.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const loop = loopFromSelection(input);
|
||||
if (!loop.length) return;
|
||||
addEntities(
|
||||
[
|
||||
hatchEntity(loop, {
|
||||
style: 'gradient',
|
||||
color: getActiveLineColor(),
|
||||
color2: '#000000',
|
||||
}),
|
||||
],
|
||||
true
|
||||
);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
|
||||
export const boundaryToolStateMachine = createSequenceTool({
|
||||
tool: Tool.BOUNDARY,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'selection', instructions: '경계를 뽑을 객체를 선택한 뒤 ENTER를 누르십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const loop = loopFromSelection(input);
|
||||
if (!loop.length) return;
|
||||
const boundary = polyLineEntity(loop);
|
||||
if (boundary) {
|
||||
addEntities([boundary], true);
|
||||
toast.success('닫힌 폴리선 경계를 만들었습니다.');
|
||||
}
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
|
||||
export const regionToolStateMachine = createSequenceTool({
|
||||
tool: Tool.REGION,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '영역으로 만들 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
const loop = loopFromSelection(input);
|
||||
if (!loop.length) return;
|
||||
const region = polyLineEntity(loop);
|
||||
if (region) {
|
||||
addEntities([region], true);
|
||||
// 2D 웹 CAD에는 별도 영역 객체가 없다 — 닫힌 폴리선이 같은 역할을 한다
|
||||
toast.info('영역은 닫힌 폴리선으로 작성했습니다.');
|
||||
}
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/** 현재 도면층·선 특성을 입혀 엔티티를 만드는 공통 생성기. */
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import { ArcEntity } from '../../entities/ArcEntity';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { HatchEntity, type HatchOptions } from '../../entities/HatchEntity';
|
||||
import { LineEntity } from '../../entities/LineEntity';
|
||||
import { PointEntity } from '../../entities/PointEntity';
|
||||
import { PolyLineEntity } from '../../entities/PolyLineEntity';
|
||||
import { TextEntity, type TextOptions } from '../../entities/TextEntity';
|
||||
import type { ArcDefinition } from '../../helpers/geometry/shape-points';
|
||||
import {
|
||||
getActiveLayerId,
|
||||
getActiveLineColor,
|
||||
getActiveLineDash,
|
||||
getActiveLineWidth,
|
||||
getActiveTextStyle,
|
||||
} from '../../state';
|
||||
|
||||
/** 현재 리본에서 고른 색·굵기·선종류를 엔티티에 입힌다 */
|
||||
export function styled<T extends Entity>(entity: T): T {
|
||||
entity.lineColor = getActiveLineColor();
|
||||
entity.lineWidth = getActiveLineWidth();
|
||||
entity.lineDash = getActiveLineDash();
|
||||
return entity;
|
||||
}
|
||||
|
||||
export const lineEntity = (start: Point, end: Point): LineEntity =>
|
||||
styled(new LineEntity(getActiveLayerId(), start, end));
|
||||
|
||||
export const pointEntity = (point: Point): PointEntity =>
|
||||
styled(new PointEntity(getActiveLayerId(), point));
|
||||
|
||||
export const arcEntity = (arc: ArcDefinition): ArcEntity =>
|
||||
styled(
|
||||
new ArcEntity(
|
||||
getActiveLayerId(),
|
||||
arc.center,
|
||||
arc.radius,
|
||||
arc.startAngle,
|
||||
arc.endAngle,
|
||||
arc.counterClockwise
|
||||
)
|
||||
);
|
||||
|
||||
/** 점렬을 하나의 폴리선 객체로 만든다 (2점 미만이면 null) */
|
||||
export function polyLineEntity(points: Point[]): PolyLineEntity | null {
|
||||
if (points.length < 2) return null;
|
||||
const segments: Entity[] = [];
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
segments.push(lineEntity(points[index - 1], points[index]));
|
||||
}
|
||||
return styled(new PolyLineEntity(getActiveLayerId(), segments));
|
||||
}
|
||||
|
||||
export const hatchEntity = (points: Point[], options?: Partial<HatchOptions>): HatchEntity =>
|
||||
styled(new HatchEntity(getActiveLayerId(), points, options));
|
||||
|
||||
export const textEntity = (label: string, basePoint: Point, options?: Partial<TextOptions>) =>
|
||||
styled(
|
||||
new TextEntity(getActiveLayerId(), label, basePoint, {
|
||||
...getActiveTextStyle(),
|
||||
...options,
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* 시퀀스 도구 공장 — AutoCAD 명령의 공통 골격(단계별 입력 → 미리보기 → 확정)을
|
||||
* 선언만으로 만든다.
|
||||
*
|
||||
* 명령마다 xstate 머신을 손으로 쓰면 2점 그리기에도 170줄이 든다. 여기서는
|
||||
* `steps`(점·숫자·문자·객체 선택)를 나열하고 `commit`만 채우면 같은 머신이 나온다.
|
||||
*/
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import { Actor, assign, createMachine, sendTo } from 'xstate';
|
||||
import { HIGHLIGHT_ENTITY_DISTANCE } from '../../App.consts';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { findClosestEntity } from '../../helpers/find-closest-entity';
|
||||
import { getPointFromEvent } from '../../helpers/get-point-from-event';
|
||||
import { queryEntitiesNearPoint } from '../../helpers/spatial-index';
|
||||
import {
|
||||
getScreenCanvasDrawController,
|
||||
getSelectedEntities,
|
||||
setActiveToolActor,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setShouldDrawHelpers,
|
||||
} from '../../state';
|
||||
import type { Tool } from '../../tools';
|
||||
import { selectToolStateMachine } from '../select-tool';
|
||||
import type {
|
||||
DrawEvent,
|
||||
MouseClickEvent,
|
||||
NumberInputEvent,
|
||||
PointInputEvent,
|
||||
StateEvent,
|
||||
TextInputEvent,
|
||||
ToolContext,
|
||||
} from '../tool.types';
|
||||
|
||||
export type SequenceValue = Point | number | string | Entity | Entity[];
|
||||
|
||||
export type SequenceStepKind = 'point' | 'number' | 'text' | 'entity' | 'selection';
|
||||
|
||||
export interface SequenceStep {
|
||||
kind: SequenceStepKind;
|
||||
/** 명령행·커서 옆에 뜨는 안내문 */
|
||||
instructions: string;
|
||||
/** number·text 단계에서 ENTER만 눌렀을 때 채택할 값 */
|
||||
defaultValue?: number | string;
|
||||
}
|
||||
|
||||
/** commit·preview에 넘어가는 입력 묶음. 단계 순서 그대로 인덱스로 읽는다. */
|
||||
export interface SequenceInput {
|
||||
values: SequenceValue[];
|
||||
/** 커서(미리보기) 또는 마지막 확정 점 */
|
||||
cursor: Point;
|
||||
point(index: number): Point;
|
||||
/** 모든 점 값 (가변 점 명령에서 사용) */
|
||||
points(): Point[];
|
||||
number(index: number): number;
|
||||
text(index: number): string;
|
||||
entity(index: number): Entity;
|
||||
entities(index: number): Entity[];
|
||||
/** 객체 선택 단계에서 실제로 클릭한 위치 (어느 쪽을 집었는지 필요한 명령용) */
|
||||
pick(index: number): Point;
|
||||
}
|
||||
|
||||
export interface SequenceToolConfig {
|
||||
tool: Tool;
|
||||
steps: SequenceStep[];
|
||||
/** 마지막 점 단계를 ENTER 전까지 반복해 점을 모은다 (폴리선·스플라인) */
|
||||
repeatLastStep?: boolean;
|
||||
/** 커서를 따라 그려줄 임시 엔티티 */
|
||||
preview?: (input: SequenceInput) => Entity[];
|
||||
/** 확정 — 엔티티 추가·상태 변경을 직접 수행한다 */
|
||||
commit: (input: SequenceInput) => void;
|
||||
/**
|
||||
* 확정 뒤 같은 명령을 처음부터 다시 시작할지.
|
||||
* 기본값은 "점만 찍는 그리기 명령이면 계속, 숫자·문자·객체 선택이 섞이면 종료".
|
||||
* 종료하면 선택 도구로 돌아가 명령행 입력이 다음 명령으로 해석된다 (AutoCAD와 같다).
|
||||
*/
|
||||
restart?: boolean;
|
||||
/** 확정 뒤 마지막 점을 첫 값으로 이어받아 계속 그린다 (선) */
|
||||
chainFromLastPoint?: boolean;
|
||||
/** 점 입력 중 스냅·각도 가이드 사용 여부 (기본 true) */
|
||||
helpers?: boolean;
|
||||
}
|
||||
|
||||
export interface SequenceContext extends ToolContext {
|
||||
values: SequenceValue[];
|
||||
/** values와 같은 자리에 놓이는 클릭 위치 (객체를 집은 지점) */
|
||||
picks: (Point | null)[];
|
||||
}
|
||||
|
||||
const STEP_STATE = (index: number) => `STEP_${index}`;
|
||||
const COMMIT_STATE = 'COMMIT';
|
||||
const INIT_STATE = 'INIT';
|
||||
|
||||
function makeInput(
|
||||
values: SequenceValue[],
|
||||
cursor: Point,
|
||||
picks: (Point | null)[] = []
|
||||
): SequenceInput {
|
||||
/** 단계 인덱스로 값을 꺼낸다. 종류가 다르면 어느 단계가 잘못됐는지 바로 알린다. */
|
||||
const expect = <T>(index: number, kind: string, ok: (value: SequenceValue) => boolean): T => {
|
||||
const value = values[index];
|
||||
if (!ok(value)) {
|
||||
throw new Error(`[sequence-tool] ${index}번 단계 값이 ${kind}이(가) 아닙니다`);
|
||||
}
|
||||
return value as T;
|
||||
};
|
||||
|
||||
return {
|
||||
values,
|
||||
cursor,
|
||||
pick: (index: number) => (picks[index] ?? cursor) as Point,
|
||||
point: (index: number) => expect<Point>(index, '점', isPointValue),
|
||||
points: () => values.filter((value) => isPointValue(value)) as Point[],
|
||||
number: (index: number) => expect<number>(index, '숫자', (v) => typeof v === 'number'),
|
||||
text: (index: number) => expect<string>(index, '문자', (v) => typeof v === 'string'),
|
||||
entity: (index: number) =>
|
||||
expect<Entity>(index, '객체', (v) => !!v && typeof (v as Entity).getType === 'function'),
|
||||
entities: (index: number) => expect<Entity[]>(index, '선택 목록', Array.isArray),
|
||||
};
|
||||
}
|
||||
|
||||
/** flatten-js Point 판별 — instanceof는 번들 중복 시 실패할 수 있어 형태로 본다. */
|
||||
function isPointValue(value: SequenceValue): boolean {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
typeof (value as Point).x === 'number' &&
|
||||
typeof (value as Point).y === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
/** 마지막으로 찍은 점 — 상대 좌표·거리 입력의 기준점이 된다. */
|
||||
function lastPoint(values: SequenceValue[]): Point | null {
|
||||
for (let index = values.length - 1; index >= 0; index--) {
|
||||
if (isPointValue(values[index])) return values[index] as Point;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function cursorPoint(event: StateEvent | undefined): Point {
|
||||
if (event && (event as DrawEvent).drawController) {
|
||||
return (event as DrawEvent).drawController.getWorldMouseLocation();
|
||||
}
|
||||
return getScreenCanvasDrawController().getWorldMouseLocation();
|
||||
}
|
||||
|
||||
/** 클릭 지점에서 가장 가까운 엔티티 (선택 반경 안에 있을 때만) */
|
||||
function pickEntityAt(worldPoint: Point): Entity | null {
|
||||
const scale = getScreenCanvasDrawController().getScreenScale() || 1;
|
||||
const radius = HIGHLIGHT_ENTITY_DISTANCE / scale;
|
||||
const candidates = queryEntitiesNearPoint(worldPoint.x, worldPoint.y, radius);
|
||||
const { distance, entity } = findClosestEntity(worldPoint, candidates);
|
||||
return entity && distance <= radius ? entity : null;
|
||||
}
|
||||
|
||||
export function createSequenceTool(config: SequenceToolConfig) {
|
||||
const { steps, tool } = config;
|
||||
const useHelpers = config.helpers !== false;
|
||||
const lastIndex = steps.length - 1;
|
||||
|
||||
const nextTarget = (index: number): string => {
|
||||
if (config.repeatLastStep && index === lastIndex) return STEP_STATE(index);
|
||||
return index < lastIndex ? STEP_STATE(index + 1) : COMMIT_STATE;
|
||||
};
|
||||
|
||||
const resetTool = () => {
|
||||
setShouldDrawHelpers(useHelpers);
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
};
|
||||
|
||||
const drawPreview = ({ context, event }: { context: SequenceContext; event: StateEvent }) => {
|
||||
if (!config.preview) return;
|
||||
const ghosts = config.preview(makeInput(context.values, cursorPoint(event), context.picks));
|
||||
setGhostHelperEntities(ghosts);
|
||||
};
|
||||
|
||||
const pushPoint = assign(({ context, event }: { context: SequenceContext; event: StateEvent }) => {
|
||||
const point = getPointFromEvent(lastPoint(context.values), event as PointInputEvent);
|
||||
setAngleGuideOriginPoint(point);
|
||||
return { values: [...context.values, point], picks: [...context.picks, point] };
|
||||
});
|
||||
|
||||
const pushNumberFromEvent = assign(
|
||||
({ context, event }: { context: SequenceContext; event: StateEvent }) => {
|
||||
const value =
|
||||
event.type === 'NUMBER_INPUT'
|
||||
? (event as NumberInputEvent).value
|
||||
: Number.parseFloat((event as TextInputEvent).value);
|
||||
return { values: [...context.values, value], picks: [...context.picks, null] };
|
||||
}
|
||||
);
|
||||
|
||||
const pushDefault = (index: number) =>
|
||||
assign(({ context }: { context: SequenceContext }) => ({
|
||||
values: [...context.values, steps[index].defaultValue as SequenceValue],
|
||||
picks: [...context.picks, null],
|
||||
}));
|
||||
|
||||
const pushText = assign(({ context, event }: { context: SequenceContext; event: StateEvent }) => ({
|
||||
values: [...context.values, (event as TextInputEvent).value],
|
||||
picks: [...context.picks, null],
|
||||
}));
|
||||
|
||||
const pushEntity = assign(
|
||||
({ context, event }: { context: SequenceContext; event: StateEvent }) => {
|
||||
const location = (event as MouseClickEvent).worldMouseLocation;
|
||||
const picked = pickEntityAt(location);
|
||||
return picked
|
||||
? { values: [...context.values, picked], picks: [...context.picks, location] }
|
||||
: { values: context.values, picks: context.picks };
|
||||
}
|
||||
);
|
||||
|
||||
const pushSelection = assign(({ context }: { context: SequenceContext }) => ({
|
||||
values: [...context.values, getSelectedEntities()],
|
||||
picks: [...context.picks, null],
|
||||
}));
|
||||
|
||||
// 점만 받는 그리기 명령은 계속 그리게 두고, 그 밖의 명령은 끝나면 선택 도구로 돌아간다
|
||||
const keepRunning =
|
||||
config.restart ?? (steps.every((step) => step.kind === 'point') || !!config.chainFromLastPoint);
|
||||
|
||||
const commit = assign(({ context }: { context: SequenceContext }) => {
|
||||
const points = context.values.filter(isPointValue) as Point[];
|
||||
const cursor = points.length ? points[points.length - 1] : cursorPoint(undefined);
|
||||
config.commit(makeInput(context.values, cursor, context.picks));
|
||||
setGhostHelperEntities([]);
|
||||
if (!keepRunning) {
|
||||
// 전이 도중에 액터를 갈아치우지 않도록 다음 틱에 도구를 바꾼다
|
||||
setTimeout(() => setActiveToolActor(new Actor(selectToolStateMachine)), 0);
|
||||
}
|
||||
if (config.chainFromLastPoint && points.length) {
|
||||
const tail = points[points.length - 1];
|
||||
setAngleGuideOriginPoint(tail);
|
||||
return { values: [tail] as SequenceValue[], picks: [tail] as (Point | null)[] };
|
||||
}
|
||||
return { values: [] as SequenceValue[], picks: [] as (Point | null)[] };
|
||||
});
|
||||
|
||||
const cancel = assign(({ context }: { context: SequenceContext }) => {
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
if (context.values.length === 0) {
|
||||
// 입력이 하나도 없는 상태의 ESC → 선택 도구로 빠져나간다 (AutoCAD와 동일)
|
||||
setActiveToolActor(new Actor(selectToolStateMachine));
|
||||
}
|
||||
return { values: [] as SequenceValue[], picks: [] as (Point | null)[] };
|
||||
});
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: xstate 상태 구성은 동적으로 만든다
|
||||
const states: Record<string, any> = {
|
||||
[INIT_STATE]: {
|
||||
always: { actions: resetTool, target: STEP_STATE(0) },
|
||||
},
|
||||
[COMMIT_STATE]: {
|
||||
always: {
|
||||
actions: commit,
|
||||
target: config.chainFromLastPoint ? STEP_STATE(1) : INIT_STATE,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
steps.forEach((step, index) => {
|
||||
const target = nextTarget(index);
|
||||
const escTransition = { actions: cancel, target: INIT_STATE };
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 이벤트 맵도 단계 종류마다 달라진다
|
||||
const on: Record<string, any> = { ESC: escTransition };
|
||||
|
||||
if (step.kind === 'point') {
|
||||
on.DRAW = { actions: drawPreview };
|
||||
on.MOUSE_CLICK = { actions: pushPoint, target };
|
||||
on.ABSOLUTE_POINT_INPUT = { actions: pushPoint, target };
|
||||
on.RELATIVE_POINT_INPUT = { actions: pushPoint, target };
|
||||
on.NUMBER_INPUT = { actions: pushPoint, target };
|
||||
if (config.repeatLastStep && index === lastIndex) {
|
||||
on.ENTER = { target: COMMIT_STATE };
|
||||
}
|
||||
} else if (step.kind === 'number') {
|
||||
on.DRAW = { actions: drawPreview };
|
||||
on.NUMBER_INPUT = { actions: pushNumberFromEvent, target };
|
||||
on.TEXT_INPUT = {
|
||||
guard: ({ event }: { event: StateEvent }) =>
|
||||
Number.isFinite(Number.parseFloat((event as TextInputEvent).value)),
|
||||
actions: pushNumberFromEvent,
|
||||
target,
|
||||
};
|
||||
if (step.defaultValue !== undefined) {
|
||||
on.ENTER = { actions: pushDefault(index), target };
|
||||
}
|
||||
} else if (step.kind === 'text') {
|
||||
on.TEXT_INPUT = { actions: pushText, target };
|
||||
on.NUMBER_INPUT = { actions: pushNumberFromEvent, target };
|
||||
if (step.defaultValue !== undefined) {
|
||||
on.ENTER = { actions: pushDefault(index), target };
|
||||
}
|
||||
} else if (step.kind === 'entity') {
|
||||
on.DRAW = { actions: drawPreview };
|
||||
on.MOUSE_CLICK = {
|
||||
guard: ({ event }: { event: StateEvent }) =>
|
||||
!!pickEntityAt((event as MouseClickEvent).worldMouseLocation),
|
||||
actions: pushEntity,
|
||||
target,
|
||||
};
|
||||
}
|
||||
|
||||
if (step.kind === 'selection') {
|
||||
const actorId = `selectInside_${index}`;
|
||||
states[STEP_STATE(index)] = {
|
||||
meta: { instructions: step.instructions },
|
||||
invoke: {
|
||||
id: actorId,
|
||||
src: selectToolStateMachine,
|
||||
onDone: { actions: pushSelection, target },
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: { actions: sendTo(actorId, ({ event }) => event) },
|
||||
ENTER: { actions: sendTo(actorId, ({ event }) => event) },
|
||||
DRAW: { actions: sendTo(actorId, ({ event }) => event) },
|
||||
ESC: escTransition,
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
states[STEP_STATE(index)] = {
|
||||
meta: { instructions: step.instructions },
|
||||
entry: () => {
|
||||
// 객체 선택 단계가 아니면 스냅·각도 가이드를 켠다
|
||||
setShouldDrawHelpers(useHelpers && step.kind === 'point');
|
||||
},
|
||||
on,
|
||||
};
|
||||
});
|
||||
|
||||
return createMachine(
|
||||
{
|
||||
types: {} as { context: SequenceContext; events: StateEvent },
|
||||
context: { values: [] as SequenceValue[], picks: [] as (Point | null)[], type: tool },
|
||||
initial: INIT_STATE,
|
||||
states,
|
||||
},
|
||||
{
|
||||
// 하위에서 돌리는 선택 도구의 액션 구현을 함께 넘긴다
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 자식 머신의 컨텍스트 타입이 달라 그대로 넘긴다
|
||||
actions: { ...(selectToolStateMachine.implementations.actions as any) },
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
import { type Point, Vector } from '@flatten-js/core';
|
||||
import { MeasurementEntity } from '../entities/MeasurementEntity';
|
||||
import {
|
||||
addEntities,
|
||||
getActiveLayerId,
|
||||
getActiveLineColor,
|
||||
getActiveLineDash,
|
||||
getActiveLineWidth,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
} from '../state';
|
||||
import { Tool } from '../tools';
|
||||
import { assign, createMachine } from 'xstate';
|
||||
import type { DrawEvent, PointInputEvent, StateEvent, ToolContext } from './tool.types';
|
||||
import { MEASUREMENT_DEFAULT_OFFSET, TO_RADIANS } from '../App.consts';
|
||||
import { getPointFromEvent } from '../helpers/get-point-from-event.ts';
|
||||
import { isPointEqual } from '../helpers/is-point-equal.ts';
|
||||
|
||||
export interface MeasurementContext extends ToolContext {
|
||||
startPoint: Point | null;
|
||||
endPoint: Point | null;
|
||||
}
|
||||
|
||||
export enum MeasurementState {
|
||||
INIT = 'INIT',
|
||||
WAITING_FOR_START_POINT = 'WAITING_FOR_START_POINT',
|
||||
WAITING_FOR_END_POINT = 'WAITING_FOR_END_POINT',
|
||||
WAITING_FOR_OFFSET = 'WAITING_FOR_OFFSET',
|
||||
}
|
||||
|
||||
export enum MeasurementAction {
|
||||
INIT_MEASUREMENT_TOOL = 'INIT_MEASUREMENT_TOOL',
|
||||
RECORD_START_POINT = 'RECORD_START_POINT',
|
||||
RECORD_END_POINT = 'RECORD_END_POINT',
|
||||
DRAW_TEMP_MEASUREMENT = 'DRAW_TEMP_MEASUREMENT',
|
||||
DRAW_FINAL_MEASUREMENT = 'DRAW_FINAL_MEASUREMENT',
|
||||
}
|
||||
|
||||
export const measurementToolStateMachine = createMachine(
|
||||
{
|
||||
types: {} as {
|
||||
context: MeasurementContext;
|
||||
events: StateEvent;
|
||||
},
|
||||
context: {
|
||||
startPoint: null,
|
||||
endPoint: null,
|
||||
type: Tool.MEASUREMENT,
|
||||
},
|
||||
initial: MeasurementState.INIT,
|
||||
states: {
|
||||
[MeasurementState.INIT]: {
|
||||
description: 'Initializing the line tool',
|
||||
always: {
|
||||
actions: MeasurementAction.INIT_MEASUREMENT_TOOL,
|
||||
target: MeasurementState.WAITING_FOR_START_POINT,
|
||||
},
|
||||
},
|
||||
[MeasurementState.WAITING_FOR_START_POINT]: {
|
||||
description: 'Select the start point of the measurement',
|
||||
meta: {
|
||||
instructions: 'Select the start point of the measurement',
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: MeasurementAction.RECORD_START_POINT,
|
||||
target: MeasurementState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: MeasurementAction.RECORD_START_POINT,
|
||||
target: MeasurementState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
},
|
||||
},
|
||||
[MeasurementState.WAITING_FOR_END_POINT]: {
|
||||
description: 'Select the end point of the measurement',
|
||||
meta: {
|
||||
instructions: 'Select the end point of the measurement',
|
||||
},
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: MeasurementAction.DRAW_TEMP_MEASUREMENT,
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: MeasurementAction.RECORD_END_POINT,
|
||||
target: MeasurementState.WAITING_FOR_OFFSET,
|
||||
},
|
||||
NUMBER_INPUT: {
|
||||
actions: MeasurementAction.RECORD_END_POINT,
|
||||
target: MeasurementState.WAITING_FOR_OFFSET,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: MeasurementAction.RECORD_END_POINT,
|
||||
target: MeasurementState.WAITING_FOR_OFFSET,
|
||||
},
|
||||
RELATIVE_POINT_INPUT: {
|
||||
actions: MeasurementAction.RECORD_END_POINT,
|
||||
target: MeasurementState.WAITING_FOR_OFFSET,
|
||||
},
|
||||
ESC: {
|
||||
target: MeasurementState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
[MeasurementState.WAITING_FOR_OFFSET]: {
|
||||
description: 'Select the offset to display the measurement at',
|
||||
meta: {
|
||||
instructions: 'Select the offset to display the measurement at',
|
||||
},
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: MeasurementAction.DRAW_TEMP_MEASUREMENT,
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: MeasurementAction.DRAW_FINAL_MEASUREMENT,
|
||||
target: MeasurementState.INIT,
|
||||
},
|
||||
NUMBER_INPUT: {
|
||||
actions: MeasurementAction.DRAW_FINAL_MEASUREMENT,
|
||||
target: MeasurementState.INIT,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: MeasurementAction.DRAW_FINAL_MEASUREMENT,
|
||||
target: MeasurementState.INIT,
|
||||
},
|
||||
RELATIVE_POINT_INPUT: {
|
||||
actions: MeasurementAction.DRAW_FINAL_MEASUREMENT,
|
||||
target: MeasurementState.INIT,
|
||||
},
|
||||
ESC: {
|
||||
target: MeasurementState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
[MeasurementAction.INIT_MEASUREMENT_TOOL]: assign(() => {
|
||||
setShouldDrawHelpers(true);
|
||||
setSelectedEntityIds([]);
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
return {
|
||||
startPoint: null,
|
||||
endPoint: null,
|
||||
};
|
||||
}),
|
||||
[MeasurementAction.RECORD_START_POINT]: assign(({ event }) => {
|
||||
const startPoint = getPointFromEvent(null, event as PointInputEvent);
|
||||
setAngleGuideOriginPoint(startPoint);
|
||||
return {
|
||||
startPoint,
|
||||
};
|
||||
}),
|
||||
[MeasurementAction.RECORD_END_POINT]: assign(({ context, event }) => {
|
||||
const endPoint = getPointFromEvent(context.startPoint, event as PointInputEvent);
|
||||
setAngleGuideOriginPoint(endPoint);
|
||||
return {
|
||||
...context,
|
||||
endPoint,
|
||||
};
|
||||
}),
|
||||
[MeasurementAction.DRAW_TEMP_MEASUREMENT]: ({ context, event }) => {
|
||||
const startPoint = context.startPoint as Point;
|
||||
|
||||
let endPoint: Point;
|
||||
let offsetPoint: Point;
|
||||
if (!context.endPoint) {
|
||||
// User has drawn startPoint, but not yet endPoint
|
||||
// Endpoint should be the mouse location and offset should be MEASUREMENT_DEFAULT_OFFSET to either direction
|
||||
endPoint = (event as DrawEvent).drawController.getWorldMouseLocation();
|
||||
|
||||
if (isPointEqual(startPoint, endPoint)) {
|
||||
return; // Cannot draw temp measurement when start and endpoint are equal
|
||||
}
|
||||
|
||||
const normalVector = new Vector(startPoint, endPoint)
|
||||
.rotate(-90 * TO_RADIANS)
|
||||
.normalize();
|
||||
// Pixel constant → world units so the default offset is zoom-independent
|
||||
const worldFactor = (event as DrawEvent).drawController.getScreenScale() || 1;
|
||||
offsetPoint = startPoint
|
||||
.clone()
|
||||
.translate(normalVector.multiply(MEASUREMENT_DEFAULT_OFFSET / worldFactor));
|
||||
} else {
|
||||
// User has already selected a startPoint and endPoint
|
||||
// The offsetPoint should be set to the mouse location
|
||||
endPoint = context.endPoint as Point;
|
||||
offsetPoint = (event as DrawEvent).drawController.getWorldMouseLocation();
|
||||
}
|
||||
|
||||
const activeMeasurement = new MeasurementEntity(
|
||||
getActiveLayerId(),
|
||||
context.startPoint as Point,
|
||||
endPoint,
|
||||
offsetPoint
|
||||
);
|
||||
activeMeasurement.lineColor = getActiveLineColor();
|
||||
activeMeasurement.lineWidth = getActiveLineWidth();
|
||||
activeMeasurement.lineDash = getActiveLineDash();
|
||||
setGhostHelperEntities([activeMeasurement]);
|
||||
},
|
||||
[MeasurementAction.DRAW_FINAL_MEASUREMENT]: ({ context, event }) => {
|
||||
const offsetPoint = getPointFromEvent(context.endPoint, event as PointInputEvent);
|
||||
const activeMeasurement = new MeasurementEntity(
|
||||
getActiveLayerId(),
|
||||
context.startPoint as Point,
|
||||
context.endPoint as Point,
|
||||
offsetPoint
|
||||
);
|
||||
activeMeasurement.lineColor = getActiveLineColor();
|
||||
activeMeasurement.lineWidth = getActiveLineWidth();
|
||||
activeMeasurement.lineDash = getActiveLineDash();
|
||||
addEntities([activeMeasurement], true);
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,113 @@
|
||||
/** 모깎기·모따기·곡선 혼합·끊기 (조사표 2절) */
|
||||
import { toast } from 'react-toastify';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { addEntities, deleteEntities } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
import { blendEntities, breakEntity, chamferLines, filletLines } from './corner.helpers';
|
||||
import type { CornerResult } from './corner.helpers';
|
||||
|
||||
function applyCorner(first: Entity, second: Entity, result: CornerResult | null): void {
|
||||
if (!result) {
|
||||
toast.warn('두 직선을 선택해야 합니다. 두 선이 평행하면 처리할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
deleteEntities([first, second], false);
|
||||
addEntities([...result.trimmed, ...(result.corner ? [result.corner] : [])], true);
|
||||
}
|
||||
|
||||
export const filletToolStateMachine = createSequenceTool({
|
||||
tool: Tool.FILLET,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'number', instructions: '모깎기 반지름을 입력하십시오 <0>.', defaultValue: 0 },
|
||||
{ kind: 'entity', instructions: '첫 번째 객체를 남길 쪽에서 선택하십시오.' },
|
||||
{ kind: 'entity', instructions: '두 번째 객체를 남길 쪽에서 선택하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const first = input.entity(1);
|
||||
const second = input.entity(2);
|
||||
applyCorner(
|
||||
first,
|
||||
second,
|
||||
filletLines(first, input.pick(1), second, input.pick(2), input.number(0))
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const chamferToolStateMachine = createSequenceTool({
|
||||
tool: Tool.CHAMFER,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'number', instructions: '첫 번째 모따기 거리를 입력하십시오 <1>.', defaultValue: 1 },
|
||||
{ kind: 'number', instructions: '두 번째 모따기 거리를 입력하십시오 <1>.', defaultValue: 1 },
|
||||
{ kind: 'entity', instructions: '첫 번째 객체를 남길 쪽에서 선택하십시오.' },
|
||||
{ kind: 'entity', instructions: '두 번째 객체를 남길 쪽에서 선택하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const first = input.entity(2);
|
||||
const second = input.entity(3);
|
||||
applyCorner(
|
||||
first,
|
||||
second,
|
||||
chamferLines(
|
||||
first,
|
||||
input.pick(2),
|
||||
second,
|
||||
input.pick(3),
|
||||
input.number(0),
|
||||
input.number(1)
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const blendToolStateMachine = createSequenceTool({
|
||||
tool: Tool.BLEND,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '혼합할 첫 번째 곡선을 선택하십시오.' },
|
||||
{ kind: 'entity', instructions: '혼합할 두 번째 곡선을 선택하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const blend = blendEntities(input.entity(0), input.entity(1));
|
||||
if (!blend) {
|
||||
toast.warn('두 객체를 이을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
addEntities([blend], true);
|
||||
},
|
||||
});
|
||||
|
||||
export const breakToolStateMachine = createSequenceTool({
|
||||
tool: Tool.BREAK,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '끊을 객체를 선택하십시오.' },
|
||||
{ kind: 'point', instructions: '첫 번째 끊기 점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '두 번째 끊기 점을 지정하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const entity = input.entity(0);
|
||||
const pieces = breakEntity(entity, input.point(1), input.point(2));
|
||||
deleteEntities([entity], false);
|
||||
addEntities(pieces, true);
|
||||
},
|
||||
});
|
||||
|
||||
export const breakAtPointToolStateMachine = createSequenceTool({
|
||||
tool: Tool.BREAK_AT_POINT,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '나눌 객체를 선택하십시오.' },
|
||||
{ kind: 'point', instructions: '나눌 점을 지정하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const entity = input.entity(0);
|
||||
const pieces = breakEntity(entity, input.point(1));
|
||||
if (pieces.length < 2) {
|
||||
toast.warn('이 위치에서는 나눌 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
deleteEntities([entity], false);
|
||||
addEntities(pieces, true);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
/** 모깎기·모따기·곡선 혼합·끊기의 기하 계산과 객체 생성 */
|
||||
import { Point, Segment } from '@flatten-js/core';
|
||||
import { ArcEntity } from '../../entities/ArcEntity';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { LineEntity } from '../../entities/LineEntity';
|
||||
import { PolyLineEntity } from '../../entities/PolyLineEntity';
|
||||
import {
|
||||
pointAtDistance,
|
||||
polylineLength,
|
||||
sampleEntityPoints,
|
||||
} from '../../helpers/geometry/sample-entity';
|
||||
import { intersectLines, splinePoints } from '../../helpers/geometry/shape-points';
|
||||
import { copyStyle } from './modify.helpers';
|
||||
|
||||
interface LinePick {
|
||||
entity: Entity;
|
||||
segment: Segment;
|
||||
pick: Point;
|
||||
}
|
||||
|
||||
const unit = (from: Point, to: Point): { x: number; y: number } => {
|
||||
const dx = to.x - from.x;
|
||||
const dy = to.y - from.y;
|
||||
const length = Math.hypot(dx, dy) || 1;
|
||||
return { x: dx / length, y: dy / length };
|
||||
};
|
||||
|
||||
const makeLine = (source: Entity, start: Point, end: Point): LineEntity =>
|
||||
copyStyle(source, new LineEntity(source.layerId, start, end));
|
||||
|
||||
/** 선을 집은 쪽에서 살아남는 끝점 (교차점 반대편 끝) */
|
||||
function keepEndpoint(segment: Segment, corner: Point, pick: Point): Point {
|
||||
const towardPick = unit(corner, pick);
|
||||
const towardStart = unit(corner, segment.start);
|
||||
const startAligned = towardPick.x * towardStart.x + towardPick.y * towardStart.y;
|
||||
return startAligned > 0 ? segment.start : segment.end;
|
||||
}
|
||||
|
||||
function asLinePick(entity: Entity, pick: Point): LinePick | null {
|
||||
const shape = entity.getShape();
|
||||
return shape instanceof Segment ? { entity, segment: shape, pick } : null;
|
||||
}
|
||||
|
||||
export interface CornerResult {
|
||||
/** 잘려서 새로 만들어진 두 선 */
|
||||
trimmed: Entity[];
|
||||
/** 모서리에 새로 놓이는 객체 (호 또는 선) */
|
||||
corner: Entity | null;
|
||||
}
|
||||
|
||||
/** FILLET — 두 선을 반지름 radius의 호로 잇는다 */
|
||||
export function filletLines(
|
||||
first: Entity,
|
||||
firstPick: Point,
|
||||
second: Entity,
|
||||
secondPick: Point,
|
||||
radius: number
|
||||
): CornerResult | null {
|
||||
const a = asLinePick(first, firstPick);
|
||||
const b = asLinePick(second, secondPick);
|
||||
if (!a || !b) return null;
|
||||
|
||||
const corner = intersectLines(a.segment.start, a.segment.end, b.segment.start, b.segment.end);
|
||||
if (!corner) return null;
|
||||
|
||||
const keepA = keepEndpoint(a.segment, corner, a.pick);
|
||||
const keepB = keepEndpoint(b.segment, corner, b.pick);
|
||||
const ua = unit(corner, keepA);
|
||||
const ub = unit(corner, keepB);
|
||||
|
||||
const angle = Math.acos(Math.min(1, Math.max(-1, ua.x * ub.x + ua.y * ub.y)));
|
||||
if (!Number.isFinite(angle) || angle < 1e-6 || Math.abs(angle - Math.PI) < 1e-6) return null;
|
||||
|
||||
if (radius <= 0) {
|
||||
// 반지름 0 = 두 선을 모서리에서 딱 맞춘다
|
||||
return {
|
||||
trimmed: [makeLine(a.entity, keepA, corner), makeLine(b.entity, keepB, corner)],
|
||||
corner: null,
|
||||
};
|
||||
}
|
||||
|
||||
const tangentDistance = radius / Math.tan(angle / 2);
|
||||
const tangentA = new Point(
|
||||
corner.x + ua.x * tangentDistance,
|
||||
corner.y + ua.y * tangentDistance
|
||||
);
|
||||
const tangentB = new Point(
|
||||
corner.x + ub.x * tangentDistance,
|
||||
corner.y + ub.y * tangentDistance
|
||||
);
|
||||
|
||||
const bisector = unit(new Point(0, 0), new Point(ua.x + ub.x, ua.y + ub.y));
|
||||
const centerDistance = radius / Math.sin(angle / 2);
|
||||
const center = new Point(
|
||||
corner.x + bisector.x * centerDistance,
|
||||
corner.y + bisector.y * centerDistance
|
||||
);
|
||||
|
||||
const startAngle = Math.atan2(tangentA.y - center.y, tangentA.x - center.x);
|
||||
const endAngle = Math.atan2(tangentB.y - center.y, tangentB.x - center.x);
|
||||
const cross =
|
||||
(tangentA.x - center.x) * (tangentB.y - center.y) -
|
||||
(tangentA.y - center.y) * (tangentB.x - center.x);
|
||||
|
||||
const arc = copyStyle(
|
||||
a.entity,
|
||||
new ArcEntity(a.entity.layerId, center, radius, startAngle, endAngle, cross > 0)
|
||||
);
|
||||
|
||||
return {
|
||||
trimmed: [makeLine(a.entity, keepA, tangentA), makeLine(b.entity, keepB, tangentB)],
|
||||
corner: arc,
|
||||
};
|
||||
}
|
||||
|
||||
/** CHAMFER — 두 선을 직선 모따기로 잇는다 */
|
||||
export function chamferLines(
|
||||
first: Entity,
|
||||
firstPick: Point,
|
||||
second: Entity,
|
||||
secondPick: Point,
|
||||
firstDistance: number,
|
||||
secondDistance: number
|
||||
): CornerResult | null {
|
||||
const a = asLinePick(first, firstPick);
|
||||
const b = asLinePick(second, secondPick);
|
||||
if (!a || !b) return null;
|
||||
|
||||
const corner = intersectLines(a.segment.start, a.segment.end, b.segment.start, b.segment.end);
|
||||
if (!corner) return null;
|
||||
|
||||
const keepA = keepEndpoint(a.segment, corner, a.pick);
|
||||
const keepB = keepEndpoint(b.segment, corner, b.pick);
|
||||
const ua = unit(corner, keepA);
|
||||
const ub = unit(corner, keepB);
|
||||
|
||||
const cutA = new Point(corner.x + ua.x * firstDistance, corner.y + ua.y * firstDistance);
|
||||
const cutB = new Point(corner.x + ub.x * secondDistance, corner.y + ub.y * secondDistance);
|
||||
|
||||
return {
|
||||
trimmed: [makeLine(a.entity, keepA, cutA), makeLine(b.entity, keepB, cutB)],
|
||||
corner: makeLine(a.entity, cutA, cutB),
|
||||
};
|
||||
}
|
||||
|
||||
/** BLEND — 두 객체의 가까운 끝점을 부드러운 곡선으로 잇는다 */
|
||||
export function blendEntities(first: Entity, second: Entity): Entity | null {
|
||||
const firstPoints = sampleEntityPoints(first);
|
||||
const secondPoints = sampleEntityPoints(second);
|
||||
if (firstPoints.length < 2 || secondPoints.length < 2) return null;
|
||||
|
||||
// 서로 가장 가까운 끝점 쌍을 고른다
|
||||
const candidates: [Point, Point, Point, Point][] = [
|
||||
[firstPoints[1], firstPoints[0], secondPoints[0], secondPoints[1]],
|
||||
[
|
||||
firstPoints[1],
|
||||
firstPoints[0],
|
||||
secondPoints[secondPoints.length - 1],
|
||||
secondPoints[secondPoints.length - 2],
|
||||
],
|
||||
[
|
||||
firstPoints[firstPoints.length - 2],
|
||||
firstPoints[firstPoints.length - 1],
|
||||
secondPoints[0],
|
||||
secondPoints[1],
|
||||
],
|
||||
[
|
||||
firstPoints[firstPoints.length - 2],
|
||||
firstPoints[firstPoints.length - 1],
|
||||
secondPoints[secondPoints.length - 1],
|
||||
secondPoints[secondPoints.length - 2],
|
||||
],
|
||||
];
|
||||
const best = candidates.reduce((chosen, candidate) =>
|
||||
candidate[1].distanceTo(candidate[2])[0] < chosen[1].distanceTo(chosen[2])[0]
|
||||
? candidate
|
||||
: chosen
|
||||
);
|
||||
|
||||
const curve = splinePoints([best[0], best[1], best[2], best[3]], 16);
|
||||
const segments: Entity[] = [];
|
||||
for (let index = 1; index < curve.length; index++) {
|
||||
segments.push(makeLine(first, curve[index - 1], curve[index]));
|
||||
}
|
||||
return copyStyle(first, new PolyLineEntity(first.layerId, segments));
|
||||
}
|
||||
|
||||
/**
|
||||
* BREAK — 두 점 사이를 지운다. 한 점만 주면 그 자리에서 둘로 나눈다.
|
||||
* 선·호는 원래 형상을 유지하고, 그 밖의 객체는 점렬로 나눈다.
|
||||
*/
|
||||
export function breakEntity(entity: Entity, first: Point, second?: Point): Entity[] {
|
||||
const cutPoints = second ? [first, second] : [first];
|
||||
|
||||
const cuttable = entity as Entity & { cutAtPoints?: (points: Point[]) => Entity[] };
|
||||
if (typeof cuttable.cutAtPoints === 'function') {
|
||||
const pieces = cuttable.cutAtPoints(cutPoints).map((piece) => copyStyle(entity, piece));
|
||||
if (!second) return pieces;
|
||||
const middle = new Point((first.x + second.x) / 2, (first.y + second.y) / 2);
|
||||
return pieces.filter((piece) => !piece.containsPointOnShape(middle));
|
||||
}
|
||||
|
||||
const points = sampleEntityPoints(entity);
|
||||
if (points.length < 2) return [entity];
|
||||
const total = polylineLength(points);
|
||||
const firstDistance = distanceAlong(points, first);
|
||||
const secondDistance = second === undefined ? firstDistance : distanceAlong(points, second);
|
||||
const [from, to] = [firstDistance, secondDistance].sort((a, b) => a - b);
|
||||
|
||||
const head = pointsUpTo(points, from);
|
||||
const tail = pointsFrom(points, to, total);
|
||||
const pieces: Entity[] = [];
|
||||
for (const piece of [head, tail]) {
|
||||
if (piece.length >= 2) {
|
||||
const segments: Entity[] = [];
|
||||
for (let index = 1; index < piece.length; index++) {
|
||||
segments.push(makeLine(entity, piece[index - 1], piece[index]));
|
||||
}
|
||||
pieces.push(copyStyle(entity, new PolyLineEntity(entity.layerId, segments)));
|
||||
}
|
||||
}
|
||||
return pieces;
|
||||
}
|
||||
|
||||
/** 점렬 시작점에서 target까지의 진행 거리 (가장 가까운 위치 기준) */
|
||||
function distanceAlong(points: Point[], target: Point): number {
|
||||
let bestDistance = 0;
|
||||
let bestGap = Number.POSITIVE_INFINITY;
|
||||
let travelled = 0;
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
const segment = new Segment(points[index - 1], points[index]);
|
||||
const [gap, connector] = target.distanceTo(segment);
|
||||
if (gap < bestGap) {
|
||||
bestGap = gap;
|
||||
bestDistance = travelled + points[index - 1].distanceTo(connector.end)[0];
|
||||
}
|
||||
travelled += points[index - 1].distanceTo(points[index])[0];
|
||||
}
|
||||
return bestDistance;
|
||||
}
|
||||
|
||||
function pointsUpTo(points: Point[], distance: number): Point[] {
|
||||
const result: Point[] = [];
|
||||
let travelled = 0;
|
||||
result.push(points[0]);
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
const step = points[index - 1].distanceTo(points[index])[0];
|
||||
if (travelled + step >= distance) break;
|
||||
travelled += step;
|
||||
result.push(points[index]);
|
||||
}
|
||||
const cut = pointAtDistance(points, distance);
|
||||
if (cut) result.push(cut);
|
||||
return result;
|
||||
}
|
||||
|
||||
function pointsFrom(points: Point[], distance: number, total: number): Point[] {
|
||||
if (distance >= total) return [];
|
||||
const result: Point[] = [];
|
||||
const cut = pointAtDistance(points, distance);
|
||||
if (cut) result.push(cut);
|
||||
let travelled = 0;
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
travelled += points[index - 1].distanceTo(points[index])[0];
|
||||
if (travelled > distance) result.push(points[index]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/** 특성 일치·그리기 순서·ByLayer·방향 반전·중복 정리·해치 편집 (조사표 2절) */
|
||||
import { toast } from 'react-toastify';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { EntityName } from '../../entities/Entity';
|
||||
import type { HatchEntity, HatchStyle } from '../../entities/HatchEntity';
|
||||
import {
|
||||
addEntities,
|
||||
deleteEntities,
|
||||
getEntities,
|
||||
getLayerById,
|
||||
setEntities,
|
||||
setSelectedEntityIds,
|
||||
} from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
import { findDuplicateEntities, reverseEntity } from './modify.helpers';
|
||||
|
||||
export const matchPropToolStateMachine = createSequenceTool({
|
||||
tool: Tool.MATCHPROP,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '특성을 가져올 원본 객체를 선택하십시오.' },
|
||||
{ kind: 'entity', instructions: '특성을 적용할 대상 객체를 선택하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const source = input.entity(0);
|
||||
const target = input.entity(1);
|
||||
target.lineColor = source.lineColor;
|
||||
target.lineWidth = source.lineWidth;
|
||||
target.lineDash = source.lineDash;
|
||||
target.layerId = source.layerId;
|
||||
setEntities([...getEntities()], true);
|
||||
},
|
||||
});
|
||||
|
||||
/** 선택 객체를 목록 맨 앞(뒤)으로 옮겨 그리기 순서를 바꾼다 */
|
||||
function reorder(selected: Entity[], toFront: boolean): void {
|
||||
if (!selected.length) return;
|
||||
const ids = new Set(selected.map((entity) => entity.id));
|
||||
const rest = getEntities().filter((entity) => !ids.has(entity.id));
|
||||
setEntities(toFront ? [...rest, ...selected] : [...selected, ...rest], true);
|
||||
}
|
||||
|
||||
export const drawOrderFrontToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DRAWORDER_FRONT,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '맨 앞으로 보낼 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
reorder(input.entities(0), true);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
|
||||
export const drawOrderBackToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DRAWORDER_BACK,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '맨 뒤로 보낼 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
reorder(input.entities(0), false);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
|
||||
export const setByLayerToolStateMachine = createSequenceTool({
|
||||
tool: Tool.SETBYLAYER,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: 'ByLayer로 되돌릴 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
let applied = 0;
|
||||
for (const entity of input.entities(0)) {
|
||||
const layer = getLayerById(entity.layerId);
|
||||
if (!layer) continue;
|
||||
if (layer.color) entity.lineColor = layer.color;
|
||||
if (layer.lineWidth) entity.lineWidth = layer.lineWidth;
|
||||
entity.lineDash = layer.lineDash ? [...layer.lineDash] : undefined;
|
||||
applied += 1;
|
||||
}
|
||||
setEntities([...getEntities()], true);
|
||||
setSelectedEntityIds([]);
|
||||
toast.success(`${applied}개 객체를 도면층 특성으로 되돌렸습니다.`);
|
||||
},
|
||||
});
|
||||
|
||||
export const reverseToolStateMachine = createSequenceTool({
|
||||
tool: Tool.REVERSE,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '방향을 뒤집을 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
const originals = input.entities(0);
|
||||
const reversed: Entity[] = [];
|
||||
const consumed: Entity[] = [];
|
||||
for (const entity of originals) {
|
||||
const flipped = reverseEntity(entity);
|
||||
if (flipped) {
|
||||
reversed.push(flipped);
|
||||
consumed.push(entity);
|
||||
}
|
||||
}
|
||||
if (!consumed.length) {
|
||||
toast.info('방향을 뒤집을 수 있는 객체가 없습니다.');
|
||||
return;
|
||||
}
|
||||
deleteEntities(consumed, false);
|
||||
addEntities(reversed, true);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
|
||||
export const overkillToolStateMachine = createSequenceTool({
|
||||
tool: Tool.OVERKILL,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '중복을 정리할 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
const selected = input.entities(0);
|
||||
const target = selected.length ? selected : getEntities();
|
||||
const duplicates = findDuplicateEntities(target);
|
||||
if (!duplicates.length) {
|
||||
toast.info('중복된 객체가 없습니다.');
|
||||
return;
|
||||
}
|
||||
deleteEntities(duplicates, true);
|
||||
setSelectedEntityIds([]);
|
||||
toast.success(`중복 객체 ${duplicates.length}개를 삭제했습니다.`);
|
||||
},
|
||||
});
|
||||
|
||||
const HATCH_STYLES: HatchStyle[] = ['solid', 'pattern', 'cross', 'gradient'];
|
||||
|
||||
export const hatchEditToolStateMachine = createSequenceTool({
|
||||
tool: Tool.HATCHEDIT,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '편집할 해치를 선택하십시오.' },
|
||||
{
|
||||
kind: 'text',
|
||||
instructions: '패턴을 입력하십시오 (SOLID · PATTERN · CROSS · GRADIENT) <PATTERN>.',
|
||||
defaultValue: 'PATTERN',
|
||||
},
|
||||
],
|
||||
commit: (input) => {
|
||||
const entity = input.entity(0);
|
||||
if (entity.getType() !== EntityName.Hatch) {
|
||||
toast.warn('해치 객체를 선택하십시오.');
|
||||
return;
|
||||
}
|
||||
const requested = input.text(1).trim().toLowerCase() as HatchStyle;
|
||||
if (!HATCH_STYLES.includes(requested)) {
|
||||
toast.warn('SOLID · PATTERN · CROSS · GRADIENT 중 하나를 입력하십시오.');
|
||||
return;
|
||||
}
|
||||
(entity as HatchEntity).options.style = requested;
|
||||
setEntities([...getEntities()], true);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/** 연장·결합·분해·지우기 (조사표 2절) */
|
||||
import { toast } from 'react-toastify';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { addEntities, deleteEntities, setSelectedEntityIds } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
import { explodeEntity, extendLineToBoundary, joinEntities } from './modify.helpers';
|
||||
|
||||
export const extendToolStateMachine = createSequenceTool({
|
||||
tool: Tool.EXTEND,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '경계로 쓸 객체를 선택하십시오.' },
|
||||
{ kind: 'entity', instructions: '연장할 선을 선택하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const boundary = input.entity(0);
|
||||
const target = input.entity(1);
|
||||
const extended = extendLineToBoundary(target, boundary);
|
||||
if (!extended) {
|
||||
toast.warn('경계와 만나도록 연장할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
deleteEntities([target], false);
|
||||
addEntities([extended], true);
|
||||
},
|
||||
});
|
||||
|
||||
export const joinToolStateMachine = createSequenceTool({
|
||||
tool: Tool.JOIN,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '결합할 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
const entities = input.entities(0);
|
||||
const joined = joinEntities(entities);
|
||||
if (!joined) {
|
||||
toast.warn('끝점이 맞닿는 객체가 없어 결합하지 못했습니다.');
|
||||
return;
|
||||
}
|
||||
deleteEntities(entities, false);
|
||||
addEntities([joined], true);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
|
||||
export const explodeToolStateMachine = createSequenceTool({
|
||||
tool: Tool.EXPLODE,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '분해할 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
const entities = input.entities(0);
|
||||
const exploded: Entity[] = [];
|
||||
const consumed: Entity[] = [];
|
||||
for (const entity of entities) {
|
||||
const parts = explodeEntity(entity);
|
||||
if (parts.length) {
|
||||
exploded.push(...parts);
|
||||
consumed.push(entity);
|
||||
}
|
||||
}
|
||||
if (!consumed.length) {
|
||||
toast.info('분해할 수 있는 복합 객체가 없습니다.');
|
||||
return;
|
||||
}
|
||||
deleteEntities(consumed, false);
|
||||
addEntities(exploded, true);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
|
||||
export const eraseToolStateMachine = createSequenceTool({
|
||||
tool: Tool.ERASE,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '지울 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
const entities = input.entities(0);
|
||||
if (!entities.length) return;
|
||||
deleteEntities(entities, true);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
/** 대칭·정렬·간격띄우기·신축·길이조정 (조사표 2절) */
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { LineEntity } from '../../entities/LineEntity';
|
||||
import { addEntities, deleteEntities, getActiveLayerId, setSelectedEntityIds } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
import { copyStyle, lengthenLine, offsetEntity } from './modify.helpers';
|
||||
|
||||
/** 원본 특성을 유지한 복사본 */
|
||||
const cloneStyled = (entity: Entity): Entity => copyStyle(entity, entity.clone());
|
||||
|
||||
export const mirrorToolStateMachine = createSequenceTool({
|
||||
tool: Tool.MIRROR,
|
||||
steps: [
|
||||
{ kind: 'selection', instructions: '대칭 복사할 객체를 선택한 뒤 ENTER.' },
|
||||
{ kind: 'point', instructions: '대칭축의 첫 점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '대칭축의 두 번째 점을 지정하십시오.' },
|
||||
],
|
||||
preview: (input) => {
|
||||
const points = input.points();
|
||||
if (points.length !== 1) return [];
|
||||
return [new LineEntity(getActiveLayerId(), points[0], input.cursor)];
|
||||
},
|
||||
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);
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
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[] = [];
|
||||
|
||||
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([]);
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from '../state';
|
||||
import type {SelectContext} from './select-tool';
|
||||
import type {MouseClickEvent} from './tool.types';
|
||||
import {expandSelectionWithGroups} from '../helpers/entity-groups';
|
||||
|
||||
export function handleFirstSelectionPoint(
|
||||
context: SelectContext,
|
||||
@@ -34,7 +35,8 @@ export function handleFirstSelectionPoint(
|
||||
// Select the entity close to the mouse
|
||||
const closestEntity = closestEntityInfo.entity;
|
||||
if (!event.holdingCtrl && !event.holdingShift) {
|
||||
setSelectedEntityIds([closestEntity.id]);
|
||||
// 그룹으로 묶인 객체는 하나만 집어도 함께 선택된다 (GROUP)
|
||||
setSelectedEntityIds(expandSelectionWithGroups([closestEntity.id]));
|
||||
} else if (event.holdingCtrl) {
|
||||
// ctrl => toggle selection
|
||||
if (isEntitySelected(closestEntity)) {
|
||||
@@ -42,11 +44,15 @@ export function handleFirstSelectionPoint(
|
||||
setSelectedEntityIds(getSelectedEntityIds().filter((id) => id !== closestEntity.id));
|
||||
} else {
|
||||
// Add the entity to the selection
|
||||
setSelectedEntityIds([...getSelectedEntityIds(), closestEntity.id]);
|
||||
setSelectedEntityIds(
|
||||
expandSelectionWithGroups([...getSelectedEntityIds(), closestEntity.id])
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// shift => add to selection
|
||||
setSelectedEntityIds([...getSelectedEntityIds(), closestEntity.id]);
|
||||
setSelectedEntityIds(
|
||||
expandSelectionWithGroups([...getSelectedEntityIds(), closestEntity.id])
|
||||
);
|
||||
}
|
||||
return {
|
||||
...context,
|
||||
@@ -116,7 +122,7 @@ export function selectEntitiesInsideRectangle(
|
||||
return null;
|
||||
})
|
||||
);
|
||||
setSelectedEntityIds(newSelectedEntityIds);
|
||||
setSelectedEntityIds(expandSelectionWithGroups(newSelectedEntityIds));
|
||||
}
|
||||
|
||||
export function drawTempSelectionRectangle(startPoint: Point, endPoint: Point) {
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import type {StateMachine} from 'xstate'; /* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {Tool} from '../tools';
|
||||
import {alignBottomToolStateMachine} from './align-bottom-tool.ts';
|
||||
import {alignCenterHorizontalToolStateMachine} from './align-center-horizontal-tool.ts';
|
||||
import {alignLeftToolStateMachine} from './align-left-tool.ts';
|
||||
import {alignCenterVerticalToolStateMachine} from './align-middle-vertical-tool.ts';
|
||||
import {alignRightToolStateMachine} from './align-right-tool.ts';
|
||||
import {alignTopToolStateMachine} from './align-top-tool.ts';
|
||||
import {arrayToolStateMachine} from './array-tool.ts';
|
||||
import {circleToolStateMachine} from './circle-tool';
|
||||
import {copyToolStateMachine} from './copy-tool.ts';
|
||||
import {eraserToolStateMachine} from './eraser-tool';
|
||||
import {imageImportToolStateMachine} from './image-import-tool';
|
||||
import {lineToolStateMachine} from './line-tool';
|
||||
import {measurementToolStateMachine} from './measurement-tool';
|
||||
import {moveToolStateMachine} from './move-tool';
|
||||
import {rectangleToolStateMachine} from './rectangle-tool';
|
||||
import {rotateToolStateMachine} from './rotate-tool';
|
||||
import {scaleToolStateMachine} from './scale-tool';
|
||||
import {selectToolStateMachine} from './select-tool';
|
||||
import {peditToolStateMachine} from "./pedit-tool.ts";
|
||||
|
||||
export const TOOL_STATE_MACHINES: Record<
|
||||
Partial<Tool>,
|
||||
StateMachine<
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any
|
||||
>
|
||||
> = {
|
||||
[Tool.LINE]: lineToolStateMachine,
|
||||
[Tool.RECTANGLE]: rectangleToolStateMachine,
|
||||
[Tool.CIRCLE]: circleToolStateMachine,
|
||||
[Tool.SELECT]: selectToolStateMachine,
|
||||
[Tool.ERASER]: eraserToolStateMachine,
|
||||
[Tool.MOVE]: moveToolStateMachine,
|
||||
[Tool.COPY]: copyToolStateMachine,
|
||||
[Tool.SCALE]: scaleToolStateMachine,
|
||||
[Tool.ROTATE]: rotateToolStateMachine,
|
||||
[Tool.IMAGE_IMPORT]: imageImportToolStateMachine,
|
||||
[Tool.MEASUREMENT]: measurementToolStateMachine,
|
||||
[Tool.ALIGN_LEFT]: alignLeftToolStateMachine,
|
||||
[Tool.ALIGN_CENTER_HORIZONTAL]: alignCenterHorizontalToolStateMachine,
|
||||
[Tool.ALIGN_RIGHT]: alignRightToolStateMachine,
|
||||
[Tool.ALIGN_TOP]: alignTopToolStateMachine,
|
||||
[Tool.ALIGN_CENTER_VERTICAL]: alignCenterVerticalToolStateMachine,
|
||||
[Tool.ALIGN_BOTTOM]: alignBottomToolStateMachine,
|
||||
[Tool.ARRAY]: arrayToolStateMachine,
|
||||
[Tool.PEDIT]: peditToolStateMachine,
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
/** 클립보드 명령 — 잘라내기·복사·붙여넣기 (조사표 3절 클립보드 패널) */
|
||||
import { toast } from 'react-toastify';
|
||||
import { copyToClipboard, hasClipboardContent, pasteFromClipboard } from '../../helpers/cad-clipboard';
|
||||
import {
|
||||
addEntities,
|
||||
deleteEntities,
|
||||
getSelectedEntities,
|
||||
setSelectedEntityIds,
|
||||
} from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
|
||||
/** COPYCLIP — 선택 객체를 클립보드로 복사 */
|
||||
export function copySelectionToClipboard(): string {
|
||||
const count = copyToClipboard(getSelectedEntities());
|
||||
if (!count) {
|
||||
toast.info('복사할 객체를 먼저 선택하십시오.');
|
||||
return '선택 없음';
|
||||
}
|
||||
toast.success(`${count}개 객체를 복사했습니다.`);
|
||||
return `복사 ${count}개`;
|
||||
}
|
||||
|
||||
/** CUTCLIP — 선택 객체를 클립보드로 옮기고 도면에서 지운다 */
|
||||
export function cutSelectionToClipboard(): string {
|
||||
const selected = getSelectedEntities();
|
||||
const count = copyToClipboard(selected);
|
||||
if (!count) {
|
||||
toast.info('잘라낼 객체를 먼저 선택하십시오.');
|
||||
return '선택 없음';
|
||||
}
|
||||
deleteEntities(selected, true);
|
||||
setSelectedEntityIds([]);
|
||||
toast.success(`${count}개 객체를 잘라냈습니다.`);
|
||||
return `잘라내기 ${count}개`;
|
||||
}
|
||||
|
||||
/** PASTEORIG — 복사한 좌표 그대로 붙여넣기 */
|
||||
export function pasteAtOriginalCoordinates(): string {
|
||||
const pasted = pasteFromClipboard();
|
||||
if (!pasted.length) {
|
||||
toast.info('클립보드가 비어 있습니다.');
|
||||
return '클립보드 비어 있음';
|
||||
}
|
||||
addEntities(pasted, true);
|
||||
toast.success(`${pasted.length}개 객체를 원래 좌표에 붙여넣었습니다.`);
|
||||
return `붙여넣기 ${pasted.length}개`;
|
||||
}
|
||||
|
||||
/** PASTEBLOCK — 붙여넣으면서 하나의 그룹으로 묶는다 (블록 대체) */
|
||||
export function pasteAsGroup(): string {
|
||||
const pasted = pasteFromClipboard();
|
||||
if (!pasted.length) {
|
||||
toast.info('클립보드가 비어 있습니다.');
|
||||
return '클립보드 비어 있음';
|
||||
}
|
||||
const groupId = crypto.randomUUID();
|
||||
for (const entity of pasted) {
|
||||
entity.groupId = groupId;
|
||||
}
|
||||
addEntities(pasted, true);
|
||||
toast.success(`${pasted.length}개 객체를 그룹으로 붙여넣었습니다.`);
|
||||
return `그룹 붙여넣기 ${pasted.length}개`;
|
||||
}
|
||||
|
||||
export const copyBaseToolStateMachine = createSequenceTool({
|
||||
tool: Tool.COPYBASE,
|
||||
steps: [
|
||||
{ kind: 'selection', instructions: '복사할 객체를 선택한 뒤 ENTER.' },
|
||||
{ kind: 'point', instructions: '기준점을 지정하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const count = copyToClipboard(input.entities(0), input.point(1));
|
||||
setSelectedEntityIds([]);
|
||||
toast.success(`기준점과 함께 ${count}개 객체를 복사했습니다.`);
|
||||
},
|
||||
});
|
||||
|
||||
export const pasteToolStateMachine = createSequenceTool({
|
||||
tool: Tool.PASTECLIP,
|
||||
steps: [{ kind: 'point', instructions: '붙여넣을 위치를 지정하십시오.' }],
|
||||
preview: (input) => (hasClipboardContent() ? pasteFromClipboard(input.cursor) : []),
|
||||
commit: (input) => {
|
||||
const pasted = pasteFromClipboard(input.point(0));
|
||||
if (!pasted.length) {
|
||||
toast.info('클립보드가 비어 있습니다.');
|
||||
return;
|
||||
}
|
||||
addEntities(pasted, true);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/** 조회 명령 — 거리·반지름·각도·면적·좌표·리스트·계산기 (조사표 3절 유틸리티) */
|
||||
import { Circle, type Point } from '@flatten-js/core';
|
||||
import { toast } from 'react-toastify';
|
||||
import { EntityName } from '../../entities/Entity';
|
||||
import { entitiesToLoop } from '../../helpers/geometry/entity-loop';
|
||||
import { polylineLength, sampleEntityPoints } from '../../helpers/geometry/sample-entity';
|
||||
import { getSelectedEntities, setSelectedEntityIds } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
|
||||
const format = (value: number, digits = 3): string => value.toFixed(digits);
|
||||
const toDegrees = (radians: number): number => (radians * 180) / Math.PI;
|
||||
|
||||
/** 닫힌 점렬의 면적 (신발끈 공식) */
|
||||
export function polygonArea(points: Point[]): number {
|
||||
let total = 0;
|
||||
for (let index = 0; index < points.length; index++) {
|
||||
const current = points[index];
|
||||
const next = points[(index + 1) % points.length];
|
||||
total += current.x * next.y - next.x * current.y;
|
||||
}
|
||||
return Math.abs(total) / 2;
|
||||
}
|
||||
|
||||
export const distanceToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DIST,
|
||||
steps: [
|
||||
{ kind: 'point', instructions: '거리를 잴 첫 점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '두 번째 점을 지정하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const [first, second] = input.points();
|
||||
const distance = first.distanceTo(second)[0];
|
||||
const angle = toDegrees(Math.atan2(second.y - first.y, second.x - first.x));
|
||||
toast.info(
|
||||
`거리 ${format(distance)} · X 증분 ${format(second.x - first.x)} · Y 증분 ${format(
|
||||
second.y - first.y
|
||||
)} · 각도 ${format(angle, 2)}°`
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const radiusToolStateMachine = createSequenceTool({
|
||||
tool: Tool.RADIUS_INQUIRY,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'entity', instructions: '반지름을 잴 원 또는 호를 선택하십시오.' }],
|
||||
commit: (input) => {
|
||||
const shape = input.entity(0).getShape();
|
||||
const radius =
|
||||
shape instanceof Circle
|
||||
? Number(shape.r)
|
||||
: Number((shape as unknown as { r?: number })?.r ?? Number.NaN);
|
||||
if (!Number.isFinite(radius)) {
|
||||
toast.warn('원 또는 호를 선택하십시오.');
|
||||
return;
|
||||
}
|
||||
toast.info(`반지름 ${format(radius)} · 지름 ${format(radius * 2)}`);
|
||||
},
|
||||
});
|
||||
|
||||
export const angleToolStateMachine = createSequenceTool({
|
||||
tool: Tool.ANGLE_INQUIRY,
|
||||
steps: [
|
||||
{ kind: 'point', instructions: '각의 꼭짓점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '첫 번째 방향의 점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '두 번째 방향의 점을 지정하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const [vertex, first, second] = input.points();
|
||||
const angle =
|
||||
toDegrees(Math.atan2(second.y - vertex.y, second.x - vertex.x)) -
|
||||
toDegrees(Math.atan2(first.y - vertex.y, first.x - vertex.x));
|
||||
const normalized = ((angle % 360) + 360) % 360;
|
||||
toast.info(`각도 ${format(normalized, 2)}° (보각 ${format(360 - normalized, 2)}°)`);
|
||||
},
|
||||
});
|
||||
|
||||
export const areaToolStateMachine = createSequenceTool({
|
||||
tool: Tool.AREA,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '면적을 잴 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
const loop = entitiesToLoop(input.entities(0));
|
||||
if (loop.length < 3) {
|
||||
toast.warn('닫힌 경계를 이루는 객체를 선택하십시오.');
|
||||
return;
|
||||
}
|
||||
toast.info(`면적 ${format(polygonArea(loop), 2)} · 둘레 ${format(polylineLength(loop), 2)}`);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
|
||||
export const idPointToolStateMachine = createSequenceTool({
|
||||
tool: Tool.ID_POINT,
|
||||
steps: [{ kind: 'point', instructions: '좌표를 확인할 점을 지정하십시오.' }],
|
||||
commit: (input) => {
|
||||
const point = input.point(0);
|
||||
toast.info(`X = ${format(point.x)} · Y = ${format(point.y)}`);
|
||||
},
|
||||
});
|
||||
|
||||
/** LIST — 선택 객체의 요약 정보 */
|
||||
export function listSelectedEntities(): string {
|
||||
const selected = getSelectedEntities();
|
||||
if (!selected.length) {
|
||||
toast.info('객체를 먼저 선택하십시오.');
|
||||
return '선택 없음';
|
||||
}
|
||||
const lines = selected.slice(0, 20).map((entity) => {
|
||||
const points = sampleEntityPoints(entity);
|
||||
const box = entity.getBoundingBox();
|
||||
const size = `${format(box.xmax - box.xmin, 2)}×${format(box.ymax - box.ymin, 2)}`;
|
||||
const length = entity.getType() === EntityName.Point ? '-' : format(polylineLength(points), 2);
|
||||
return `${entity.getType()} · 길이 ${length} · 크기 ${size} · 색 ${entity.lineColor}`;
|
||||
});
|
||||
toast.info(lines.join('\n'), { autoClose: 8000 });
|
||||
return `리스트 ${selected.length}개`;
|
||||
}
|
||||
|
||||
const CALCULATOR_PATTERN = /^[0-9+\-*/(). %]+$/;
|
||||
|
||||
export const quickCalcToolStateMachine = createSequenceTool({
|
||||
tool: Tool.QUICKCALC,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'text', instructions: '계산할 수식을 입력하십시오 (예: 12*3.5).' }],
|
||||
commit: (input) => {
|
||||
const expression = input.text(0).replace(/\s/g, '');
|
||||
if (!CALCULATOR_PATTERN.test(expression)) {
|
||||
toast.warn('숫자와 + - * / ( ) 만 사용할 수 있습니다.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 위 정규식으로 숫자·연산자만 남긴 문자열이라 임의 코드가 들어올 수 없다
|
||||
const result = Function(`"use strict";return (${expression})`)() as number;
|
||||
toast.success(`${expression} = ${result}`);
|
||||
} catch {
|
||||
toast.error('수식을 계산할 수 없습니다.');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const measureGeomToolStateMachine = createSequenceTool({
|
||||
tool: Tool.MEASUREGEOM,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{
|
||||
kind: 'text',
|
||||
instructions: '측정 항목을 입력하십시오 (DIST · RADIUS · ANGLE · AREA) <DIST>.',
|
||||
defaultValue: 'DIST',
|
||||
},
|
||||
],
|
||||
commit: (input) => {
|
||||
const mode = input.text(0).trim().toUpperCase();
|
||||
// 각 측정은 이미 개별 명령으로 있으므로 그쪽으로 넘긴다
|
||||
const target = ['DIST', 'RADIUS', 'ANGLE', 'AREA'].includes(mode) ? mode : 'DIST';
|
||||
// 이 명령이 끝나며 선택 도구로 돌아가는 전환보다 뒤에 실행되어야 한다
|
||||
void import('../../commands/run-command').then(({ runCommandInput }) => {
|
||||
setTimeout(() => runCommandInput(target), 0);
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
/** 도면층 명령 (조사표 3절 도면층 패널) */
|
||||
import { toast } from 'react-toastify';
|
||||
import type { Layer } from '../../App.types';
|
||||
import { openInspector } from '../../components/ui-state';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import {
|
||||
popLayerHistory,
|
||||
pushLayerHistory,
|
||||
restoreLayerState,
|
||||
saveLayerState,
|
||||
} from '../../helpers/layer-history';
|
||||
import {
|
||||
deleteEntities,
|
||||
getActiveLayerId,
|
||||
getEntities,
|
||||
getLayers,
|
||||
setActiveLayerId,
|
||||
setEntities,
|
||||
setLayers,
|
||||
setSelectedEntityIds,
|
||||
} from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
|
||||
/** 도면층을 바꾸기 전에 직전 상태를 기록한다 (LAYERP가 되돌린다) */
|
||||
function updateLayers(mutate: (layers: Layer[]) => Layer[]): void {
|
||||
const current = getLayers();
|
||||
pushLayerHistory(current);
|
||||
setLayers(mutate(current.map((layer) => ({ ...layer }))));
|
||||
}
|
||||
|
||||
const layerNameOf = (layerId: string): string =>
|
||||
getLayers().find((layer) => layer.id === layerId)?.name ?? layerId;
|
||||
|
||||
/** LAYER — 도면층 특성 관리자(좌측 팔레트)를 연다 */
|
||||
export function openLayerManager(): string {
|
||||
openInspector('layers');
|
||||
return '도면층 관리자';
|
||||
}
|
||||
|
||||
export const layerCurrentToolStateMachine = createSequenceTool({
|
||||
tool: Tool.LAYER_CURRENT,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'entity', instructions: '현재 도면층으로 지정할 객체를 선택하십시오.' }],
|
||||
commit: (input) => {
|
||||
const layerId = input.entity(0).layerId;
|
||||
setActiveLayerId(layerId);
|
||||
toast.success(`현재 도면층: ${layerNameOf(layerId)}`);
|
||||
},
|
||||
});
|
||||
|
||||
export const layerOffToolStateMachine = createSequenceTool({
|
||||
tool: Tool.LAYOFF,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'entity', instructions: '끌 도면층의 객체를 선택하십시오.' }],
|
||||
commit: (input) => {
|
||||
const layerId = input.entity(0).layerId;
|
||||
updateLayers((layers) =>
|
||||
layers.map((layer) => (layer.id === layerId ? { ...layer, isVisible: false } : layer))
|
||||
);
|
||||
toast.info(`도면층 끄기: ${layerNameOf(layerId)}`);
|
||||
},
|
||||
});
|
||||
|
||||
/** LAYON — 모든 도면층 켜기 */
|
||||
export function turnAllLayersOn(): string {
|
||||
updateLayers((layers) => layers.map((layer) => ({ ...layer, isVisible: true })));
|
||||
toast.success('모든 도면층을 켰습니다.');
|
||||
return '모든 도면층 켜기';
|
||||
}
|
||||
|
||||
export const layerFreezeToolStateMachine = createSequenceTool({
|
||||
tool: Tool.LAYFRZ,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'entity', instructions: '동결할 도면층의 객체를 선택하십시오.' }],
|
||||
commit: (input) => {
|
||||
const layerId = input.entity(0).layerId;
|
||||
if (layerId === getActiveLayerId()) {
|
||||
toast.warn('현재 도면층은 동결할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
updateLayers((layers) =>
|
||||
layers.map((layer) => (layer.id === layerId ? { ...layer, isFrozen: true } : layer))
|
||||
);
|
||||
toast.info(`도면층 동결: ${layerNameOf(layerId)}`);
|
||||
},
|
||||
});
|
||||
|
||||
/** LAYTHW — 모든 도면층 동결 해제 */
|
||||
export function thawAllLayers(): string {
|
||||
updateLayers((layers) => layers.map((layer) => ({ ...layer, isFrozen: false })));
|
||||
toast.success('모든 도면층을 동결 해제했습니다.');
|
||||
return '동결 해제';
|
||||
}
|
||||
|
||||
export const layerLockToolStateMachine = createSequenceTool({
|
||||
tool: Tool.LAYLCK,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'entity', instructions: '잠글 도면층의 객체를 선택하십시오.' }],
|
||||
commit: (input) => {
|
||||
const layerId = input.entity(0).layerId;
|
||||
updateLayers((layers) =>
|
||||
layers.map((layer) => (layer.id === layerId ? { ...layer, isLocked: true } : layer))
|
||||
);
|
||||
toast.info(`도면층 잠금: ${layerNameOf(layerId)}`);
|
||||
},
|
||||
});
|
||||
|
||||
export const layerUnlockToolStateMachine = createSequenceTool({
|
||||
tool: Tool.LAYULK,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'entity', instructions: '잠금 해제할 도면층의 객체를 선택하십시오.' }],
|
||||
commit: (input) => {
|
||||
const layerId = input.entity(0).layerId;
|
||||
updateLayers((layers) =>
|
||||
layers.map((layer) => (layer.id === layerId ? { ...layer, isLocked: false } : layer))
|
||||
);
|
||||
toast.success(`도면층 잠금 해제: ${layerNameOf(layerId)}`);
|
||||
},
|
||||
});
|
||||
|
||||
export const layerIsolateToolStateMachine = createSequenceTool({
|
||||
tool: Tool.LAYISO,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'entity', instructions: '남길 도면층의 객체를 선택하십시오.' }],
|
||||
commit: (input) => {
|
||||
const layerId = input.entity(0).layerId;
|
||||
updateLayers((layers) =>
|
||||
layers.map((layer) => ({ ...layer, isVisible: layer.id === layerId }))
|
||||
);
|
||||
toast.info(`도면층 분리: ${layerNameOf(layerId)}`);
|
||||
},
|
||||
});
|
||||
|
||||
/** LAYUNISO — 도면층 분리 해제 */
|
||||
export function unisolateLayers(): string {
|
||||
updateLayers((layers) => layers.map((layer) => ({ ...layer, isVisible: true })));
|
||||
toast.success('도면층 분리를 해제했습니다.');
|
||||
return '도면층 분리 해제';
|
||||
}
|
||||
|
||||
/** LAYERP — 직전 도면층 상태로 되돌리기 */
|
||||
export function restorePreviousLayers(): string {
|
||||
const previous = popLayerHistory();
|
||||
if (!previous) {
|
||||
toast.info('되돌릴 도면층 상태가 없습니다.');
|
||||
return '되돌릴 상태 없음';
|
||||
}
|
||||
setLayers(previous);
|
||||
toast.success('직전 도면층 상태로 되돌렸습니다.');
|
||||
return '직전 도면층 상태';
|
||||
}
|
||||
|
||||
export const layerStateSaveToolStateMachine = createSequenceTool({
|
||||
tool: Tool.LAYERSTATE,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'text', instructions: '저장할 도면층 상태 이름을 입력하십시오.' }],
|
||||
commit: (input) => {
|
||||
saveLayerState(input.text(0), getLayers());
|
||||
toast.success(`도면층 상태 저장: ${input.text(0)}`);
|
||||
},
|
||||
});
|
||||
|
||||
export const layerStateRestoreToolStateMachine = createSequenceTool({
|
||||
tool: Tool.LAYERSTATE_RESTORE,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'text', instructions: '복원할 도면층 상태 이름을 입력하십시오.' }],
|
||||
commit: (input) => {
|
||||
const restored = restoreLayerState(input.text(0));
|
||||
if (!restored) {
|
||||
toast.warn('그 이름으로 저장한 도면층 상태가 없습니다.');
|
||||
return;
|
||||
}
|
||||
pushLayerHistory(getLayers());
|
||||
setLayers(restored);
|
||||
toast.success(`도면층 상태 복원: ${input.text(0)}`);
|
||||
},
|
||||
});
|
||||
|
||||
export const layerMatchToolStateMachine = createSequenceTool({
|
||||
tool: Tool.LAYMCH,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'selection', instructions: '옮길 객체를 선택한 뒤 ENTER.' },
|
||||
{ kind: 'entity', instructions: '대상 도면층의 객체를 선택하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const layerId = input.entity(1).layerId;
|
||||
for (const entity of input.entities(0)) {
|
||||
entity.layerId = layerId;
|
||||
}
|
||||
setEntities([...getEntities()], true);
|
||||
setSelectedEntityIds([]);
|
||||
toast.success(`${input.entities(0).length}개 객체를 ${layerNameOf(layerId)}(으)로 옮겼습니다.`);
|
||||
},
|
||||
});
|
||||
|
||||
export const layerToCurrentToolStateMachine = createSequenceTool({
|
||||
tool: Tool.LAYCUR,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '현재 도면층으로 옮길 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
const layerId = getActiveLayerId();
|
||||
for (const entity of input.entities(0)) {
|
||||
entity.layerId = layerId;
|
||||
}
|
||||
setEntities([...getEntities()], true);
|
||||
setSelectedEntityIds([]);
|
||||
toast.success(`${input.entities(0).length}개 객체를 현재 도면층으로 옮겼습니다.`);
|
||||
},
|
||||
});
|
||||
|
||||
export const layerMergeToolStateMachine = createSequenceTool({
|
||||
tool: Tool.LAYMRG,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '병합할(사라질) 도면층의 객체를 선택하십시오.' },
|
||||
{ kind: 'entity', instructions: '대상 도면층의 객체를 선택하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const sourceId = input.entity(0).layerId;
|
||||
const targetId = input.entity(1).layerId;
|
||||
if (sourceId === targetId) {
|
||||
toast.warn('같은 도면층입니다.');
|
||||
return;
|
||||
}
|
||||
for (const entity of getEntities()) {
|
||||
if (entity.layerId === sourceId) entity.layerId = targetId;
|
||||
}
|
||||
setEntities([...getEntities()], true);
|
||||
updateLayers((layers) => layers.filter((layer) => layer.id !== sourceId));
|
||||
if (getActiveLayerId() === sourceId) setActiveLayerId(targetId);
|
||||
toast.success(`도면층을 ${layerNameOf(targetId)}(으)로 병합했습니다.`);
|
||||
},
|
||||
});
|
||||
|
||||
export const layerDeleteToolStateMachine = createSequenceTool({
|
||||
tool: Tool.LAYDEL,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'entity', instructions: '삭제할 도면층의 객체를 선택하십시오.' }],
|
||||
commit: (input) => {
|
||||
const layerId = input.entity(0).layerId;
|
||||
if (getLayers().length <= 1) {
|
||||
toast.warn('마지막 도면층은 삭제할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
const doomed: Entity[] = getEntities().filter((entity) => entity.layerId === layerId);
|
||||
deleteEntities(doomed, true);
|
||||
updateLayers((layers) => layers.filter((layer) => layer.id !== layerId));
|
||||
if (getActiveLayerId() === layerId) setActiveLayerId(getLayers()[0].id);
|
||||
toast.success(`도면층과 객체 ${doomed.length}개를 삭제했습니다.`);
|
||||
},
|
||||
});
|
||||
|
||||
/** LAYWALK — 부를 때마다 다음 도면층 하나만 보여 준다 */
|
||||
let walkIndex = -1;
|
||||
export function walkLayers(): string {
|
||||
const layers = getLayers();
|
||||
if (!layers.length) return '도면층 없음';
|
||||
walkIndex = (walkIndex + 1) % layers.length;
|
||||
const target = layers[walkIndex];
|
||||
updateLayers((all) => all.map((layer) => ({ ...layer, isVisible: layer.id === target.id })));
|
||||
toast.info(`도면층 탐색: ${target.name} (${walkIndex + 1}/${layers.length})`);
|
||||
return `도면층 탐색 ${target.name}`;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/** 특성 명령 — 투명도와 특성 팔레트 열기 (조사표 3절 특성 패널) */
|
||||
import { toast } from 'react-toastify';
|
||||
import { openInspector, setQuickPropertiesVisible, isQuickPropertiesVisible } from '../../components/ui-state';
|
||||
import { getEntities, setEntities, setSelectedEntityIds } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
|
||||
/** PROPERTIES — 좌측 특성 팔레트를 연다 */
|
||||
export function openPropertiesPalette(): string {
|
||||
openInspector('properties');
|
||||
return '특성 팔레트';
|
||||
}
|
||||
|
||||
/** QUICKPROPERTIES — 선택 객체 옆의 간이 특성 상자를 켜고 끈다 */
|
||||
export function toggleQuickProperties(): string {
|
||||
const next = !isQuickPropertiesVisible();
|
||||
setQuickPropertiesVisible(next);
|
||||
return next ? '빠른 특성 켜기' : '빠른 특성 끄기';
|
||||
}
|
||||
|
||||
/** 이름·16진수 색을 모두 받는다 (AutoCAD의 색 이름 관행) */
|
||||
const NAMED_COLORS: Record<string, string> = {
|
||||
RED: '#ff0000',
|
||||
YELLOW: '#ffff00',
|
||||
GREEN: '#00ff00',
|
||||
CYAN: '#00ffff',
|
||||
BLUE: '#0000ff',
|
||||
MAGENTA: '#ff00ff',
|
||||
WHITE: '#ffffff',
|
||||
BLACK: '#000000',
|
||||
GRAY: '#808080',
|
||||
};
|
||||
|
||||
const LINE_DASHES: Record<string, number[] | undefined> = {
|
||||
실선: undefined,
|
||||
SOLID: undefined,
|
||||
파선: [10, 5],
|
||||
DASHED: [10, 5],
|
||||
'1점쇄선': [12, 4, 2, 4],
|
||||
DASHDOT: [12, 4, 2, 4],
|
||||
점선: [2, 4],
|
||||
DOTTED: [2, 4],
|
||||
};
|
||||
|
||||
export const colorToolStateMachine = createSequenceTool({
|
||||
tool: Tool.COLOR,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'selection', instructions: '색을 바꿀 객체를 선택한 뒤 ENTER.' },
|
||||
{ kind: 'text', instructions: '색을 입력하십시오 (#ff0000 또는 RED).' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const raw = input.text(1).trim();
|
||||
const color = NAMED_COLORS[raw.toUpperCase()] ?? (raw.startsWith('#') ? raw : '');
|
||||
if (!color) {
|
||||
toast.warn('#rrggbb 형식이나 색 이름을 입력하십시오.');
|
||||
return;
|
||||
}
|
||||
for (const entity of input.entities(0)) {
|
||||
entity.lineColor = color;
|
||||
}
|
||||
setEntities([...getEntities()], true);
|
||||
setSelectedEntityIds([]);
|
||||
toast.success(`색상 ${color}`);
|
||||
},
|
||||
});
|
||||
|
||||
export const lineTypeToolStateMachine = createSequenceTool({
|
||||
tool: Tool.LINETYPE,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'selection', instructions: '선종류를 바꿀 객체를 선택한 뒤 ENTER.' },
|
||||
{ kind: 'text', instructions: '선종류를 입력하십시오 (실선·파선·1점쇄선·점선).', defaultValue: '실선' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const key = input.text(1).trim();
|
||||
if (!(key in LINE_DASHES) && !(key.toUpperCase() in LINE_DASHES)) {
|
||||
toast.warn('실선·파선·1점쇄선·점선 중에서 입력하십시오.');
|
||||
return;
|
||||
}
|
||||
const dash = LINE_DASHES[key] ?? LINE_DASHES[key.toUpperCase()];
|
||||
for (const entity of input.entities(0)) {
|
||||
entity.lineDash = dash ? [...dash] : undefined;
|
||||
}
|
||||
setEntities([...getEntities()], true);
|
||||
setSelectedEntityIds([]);
|
||||
toast.success(`선종류 ${key}`);
|
||||
},
|
||||
});
|
||||
|
||||
export const lineWeightToolStateMachine = createSequenceTool({
|
||||
tool: Tool.LWEIGHT,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'selection', instructions: '선가중치를 바꿀 객체를 선택한 뒤 ENTER.' },
|
||||
{ kind: 'number', instructions: '선 굵기를 입력하십시오 <1>.', defaultValue: 1 },
|
||||
],
|
||||
commit: (input) => {
|
||||
const width = Math.max(1, Math.round(input.number(1)));
|
||||
for (const entity of input.entities(0)) {
|
||||
entity.lineWidth = width;
|
||||
}
|
||||
setEntities([...getEntities()], true);
|
||||
setSelectedEntityIds([]);
|
||||
toast.success(`선가중치 ${width}px`);
|
||||
},
|
||||
});
|
||||
|
||||
export const transparencyToolStateMachine = createSequenceTool({
|
||||
tool: Tool.TRANSPARENCY,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'selection', instructions: '투명도를 바꿀 객체를 선택한 뒤 ENTER.' },
|
||||
{ kind: 'number', instructions: '투명도를 입력하십시오 (0~90) <0>.', defaultValue: 0 },
|
||||
],
|
||||
commit: (input) => {
|
||||
const percent = Math.min(90, Math.max(0, input.number(1)));
|
||||
for (const entity of input.entities(0)) {
|
||||
entity.opacity = 1 - percent / 100;
|
||||
}
|
||||
setEntities([...getEntities()], true);
|
||||
setSelectedEntityIds([]);
|
||||
toast.success(`투명도 ${percent}%를 적용했습니다.`);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/** 선택·표시 명령 — 빠른 선택·유사 선택·객체 분리·그룹 (조사표 3절) */
|
||||
import { toast } from 'react-toastify';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { hideEntities, isolateEntities, showAllEntities } from '../../helpers/visibility';
|
||||
import { getEntities, setEntities, setSelectedEntityIds } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
|
||||
export const qSelectToolStateMachine = createSequenceTool({
|
||||
tool: Tool.QSELECT,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{
|
||||
kind: 'text',
|
||||
instructions: '선택할 객체 유형을 입력하십시오 (Line · Circle · Arc · Text · PolyLine · Hatch).',
|
||||
},
|
||||
],
|
||||
commit: (input) => {
|
||||
const wanted = input.text(0).trim().toLowerCase();
|
||||
const matched = getEntities().filter(
|
||||
(entity) => entity.getType().toLowerCase() === wanted
|
||||
);
|
||||
if (!matched.length) {
|
||||
toast.info('조건에 맞는 객체가 없습니다.');
|
||||
return;
|
||||
}
|
||||
setSelectedEntityIds(matched.map((entity) => entity.id));
|
||||
toast.success(`${matched.length}개 객체를 선택했습니다.`);
|
||||
},
|
||||
});
|
||||
|
||||
export const selectSimilarToolStateMachine = createSequenceTool({
|
||||
tool: Tool.SELECTSIMILAR,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'entity', instructions: '기준이 될 객체를 선택하십시오.' }],
|
||||
commit: (input) => {
|
||||
const reference = input.entity(0);
|
||||
const similar = getEntities().filter(
|
||||
(entity) =>
|
||||
entity.getType() === reference.getType() &&
|
||||
entity.lineColor === reference.lineColor &&
|
||||
entity.layerId === reference.layerId
|
||||
);
|
||||
setSelectedEntityIds(similar.map((entity) => entity.id));
|
||||
toast.success(`유사 객체 ${similar.length}개를 선택했습니다.`);
|
||||
},
|
||||
});
|
||||
|
||||
export const isolateObjectsToolStateMachine = createSequenceTool({
|
||||
tool: Tool.ISOLATEOBJECTS,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '남길 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
const selected = input.entities(0);
|
||||
if (!selected.length) return;
|
||||
isolateEntities(selected, getEntities());
|
||||
setSelectedEntityIds([]);
|
||||
toast.info(`${selected.length}개 객체만 표시합니다.`);
|
||||
},
|
||||
});
|
||||
|
||||
export const hideObjectsToolStateMachine = createSequenceTool({
|
||||
tool: Tool.HIDEOBJECTS,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '숨길 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
const selected = input.entities(0);
|
||||
if (!selected.length) return;
|
||||
hideEntities(selected);
|
||||
setSelectedEntityIds([]);
|
||||
toast.info(`${selected.length}개 객체를 숨겼습니다.`);
|
||||
},
|
||||
});
|
||||
|
||||
/** UNISOLATEOBJECTS — 숨긴 객체를 모두 되살린다 */
|
||||
export function unhideAllObjects(): string {
|
||||
showAllEntities();
|
||||
toast.success('숨긴 객체를 모두 표시했습니다.');
|
||||
return '객체 분리 종료';
|
||||
}
|
||||
|
||||
export const groupToolStateMachine = createSequenceTool({
|
||||
tool: Tool.GROUP,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '그룹으로 묶을 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
const selected = input.entities(0);
|
||||
if (selected.length < 2) {
|
||||
toast.warn('두 개 이상의 객체를 선택하십시오.');
|
||||
return;
|
||||
}
|
||||
const groupId = crypto.randomUUID();
|
||||
for (const entity of selected) {
|
||||
entity.groupId = groupId;
|
||||
}
|
||||
setEntities([...getEntities()], true);
|
||||
setSelectedEntityIds([]);
|
||||
toast.success(`${selected.length}개 객체를 그룹으로 묶었습니다.`);
|
||||
},
|
||||
});
|
||||
|
||||
export const ungroupToolStateMachine = createSequenceTool({
|
||||
tool: Tool.UNGROUP,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '그룹을 해제할 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
const selected: Entity[] = input.entities(0);
|
||||
const groupIds = new Set(selected.map((entity) => entity.groupId).filter(Boolean));
|
||||
if (!groupIds.size) {
|
||||
toast.info('그룹으로 묶인 객체가 없습니다.');
|
||||
return;
|
||||
}
|
||||
for (const entity of getEntities()) {
|
||||
if (entity.groupId && groupIds.has(entity.groupId)) {
|
||||
entity.groupId = undefined;
|
||||
}
|
||||
}
|
||||
setEntities([...getEntities()], true);
|
||||
setSelectedEntityIds([]);
|
||||
toast.success('그룹을 해제했습니다.');
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user