diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.modify.ts b/B07_DesignDetail/openwebcad/src/commands/commands.modify.ts index 1efeadeaf..a4bae8470 100644 --- a/B07_DesignDetail/openwebcad/src/commands/commands.modify.ts +++ b/B07_DesignDetail/openwebcad/src/commands/commands.modify.ts @@ -17,6 +17,7 @@ import { chamferToolStateMachine, filletToolStateMachine, } from '../tools/modify/corner-tools'; +import { lengthenToolStateMachine } from '../tools/modify/lengthen-tool'; import { drawOrderBackToolStateMachine, drawOrderFrontToolStateMachine, @@ -35,7 +36,6 @@ import { import { offsetToolStateMachine } from '../tools/modify/offset-tool'; import { alignToolStateMachine, - lengthenToolStateMachine, mirrorToolStateMachine, stretchToolStateMachine, } from '../tools/modify/transform-tools'; diff --git a/B07_DesignDetail/openwebcad/src/entities/MeasurementEntity.ts b/B07_DesignDetail/openwebcad/src/entities/MeasurementEntity.ts index 08504b3b9..36f3e211b 100644 --- a/B07_DesignDetail/openwebcad/src/entities/MeasurementEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/MeasurementEntity.ts @@ -51,6 +51,8 @@ export class MeasurementEntity implements Entity { private startPoint: Point; private endPoint: Point; private offsetPoint: Point; + /** 치수 문자 바꿈(DIMLINEAR 문자(T)) — `<>` = 잰 값 · 없으면 잰 값 */ + public textOverride?: string; constructor(layerId: string, startPoint: Point, endPoint: Point, offsetPoint: Point) { this.layerId = layerId; @@ -75,6 +77,17 @@ export class MeasurementEntity implements Entity { this.offsetPoint = point; } + /** 잰 값(치수 소수 자릿수) */ + public getMeasuredText(): string { + return String(round(pointDistance(this.startPoint, this.endPoint), getDimDecimals())); + } + + /** 보이는 치수 문자 */ + public getLabel(): string { + const measured = this.getMeasuredText(); + return this.textOverride ? this.textOverride.replaceAll('<>', measured) : measured; + } + public getDrawPoints() { // Return if measurement is zero length if (isPointEqual(this.startPoint, this.endPoint)) { @@ -301,7 +314,7 @@ export class MeasurementEntity implements Entity { drawController.drawLine(offsetStartPointMargin, offsetStartPointExtend); drawController.drawLine(offsetEndPointMargin, offsetEndPointExtend); - const distance = String(round(pointDistance(this.startPoint, this.endPoint), getDimDecimals())); + const distance = this.getLabel(); const originalTextDirection = normalUnit.rotate90CW(); let finalTextDirection = originalTextDirection; if ( @@ -343,12 +356,14 @@ export class MeasurementEntity implements Entity { } public clone(): MeasurementEntity { - return new MeasurementEntity( + const copy = new MeasurementEntity( getActiveLayerId(), this.startPoint.clone(), this.endPoint.clone(), this.offsetPoint.clone() ); + copy.textOverride = this.textOverride; + return copy; } public intersectsWithBox(box: Box): boolean { @@ -416,7 +431,7 @@ export class MeasurementEntity implements Entity { ]; // Calculate text properties - const distance = String(round(pointDistance(this.startPoint, this.endPoint), getDimDecimals())); + const distance = this.getLabel(); const worldFactor = annotationWorldFactor(); const textHeight = getDimTextHeight() / worldFactor; // Estimate width: textString.length * fontSize * aspectRatioFactor @@ -582,6 +597,7 @@ export class MeasurementEntity implements Entity { startPoint: { x: this.startPoint.x, y: this.startPoint.y }, endPoint: { x: this.endPoint.x, y: this.endPoint.y }, offsetPoint: { x: this.offsetPoint.x, y: this.offsetPoint.y }, + ...(this.textOverride ? { textOverride: this.textOverride } : {}), }, }; } @@ -611,6 +627,7 @@ export class MeasurementEntity implements Entity { measurementEntity.lineColor = jsonEntity.lineColor; measurementEntity.lineWidth = jsonEntity.lineWidth; measurementEntity.lineDash = jsonEntity.lineDash; + measurementEntity.textOverride = jsonEntity.shapeData.textOverride; return measurementEntity; } } @@ -619,4 +636,5 @@ export interface MeasurementJsonData { startPoint: { x: number; y: number }; endPoint: { x: number; y: number }; offsetPoint: { x: number; y: number }; + textOverride?: string; } diff --git a/B07_DesignDetail/openwebcad/src/inputController/prompt-input.ts b/B07_DesignDetail/openwebcad/src/inputController/prompt-input.ts index 44978e933..d08439020 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/prompt-input.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/prompt-input.ts @@ -379,8 +379,9 @@ export function routeTyped(raw: string): void { return; } if (text.startsWith("'")) return routeTransparent(text.slice(1)); - const own = subPrompt()?.onText?.(text); - if (own) return settle(own); + const textSub = subPrompt(); + const own = textSub?.onText?.(text); + if (textSub && own) return settle(textSub, own); if (prompt.kind === 'idle') { // ⑨ 이름 > 내 별칭 > 기본 별칭 딱 맞음만 const command = resolveCommandInput(text); diff --git a/B07_DesignDetail/openwebcad/src/tools/annotate/dimension-radial-tools.ts b/B07_DesignDetail/openwebcad/src/tools/annotate/dimension-radial-tools.ts index 174c26035..3776892b5 100644 --- a/B07_DesignDetail/openwebcad/src/tools/annotate/dimension-radial-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/annotate/dimension-radial-tools.ts @@ -1,6 +1,12 @@ -/** 반지름·지름·각도·호길이·세로좌표 치수와 중심 표식 (조사표 5절) */ +/** + * 반지름·지름·각도·호길이·세로좌표 치수와 중심 표식 (조사표 5절). + * 오토캐드 물음 차례(337 설계 1-2) — DIMRADIUS · DIMDIAMETER = 「호 또는 원 선택:」 → + * 「치수선의 위치 지정 또는 [문자(T)]:」 · 한 번 뒤 끝 · 그 밖 치수 = 물음 글만. + */ import { Arc, Circle, Point } from '@flatten-js/core'; import { toast } from 'react-toastify'; +import { OPT, type OptionSpec } from '../../commands/prompt'; +import { logLine } from '../../commands/run-command'; import { getDimDecimals } from '../../commands/dim-settings'; import type { Entity } from '../../entities/Entity'; import { MeasurementEntity } from '../../entities/MeasurementEntity'; @@ -8,7 +14,7 @@ import { polylineLength, sampleEntityPoints } from '../../helpers/geometry/sampl 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 { createSequenceTool, type SequenceInput } from '../factories/sequence-tool'; import { annotationText, arrowHead, dimensionText } from './annotation.helpers'; interface CircularShape { @@ -24,7 +30,24 @@ function circularOf(entity: Entity): CircularShape | null { return null; } -function radialDimension(entity: Entity, textPoint: Point, diameter: boolean): Entity[] | null { +/** 잰 값 글(「R10.00」 · 「Ø20.00」) */ +function radialLabel(entity: Entity, diameter: boolean): string | null { + const circular = circularOf(entity); + if (!circular) return null; + const value = diameter ? circular.radius * 2 : circular.radius; + return `${diameter ? 'Ø' : 'R'}${value.toFixed(getDimDecimals())}`; +} + +/** 바꾼 글(`<>` = 잰 값) · 없으면 잰 값 */ +const labelWith = (measured: string, text?: string) => + text && text !== '<>' ? text.replaceAll('<>', measured) : measured; + +function radialDimension( + entity: Entity, + textPoint: Point, + diameter: boolean, + text?: string +): Entity[] | null { const circular = circularOf(entity); if (!circular) return null; @@ -43,52 +66,71 @@ function radialDimension(entity: Entity, textPoint: Point, diameter: boolean): E return [ lineEntity(start, textPoint), arrowHead(edge, circular.center), - dimensionText( - diameter ? circular.radius * 2 : circular.radius, - textPoint, - diameter ? 'Ø' : 'R' - ), + text && text !== '<>' + ? annotationText(labelWith(radialLabel(entity, diameter) as string, text), textPoint) + : 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); - }, -}); +const MTEXT: OptionSpec = { key: 'MTEXT', kw: 'M', en: 'MTEXT', label: '여러 줄 문자' }; +const ANGLE: OptionSpec = { key: 'ANGLE', kw: 'A', en: 'ANGLE', label: '각도' }; -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); - }, -}); +/** DIMRADIUS · DIMDIAMETER — 원 · 호 → [문자(T)] → 위치 */ +function radialTool(tool: Tool, diameter: boolean) { + const measured = (input: SequenceInput) => + input.has('obj') ? radialLabel(input.get('obj'), diameter) : null; + return createSequenceTool({ + tool, + steps: [ + { kind: 'entity', id: '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: [OPT.TEXT], absent: [MTEXT, ANGLE] }, + options: { TEXT: { mode: 'text', next: 'txt' } }, + }, + ], + commit: (input) => { + const entity = input.get('obj'); + const text = input.has('txt') ? input.get('txt') : undefined; + const parts = radialDimension(entity, input.get('place'), diameter, text); + if (!parts) { + toast.warn('원 또는 호를 선택하십시오.'); + return; + } + addEntities(parts, true); + logLine(`치수 문자 = ${labelWith(radialLabel(entity, diameter) as string, text)}`); + }, + }); +} + +export const dimRadiusToolStateMachine = radialTool(Tool.DIMRADIUS, false); +export const dimDiameterToolStateMachine = radialTool(Tool.DIMDIAMETER, true); export const dimAngularToolStateMachine = createSequenceTool({ tool: Tool.DIMANGULAR, steps: [ - { kind: 'point', instructions: '각의 꼭짓점을 지정하십시오.' }, - { kind: 'point', instructions: '첫 번째 변 위의 점을 지정하십시오.' }, - { kind: 'point', instructions: '두 번째 변 위의 점을 지정하십시오.' }, + { kind: 'point', prompt: { text: '각도 정점 지정' } }, + { kind: 'point', prompt: { text: '첫 번째 각도 끝점 지정' } }, + { kind: 'point', prompt: { text: '두 번째 각도 끝점 지정' } }, ], commit: (input) => { const [vertex, first, second] = input.points(); @@ -128,8 +170,8 @@ export const dimAngularToolStateMachine = createSequenceTool({ export const dimArcToolStateMachine = createSequenceTool({ tool: Tool.DIMARC, steps: [ - { kind: 'entity', instructions: '호 길이를 기입할 호를 선택하십시오.' }, - { kind: 'point', instructions: '치수 문자 위치를 지정하십시오.' }, + { kind: 'entity', prompt: { text: '호 또는 폴리선 호 세그먼트 선택' } }, + { kind: 'point', prompt: { text: '호 길이 치수 위치 지정' } }, ], commit: (input) => { const entity = input.entity(0); @@ -146,8 +188,8 @@ export const dimArcToolStateMachine = createSequenceTool({ export const dimOrdinateToolStateMachine = createSequenceTool({ tool: Tool.DIMORDINATE, steps: [ - { kind: 'point', instructions: '좌표를 기입할 피처 위치를 지정하십시오.' }, - { kind: 'point', instructions: '지시선 끝점을 지정하십시오.' }, + { kind: 'point', prompt: { text: '피쳐 위치 지정' } }, + { kind: 'point', prompt: { text: '지시선 끝점 지정' } }, ], commit: (input) => { const [feature, leaderEnd] = input.points(); diff --git a/B07_DesignDetail/openwebcad/src/tools/annotate/dimension-tools.ts b/B07_DesignDetail/openwebcad/src/tools/annotate/dimension-tools.ts index c29a53dae..02c0a5c51 100644 --- a/B07_DesignDetail/openwebcad/src/tools/annotate/dimension-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/annotate/dimension-tools.ts @@ -1,27 +1,55 @@ -/** 선형 치수 계열 — 선형·정렬·기준선·연속·빠른 치수·자동 치수 (조사표 5절) */ +/** + * 선형 치수 계열 — 선형·정렬·기준선·연속·빠른 치수·자동 치수 (조사표 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 { EntityName } from '../../entities/Entity'; +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 { addEntities, getActiveLayerId, setSelectedEntityIds } from '../../state'; +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 } from '../factories/sequence-tool'; +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): MeasurementEntity { +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): [Point, Point] { +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 = @@ -32,89 +60,268 @@ function projectForLinear(start: Point, end: Point, offset: Point): [Point, Poin 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 +/** 객체 선택 — 선 · 폴리선(누른 토막) · 그 밖은 처음 · 끝 점 */ +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 ); - addEntities([makeDimension(start, input.point(0), nextOffset)], true); - }, -}); + } + const points = sampleEntityPoints(target); + if (points.length < 2) return null; + return [points[0], points[points.length - 1]]; +} -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 - ); - }, +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('obj'); + return endsOf(entity, input.pick(input.values.indexOf(entity))); + } + if (!input.has('p2')) return null; + return [input.get('p1'), input.get('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('txt'); + return [ghost]; + }, + commit: (input) => { + const offset = input.get('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('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, + }); +} + +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', instructions: '치수를 넣을 객체를 선택한 뒤 ENTER.' }, - { kind: 'point', instructions: '치수선 위치를 지정하십시오.' }, + { kind: 'selection', prompt: { ...SELECT_OBJECTS, text: '치수 기입할 형상 선택' } }, + { kind: 'point', prompt: { text: '치수선의 위치 지정' } }, ], commit: (input) => { const offset = input.point(1); @@ -140,8 +347,8 @@ export const dimAutoToolStateMachine = createSequenceTool({ tool: Tool.DIM, helpers: false, steps: [ - { kind: 'entity', instructions: '치수를 넣을 객체를 선택하십시오.' }, - { kind: 'point', instructions: '치수선 위치를 지정하십시오.' }, + { kind: 'entity', prompt: { text: '치수 기입할 객체 선택' } }, + { kind: 'point', prompt: { text: '치수선의 위치 지정' } }, ], commit: (input) => { const entity = input.entity(0); diff --git a/B07_DesignDetail/openwebcad/src/tools/draw/divide-tools.ts b/B07_DesignDetail/openwebcad/src/tools/draw/divide-tools.ts index 19ddd7c3c..8cd40d337 100644 --- a/B07_DesignDetail/openwebcad/src/tools/draw/divide-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/draw/divide-tools.ts @@ -1,5 +1,9 @@ -/** 등분(DIVIDE)·길이분할(MEASURE) — 객체를 자르지 않고 점만 놓는다 */ +/** + * 등분(DIVIDE)·길이분할(MEASURE) — 객체를 자르지 않고 점만 놓는다. + * 오토캐드 물음 차례(337 설계 1-2) · 기본값 없음 · 한 번 뒤 끝 · 블록(B) 없음. + */ import { toast } from 'react-toastify'; +import type { OptionSpec } from '../../commands/prompt'; import { dividePoints, measurePoints, @@ -10,12 +14,21 @@ import { Tool } from '../../tools'; import { pointEntity } from '../factories/entity-factory'; import { createSequenceTool } from '../factories/sequence-tool'; +const BLOCK: OptionSpec = { key: 'BLOCK', kw: 'B', en: 'BLOCK', label: '블록' }; + export const divideToolStateMachine = createSequenceTool({ tool: Tool.DIVIDE, helpers: false, steps: [ - { kind: 'entity', instructions: '등분할 객체를 선택하십시오.' }, - { kind: 'number', instructions: '세그먼트 수를 입력하십시오 <4>.', defaultValue: 4 }, + { kind: 'entity', prompt: { text: '등분할 객체 선택' } }, + { + kind: 'number', + prompt: { + text: '세그먼트 수 입력', + absent: [BLOCK], + number: { int: true, min: 2, max: 32767 }, + }, + }, ], commit: (input) => { const points = dividePoints(sampleEntityPoints(input.entity(0)), input.number(1)); @@ -31,11 +44,19 @@ export const measureLengthToolStateMachine = createSequenceTool({ tool: Tool.MEASURE_LENGTH, helpers: false, steps: [ - { kind: 'entity', instructions: '길이로 분할할 객체를 선택하십시오.' }, - { kind: 'number', instructions: '세그먼트 길이를 입력하십시오 <10>.', defaultValue: 10 }, + { kind: 'entity', prompt: { text: '길이 분할 객체 선택' } }, + { + kind: 'number', + prompt: { text: '세그먼트의 길이 지정', absent: [BLOCK], number: { positive: true } }, + }, ], commit: (input) => { - const points = measurePoints(sampleEntityPoints(input.entity(0)), input.number(1)); + // 누른 자리에 가까운 끝부터 잼(오토캐드) + const path = sampleEntityPoints(input.entity(0)); + const pick = input.pick(0); + const nearEnd = + path.length > 1 && path[path.length - 1].distanceTo(pick)[0] < path[0].distanceTo(pick)[0]; + const points = measurePoints(nearEnd ? [...path].reverse() : path, input.number(1)); if (!points.length) { toast.warn('지정한 길이로 나눌 수 없습니다.'); return; diff --git a/B07_DesignDetail/openwebcad/src/tools/modify/lengthen-tool.ts b/B07_DesignDetail/openwebcad/src/tools/modify/lengthen-tool.ts new file mode 100644 index 000000000..1c5290d84 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/modify/lengthen-tool.ts @@ -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 = { + 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 = { + 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 = { + DELTA: 'LENGTHEN.DE', + PERCENT: 'LENGTHEN.P', + TOTAL: 'LENGTHEN.T', +}; + +const lastMode = (): Mode => recall('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, +}); diff --git a/B07_DesignDetail/openwebcad/src/tools/utility/inquiry-tools.ts b/B07_DesignDetail/openwebcad/src/tools/utility/inquiry-tools.ts index 84706b436..6babd6ab6 100644 --- a/B07_DesignDetail/openwebcad/src/tools/utility/inquiry-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/utility/inquiry-tools.ts @@ -1,6 +1,8 @@ /** 조회 명령 — 거리·반지름·각도·면적·좌표·리스트·계산기 (조사표 3절 유틸리티) */ import { Circle, type Point } from '@flatten-js/core'; import { toast } from 'react-toastify'; +import type { OptionSpec } from '../../commands/prompt'; +import { logLine } from '../../commands/run-command'; import { EntityName } from '../../entities/Entity'; import { entitiesToLoop } from '../../helpers/geometry/entity-loop'; import { polylineLength, sampleEntityPoints } from '../../helpers/geometry/sample-entity'; @@ -22,21 +24,30 @@ export function polygonArea(points: Point[]): number { return Math.abs(total) / 2; } +const MULTIPLE_POINTS: OptionSpec = { key: 'MULTIPLE', kw: 'M', en: 'MULTIPLE', label: '다중 점' }; + +/** DIST 결과 두 줄(오토캐드 꼴 · 길이 소수 넷 · 각도 AUPREC 0) */ +export function distanceReport(first: Point, second: Point): [string, string] { + const dx = second.x - first.x; + const dy = second.y - first.y; + const angle = (toDegrees(Math.atan2(dy, dx)) + 360) % 360; + return [ + `거리 = ${format(Math.hypot(dx, dy), 4)}, XY 평면에서의 각도 = ${Math.round(angle) % 360}, XY 평면으로부터의 각도 = 0`, + `X 증분 = ${format(dx, 4)}, Y 증분 = ${format(dy, 4)}, Z 증분 = ${format(0, 4)}`, + ]; +} + export const distanceToolStateMachine = createSequenceTool({ tool: Tool.DIST, steps: [ - { kind: 'point', instructions: '거리를 잴 첫 점을 지정하십시오.' }, - { kind: 'point', instructions: '두 번째 점을 지정하십시오.' }, + { kind: 'point', prompt: { text: '첫 번째 점 지정' } }, + { kind: 'point', prompt: { text: '두 번째 점 지정', absent: [MULTIPLE_POINTS] } }, ], 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)}°` - ); + const lines = distanceReport(first, second); + for (const line of lines) logLine(line); + toast.info(lines.join('\n')); }, }); diff --git a/resources/tester/cad/measure-acad.test.ts b/resources/tester/cad/measure-acad.test.ts new file mode 100644 index 000000000..4cc62edf8 --- /dev/null +++ b/resources/tester/cad/measure-acad.test.ts @@ -0,0 +1,363 @@ +/** + * 337 다-2 — 늘이기 · 재기 · 치수 오토캐드 조작(설계 1-2 · 6-3 다 줄 중 LENGTHEN · DIVIDE · MEASURE · DIST · DIM). + * 물음 글 · 옵션 글자 · 기본값 · 빈 Enter · Esc · 끝난 뒤를 키 · 마우스로 넣어 숫자로 판정. + * 객체는 앱 코드(fromJson)로만 만듦 — 시험 쪽 flatten-js 는 다른 사본. + * 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad measure-acad + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("react-toastify", () => ({ + toast: Object.assign(vi.fn(), { error: vi.fn(), info: vi.fn(), warn: vi.fn(), success: vi.fn() }), +})); + +import { getCommandHistory } from "../../../B07_DesignDetail/openwebcad/src/commands/run-command"; +import { getDimDecimals } from "../../../B07_DesignDetail/openwebcad/src/commands/dim-settings"; +import { ArcEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/ArcEntity"; +import { CircleEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/CircleEntity"; +import { EntityName, type Entity } from "../../../B07_DesignDetail/openwebcad/src/entities/Entity"; +import { LineEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/LineEntity"; +import type { MeasurementEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/MeasurementEntity"; +import { getActiveLayerId, setEntities } from "../../../B07_DesignDetail/openwebcad/src/state"; +import { Tool } from "../../../B07_DesignDetail/openwebcad/src/tools"; +import { + activeTool, + click, + entities, + initIdle, + key, + noticeLine, + promptLine, + type, + typeEnter, +} from "./helpers/cad-drive"; + +type Xy = { x: number; y: number }; +const enter = () => key("Enter"); +const esc = () => key("Escape"); +const base = () => ({ lineColor: "#fff", lineWidth: 1, layerId: getActiveLayerId() }); + +async function addLine(x1: number, y1: number, x2: number, y2: number): Promise { + const line = await LineEntity.fromJson({ + id: `line-${x1}-${y1}-${x2}-${y2}`, + type: EntityName.Line, + ...base(), + shapeData: { startPoint: { x: x1, y: y1 }, endPoint: { x: x2, y: y2 } }, + } as never); + setEntities([...entities(), line], true); // 그린 것처럼 되돌림 기록에 올림 + return line; +} + +async function addArc(): Promise { + // 중심 (0, 0) · 반지름 50 · 0° → 90° 반시계(길이 25π) + const arc = await ArcEntity.fromJson({ + id: "arc", + type: EntityName.Arc, + ...base(), + shapeData: { center: { x: 0, y: 0 }, radius: 50, startAngle: 0, endAngle: Math.PI / 2, counterClockwise: true }, + } as never); + setEntities([...entities(), arc], true); // 그린 것처럼 되돌림 기록에 올림 + return arc; +} + +async function addCircle(): Promise { + const circle = await CircleEntity.fromJson({ + id: "circle", + type: EntityName.Circle, + ...base(), + shapeData: { center: { x: 300, y: 300 }, radius: 10 }, + } as never); + setEntities([...entities(), circle], true); // 그린 것처럼 되돌림 기록에 올림 + return circle; +} + +const ends = (entity: Entity): [Xy, Xy] => { + const shape = entity.getShape() as unknown as { start: Xy; end: Xy }; + return [ + { x: shape.start.x, y: shape.start.y }, + { x: shape.end.x, y: shape.end.y }, + ]; +}; +const onlyLine = () => entities("Line")[0]; +const lineLength = () => { + const [a, b] = ends(onlyLine()); + return Math.hypot(b.x - a.x, b.y - a.y); +}; +const points = () => + entities("Point") + .map((entity) => { + const p = entity.getShape() as unknown as Xy; + return { x: Math.round(p.x * 1000) / 1000, y: Math.round(p.y * 1000) / 1000 }; + }) + .sort((a, b) => a.x - b.x); +const dims = () => entities("Measurement") as MeasurementEntity[]; +const lastLog = (n = 1) => getCommandHistory().slice(-n); +/** 선형 치수 글(반올림 · 끝 0 뗌) · 반지름 치수 글(소수 자릿수 그대로) */ +const fmt = (value: number) => String(Number(value.toFixed(getDimDecimals()))); +const fixed = (value: number) => value.toFixed(getDimDecimals()); + +beforeEach(() => { + initIdle(); +}); + +describe("LENGTHEN(LEN)", () => { + const FIRST = "측정할 객체 선택 또는 [증분(DE)/퍼센트(P)/전체(T)]"; + + it("누름 = 현재 길이 · DE → 되풀이 · U = 이 명령 안 바꿈만 · 빈 Enter = 끝", async () => { + await addLine(0, 0, 100, 0); + typeEnter("len"); + expect(activeTool()).toBe(Tool.LENGTHEN); + expect(promptLine()).toBe(`${FIRST} <전체(T)>:`); + click(50, 0); + expect(noticeLine()).toBe("현재 길이: 100.0000"); + expect(promptLine()).toBe(`${FIRST} <전체(T)>:`); + type("d"); // DE 보다 짧음 — 안 맞음 + expect(noticeLine()).toBe("*선택이 잘못됨*"); + type("dy"); + expect(noticeLine()).toBe("「동적(DY)」 옵션은 이 CAD 에 없음."); + type("de"); + expect(promptLine()).toBe("증분 길이 입력 <0.0000>:"); + type("10"); + expect(promptLine()).toBe("변경할 객체 선택 또는 [명령 취소(U)]:"); + click(95, 0); // 끝 쪽 + expect(ends(onlyLine())[1].x).toBeCloseTo(110); + click(105, 0); + expect(ends(onlyLine())[1].x).toBeCloseTo(120); + expect(activeTool()).toBe(Tool.LENGTHEN); // 되풀이 + type("u"); + expect(ends(onlyLine())[1].x).toBeCloseTo(110); + type("u"); + type("u"); // 이 명령 안 바꿈 다 되돌린 뒤 = 아무 일 없음(앞 객체 지키기) + expect(entities("Line")).toHaveLength(1); + expect(lineLength()).toBeCloseTo(100); + click(5, 0); // 시작 쪽 + expect(ends(onlyLine())[0].x).toBeCloseTo(-10); + enter(); + expect(activeTool()).toBe(Tool.SELECT); + }); + + it("마지막 방식 · 값 기억 — 첫 물음 빈 Enter = 그 방식 · 값 빈 Enter = 앞 값", async () => { + await addLine(0, 0, 100, 0); + typeEnter("len"); + type("de"); + type("10"); + esc(); + typeEnter("len"); + expect(promptLine()).toBe(`${FIRST} <증분(DE)>:`); + enter(); + expect(promptLine()).toBe("증분 길이 입력 <10.0000>:"); + enter(); + click(95, 0); + expect(lineLength()).toBeCloseTo(110); + esc(); // Esc = 끝 · 바꾼 것 남음 + expect(activeTool()).toBe(Tool.SELECT); + expect(lineLength()).toBeCloseTo(110); + }); + + it("P · T — 퍼센트 · 전체 · 양수 거절 · 호", async () => { + await addLine(0, 0, 100, 0); + typeEnter("len"); + type("p"); + expect(promptLine()).toBe("퍼센트 길이 입력 <100.0000>:"); + type("-5"); + expect(noticeLine()).toBe("값은 양수이고 0이 아니어야 함."); + type("50"); + click(90, 0); + expect(ends(onlyLine())).toEqual([ + { x: 0, y: 0 }, + { x: 50, y: 0 }, + ]); + esc(); + typeEnter("len"); + expect(promptLine()).toBe(`${FIRST} <퍼센트(P)>:`); + type("t"); + expect(promptLine()).toBe("전체 길이 지정 <1.0000>:"); + type("a"); + expect(noticeLine()).toBe("「각도(A)」 옵션은 이 CAD 에 없음."); + type("200"); + click(45, 0); + expect(lineLength()).toBeCloseTo(200); + await addArc(); + click(0, 50); // 호 끝(90°) 쪽 + const arc = entities("Arc")[0].getShape() as unknown as { length: number; endAngle: number }; + expect(arc.length).toBeCloseTo(200); + expect(arc.endAngle).toBeCloseTo(4); // 200 / 50 rad + esc(); + }); +}); + +describe("DIVIDE(DIV) · MEASURE(ME)", () => { + it("DIVIDE — 기본 없음 · 정수 2~32767 · 블록 없음 · 한 번 뒤 끝", async () => { + await addLine(0, 0, 100, 0); + typeEnter("div"); + expect(promptLine()).toBe("등분할 객체 선택:"); + click(50, 0); + expect(promptLine()).toBe("세그먼트 수 입력:"); + enter(); // 기본 없음 — 그대로 + expect(promptLine()).toBe("세그먼트 수 입력:"); + type("2.5"); + expect(noticeLine()).toBe("정수 값을 요구함."); + type("1"); + expect(noticeLine()).toBe("2에서 32767 사이의 정수를 요구함."); + type("b"); + expect(noticeLine()).toBe("「블록(B)」 옵션은 이 CAD 에 없음."); + type("4"); + expect(points()).toEqual([ + { x: 25, y: 0 }, + { x: 50, y: 0 }, + { x: 75, y: 0 }, + ]); + expect(activeTool()).toBe(Tool.SELECT); + }); + + it("MEASURE — 누른 자리 가까운 끝부터 · 양수", async () => { + await addLine(0, 0, 100, 0); + typeEnter("me"); + expect(promptLine()).toBe("길이 분할 객체 선택:"); + click(90, 0); // 끝 쪽 + expect(promptLine()).toBe("세그먼트의 길이 지정:"); + type("0"); + expect(noticeLine()).toBe("값은 양수이고 0이 아니어야 함."); + type("30"); + expect(points().map((p) => p.x)).toEqual([10, 40, 70]); + expect(activeTool()).toBe(Tool.SELECT); + }); +}); + +describe("DIST(DI)", () => { + it("두 점 → 오토캐드 꼴 두 줄 기록 · 한 번 뒤 끝 · 다중 점 없음", () => { + typeEnter("di"); + expect(promptLine()).toBe("첫 번째 점 지정:"); + type("0,0"); + expect(promptLine()).toBe("두 번째 점 지정:"); + type("m"); + expect(noticeLine()).toBe("「다중 점(M)」 옵션은 이 CAD 에 없음."); + type("30,40"); + expect(lastLog(2)).toEqual([ + "거리 = 50.0000, XY 평면에서의 각도 = 53, XY 평면으로부터의 각도 = 0", + "X 증분 = 30.0000, Y 증분 = 40.0000, Z 증분 = 0.0000", + ]); + expect(activeTool()).toBe(Tool.SELECT); + }); +}); + +describe("DIMLINEAR(DLI) · DIMALIGNED(DAL)", () => { + const PLACE = "치수선의 위치 지정 또는 [문자(T)/수평(H)/수직(V)]:"; + + it("원점 둘 → V 고정 → T 문자(`<>` = 잰 값) → 놓음 · 기록 · 한 번 뒤 끝", () => { + typeEnter("dli"); + expect(promptLine()).toBe("첫 번째 치수보조선 원점 지정 또는 <객체 선택>:"); + type("0,0"); + expect(promptLine()).toBe("두 번째 치수보조선 원점 지정:"); + type("100,30"); + expect(promptLine()).toBe(PLACE); + type("m"); + expect(noticeLine()).toBe("「여러 줄 문자(M)」 옵션은 이 CAD 에 없음."); + type("v"); + expect(promptLine()).toBe(PLACE); + type("t"); + expect(promptLine()).toBe(`치수 문자 입력 <${fmt(30)}>:`); + typeEnter("L=<>"); + expect(promptLine()).toBe(PLACE); + type("120,15"); + const [dim] = dims(); + expect(dim.getLabel()).toBe(`L=${fmt(30)}`); + expect(dim.getEndPoint().x).toBeCloseTo(0); // 수직 — 끝점 x 를 시작점에 맞춤 + expect(dim.getEndPoint().y).toBeCloseTo(30); + expect(lastLog()).toEqual([`치수 문자 = L=${fmt(30)}`]); + expect(activeTool()).toBe(Tool.SELECT); + }); + + it("첫 물음 빈 Enter = 객체 선택 → H 고정 · T 빈 Enter = 잰 값 그대로", async () => { + await addLine(0, 0, 80, 60); + typeEnter("dli"); + enter(); + expect(promptLine()).toBe("치수 기입할 객체 선택:"); + click(40, 30); + expect(promptLine()).toBe(PLACE); + type("h"); + type("t"); + expect(promptLine()).toBe(`치수 문자 입력 <${fmt(80)}>:`); + enter(); + type("40,-20"); // 수평 고정이라 옆에 놓아도 가로 + const [dim] = dims(); + expect(dim.getLabel()).toBe(fmt(80)); + expect(dim.textOverride).toBeUndefined(); + expect(activeTool()).toBe(Tool.SELECT); + }); + + it("Esc = 끝 · 안 만듦 · DIMALIGNED 는 [문자(T)] 만", () => { + typeEnter("dli"); + type("0,0"); + type("10,0"); + esc(); + expect(activeTool()).toBe(Tool.SELECT); + expect(dims()).toHaveLength(0); + typeEnter("dal"); + type("0,0"); + type("30,40"); + expect(promptLine()).toBe("치수선의 위치 지정 또는 [문자(T)]:"); + type("h"); + expect(noticeLine()).toBe("점 또는 옵션 키워드를 요구함."); + type("0,50"); + expect(dims()[0].getLabel()).toBe(fmt(50)); + }); +}); + +describe("DIMCONTINUE(DCO) · DIMBASELINE(DBA)", () => { + const CHAIN = "두 번째 치수보조선 원점 지정 또는 [명령 취소(U)]:"; + + it("되풀이 · U = 이 명령 안 마지막 치수 · 빈 Enter = 끝", () => { + typeEnter("dli"); + type("0,0"); + type("100,0"); + type("50,20"); + expect(dims()).toHaveLength(1); + typeEnter("dco"); + expect(promptLine()).toBe(CHAIN); + type("s"); + expect(noticeLine()).toBe("「선택(S)」 옵션은 이 CAD 에 없음."); + type("150,0"); + type("200,0"); + expect(dims()).toHaveLength(3); + expect(dims()[2].getStartPoint().x).toBeCloseTo(150); // 앞 치수 끝에서 이어짐 + expect(promptLine()).toBe(CHAIN); + type("u"); + expect(dims()).toHaveLength(2); + type("u"); + type("u"); // 이 명령 밖 치수는 안 지움 + expect(dims()).toHaveLength(1); + type("130,0"); + expect(dims()[1].getStartPoint().x).toBeCloseTo(100); // U 뒤 앞 치수에서 다시 이음 + enter(); + expect(activeTool()).toBe(Tool.SELECT); + typeEnter("dba"); + expect(promptLine()).toBe(CHAIN); + type("180,0"); + expect(dims()).toHaveLength(3); + expect(dims()[2].getStartPoint().x).toBeCloseTo(100); // 기준선 = 앞 치수 시작점 + esc(); + expect(activeTool()).toBe(Tool.SELECT); + }); +}); + +describe("DIMRADIUS(DRA) · DIMDIAMETER(DDI)", () => { + it("호 또는 원 → [문자(T)] → 위치 · 기록 · 한 번 뒤 끝", async () => { + await addCircle(); + typeEnter("dra"); + expect(promptLine()).toBe("호 또는 원 선택:"); + click(310, 300); + expect(promptLine()).toBe("치수선의 위치 지정 또는 [문자(T)]:"); + type("a"); + expect(noticeLine()).toBe("「각도(A)」 옵션은 이 CAD 에 없음."); + type("t"); + expect(promptLine()).toBe(`치수 문자 입력 :`); + typeEnter("<> BASE"); + type("330,300"); + expect(lastLog()).toEqual([`치수 문자 = R${fixed(10)} BASE`]); + expect(activeTool()).toBe(Tool.SELECT); + typeEnter("ddi"); + click(310, 300); + type("330,300"); + expect(lastLog()).toEqual([`치수 문자 = Ø${fixed(20)}`]); + }); +});