Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -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],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
})
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 옮기기 · 크기 · 회전 · 복사 · 배열 복제가 색 · 층 · 선 꼴을 지키는지(84-3).
|
||||
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --config <include 를 이 파일로 바꾼 vitest.tmp.config.ts>
|
||||
*/
|
||||
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 = <T extends Entity>(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);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user