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
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* LENGTHEN(LEN) — 오토캐드 물음 차례(337 설계 1-2).
|
||||
* 「측정할 객체 선택 또는 [증분(DE)/퍼센트(P)/전체(T)] <전체(T)>:」(누름 = 현재 길이 알림)
|
||||
* → 방식 값 → 「변경할 객체 선택 또는 [명령 취소(U)]:」 되풀이(빈 Enter · Esc = 끝 · 바꾼 것 남음).
|
||||
* 손으로 짠 도구(설계 2-14) — 바꾸고 같은 물음에 머무는 되풀이 · U 는 이 명령 안 바꿈만 되돌림.
|
||||
*/
|
||||
import { Arc, type Point, Segment } from '@flatten-js/core';
|
||||
import { assign, createMachine } from 'xstate';
|
||||
import { type OptionSpec, type PromptSpec, recall, remember } from '../../commands/prompt';
|
||||
import { ArcEntity } from '../../entities/ArcEntity';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { findClosestEntity } from '../../helpers/find-closest-entity';
|
||||
import { polylineLength, sampleEntityPoints } from '../../helpers/geometry/sample-entity';
|
||||
import { pickRadius } from '../../helpers/pick-radius';
|
||||
import { queryEntitiesNearPoint } from '../../helpers/spatial-index';
|
||||
import { notice } from '../../inputController/prompt-input';
|
||||
import { addEntities, deleteEntities, getLayerById, setShouldDrawHelpers, undo } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import {
|
||||
END_STATE,
|
||||
type MouseClickEvent,
|
||||
type NumberInputEvent,
|
||||
type StateEvent,
|
||||
type ToolContext,
|
||||
} from '../tool.types';
|
||||
import { copyStyle, lengthenLine } from './modify.helpers';
|
||||
|
||||
type Mode = 'DELTA' | 'PERCENT' | 'TOTAL';
|
||||
|
||||
const MODE_OPTIONS: Record<Mode, OptionSpec> = {
|
||||
DELTA: { key: 'DELTA', kw: 'DE', en: 'DELTA', label: '증분' },
|
||||
PERCENT: { key: 'PERCENT', kw: 'P', en: 'PERCENT', label: '퍼센트' },
|
||||
TOTAL: { key: 'TOTAL', kw: 'T', en: 'TOTAL', label: '전체' },
|
||||
};
|
||||
const DYNAMIC: OptionSpec = { key: 'DYNAMIC', kw: 'DY', en: 'DYNAMIC', label: '동적' };
|
||||
const ANGLE: OptionSpec = { key: 'ANGLE', kw: 'A', en: 'ANGLE', label: '각도' };
|
||||
const UNDO: OptionSpec = { key: 'UNDO', kw: 'U', en: 'UNDO', label: '명령 취소' };
|
||||
|
||||
/** 방식별 값 물음 — 기억 열쇠 LENGTHEN.DE · .P · .T */
|
||||
const VALUE_PROMPTS: Record<Mode, PromptSpec> = {
|
||||
DELTA: {
|
||||
text: '증분 길이 입력',
|
||||
absent: [ANGLE],
|
||||
default: { kind: 'value', remember: 'LENGTHEN.DE', initial: 0 },
|
||||
},
|
||||
PERCENT: {
|
||||
text: '퍼센트 길이 입력',
|
||||
number: { positive: true },
|
||||
default: { kind: 'value', remember: 'LENGTHEN.P', initial: 100 },
|
||||
},
|
||||
TOTAL: {
|
||||
text: '전체 길이 지정',
|
||||
absent: [ANGLE],
|
||||
number: { positive: true },
|
||||
default: { kind: 'value', remember: 'LENGTHEN.T', initial: 1 },
|
||||
},
|
||||
};
|
||||
const REMEMBER_KEY: Record<Mode, string> = {
|
||||
DELTA: 'LENGTHEN.DE',
|
||||
PERCENT: 'LENGTHEN.P',
|
||||
TOTAL: 'LENGTHEN.T',
|
||||
};
|
||||
|
||||
const lastMode = (): Mode => recall<Mode>('LENGTHEN.MODE') ?? 'TOTAL';
|
||||
|
||||
const MEASURE_PROMPT = (): PromptSpec => {
|
||||
const mode = MODE_OPTIONS[lastMode()];
|
||||
return {
|
||||
text: '측정할 객체 선택',
|
||||
options: [MODE_OPTIONS.DELTA, MODE_OPTIONS.PERCENT, MODE_OPTIONS.TOTAL],
|
||||
absent: [DYNAMIC],
|
||||
default: { kind: 'option', key: mode.key, label: `${mode.label}(${mode.kw})` },
|
||||
};
|
||||
};
|
||||
const TARGET_PROMPT: PromptSpec = { text: '변경할 객체 선택', options: [UNDO] };
|
||||
|
||||
/** 누른 자리 가장 가까운 객체(잠금 층 뺌 · 반경 안만) */
|
||||
function pickAt(point: Point): Entity | null {
|
||||
const radius = pickRadius();
|
||||
const candidates = queryEntitiesNearPoint(point.x, point.y, radius).filter(
|
||||
(entity) => !getLayerById(entity.layerId)?.isLocked
|
||||
);
|
||||
const { distance, entity } = findClosestEntity(point, candidates);
|
||||
return entity && distance <= radius ? entity : null;
|
||||
}
|
||||
|
||||
export function entityLength(entity: Entity): number {
|
||||
const shape = entity.getShape();
|
||||
if (shape instanceof Segment || shape instanceof Arc) return shape.length;
|
||||
return polylineLength(sampleEntityPoints(entity));
|
||||
}
|
||||
|
||||
/** 호 — 누른 쪽 끝을 호 길이 delta 만큼(사이각 0 ~ 360° 안) */
|
||||
function lengthenArc(entity: Entity, arc: Arc, delta: number, near: Point): Entity | null {
|
||||
const radius = Number(arc.r);
|
||||
const turn = delta / radius;
|
||||
const sweep = arc.sweep + turn;
|
||||
if (sweep <= 0 || sweep >= 2 * Math.PI) return null;
|
||||
const atStart = arc.start.distanceTo(near)[0] < arc.end.distanceTo(near)[0];
|
||||
const sign = arc.counterClockwise ? 1 : -1;
|
||||
const startAngle = atStart ? arc.startAngle - sign * turn : arc.startAngle;
|
||||
const endAngle = atStart ? arc.endAngle : arc.endAngle + sign * turn;
|
||||
return copyStyle(
|
||||
entity,
|
||||
new ArcEntity(entity.layerId, arc.center, radius, startAngle, endAngle, arc.counterClockwise)
|
||||
);
|
||||
}
|
||||
|
||||
/** 방식 · 값 → 바뀐 객체(못 바꾸면 null) */
|
||||
function changed(entity: Entity, mode: Mode, value: number, near: Point): Entity | null {
|
||||
const shape = entity.getShape();
|
||||
if (!(shape instanceof Segment) && !(shape instanceof Arc)) return null;
|
||||
const length = shape.length;
|
||||
const target =
|
||||
mode === 'DELTA' ? length + value : mode === 'PERCENT' ? (length * value) / 100 : value;
|
||||
if (target <= 1e-9) return null;
|
||||
const delta = target - length;
|
||||
return shape instanceof Arc
|
||||
? lengthenArc(entity, shape, delta, near)
|
||||
: lengthenLine(entity, delta, near);
|
||||
}
|
||||
|
||||
interface LengthenContext extends ToolContext {
|
||||
mode: Mode;
|
||||
value: number;
|
||||
/** 이 명령 안에서 바꾼 횟수 — U 는 이만큼만 되돌림 */
|
||||
changes: number;
|
||||
}
|
||||
|
||||
type Ctx = { context: LengthenContext; event: StateEvent };
|
||||
|
||||
const toValue = (mode: Mode) => ({
|
||||
target: `VALUE_${mode}`,
|
||||
actions: assign({ mode: () => mode }),
|
||||
});
|
||||
|
||||
const clickPoint = (event: StateEvent) => (event as MouseClickEvent).worldMouseLocation;
|
||||
|
||||
export const lengthenToolStateMachine = createMachine({
|
||||
types: {} as { context: LengthenContext; events: StateEvent },
|
||||
context: { type: Tool.LENGTHEN, mode: 'TOTAL', value: 1, changes: 0 },
|
||||
initial: 'MEASURE',
|
||||
states: {
|
||||
MEASURE: {
|
||||
meta: { kind: 'entity', prompt: MEASURE_PROMPT },
|
||||
entry: () => setShouldDrawHelpers(false),
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: ({ event }: Ctx) => {
|
||||
const entity = pickAt(clickPoint(event));
|
||||
if (entity) notice(`현재 길이: ${entityLength(entity).toFixed(4)}`);
|
||||
},
|
||||
},
|
||||
'OPTION.DELTA': toValue('DELTA'),
|
||||
'OPTION.PERCENT': toValue('PERCENT'),
|
||||
'OPTION.TOTAL': toValue('TOTAL'),
|
||||
ESC: END_STATE,
|
||||
},
|
||||
},
|
||||
...Object.fromEntries(
|
||||
(Object.keys(VALUE_PROMPTS) as Mode[]).map((mode) => [
|
||||
`VALUE_${mode}`,
|
||||
{
|
||||
meta: { kind: 'number', prompt: VALUE_PROMPTS[mode] },
|
||||
on: {
|
||||
NUMBER_INPUT: {
|
||||
target: 'TARGET',
|
||||
actions: assign(({ event }: Ctx) => {
|
||||
const value = (event as NumberInputEvent).value;
|
||||
remember('LENGTHEN.MODE', mode);
|
||||
remember(REMEMBER_KEY[mode], value);
|
||||
return { value };
|
||||
}),
|
||||
},
|
||||
ESC: END_STATE,
|
||||
},
|
||||
},
|
||||
])
|
||||
),
|
||||
TARGET: {
|
||||
meta: { kind: 'entity', prompt: TARGET_PROMPT },
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: assign(({ context, event }: Ctx) => {
|
||||
const near = clickPoint(event);
|
||||
const entity = pickAt(near);
|
||||
if (!entity) return {};
|
||||
const next = changed(entity, context.mode, context.value, near);
|
||||
if (!next) {
|
||||
notice('이 객체는 길이를 바꿀 수 없음.');
|
||||
return {};
|
||||
}
|
||||
deleteEntities([entity], false);
|
||||
addEntities([next], true);
|
||||
return { changes: context.changes + 1 };
|
||||
}),
|
||||
},
|
||||
'OPTION.UNDO': {
|
||||
actions: assign(({ context }: Ctx) => {
|
||||
if (!context.changes) return {};
|
||||
undo();
|
||||
return { changes: context.changes - 1 };
|
||||
}),
|
||||
},
|
||||
ENTER: END_STATE,
|
||||
ESC: END_STATE,
|
||||
},
|
||||
},
|
||||
[END_STATE]: { type: 'final' },
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 손으로 짠 상태 — 사건마다 assign 꼴이 달라 풀어 둠(sequence-tool 과 같음)
|
||||
} as Record<string, any>,
|
||||
});
|
||||
Reference in New Issue
Block a user