Files
Aislo/B07_DesignDetail/openwebcad/src/tools/annotate/dimension-tools.ts
T
eomsangdonandClaude Opus 5.5 c15be5116a feat(B07 CAD): 337-4b 다-2 늘이기 · 재기 · 치수 오토캐드 조작 — LENGTHEN · DIVIDE · MEASURE · DIST · DIM 류
- 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
2026-10-16 00:15:35 +09:00

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);
},
});