- LENGTHEN 새 도구(lengthen-tool.ts) — 측정할 객체 [증분(DE)/퍼센트(P)/전체(T)] <마지막 방식> · 누름 = 현재 길이 알림 · 방식별 값 기억 · 변경할 객체 되풀이 · U = 이 명령 안 바꿈만 · 빈 Enter · Esc 끝 · 선 · 호 - DIVIDE · MEASURE — 기본값 없음 · 정수 2~32767 · 양수 · 블록(B) 없음 안내 · MEASURE 누른 끝부터 - DIST — 오토캐드 꼴 두 줄 기록(거리 · 각도 · 증분) · 다중 점(M) 없음 안내 - DIMLINEAR · DIMALIGNED — 빈 Enter = 객체 선택 · [문자(T)/수평(H)/수직(V)] · 치수 문자 = 기록 · 치수 객체 문자 바꿈 칸(<> = 잰 값) - DIMCONTINUE · DIMBASELINE — 되풀이 · 명령 취소(U) · 빈 Enter 끝 · DIMRADIUS · DIMDIAMETER [문자(T)] · 그 밖 치수 물음 글 - 시험 measure-acad 11 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
371 lines
13 KiB
TypeScript
371 lines
13 KiB
TypeScript
/**
|
|
* 선형 치수 계열 — 선형·정렬·기준선·연속·빠른 치수·자동 치수 (조사표 5절).
|
|
* 오토캐드 물음 차례(337 설계 1-2) — DIMLINEAR · DIMALIGNED = 원점 둘(빈 Enter = 객체 선택) → 치수선 위치
|
|
* [문자(T)/수평(H)/수직(V)] · 한 번 뒤 끝 · DIMCONTINUE · DIMBASELINE = 원점 되풀이 [명령 취소(U)] · 빈 Enter 끝.
|
|
*/
|
|
import { Circle, Point } from '@flatten-js/core';
|
|
import { toast } from 'react-toastify';
|
|
import { assign, createMachine } from 'xstate';
|
|
import { type OptionSpec, type PromptSpec, OPT, SELECT_OBJECTS } from '../../commands/prompt';
|
|
import { logLine } from '../../commands/run-command';
|
|
import { EntityName, type Entity } from '../../entities/Entity';
|
|
import { MeasurementEntity } from '../../entities/MeasurementEntity';
|
|
import type { PolyLineEntity } from '../../entities/PolyLineEntity';
|
|
import { sampleEntityPoints } from '../../helpers/geometry/sample-entity';
|
|
import { getPointFromEvent } from '../../helpers/get-point-from-event';
|
|
import { notice } from '../../inputController/prompt-input';
|
|
import {
|
|
addEntities,
|
|
getActiveLayerId,
|
|
setAngleGuideOriginPoint,
|
|
setSelectedEntityIds,
|
|
setShouldDrawHelpers,
|
|
undo,
|
|
} from '../../state';
|
|
import { Tool } from '../../tools';
|
|
import { styled } from '../factories/entity-factory';
|
|
import { createSequenceTool, type SequenceInput } from '../factories/sequence-tool';
|
|
import { END_STATE, type PointInputEvent, type StateEvent, type ToolContext } from '../tool.types';
|
|
import { getLastDimension, rememberDimension } from './annotation.helpers';
|
|
|
|
/** 치수 객체 하나 만들기 — 현재 선 특성을 입히고 직전 치수로 기억한다 */
|
|
function makeDimension(start: Point, end: Point, offset: Point, text?: string): MeasurementEntity {
|
|
const dimension = styled(new MeasurementEntity(getActiveLayerId(), start, end, offset));
|
|
if (text && text !== '<>') dimension.textOverride = text;
|
|
rememberDimension(dimension);
|
|
return dimension;
|
|
}
|
|
|
|
type Fixed = 'H' | 'V' | null;
|
|
|
|
/**
|
|
* 선형 치수 — 수평(H) · 수직(V) 고정이면 그 방향, 아니면 치수선을 놓은 방향으로 고른다.
|
|
* 위·아래에 놓으면 가로 거리, 좌·우에 놓으면 세로 거리를 잰다 (AutoCAD와 같다).
|
|
*/
|
|
function projectForLinear(
|
|
start: Point,
|
|
end: Point,
|
|
offset: Point,
|
|
fixed: Fixed = null
|
|
): [Point, Point] {
|
|
if (fixed === 'H') return [start, new Point(end.x, start.y)];
|
|
if (fixed === 'V') return [start, new Point(start.x, end.y)];
|
|
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)];
|
|
}
|
|
|
|
/** 객체 선택 — 선 · 폴리선(누른 토막) · 그 밖은 처음 · 끝 점 */
|
|
function endsOf(entity: Entity, pick: Point): [Point, Point] | null {
|
|
let target = entity;
|
|
if (entity.getType() === EntityName.PolyLine) {
|
|
const pieces = (entity as PolyLineEntity).getEntities();
|
|
target = pieces.reduce(
|
|
(best, piece) =>
|
|
(piece.getShape()?.distanceTo(pick)[0] ?? Number.POSITIVE_INFINITY) <
|
|
(best.getShape()?.distanceTo(pick)[0] ?? Number.POSITIVE_INFINITY)
|
|
? piece
|
|
: best,
|
|
pieces[0] ?? entity
|
|
);
|
|
}
|
|
const points = sampleEntityPoints(target);
|
|
if (points.length < 2) return null;
|
|
return [points[0], points[points.length - 1]];
|
|
}
|
|
|
|
const option = (key: string, kw: string, label: string): OptionSpec => ({
|
|
key,
|
|
kw,
|
|
en: key,
|
|
label,
|
|
});
|
|
/** 빈 Enter = 객체 선택 — 숨은 옵션(그림은 <객체 선택>) */
|
|
const OBJECT = { ...option('OBJECT', 'OBJECT', '객체 선택'), hidden: true as const };
|
|
const HORIZONTAL = option('HORIZONTAL', 'H', '수평');
|
|
const VERTICAL = option('VERTICAL', 'V', '수직');
|
|
const MTEXT = option('MTEXT', 'M', '여러 줄 문자');
|
|
const ANGLE = option('ANGLE', 'A', '각도');
|
|
const ROTATED = option('ROTATED', 'R', '회전됨');
|
|
const SELECT = option('SELECT', 'S', '선택');
|
|
|
|
/** 원점 둘 또는 객체 끝 */
|
|
function originsOf(input: SequenceInput): [Point, Point] | null {
|
|
if (input.has('obj')) {
|
|
const entity = input.get<Entity>('obj');
|
|
return endsOf(entity, input.pick(input.values.indexOf(entity)));
|
|
}
|
|
if (!input.has('p2')) return null;
|
|
return [input.get<Point>('p1'), input.get<Point>('p2')];
|
|
}
|
|
|
|
/** 수평 · 수직 고정 — 명령 하나 동안(확정 · Esc 에서 풂) */
|
|
let fixed: Fixed = null;
|
|
|
|
/** DIMLINEAR · DIMALIGNED 공통 물음 */
|
|
function linearSteps(tool: Tool, aligned: boolean) {
|
|
const measure = (input: SequenceInput, offset: Point): [Point, Point] | null => {
|
|
const origins = originsOf(input);
|
|
if (!origins) return null;
|
|
return aligned ? origins : projectForLinear(origins[0], origins[1], offset, fixed);
|
|
};
|
|
const measured = (input: SequenceInput): string | null => {
|
|
const origins = originsOf(input);
|
|
if (!origins) return null;
|
|
const [a, b] = origins;
|
|
const wide = Math.abs(b.x - a.x) >= Math.abs(b.y - a.y);
|
|
const [start, end] = aligned ? origins : projectForLinear(a, b, b, fixed ?? (wide ? 'H' : 'V'));
|
|
return new MeasurementEntity(getActiveLayerId(), start, end, end).getMeasuredText();
|
|
};
|
|
return createSequenceTool({
|
|
tool,
|
|
steps: [
|
|
{
|
|
kind: 'point',
|
|
id: 'p1',
|
|
prompt: {
|
|
text: '첫 번째 치수보조선 원점 지정 또는',
|
|
options: [OBJECT],
|
|
default: { kind: 'option', key: 'OBJECT', label: '객체 선택' },
|
|
},
|
|
options: { OBJECT: { mode: 'object', next: 'obj' } },
|
|
},
|
|
{
|
|
kind: 'entity',
|
|
id: 'obj',
|
|
when: (input) => input.mode === 'object' && !input.has('obj'),
|
|
next: 'place',
|
|
prompt: { text: '치수 기입할 객체 선택' },
|
|
},
|
|
{
|
|
kind: 'point',
|
|
id: 'p2',
|
|
when: (input) => !input.has('obj'),
|
|
prompt: { text: '두 번째 치수보조선 원점 지정' },
|
|
},
|
|
{
|
|
kind: 'text',
|
|
id: 'txt',
|
|
when: (input) => input.mode === 'text',
|
|
next: 'place',
|
|
defaultValue: '<>',
|
|
prompt: (input) => {
|
|
const value = measured(input);
|
|
return {
|
|
text: '치수 문자 입력',
|
|
...(value === null ? {} : { default: { kind: 'enter' as const, label: value } }),
|
|
};
|
|
},
|
|
},
|
|
{
|
|
kind: 'point',
|
|
id: 'place',
|
|
prompt: {
|
|
text: '치수선의 위치 지정',
|
|
options: aligned ? [OPT.TEXT] : [OPT.TEXT, HORIZONTAL, VERTICAL],
|
|
absent: aligned ? [MTEXT, ANGLE] : [MTEXT, ANGLE, ROTATED],
|
|
},
|
|
options: {
|
|
TEXT: { mode: 'text', next: 'txt' },
|
|
...(aligned
|
|
? {}
|
|
: {
|
|
HORIZONTAL: {
|
|
run: () => {
|
|
fixed = 'H';
|
|
},
|
|
},
|
|
VERTICAL: {
|
|
run: () => {
|
|
fixed = 'V';
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
},
|
|
],
|
|
preview: (input) => {
|
|
if (input.has('place') || !originsOf(input)) return [];
|
|
const span = measure(input, input.cursor);
|
|
if (!span) return [];
|
|
const ghost = new MeasurementEntity(getActiveLayerId(), span[0], span[1], input.cursor);
|
|
if (input.has('txt')) ghost.textOverride = input.get<string>('txt');
|
|
return [ghost];
|
|
},
|
|
commit: (input) => {
|
|
const offset = input.get<Point>('place');
|
|
const span = measure(input, offset);
|
|
fixed = null;
|
|
if (!span) {
|
|
toast.warn('치수를 넣을 수 없는 객체입니다.');
|
|
return;
|
|
}
|
|
const dimension = makeDimension(
|
|
span[0],
|
|
span[1],
|
|
offset,
|
|
input.has('txt') ? input.get<string>('txt') : undefined
|
|
);
|
|
addEntities([dimension], true);
|
|
logLine(`치수 문자 = ${dimension.getLabel()}`);
|
|
},
|
|
onCancel: () => {
|
|
fixed = null;
|
|
},
|
|
});
|
|
}
|
|
|
|
export const dimLinearToolStateMachine = linearSteps(Tool.DIMLINEAR, false);
|
|
export const dimAlignedToolStateMachine = linearSteps(Tool.DIMALIGNED, true);
|
|
|
|
/** 기준선 치수는 같은 시작점에서 재고, 치수선을 한 칸 더 띄운다 */
|
|
function baselineDimension(previous: MeasurementEntity, point: Point): MeasurementEntity {
|
|
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
|
|
);
|
|
return makeDimension(start, point, nextOffset);
|
|
}
|
|
|
|
const continueDimension = (previous: MeasurementEntity, point: Point): MeasurementEntity =>
|
|
makeDimension(previous.getEndPoint(), point, previous.getOffsetPoint());
|
|
|
|
interface ChainContext extends ToolContext {
|
|
/** 명령 시작 때 직전 치수 · 이 명령이 만든 치수(U 가 뒤에서 뺌) */
|
|
first: MeasurementEntity | null;
|
|
made: MeasurementEntity[];
|
|
}
|
|
type ChainCtx = { context: ChainContext; event: StateEvent };
|
|
|
|
const CHAIN_PROMPT: PromptSpec = {
|
|
text: '두 번째 치수보조선 원점 지정',
|
|
options: [OPT.UNDO],
|
|
absent: [SELECT],
|
|
};
|
|
|
|
/** DIMCONTINUE · DIMBASELINE — 앞 치수에 이어 원점 되풀이 · U = 이 명령 안 마지막 치수 · 빈 Enter · Esc = 끝 */
|
|
function chainTool(
|
|
tool: Tool,
|
|
build: (previous: MeasurementEntity, point: Point) => MeasurementEntity
|
|
) {
|
|
const base = (context: ChainContext) => context.made[context.made.length - 1] ?? context.first;
|
|
const hasBase = ({ context }: ChainCtx) => !!base(context);
|
|
const place = {
|
|
guard: hasBase,
|
|
actions: assign(({ context, event }: ChainCtx) => {
|
|
const previous = base(context) as MeasurementEntity;
|
|
const point = getPointFromEvent(previous.getEndPoint(), event as PointInputEvent);
|
|
const dimension = build(previous, point);
|
|
addEntities([dimension], true);
|
|
logLine(`치수 문자 = ${dimension.getLabel()}`);
|
|
setAngleGuideOriginPoint(dimension.getEndPoint());
|
|
return { made: [...context.made, dimension] };
|
|
}),
|
|
};
|
|
const noBase = {
|
|
target: END_STATE,
|
|
actions: () => notice('먼저 치수를 하나 작성하십시오.'),
|
|
};
|
|
return createMachine({
|
|
types: {} as { context: ChainContext; events: StateEvent },
|
|
context: () => ({ type: tool, first: getLastDimension(), made: [] }),
|
|
initial: 'PICK',
|
|
states: {
|
|
PICK: {
|
|
meta: { kind: 'point', prompt: CHAIN_PROMPT },
|
|
entry: ({ context }: ChainCtx) => {
|
|
setShouldDrawHelpers(true);
|
|
setAngleGuideOriginPoint(base(context)?.getEndPoint() ?? null);
|
|
},
|
|
on: {
|
|
MOUSE_CLICK: [place, noBase],
|
|
ABSOLUTE_POINT_INPUT: [place, noBase],
|
|
RELATIVE_POINT_INPUT: [place, noBase],
|
|
NUMBER_INPUT: [place, noBase],
|
|
'OPTION.UNDO': {
|
|
actions: assign(({ context }: ChainCtx) => {
|
|
if (!context.made.length) return {};
|
|
undo();
|
|
const made = context.made.slice(0, -1);
|
|
const previous = made[made.length - 1] ?? context.first;
|
|
if (previous) rememberDimension(previous);
|
|
setAngleGuideOriginPoint(previous?.getEndPoint() ?? null);
|
|
return { made };
|
|
}),
|
|
},
|
|
ENTER: END_STATE,
|
|
ESC: END_STATE,
|
|
},
|
|
},
|
|
[END_STATE]: { type: 'final' },
|
|
// biome-ignore lint/suspicious/noExplicitAny: 손으로 짠 상태 — 사건마다 assign 꼴이 달라 풀어 둠(sequence-tool 과 같음)
|
|
} as Record<string, any>,
|
|
});
|
|
}
|
|
|
|
export const dimBaselineToolStateMachine = chainTool(Tool.DIMBASELINE, baselineDimension);
|
|
export const dimContinueToolStateMachine = chainTool(Tool.DIMCONTINUE, continueDimension);
|
|
|
|
export const qDimToolStateMachine = createSequenceTool({
|
|
tool: Tool.QDIM,
|
|
helpers: false,
|
|
steps: [
|
|
{ kind: 'selection', prompt: { ...SELECT_OBJECTS, text: '치수 기입할 형상 선택' } },
|
|
{ kind: 'point', prompt: { text: '치수선의 위치 지정' } },
|
|
],
|
|
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', prompt: { text: '치수 기입할 객체 선택' } },
|
|
{ kind: 'point', prompt: { text: '치수선의 위치 지정' } },
|
|
],
|
|
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);
|
|
},
|
|
});
|