fix(B07): 이동 · 크기 · 회전 · 복사 · 배열 때 색 · 층 유지 · 호 대칭 축 · 시계방향 호 내보내기(84-3)

- 복제 함수 cloneKeepingLook 한 곳(move-tool.helpers) — 옮기기 · 크기 · 회전은 id · 묶음까지 그대로 · 복사 · 배열은 새 id
- LineEntity · ArcEntity clone 이 색 · 층 · 선 꼴 유지(폴리선 자식 포함)
- 복사가 누를 때마다 원본을 한 벌씩 더 쌓던 흠 고침
- ArcEntity mirror 가 축 각도 따름(α → 2θ − α) · 그리기 끝각 = 시작각 ± 쓸어간 각(끝각 0 → 2π 온 원 흠)
- DXF 내보내기 시계방향 호는 시작 · 끝 바꿔 같은 작은 호
- 시험: ArcEntity.test.ts 10 · resources/tester/cad/clone-keeps-look.test.ts 7

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s
This commit is contained in:
2026-10-08 12:08:03 +09:00
co-authored by Claude Opus 5.5
parent 04dd8cd012
commit edd51b66e9
11 changed files with 182 additions and 31 deletions
@@ -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,
@@ -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);
});
});