diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Export.py b/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Export.py index e95c3b958..da64d98ee 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Export.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Export.py @@ -195,6 +195,9 @@ def _add_entity( start_angle = shape.get("startAngle") end_angle = shape.get("endAngle") if isinstance(start_angle, (int, float)) and isinstance(end_angle, (int, float)): + # DXF 호는 늘 반시계 — 시계방향 호는 시작 · 끝을 바꿔야 같은 작은 호. + if shape.get("counterClockwise") is False: + start_angle, end_angle = end_angle, start_angle space.add_arc( center, radius, diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Import.py b/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Import.py index 8472d92fd..673df9a21 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Import.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Import.py @@ -29,6 +29,8 @@ from uuid import NAMESPACE_URL, uuid5 import ezdxf from ezdxf import colors as ezdxf_colors +from ezdxf import path as ezdxf_path +from ezdxf.enums import TextEntityAlignment from ezdxf.lldxf.encoding import decode_dxf_unicode, has_dxf_unicode logger = logging.getLogger(__name__) @@ -38,6 +40,8 @@ _DEFAULT_COLOR = "#f5f7fa" # 곡선을 선분으로 풀 때 허용 오차(mm) — 도각 크기(840x594mm)에서 눈에 띄지 않는다. _FLATTEN_MM = 0.2 _MAX_ENTITIES = 20000 +# 블록 속 블록을 풀 깊이 — 자기를 품는 깨진 블록에서 끝없이 돌지 않게. +_MAX_BLOCK_DEPTH = 16 # 프로그램에 함께 담은 LibreDWG 자리 — `B07_DesignDetail/openwebcad/tools/libredwg/`. @@ -117,16 +121,26 @@ def _plain(label: str) -> str: return decode_dxf_unicode(label) if has_dxf_unicode(label) else label -def _text(index: int, entity: Any, label: str, insert: Any, height: float) -> dict[str, Any]: - rotation = float(getattr(entity.dxf, "rotation", 0.0) or 0.0) +def _text( + index: int, + entity: Any, + label: str, + insert: Any, + height: float, + direction: Any = None, + align: str = "left", +) -> dict[str, Any]: + if direction is None: + rotation = float(getattr(entity.dxf, "rotation", 0.0) or 0.0) + direction = (cos(radians(rotation)), sin(radians(rotation))) return { **_base(index, entity, "Text"), "shapeData": { "label": _plain(label), "basePoint": _point(insert[0], insert[1]), "options": { - "textDirection": _point(cos(radians(rotation)), sin(radians(rotation))), - "textAlign": "left", + "textDirection": _point(direction[0], direction[1]), + "textAlign": align, "textColor": _color(entity), "fontSize": float(height) or 3.0, "fontFamily": "sans-serif", @@ -143,16 +157,50 @@ def _flatten(entity: Any) -> list[Any] | None: return None +def _polyline_points(entity: Any) -> tuple[list[Any], bool]: + """폴리선 → WCS 점열. bulge(호 마디)는 펴고 OCS(압출 −Z 거울 블록)는 WCS 로. + + `make_path` 가 닫힌 폴리선의 닫는 마디까지 넣으므로 닫힘 표시는 False. + 면 · 메시 POLYLINE 처럼 경로로 못 바꾸면 꼭짓점을 그대로 잇는다. + """ + try: + return list(ezdxf_path.make_path(entity).flattening(_FLATTEN_MM)), False + except Exception: + if entity.dxftype() == "LWPOLYLINE": + return list(entity.get_points("xy")), bool(entity.closed) + return [vertex.dxf.location for vertex in entity.vertices], bool(entity.is_closed) + + +# DXF 가로 정렬 → CAD 글 정렬. 맞춤(ALIGNED · FIT)은 첫 점에서 왼쪽. +_TEXT_ALIGN = { + TextEntityAlignment.CENTER: "center", + TextEntityAlignment.MIDDLE: "center", + TextEntityAlignment.TOP_CENTER: "center", + TextEntityAlignment.MIDDLE_CENTER: "center", + TextEntityAlignment.BOTTOM_CENTER: "center", + TextEntityAlignment.RIGHT: "right", + TextEntityAlignment.TOP_RIGHT: "right", + TextEntityAlignment.MIDDLE_RIGHT: "right", + TextEntityAlignment.BOTTOM_RIGHT: "right", +} + + +def _text_placement(entity: Any) -> tuple[Any, Any, str]: + """TEXT 의 (WCS 기준점, WCS 방향, 가로 정렬). 정렬점 · OCS(압출 −Z)를 따른다.""" + align, first, _second = entity.get_placement() + ocs = entity.ocs() + rotation = radians(float(entity.dxf.rotation or 0.0)) + direction = ocs.to_wcs((cos(rotation), sin(rotation), 0.0)) + return ocs.to_wcs(first), direction, _TEXT_ALIGN.get(align, "left") + + def _convert_entity(index: int, entity: Any) -> list[dict[str, Any]]: kind = entity.dxftype() if kind == "LINE": return [_line(index, entity, entity.dxf.start, entity.dxf.end)] - if kind == "LWPOLYLINE": - shape = _polyline(index, entity, list(entity.get_points("xy")), bool(entity.closed)) - return [shape] if shape else [] - if kind == "POLYLINE": - points = [vertex.dxf.location for vertex in entity.vertices] - shape = _polyline(index, entity, points, bool(entity.is_closed)) + if kind in ("LWPOLYLINE", "POLYLINE"): + points, closed = _polyline_points(entity) + shape = _polyline(index, entity, points, closed) return [shape] if shape else [] if kind in ("CIRCLE", "ARC", "ELLIPSE", "SPLINE"): points = _flatten(entity) @@ -172,24 +220,32 @@ def _convert_entity(index: int, entity: Any) -> list[dict[str, Any]]: label = str(entity.dxf.text or "").strip() if not label: return [] - return [_text(index, entity, label, entity.dxf.insert, float(entity.dxf.height or 3.0))] + insert, direction, align = _text_placement(entity) + height = float(entity.dxf.height or 3.0) + return [_text(index, entity, label, insert, height, direction, align)] if kind == "MTEXT": label = str(entity.plain_text() or "").strip() if not label: return [] height = float(entity.dxf.char_height or 3.0) - return [_text(index, entity, label, entity.dxf.insert, height)] + # 붙임점 1~9 = 위 · 가운데 · 아래 줄의 왼 · 가운데 · 오른. + align = ("left", "center", "right")[(int(entity.dxf.attachment_point or 1) - 1) % 3] + return [_text(index, entity, label, entity.dxf.insert, height, align=align)] return [] -def _expand(entity: Any) -> list[Any]: - """블록·치수처럼 속에 도형을 품은 것은 풀어서 낱개로 만든다. 못 풀면 버린다.""" +def _expand(entity: Any, depth: int = 0) -> list[Any]: + """블록·치수처럼 속에 도형을 품은 것은 풀어서 낱개로 — 블록 속 블록까지. 못 풀면 버린다.""" if entity.dxftype() in ("INSERT", "DIMENSION", "LEADER", "MULTILEADER"): + if depth >= _MAX_BLOCK_DEPTH: + logger.info("B07 도각 불러오기 — 블록이 %d 겹 넘게 겹쳐 건너뜀", _MAX_BLOCK_DEPTH) + return [] try: - return list(entity.virtual_entities()) + inner = list(entity.virtual_entities()) except Exception: logger.info("B07 도각 불러오기 — %s 는 풀지 못해 건너뜀", entity.dxftype()) return [] + return [item for child in inner for item in _expand(child, depth + 1)] return [entity] diff --git a/B07_DesignDetail/openwebcad/src/entities/ArcEntity.test.ts b/B07_DesignDetail/openwebcad/src/entities/ArcEntity.test.ts index c094d88b7..75114f281 100644 --- a/B07_DesignDetail/openwebcad/src/entities/ArcEntity.test.ts +++ b/B07_DesignDetail/openwebcad/src/entities/ArcEntity.test.ts @@ -1,7 +1,10 @@ import { type Arc, Point } from '@flatten-js/core'; import { describe, expect, it } from 'vitest'; import { EPSILON } from '../App.consts.ts'; +import type { DrawController } from '../drawControllers/DrawController.ts'; +import { mirrorPointOverAxis } from '../helpers/mirror-point-over-axis.ts'; import { ArcEntity } from './ArcEntity.ts'; +import { LineEntity } from './LineEntity.ts'; describe('ArcEntity.distanceTo', () => { /** @@ -57,3 +60,62 @@ describe('ArcEntity.distanceTo', () => { expect(distanceInfo[0]).toBeLessThan(1); }); }); + +describe('ArcEntity 대칭 · 그리기 · 복제(84-3)', () => { + const middle = (entity: ArcEntity) => { + const arc = entity.getShape() as Arc; + return arc.pointAtLength(arc.length / 2) as Point; + }; + const mirrorOver = (axisEnd: Point) => { + const arc = new ArcEntity('layer1', new Point(2, 1), 1, 0, Math.PI / 2, true); + const before = arc.getShape() as Arc; + const axis = new LineEntity('layer1', new Point(0, 0), axisEnd); + const expected = [before.start, middle(arc), before.end].map((point) => + mirrorPointOverAxis(point, axis) + ); + arc.mirror(axis); + const after = arc.getShape() as Arc; + return { expected, actual: [after.start, middle(arc), after.end], length: after.length }; + }; + + for (const [name, axisEnd] of [ + ['세로축', new Point(0, 1)], + ['가로축', new Point(1, 0)], + ['45° 축', new Point(1, 1)], + ['30° 축', new Point(Math.cos(Math.PI / 6), Math.sin(Math.PI / 6))], + ] as const) { + it(`${name} 대칭 — 끝점 · 가운데 점이 대칭 자리 · 작은 호 그대로`, () => { + const { expected, actual, length } = mirrorOver(axisEnd); + for (let index = 0; index < 3; index++) { + expect(actual[index].x).toBeCloseTo(expected[index].x, 9); + expect(actual[index].y).toBeCloseTo(expected[index].y, 9); + } + expect(length).toBeCloseTo(Math.PI / 2, 9); + }); + } + + it('시작각이 음수 · 끝각 0 인 호 — 끝각을 2π 로 바꾸지 않음(온 원으로 안 그림)', () => { + const arc = new ArcEntity('layer1', new Point(0, 0), 1, -Math.PI / 2, 0, true); + const calls: number[][] = []; + const controller = { + setLineStyles: () => {}, + drawArc: (_c: Point, _r: number, start: number, end: number) => calls.push([start, end]), + } as unknown as DrawController; + arc.draw(controller); + expect(calls[0][1] - calls[0][0]).toBeCloseTo(Math.PI / 2, 9); + }); + + it('복제 — 색 · 층 · 선 꼴 그대로', () => { + const arc = new ArcEntity('구조물', new Point(0, 0), 1, 0, 1, false); + arc.lineColor = '#ff0000'; + arc.lineWidth = 3; + arc.lineDash = [2, 1]; + const copy = arc.clone(); + expect([copy.layerId, copy.lineColor, copy.lineWidth, copy.lineDash]).toEqual([ + '구조물', + '#ff0000', + 3, + [2, 1], + ]); + }); +}); diff --git a/B07_DesignDetail/openwebcad/src/entities/ArcEntity.ts b/B07_DesignDetail/openwebcad/src/entities/ArcEntity.ts index f5e9e49c8..43b522328 100644 --- a/B07_DesignDetail/openwebcad/src/entities/ArcEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/ArcEntity.ts @@ -60,8 +60,9 @@ export class ArcEntity implements Entity, StartAndEndpointEntity { drawController.drawArc( this.arc.center, this.arc.r.valueOf(), - this.arc?.startAngle || 0, - this.arc?.endAngle || 2 * Math.PI, + this.arc.startAngle, + // 끝각 = 시작각 ± 쓸어간 각 — 끝각 0 을 2π 로 바꾸면 시작각이 음수인 호(대칭 뒤)가 온 원으로 그려짐. + this.arc.startAngle + (this.arc.counterClockwise ? this.arc.sweep : -this.arc.sweep), this.arc.counterClockwise ); } @@ -87,12 +88,13 @@ export class ArcEntity implements Entity, StartAndEndpointEntity { public mirror(mirrorAxis: LineEntity) { const mirroredCenter = mirrorPointOverAxis(this.arc.center, mirrorAxis); - mirrorAxis.getAngle(); + // 축 각 θ 에 대한 대칭 — 각 α → 2θ − α · 도는 방향 반대. + const doubleAxisAngle = 2 * mirrorAxis.getAngle(); this.arc = new Arc( mirroredCenter, this.arc.r.valueOf(), - -this.arc.startAngle, - -this.arc.endAngle, + doubleAxisAngle - this.arc.startAngle, + doubleAxisAngle - this.arc.endAngle, !this.arc.counterClockwise ); } @@ -100,14 +102,13 @@ export class ArcEntity implements Entity, StartAndEndpointEntity { public clone(): Entity { if (this.arc) { const { center, r, startAngle, endAngle, counterClockwise } = this.arc; - return new ArcEntity( - getActiveLayerId(), - center, - r.valueOf(), - startAngle, - endAngle, - counterClockwise - ); + // 색 · 층 · 선 꼴은 그대로 — 폴리선 자식도 이 길로 복제됨. + const copy = new ArcEntity(this.layerId, center, r.valueOf(), startAngle, endAngle, counterClockwise); + copy.lineColor = this.lineColor; + copy.lineWidth = this.lineWidth; + copy.lineDash = this.lineDash; + copy.opacity = this.opacity; + return copy; } return this; } diff --git a/B07_DesignDetail/openwebcad/src/entities/LineEntity.ts b/B07_DesignDetail/openwebcad/src/entities/LineEntity.ts index 590a5d1bf..bed94b431 100644 --- a/B07_DesignDetail/openwebcad/src/entities/LineEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/LineEntity.ts @@ -76,7 +76,13 @@ export class LineEntity implements Entity, StartAndEndpointEntity { } public clone(): LineEntity { - return new LineEntity(getActiveLayerId(), this.segment.clone()); + // 색 · 층 · 선 꼴은 그대로 — 폴리선 자식도 이 길로 복제됨. + const copy = new LineEntity(this.layerId, this.segment.clone()); + copy.lineColor = this.lineColor; + copy.lineWidth = this.lineWidth; + copy.lineDash = this.lineDash; + copy.opacity = this.opacity; + return copy; } public intersectsWithBox(box: Box): boolean { diff --git a/B07_DesignDetail/openwebcad/src/tools/array-tool.ts b/B07_DesignDetail/openwebcad/src/tools/array-tool.ts index 829a3454a..2fd83edff 100644 --- a/B07_DesignDetail/openwebcad/src/tools/array-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/array-tool.ts @@ -16,6 +16,7 @@ import { } from '../state'; import { Tool } from '../tools'; import { CopyAction } from './copy-tool.ts'; +import { cloneKeepingLook } from './move-tool.helpers.ts'; import { selectToolStateMachine } from './select-tool.ts'; import type { AbsolutePointInputEvent, @@ -337,7 +338,7 @@ export const arrayToolStateMachine = createMachine( const y = distanceVector.y * i; resultEntities.push( ...getSelectedEntities().map((entity) => { - const clone = entity.clone(); + const clone = cloneKeepingLook(entity); clone.move(x, y); return clone; }) @@ -346,7 +347,7 @@ export const arrayToolStateMachine = createMachine( const angleRad = context.angleStep * i * TO_RADIANS; resultEntities.push( ...getSelectedEntities().map((entity) => { - const clone = entity.clone(); + const clone = cloneKeepingLook(entity); clone.rotate(context.pivotPoint as Point, angleRad); return clone; }) diff --git a/B07_DesignDetail/openwebcad/src/tools/copy-tool.ts b/B07_DesignDetail/openwebcad/src/tools/copy-tool.ts index 7ef246f2f..73b6811fb 100644 --- a/B07_DesignDetail/openwebcad/src/tools/copy-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/copy-tool.ts @@ -17,7 +17,7 @@ import type { Entity } from '../entities/Entity'; import { compact } from 'es-toolkit'; import { LineEntity } from '../entities/LineEntity'; import { GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH } from '../App.consts'; -import { moveEntities } from './move-tool.helpers'; +import { cloneKeepingLook, moveEntities } from './move-tool.helpers'; export interface CopyContext extends ToolContext { startPoint: Point | null; @@ -208,7 +208,7 @@ export const copyToolStateMachine = createMachine( return { startPoint: context.startPoint, // Make a copy of the selected entities before copying them, so we can restore them when the user cancels the copy action - originalSelectedEntities: compact(selectedEntities.map((entity) => entity.clone())), + originalSelectedEntities: compact(selectedEntities.map((entity) => cloneKeepingLook(entity))), copiedEntities: selectedEntities, }; }), @@ -221,7 +221,7 @@ export const copyToolStateMachine = createMachine( // Copy the entities to the new location // Draw all selected entities according to translation vector, so the user gets visual feedback of where the entities will be copied; - const movedEntities = context.originalSelectedEntities.map((entity) => entity.clone()); + const movedEntities = context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity)); moveEntities( movedEntities, endPointTemp.x - context.startPoint.x, @@ -246,15 +246,15 @@ export const copyToolStateMachine = createMachine( // Copy the entities one final time const currentEndPoint = (event as MouseClickEvent).worldMouseLocation; - const copiedEntities = context.originalSelectedEntities.map((entity) => entity.clone()); + const copiedEntities = context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity)); moveEntities( copiedEntities, currentEndPoint.x - context.startPoint.x, currentEndPoint.y - context.startPoint.y ); - // Switch the copied entities back from the ghost helper entities to the real entities - addEntities([...context.originalSelectedEntities, ...copiedEntities], true); + // 원본은 목록에 그대로 있음 — 복사본만 더함(원본까지 더하면 누를 때마다 원본이 겹겹이 쌓임). + addEntities(copiedEntities, true); }, [CopyAction.DESELECT_ENTITIES]: assign(() => { setGhostHelperEntities([]); diff --git a/B07_DesignDetail/openwebcad/src/tools/move-tool.helpers.ts b/B07_DesignDetail/openwebcad/src/tools/move-tool.helpers.ts index 97c15c503..c05b9451c 100644 --- a/B07_DesignDetail/openwebcad/src/tools/move-tool.helpers.ts +++ b/B07_DesignDetail/openwebcad/src/tools/move-tool.helpers.ts @@ -1,4 +1,5 @@ import type { Entity } from '../entities/Entity'; +import { copyStyle } from './modify/modify.helpers'; /** * Move entities by the difference between the start and end points @@ -11,3 +12,17 @@ export function moveEntities(entities: Entity[], deltaX: number, deltaY: number) entity.move(deltaX, deltaY); } } + +/** + * 복제해도 색 · 층 · 선 꼴 · 투명도를 그대로 둔다(`clone()` 은 활성 층 · 흰색으로 새로 만듦). + * `inPlace` = 옮기기 · 크기 · 회전처럼 같은 도형을 갈아 끼울 때 — id · 묶음까지 그대로. + */ +export function cloneKeepingLook(entity: Entity, inPlace = false): Entity { + const copy = copyStyle(entity, entity.clone()); + copy.opacity = entity.opacity; + if (inPlace) { + copy.id = entity.id; + copy.groupId = entity.groupId; + } + return copy; +} diff --git a/B07_DesignDetail/openwebcad/src/tools/move-tool.ts b/B07_DesignDetail/openwebcad/src/tools/move-tool.ts index 346be9091..ba19c7073 100644 --- a/B07_DesignDetail/openwebcad/src/tools/move-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/move-tool.ts @@ -16,7 +16,7 @@ import { assign, createMachine, sendTo } from 'xstate'; import { selectToolStateMachine } from './select-tool'; import type { Entity } from '../entities/Entity'; import { compact } from 'es-toolkit'; -import { moveEntities } from './move-tool.helpers'; +import { cloneKeepingLook, moveEntities } from './move-tool.helpers'; import { LineEntity } from '../entities/LineEntity'; import { GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH } from '../App.consts'; @@ -211,7 +211,7 @@ export const moveToolStateMachine = createMachine( return { startPoint: context.startPoint, // Make a copy of the selected entities before moving them, so we can restore them when the user cancels the move action - originalSelectedEntities: compact(selectedEntities.map((entity) => entity.clone())), + originalSelectedEntities: compact(selectedEntities.map((entity) => cloneKeepingLook(entity, true))), movedEntities: selectedEntities, }; }), @@ -224,7 +224,7 @@ export const moveToolStateMachine = createMachine( // Move the entities to the new location // Draw all selected entities according to translation vector, so the user gets visual feedback of where the entities will be moved; - const movedEntities = context.originalSelectedEntities.map((entity) => entity.clone()); + const movedEntities = context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity, true)); moveEntities( movedEntities, endPointTemp.x - context.startPoint.x, diff --git a/B07_DesignDetail/openwebcad/src/tools/rotate-tool.ts b/B07_DesignDetail/openwebcad/src/tools/rotate-tool.ts index 0d792a54e..46888d7a5 100644 --- a/B07_DesignDetail/openwebcad/src/tools/rotate-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/rotate-tool.ts @@ -16,6 +16,7 @@ import { selectToolStateMachine } from './select-tool'; import type { Entity } from '../entities/Entity'; import { compact } from 'es-toolkit'; import { rotateEntities } from './rotate-tool.helpers'; +import { cloneKeepingLook } from './move-tool.helpers'; export interface RotateContext extends ToolContext { rotationOrigin: Point | null; @@ -231,7 +232,7 @@ export const rotateToolStateMachine = createMachine( return { ...context, // Make a copy of the selected entities before rotating them, so we can restore them when the user cancels the rotate action - originalSelectedEntities: compact(selectedEntities.map((entity) => entity.clone())), + originalSelectedEntities: compact(selectedEntities.map((entity) => cloneKeepingLook(entity, true))), }; }), [RotateAction.DRAW_TEMP_ROTATE_ENTITIES]: ({ context, event }) => { @@ -245,7 +246,7 @@ export const rotateToolStateMachine = createMachine( // Draw all selected entities according to rotate vector, so the user gets visual feedback of where the entities will be end up after rotating const rotatedEntities = compact( - context.originalSelectedEntities.map((entity) => entity.clone()) + context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity, true)) ); rotateEntities( rotatedEntities, @@ -264,7 +265,7 @@ export const rotateToolStateMachine = createMachine( // Rotate the entities one final time const rotatedEntities = compact( - context.originalSelectedEntities.map((entity) => entity.clone()) + context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity, true)) ); rotateEntities( rotatedEntities, diff --git a/B07_DesignDetail/openwebcad/src/tools/scale-tool.ts b/B07_DesignDetail/openwebcad/src/tools/scale-tool.ts index 99b5ed11c..660a885b2 100644 --- a/B07_DesignDetail/openwebcad/src/tools/scale-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/scale-tool.ts @@ -16,6 +16,7 @@ import { selectToolStateMachine } from './select-tool'; import type { Entity } from '../entities/Entity'; import { compact } from 'es-toolkit'; import { scaleEntities } from './scale-tool.helpers'; +import { cloneKeepingLook } from './move-tool.helpers'; export interface ScaleContext extends ToolContext { baseVectorStartPoint: Point | null; @@ -234,7 +235,7 @@ export const scaleToolStateMachine = createMachine( return { ...context, // Make a copy of the selected entities before scaling them, so we can restore them when the user cancels the scale action - originalSelectedEntities: compact(selectedEntities.map((entity) => entity.clone())), + originalSelectedEntities: compact(selectedEntities.map((entity) => cloneKeepingLook(entity, true))), }; }), [ScaleAction.DRAW_TEMP_SCALE_ENTITIES]: ({ context, event }) => { @@ -248,7 +249,7 @@ export const scaleToolStateMachine = createMachine( // Draw all selected entities according to scale vector, so the user gets visual feedback of where the entities will be end up after scaling const scaledEntities = compact( - context.originalSelectedEntities.map((entity) => entity.clone()) + context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity, true)) ); scaleEntities( scaledEntities, @@ -267,7 +268,7 @@ export const scaleToolStateMachine = createMachine( // Scale the entities one final time const scaledEntities = compact( - context.originalSelectedEntities.map((entity) => entity.clone()) + context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity, true)) ); scaleEntities( scaledEntities, diff --git a/resources/tester/cad/clone-keeps-look.test.ts b/resources/tester/cad/clone-keeps-look.test.ts new file mode 100644 index 000000000..bb39eeb68 --- /dev/null +++ b/resources/tester/cad/clone-keeps-look.test.ts @@ -0,0 +1,61 @@ +/** + * 옮기기 · 크기 · 회전 · 복사 · 배열 복제가 색 · 층 · 선 꼴을 지키는지(84-3). + * 실행: cd B07_DesignDetail/openwebcad && npx vitest run --config + */ +import { Point } from "@flatten-js/core"; +import { describe, expect, it } from "vitest"; +import { LineEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/LineEntity"; +import { PointEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/PointEntity"; +import { PolyLineEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/PolyLineEntity"; +import { TextEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/TextEntity"; +import { cloneKeepingLook } from "../../../B07_DesignDetail/openwebcad/src/tools/move-tool.helpers"; +import type { Entity } from "../../../B07_DesignDetail/openwebcad/src/entities/Entity"; + +const styled = (entity: T): T => { + entity.lineColor = "#ff0000"; + entity.lineWidth = 2; + entity.lineDash = [4, 2]; + entity.opacity = 0.5; + entity.groupId = "g1"; + return entity; +}; +const look = (entity: Entity) => [ + entity.layerId, + entity.lineColor, + entity.lineWidth, + entity.lineDash, + entity.opacity, +]; +const LOOK = ["구조물", "#ff0000", 2, [4, 2], 0.5]; + +describe("cloneKeepingLook", () => { + const samples: [string, () => Entity][] = [ + ["선", () => styled(new LineEntity("구조물", new Point(0, 0), new Point(1, 0)))], + ["점", () => styled(new PointEntity("구조물", new Point(1, 1)))], + ["글", () => styled(new TextEntity("구조물", "배수관", new Point(0, 0)))], + ]; + for (const [name, make] of samples) { + it(`${name} — 옮기기(inPlace) 는 id · 묶음 · 색 · 층 그대로`, () => { + const source = make(); + const copy = cloneKeepingLook(source, true); + copy.move(5, 5); + expect(look(copy)).toEqual(LOOK); + expect([copy.id, copy.groupId]).toEqual([source.id, "g1"]); + }); + it(`${name} — 복사는 새 id · 색 · 층 그대로`, () => { + const source = make(); + const copy = cloneKeepingLook(source); + expect(look(copy)).toEqual(LOOK); + expect(copy.id).not.toBe(source.id); + }); + } + + it("폴리선 — 자식 선까지 색 · 층 그대로(크기 바꿈 뒤)", () => { + const child = styled(new LineEntity("구조물", new Point(0, 0), new Point(1, 0))); + const polyline = styled(new PolyLineEntity("구조물", [child])); + const copy = cloneKeepingLook(polyline, true) as PolyLineEntity; + copy.scale(new Point(0, 0), 2); + expect(look(copy)).toEqual(LOOK); + expect(look(copy.getEntities()[0])).toEqual(LOOK); + }); +}); diff --git a/resources/tester/test_b07_frame_import_shapes.py b/resources/tester/test_b07_frame_import_shapes.py new file mode 100644 index 000000000..a93942d27 --- /dev/null +++ b/resources/tester/test_b07_frame_import_shapes.py @@ -0,0 +1,135 @@ +"""B07 도면 불러오기 — 호 · bulge 폴리선 · 거울 블록 · 겹 블록 견본 DXF(84-2) · 내보내기 시계방향 호(84-3). + +견본은 ezdxf 로 그 자리에서 만들어 끌어다 놓기 길(`drawing_file_to_entities`)로 읽는다. +""" + +from __future__ import annotations + +import io +import math + +import ezdxf +from ezdxf.enums import TextEntityAlignment + +from B07_DesignDetail import B07_DesignDetail_Engine_Frame_Export as export +from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Import import drawing_file_to_entities + + +def _read(doc: ezdxf.document.Drawing) -> list[dict]: + stream = io.StringIO() + doc.write(stream) + entities, _layers = drawing_file_to_entities("견본.dxf", stream.getvalue().encode("utf-8")) + return entities + + +def _points(polyline: dict) -> list[tuple[float, float]]: + children = polyline["children"] + points = [ + (c["shapeData"]["startPoint"]["x"], c["shapeData"]["startPoint"]["y"]) for c in children + ] + last = children[-1]["shapeData"]["endPoint"] + return [*points, (last["x"], last["y"])] + + +def _on_circle(points, cx: float, cy: float, r: float) -> bool: + return all(abs(math.hypot(x - cx, y - cy) - r) < 0.21 for x, y in points) + + +def test_호는_1사분면_작은_호(): + doc = ezdxf.new() + doc.modelspace().add_arc((0, 0), 10, 0, 90) + (arc,) = _read(doc) + points = _points(arc) + assert _on_circle(points, 0, 0, 10) + assert points[0] == (10.0, 0.0) and math.isclose(points[-1][0], 0, abs_tol=1e-9) + assert all(x >= -1e-9 and y >= -1e-9 for x, y in points) # 큰 호(270°)로 뒤집히지 않음 + + +def test_bulge_폴리선은_호로_펴짐(): + doc = ezdxf.new() + space = doc.modelspace() + # 반원(bulge 1 · 반시계 → 아래로) · 작은 호(bulge −0.2 · 시계 → 위로 1) + space.add_lwpolyline([(0, 0, 0, 0, 1), (10, 0)], format="xyseb") + space.add_lwpolyline([(0, 20, 0, 0, -0.2), (10, 20)], format="xyseb") + half, small = _read(doc) + half_points = _points(half) + assert len(half_points) > 3 and _on_circle(half_points, 5, 0, 5) + assert min(y for _x, y in half_points) < -4.9 + small_points = _points(small) + assert len(small_points) > 3 + assert math.isclose(max(y for _x, y in small_points), 21.0, abs_tol=0.01) # 처짐 = 0.2 × 10 / 2 + assert min(y for _x, y in small_points) >= 20 - 1e-9 + + +def test_거울_블록은_WCS_자리(): + doc = ezdxf.new() + block = doc.blocks.new("거울") + block.add_lwpolyline([(0, 0, 0, 0, -0.2), (10, 0)], format="xyseb") + block.add_arc((20, 0), 2, 0, 90) + block.add_text("가", dxfattribs={"height": 2.5}).set_placement((2, 3)) + doc.modelspace().add_blockref("거울", (100, 0), dxfattribs={"xscale": -1}) + by_type: dict[str, list[dict]] = {} + for entity in _read(doc): + by_type.setdefault(entity["type"], []).append(entity) + bulge, arc = by_type["PolyLine"] + bulge_points = _points(bulge) + assert ( + min(x for x, _y in bulge_points) >= 90 - 1e-9 + and max(x for x, _y in bulge_points) <= 100 + 1e-9 + ) + assert math.isclose(max(y for _x, y in bulge_points), 1.0, abs_tol=0.01) # 위로 그대로 + arc_points = _points(arc) + assert _on_circle(arc_points, 80, 0, 2) + assert all(x <= 80 + 1e-9 and y >= -1e-9 for x, y in arc_points) # 2사분면으로 대칭 + (text,) = by_type["Text"] + assert text["shapeData"]["basePoint"] == {"x": 98.0, "y": 3.0} + + +def test_겹_블록도_풀림(): + doc = ezdxf.new() + doc.blocks.new("속").add_line((0, 0), (1, 0)) + doc.blocks.new("겉").add_blockref("속", (5, 0)) + doc.modelspace().add_blockref("겉", (10, 0)) + (line,) = _read(doc) + assert line["type"] == "Line" + assert line["shapeData"] == { + "startPoint": {"x": 15.0, "y": 0.0}, + "endPoint": {"x": 16.0, "y": 0.0}, + } + + +def test_TEXT_정렬점_자리(): + doc = ezdxf.new() + space = doc.modelspace() + space.add_text("가운데", dxfattribs={"height": 2}).set_placement( + (50, 5), align=TextEntityAlignment.MIDDLE_CENTER + ) + space.add_text("오른", dxfattribs={"height": 2}).set_placement( + (80, 5), align=TextEntityAlignment.BOTTOM_RIGHT + ) + center, right = _read(doc) + assert center["shapeData"]["basePoint"] == {"x": 50.0, "y": 5.0} + assert center["shapeData"]["options"]["textAlign"] == "center" + assert right["shapeData"]["basePoint"] == {"x": 80.0, "y": 5.0} + assert right["shapeData"]["options"]["textAlign"] == "right" + + +def test_내보내기_시계방향_작은_호(tmp_path): + arc = { + "type": "Arc", + "layerId": "기본", + "shapeData": { + "center": {"x": 0, "y": 0}, + "radius": 1, + "startAngle": math.pi / 2, + "endAngle": 0, + "counterClockwise": False, + }, + } + raw, skipped = export.drawing_to_dxf({"entities": [arc], "layers": []}) + assert skipped == {} + path = tmp_path / "out.dxf" + path.write_bytes(raw) + (out,) = ezdxf.readfile(path).modelspace().query("ARC") + span = (out.dxf.end_angle - out.dxf.start_angle) % 360 + assert math.isclose(span, 90) and math.isclose(out.dxf.start_angle % 360, 0, abs_tol=1e-9)