/** * 337-3a 나-1 그리기 8 — LINE · PLINE · RECTANG · CIRCLE · POLYGON · ARC · ELLIPSE · SPLINE 를 오토캐드 차례로. * 설계 `docs/raw/조사/2026-10-15_337_CAD오토캐드조작_설계.md` 1-1 · 6-3 나 줄. * 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad draw-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 { remember } from "../../../B07_DesignDetail/openwebcad/src/commands/prompt"; import { Tool } from "../../../B07_DesignDetail/openwebcad/src/tools"; import { activeTool, click, entities, initIdle, key, move, noticeLine, promptLine, type, typed, } from "./helpers/cad-drive"; type Xy = { x: number; y: number }; type Ends = { getStartPoint(): Xy; getEndPoint(): Xy }; const enter = () => key("Enter"); const esc = () => key("Escape"); const expectXy = (point: Xy, x: number, y: number) => { expect(point.x).toBeCloseTo(x, 3); expect(point.y).toBeCloseTo(y, 3); }; const lines = () => entities("Line") as unknown as Ends[]; const expectLine = (line: Ends, a: [number, number], b: [number, number]) => { expectXy(line.getStartPoint(), ...a); expectXy(line.getEndPoint(), ...b); }; beforeEach(() => { initIdle(); for (const name of ["LINE.END", "CIRCLERAD", "RECT.L", "RECT.W", "POLYSIDES", "POLYGON.IC"]) { remember(name, undefined); // 세션 기억 — 시험끼리 안 섞임 } }); describe("LINE", () => { it("U = 마지막 토막 · 그 시작점에서 이음 · 그린 토막 없으면 첫 점부터", () => { type("l"); expect(promptLine()).toBe("첫 번째 점 지정:"); type("0,0"); expect(promptLine()).toBe("다음 점 지정 또는 [명령 취소(U)]:"); type("10,0"); type("10,10"); type("u"); expect(lines()).toHaveLength(1); expect(promptLine()).toBe("다음 점 지정 또는 [명령 취소(U)]:"); type("20,0"); expect(lines()).toHaveLength(2); expectLine(lines()[1], [10, 0], [20, 0]); type("u"); type("u"); type("u"); expect(lines()).toHaveLength(0); expect(promptLine()).toBe("첫 번째 점 지정:"); expect(activeTool()).toBe(Tool.LINE); }); it("C 는 둘째 토막부터 — 그 전엔 거절 · 닫고 끝 → 쉼", () => { type("l"); type("0,0"); type("10,0"); type("c"); expect(noticeLine()).toBe("점 또는 옵션 키워드를 요구함."); expect(lines()).toHaveLength(1); type("10,10"); expect(promptLine()).toBe("다음 점 지정 또는 [닫기(C)/명령 취소(U)]:"); type("C"); expect(lines()).toHaveLength(3); expectLine(lines()[2], [10, 10], [0, 0]); expect(activeTool()).toBe(Tool.SELECT); }); it("Enter = 끝 → 쉼 · Space = 다시 LINE · 첫 점 빈 Enter = 마지막 끝점에서 이음", () => { type("l"); type("0,0"); type("10,0"); enter(); expect(activeTool()).toBe(Tool.SELECT); expect(lines()).toHaveLength(1); key(" "); expect(activeTool()).toBe(Tool.LINE); expect(promptLine()).toBe("첫 번째 점 지정:"); key(" "); expect(promptLine()).toBe("다음 점 지정 또는 [명령 취소(U)]:"); type("10,5"); expectLine(lines()[1], [10, 0], [10, 5]); }); it("첫 점 빈 Enter — 그린 것 없으면 아무 일 없음", () => { type("l"); enter(); expect(activeTool()).toBe(Tool.LINE); expect(promptLine()).toBe("첫 번째 점 지정:"); }); it("Esc = 끝 · 그린 토막 남음", () => { type("l"); type("0,0"); type("10,0"); type("20,0"); move(30, 5); esc(); expect(activeTool()).toBe(Tool.SELECT); expect(lines()).toHaveLength(2); }); }); type Box = { xmin: number; ymin: number; xmax: number; ymax: number }; type Shape = { getBoundingBox(): Box; getType(): string }; type Poly = Shape & { getEntities(): (Shape & Ends)[] }; const expectBox = (shape: Shape, xmin: number, ymin: number, xmax: number, ymax: number) => { const box = shape.getBoundingBox(); expect([box.xmin, box.ymin, box.xmax, box.ymax].map((v) => Number(v.toFixed(3)))).toEqual([ xmin, ymin, xmax, ymax, ]); }; const polys = () => entities("PolyLine") as unknown as Poly[]; const arcShape = (arc: Shape) => (arc as unknown as { getShape(): { center: Xy; r: number } }).getShape(); describe("PLINE", () => { it("L = 앞 방향 길이 · U · A 접선 호 · L 선 · 호 뒤 길이 = 접선 · C 닫기", () => { type("pline"); expect(promptLine()).toBe("시작점 지정:"); type("0,0"); expect(promptLine()).toBe("다음 점 지정 또는 [호(A)/길이(L)/명령 취소(U)]:"); type("10,0"); expect(promptLine()).toBe("다음 점 지정 또는 [호(A)/닫기(C)/길이(L)/명령 취소(U)]:"); type("l"); expect(promptLine()).toBe("선의 길이 지정:"); type("5"); type("u"); type("a"); expect(promptLine()).toBe("호의 끝점 지정 또는 [닫기(CL)/선(L)/명령 취소(U)]:"); type("20,10"); type("l"); type("L"); type("5"); type("c"); expect(activeTool()).toBe(Tool.SELECT); expect(polys()).toHaveLength(1); const parts = polys()[0].getEntities(); expect(parts.map((part) => part.getType())).toEqual(["Line", "Arc", "Line", "Line"]); expectLine(parts[0], [0, 0], [10, 0]); expectXy(arcShape(parts[1]).center, 10, 10); // 동쪽 접선 → 중심 (10, 10) expectLine(parts[2], [20, 10], [20, 15]); // 호 끝 접선 = 북쪽 expectLine(parts[3], [20, 15], [0, 0]); }); it("첫 토막 호 = 동쪽 접선 · CL = 호로 닫음 · 호 모드 C 는 거절", () => { type("pline"); type("0,0"); type("a"); expect(promptLine()).toBe("호의 끝점 지정 또는 [선(L)/명령 취소(U)]:"); type("10,10"); type("c"); expect(noticeLine()).toBe("점 또는 옵션 키워드를 요구함."); type("cl"); const parts = polys()[0].getEntities(); expect(parts.map((part) => part.getType())).toEqual(["Arc", "Arc"]); expectXy(arcShape(parts[1]).center, 0, 10); expectXy(parts[1].getEndPoint(), 0, 0); }); it("Esc = 그린 데까지 남김 · 시작점만이면 없음 · 빈 Enter 끝 · 시작점 Enter = 마지막 끝점", () => { type("pline"); type("0,0"); type("10,0"); type("20,5"); move(40, 40); esc(); expect(activeTool()).toBe(Tool.SELECT); expect(polys()).toHaveLength(1); expect(polys()[0].getEntities()).toHaveLength(2); type("pline"); type("1,1"); esc(); expect(polys()).toHaveLength(1); type("pline"); enter(); type("@0,10"); enter(); expect(polys()).toHaveLength(2); expectLine(polys()[1].getEntities()[0], [20, 5], [20, 15]); }); }); describe("RECTANG", () => { it("D = 길이 · 폭(기억) → 누른 쪽 사분면 · 없는 옵션 안내 · 하나 뒤 끝", () => { type("rec"); expect(promptLine()).toBe("첫 번째 구석점 지정:"); type("e"); expect(noticeLine()).toBe("「고도(E)」 옵션은 이 CAD 에 없음."); type("0,0"); expect(promptLine()).toBe("다른 구석점 지정 또는 [치수(D)]:"); type("d"); expect(promptLine()).toBe("직사각형의 길이 지정 <10.0000>:"); type("30"); expect(promptLine()).toBe("직사각형의 폭 지정 <10.0000>:"); type("20"); expect(promptLine()).toBe("다른 구석점 지정 또는 [치수(D)]:"); type("-1,-1"); expect(activeTool()).toBe(Tool.SELECT); expectBox(entities("Rectangle")[0], -30, -20, 0, 0); key(" "); type("100,100"); type("d"); expect(promptLine()).toBe("직사각형의 길이 지정 <30.0000>:"); enter(); expect(promptLine()).toBe("직사각형의 폭 지정 <20.0000>:"); enter(); type("101,99"); expectBox(entities("Rectangle")[1], 100, 80, 130, 100); }); it("두 구석 · Esc 첫 구석 = 끝", () => { type("rec"); esc(); expect(activeTool()).toBe(Tool.SELECT); type("rec"); type("0,0"); type("@5,3"); expectBox(entities("Rectangle")[0], 0, 0, 5, 3); expect(activeTool()).toBe(Tool.SELECT); }); }); describe("CIRCLE", () => { const radius = (index: number) => (entities("Circle")[index] as unknown as { getRadius(): number }).getRadius(); it("반지름 · 처음엔 <> 없음(Enter 아무 일 없음) · 기억 · 한 번 뒤 끝 · 음수 거절 · Ttr 없음", () => { type("c"); expect(promptLine()).toBe("원에 대한 중심점 지정 또는 [3점(3P)/2점(2P)]:"); type("t"); expect(noticeLine()).toBe("「Ttr - 접선 접선 반지름(T)」 옵션은 이 CAD 에 없음."); type("0,0"); expect(promptLine()).toBe("원의 반지름 지정 또는 [지름(D)]:"); enter(); expect(entities("Circle")).toHaveLength(0); type("-5"); expect(noticeLine()).toBe("값은 양수이고 0이 아니어야 함."); type("5"); expect(activeTool()).toBe(Tool.SELECT); expect(radius(0)).toBeCloseTo(5); key(" "); type("20,0"); expect(promptLine()).toBe("원의 반지름 지정 또는 [지름(D)] <5.0000>:"); enter(); expect(radius(1)).toBeCloseTo(5); expectBox(entities("Circle")[1], 15, -5, 25, 5); }); it("D 지름 <2r> · 누른 점 반지름도 기억", () => { remember("CIRCLERAD", 3); type("c"); type("0,0"); type("d"); expect(promptLine()).toBe("원의 지름 지정 <6.0000>:"); type("8"); expect(radius(0)).toBeCloseTo(4); type("c"); type("50,50"); expect(promptLine()).toBe("원의 반지름 지정 또는 [지름(D)] <4.0000>:"); type("@0,7"); expect(radius(1)).toBeCloseTo(7); type("c"); type("90,90"); expect(promptLine()).toBe("원의 반지름 지정 또는 [지름(D)] <7.0000>:"); }); it("3P · 2P", () => { type("c"); type("3p"); expect(promptLine()).toBe("원 위의 첫 번째 점 지정:"); type("0,0"); expect(promptLine()).toBe("원 위의 두 번째 점 지정:"); type("10,0"); expect(promptLine()).toBe("원 위의 세 번째 점 지정:"); type("5,5"); expect(activeTool()).toBe(Tool.SELECT); expectBox(entities("Circle")[0], 0, -5, 10, 5); type("c"); type("2P"); expect(promptLine()).toBe("원 지름의 첫 번째 끝점 지정:"); type("20,0"); expect(promptLine()).toBe("원 지름의 두 번째 끝점 지정:"); type("40,0"); expectBox(entities("Circle")[1], 20, -10, 40, 10); expect(activeTool()).toBe(Tool.SELECT); }); }); /** 폴리선 꼭짓점(소수 셋) */ const corners = (poly: Poly) => poly.getEntities().map((part) => { const point = part.getStartPoint(); return [Number(point.x.toFixed(3)) + 0, Number(point.y.toFixed(3)) + 0]; }); describe("POLYGON", () => { it("변 수 <4> · 2.5 · 2 거절 · 외접 숫자 = 아래 변 수평 · 기억 · 한 번 뒤 끝", () => { type("pol"); expect(promptLine()).toBe("변의 수 입력 <4>:"); type("2.5"); expect(noticeLine()).toBe("정수 값을 요구함."); type("2"); expect(noticeLine()).toBe("3에서 1024 사이의 정수를 요구함."); type("6"); expect(promptLine()).toBe("폴리곤의 중심 지정 또는 [모서리(E)]:"); type("0,0"); expect(promptLine()).toBe("옵션 입력 [원에 내접(I)/원에 외접(C)] :"); type("c"); expect(promptLine()).toBe("원의 반지름 지정:"); type("10"); expect(activeTool()).toBe(Tool.SELECT); const hexagon = polys()[0]; expect(hexagon.getEntities()).toHaveLength(6); expect(hexagon.getBoundingBox().ymin).toBeCloseTo(-10); // 아래 변 = 반지름 거리 · 수평 expect(corners(hexagon).filter(([, y]) => y === -10)).toHaveLength(2); key(" "); expect(promptLine()).toBe("변의 수 입력 <6>:"); enter(); type("100,0"); expect(promptLine()).toBe("옵션 입력 [원에 내접(I)/원에 외접(C)] :"); type("i"); type("10"); const inscribed = polys()[1]; expect(corners(inscribed).filter(([, y]) => y === -8.66)).toHaveLength(2); // 내접 · 아래 변 수평(꼭짓점 −60° · −120°) expect(inscribed.getBoundingBox().xmax).toBeCloseTo(110); }); it("점 = 크기 + 돌림(내접 꼭짓점) · E 모서리 = 왼쪽(반시계)", () => { type("pol"); type("4"); type("0,0"); enter(); // type("0,10"); expect(corners(polys()[0])).toContainEqual([0, 10]); type("pol"); enter(); type("e"); expect(promptLine()).toBe("모서리의 첫 번째 끝점 지정:"); type("0,0"); expect(promptLine()).toBe("모서리의 두 번째 끝점 지정:"); type("10,0"); expectBox(polys()[1], 0, 0, 10, 10); }); }); describe("ARC", () => { it("세 점 · 한 번 뒤 끝", () => { type("a"); expect(promptLine()).toBe("호의 시작점 지정:"); type("10,0"); expect(promptLine()).toBe("호의 두 번째 점 지정 또는 [중심(C)/끝(E)]:"); type("0,10"); expect(promptLine()).toBe("호의 끝점 지정:"); type("-10,0"); expect(activeTool()).toBe(Tool.SELECT); expectBox(entities("Arc")[0], -10, 0, 10, 10); }); it("C 중심 → 끝점 · A 사이각(− = 시계)", () => { type("a"); type("10,0"); type("c"); expect(promptLine()).toBe("호의 중심점 지정:"); type("0,0"); expect(promptLine()).toBe("호의 끝점 지정 또는 [각도(A)]:"); type("0,5"); expectBox(entities("Arc")[0], 0, 0, 10, 10); type("a"); type("10,0"); type("c"); type("0,0"); type("a"); expect(promptLine()).toBe("사이각 지정:"); type("-90"); expectBox(entities("Arc")[1], 0, -10, 10, 0); }); it("E 끝 → 중심 · R 반지름 · 시작점 Enter 없음", () => { type("a"); enter(); expect(promptLine()).toBe("호의 시작점 지정:"); type("10,0"); type("e"); expect(promptLine()).toBe("호의 끝점 지정:"); type("-10,0"); expect(promptLine()).toBe("호의 중심점 지정 또는 [반지름(R)]:"); type("0,0"); expectBox(entities("Arc")[0], -10, 0, 10, 10); type("a"); type("10,0"); type("e"); type("0,10"); type("r"); expect(promptLine()).toBe("호의 반지름 지정:"); type("10"); expect(arcShape(entities("Arc")[1]).r).toBeCloseTo(10); expectXy(arcShape(entities("Arc")[1]).center, 0, 0); }); }); describe("ELLIPSE", () => { it("축 두 끝 · 다른 축 거리 숫자 = 중심에서 반 길이(N10) · 호 없음", () => { type("el"); expect(promptLine()).toBe("타원의 축 끝점 지정 또는 [중심(C)]:"); type("a"); expect(noticeLine()).toBe("「호(A)」 옵션은 이 CAD 에 없음."); type("0,0"); expect(promptLine()).toBe("축의 다른 끝점 지정:"); type("20,0"); expect(promptLine()).toBe("다른 축으로 거리를 지정 또는 [회전(R)]:"); move(10, 30); type("5"); expect(activeTool()).toBe(Tool.SELECT); expectBox(polys()[0], 0, -5, 20, 5); }); it("C 중심 · R 회전 60° = 축 × cos", () => { type("el"); type("c"); expect(promptLine()).toBe("타원의 중심 지정:"); type("0,0"); expect(promptLine()).toBe("축의 끝점 지정:"); type("10,0"); type("r"); expect(promptLine()).toBe("주축 둘레로 회전 지정:"); type("60"); expectBox(polys()[0], -10, -5, 10, 5); }); }); describe("SPLINE", () => { it("물음 차례 · U · 넷째 점부터 C(닫고 끝)", () => { type("spl"); expect(promptLine()).toBe("첫 번째 점 지정:"); type("0,0"); expect(promptLine()).toBe("다음 점 입력:"); type("10,0"); expect(promptLine()).toBe("다음 점 입력 또는 [명령 취소(U)]:"); type("c"); expect(noticeLine()).toBe("점 또는 옵션 키워드를 요구함."); type("10,10"); expect(promptLine()).toBe("다음 점 입력 또는 [명령 취소(U)/닫기(C)]:"); type("u"); expect(promptLine()).toBe("다음 점 입력 또는 [명령 취소(U)]:"); type("10,10"); type("0,10"); type("c"); expect(activeTool()).toBe(Tool.SELECT); const parts = polys()[0].getEntities(); expectXy(parts[parts.length - 1].getEndPoint(), 0, 0); expectXy(parts[0].getStartPoint(), 0, 0); }); it("빈 Enter = 확정 · Esc = 버림", () => { type("spl"); type("0,0"); type("10,0"); type("20,5"); enter(); expect(polys()).toHaveLength(1); expect(activeTool()).toBe(Tool.SELECT); type("spl"); type("0,0"); type("10,0"); type("20,5"); esc(); expect(polys()).toHaveLength(1); expect(activeTool()).toBe(Tool.SELECT); }); });