feat(488-2 CAD 선 속성): 색 · 선 두께 · 선종류 = 고른 것의 값(다르면 빈 칸 · 색 = 점선 빈 견본) · 고른 것만 바꿈 · 폴리선 자식까지 · 사본 갈아 끼움으로 Ctrl+Z 한 번 = 옛 값 — 새 helpers/selection-style.ts 를 리본 · 특성 판 · COLOR · LINETYPE · LWEIGHT · TRANSPARENCY · MATCHPROP · SETBYLAYER · LAYMCH · LAYCUR 가 같이 씀 · 시험 selection-style.test.ts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -334,6 +334,8 @@ body > canvas[data-id="canvas"] {
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* 고른 것들 색이 다름(488) — 빈 견본(투명 · 점선 테두리) */
|
||||
input[type="color"][data-mixed="true"]::-webkit-color-swatch { background: transparent !important; border: 1px dashed var(--cad-text-muted); }
|
||||
/* 위젯 패널 안의 작은 명령 버튼은 한 줄 높이에 맞춘다 */
|
||||
.cad-ribbon-props .cad-tool[data-size="small"] {
|
||||
height: 22px;
|
||||
|
||||
@@ -2,25 +2,31 @@
|
||||
import type { FC } from 'react';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
import { polylineLength, sampleEntityPoints } from '../helpers/geometry/sample-entity';
|
||||
import { getEntities, getLayers, getSelectedEntities, setEntities } from '../state';
|
||||
import { dashToLineType, LINE_TYPES, LINE_WIDTHS } from './RibbonWidgets';
|
||||
import {
|
||||
commonValue,
|
||||
LINE_TYPES,
|
||||
LINE_WIDTHS,
|
||||
restyle,
|
||||
selectionLineStyle,
|
||||
styleTargets,
|
||||
} from '../helpers/selection-style';
|
||||
import { getLayers, getSelectedEntities } from '../state';
|
||||
|
||||
interface PropertiesEditorProps {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
function applyToSelection(mutate: (entity: Entity) => void): void {
|
||||
const selected = getSelectedEntities();
|
||||
if (!selected.length) return;
|
||||
for (const entity of selected) mutate(entity);
|
||||
setEntities([...getEntities()], true);
|
||||
}
|
||||
/** 고른 것을 사본으로 바꿈 — 되돌리기 한 칸(488) · 선 특성은 글 뺌 */
|
||||
const applyToSelection = (mutate: (entity: Entity) => void) => restyle(getSelectedEntities(), mutate);
|
||||
const applyToLines = (mutate: (entity: Entity) => void) =>
|
||||
restyle(styleTargets(getSelectedEntities()), mutate);
|
||||
|
||||
/** 여러 객체가 값이 다르면 '*가지각색' 대신 첫 객체 값을 보여 준다 (AutoCAD와 같은 관행) */
|
||||
/** 여러 객체가 값이 다르면 빈 칸(488 · 리본과 같은 규칙 · 고르면 모두에 적용) */
|
||||
export const PropertiesEditor: FC<PropertiesEditorProps> = ({ compact = false }) => {
|
||||
const selected = getSelectedEntities();
|
||||
const layers = getLayers();
|
||||
const first = selected[0];
|
||||
const view = selectionLineStyle();
|
||||
|
||||
if (!first) {
|
||||
return <p className="cad-properties__empty">선택된 객체가 없습니다.</p>;
|
||||
@@ -39,9 +45,10 @@ export const PropertiesEditor: FC<PropertiesEditorProps> = ({ compact = false })
|
||||
<span>색상</span>
|
||||
<input
|
||||
type="color"
|
||||
value={first.lineColor}
|
||||
value={view.color || '#000000'}
|
||||
data-mixed={view.color ? undefined : 'true'}
|
||||
onChange={(event) =>
|
||||
applyToSelection((entity) => {
|
||||
applyToLines((entity) => {
|
||||
entity.lineColor = event.target.value;
|
||||
})
|
||||
}
|
||||
@@ -51,13 +58,14 @@ export const PropertiesEditor: FC<PropertiesEditorProps> = ({ compact = false })
|
||||
<label>
|
||||
<span>선 두께</span>
|
||||
<select
|
||||
value={first.lineWidth}
|
||||
value={view.width}
|
||||
onChange={(event) =>
|
||||
applyToSelection((entity) => {
|
||||
applyToLines((entity) => {
|
||||
entity.lineWidth = Number(event.target.value);
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="" hidden />
|
||||
{LINE_WIDTHS.map((width) => (
|
||||
<option key={width} value={width}>
|
||||
{width}px
|
||||
@@ -69,14 +77,15 @@ export const PropertiesEditor: FC<PropertiesEditorProps> = ({ compact = false })
|
||||
<label>
|
||||
<span>선종류</span>
|
||||
<select
|
||||
value={dashToLineType(first.lineDash)}
|
||||
value={view.lineType}
|
||||
onChange={(event) => {
|
||||
const dash = LINE_TYPES.find((type) => type.value === event.target.value)?.dash;
|
||||
applyToSelection((entity) => {
|
||||
applyToLines((entity) => {
|
||||
entity.lineDash = dash ? [...dash] : undefined;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="" hidden />
|
||||
{LINE_TYPES.map((type) => (
|
||||
<option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
@@ -88,13 +97,14 @@ export const PropertiesEditor: FC<PropertiesEditorProps> = ({ compact = false })
|
||||
<label>
|
||||
<span>레이어</span>
|
||||
<select
|
||||
value={first.layerId}
|
||||
value={commonValue(selected, (entity) => entity.layerId)}
|
||||
onChange={(event) =>
|
||||
applyToSelection((entity) => {
|
||||
entity.layerId = event.target.value;
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="" hidden />
|
||||
{layers.map((layer) => (
|
||||
<option key={layer.id} value={layer.id}>
|
||||
{layer.name}
|
||||
|
||||
@@ -4,11 +4,16 @@ import { runCommandInput } from '../commands/run-command';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
import { EntityName } from '../entities/Entity';
|
||||
import type { TextEntity } from '../entities/TextEntity';
|
||||
import {
|
||||
LINE_TYPES,
|
||||
LINE_WIDTHS,
|
||||
repaint,
|
||||
restyle,
|
||||
selectionLineStyle,
|
||||
styleTargets,
|
||||
} from '../helpers/selection-style';
|
||||
import {
|
||||
getActiveLayerId,
|
||||
getActiveLineColor,
|
||||
getActiveLineDash,
|
||||
getActiveLineWidth,
|
||||
getActiveTextStyle,
|
||||
getEntities,
|
||||
getLayers,
|
||||
@@ -21,50 +26,31 @@ import {
|
||||
setEntities,
|
||||
} from '../state';
|
||||
|
||||
export const LINE_TYPES: { value: string; label: string; dash: number[] | undefined }[] = [
|
||||
{ value: 'solid', label: '실선', dash: undefined },
|
||||
{ value: 'dashed', label: '파선', dash: [10, 5] },
|
||||
{ value: 'dashdot', label: '1점쇄선', dash: [12, 4, 2, 4] },
|
||||
{ value: 'dotted', label: '점선', dash: [2, 4] },
|
||||
];
|
||||
|
||||
export const LINE_WIDTHS = [1, 2, 3, 4, 5];
|
||||
|
||||
const FONT_FAMILIES = ['Noto Sans KR', 'Malgun Gothic', 'Pretendard', 'Arial', 'monospace'];
|
||||
|
||||
export const dashToLineType = (dash: number[] | undefined): string =>
|
||||
LINE_TYPES.find((type) => JSON.stringify(type.dash) === JSON.stringify(dash))?.value ?? 'solid';
|
||||
|
||||
/** 선택 객체가 있으면 즉시 적용하고, 없으면 이후 그리기 기본값만 바꾼다. */
|
||||
function applyToSelection(mutate: (entity: Entity) => void, trackInUndoStack = true): boolean {
|
||||
const selected = getSelectedEntities();
|
||||
if (!selected.length) return false;
|
||||
for (const entity of selected) {
|
||||
mutate(entity);
|
||||
}
|
||||
setEntities([...getEntities()], trackInUndoStack);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 색 선택기는 드래그하는 내내 값 변경을 쏜다. 그 하나하나를 되돌리기·변경통지에 실으면
|
||||
* 색 한 번 고르는 동안 수십 건이 나간다(2026-09-01 지적). 끄는 동안에는 화면만 칠하고,
|
||||
* 멎은 뒤 한 번만 되돌리기·통지에 싣는다.
|
||||
* 색 한 번 고르는 동안 수십 건이 나간다(2026-09-01 지적). 첫 변경에 사본을 끼우고 끄는 동안에는
|
||||
* 그 사본만 칠하고, 멎은 뒤 한 번만 되돌리기·통지에 싣는다(488 — Ctrl+Z 한 번에 옛 색).
|
||||
*/
|
||||
const COLOR_COMMIT_DELAY_MS = 350;
|
||||
let colorCommitTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
function paintNowCommitLater(mutate: (entity: Entity) => void): void {
|
||||
applyToSelection(mutate, false); // 화면만 — 되돌리기·미저장 표시는 아직
|
||||
let painting: Entity[] | null = null;
|
||||
function paintNowCommitLater(targets: Entity[], change: (entity: Entity) => void): void {
|
||||
if (painting) repaint(painting, change);
|
||||
else painting = restyle(targets, change, false); // 화면만
|
||||
clearTimeout(colorCommitTimer);
|
||||
colorCommitTimer = setTimeout(() => {
|
||||
applyToSelection(() => {}, true); // 멎었다 — 이번 한 번만 실어 보낸다
|
||||
painting = null;
|
||||
setEntities([...getEntities()], true); // 멎었다 — 이번 한 번만 실어 보낸다
|
||||
}, COLOR_COMMIT_DELAY_MS);
|
||||
}
|
||||
|
||||
/** 고른 선 있으면 그것만 바꿈(488 — 그리기 기본값 그대로) · 없으면 그리기 기본값만 */
|
||||
const selectedLines = () => styleTargets(getSelectedEntities());
|
||||
|
||||
export const PropertiesWidget: FC = () => {
|
||||
const lineColor = getActiveLineColor();
|
||||
const lineWidth = getActiveLineWidth();
|
||||
const lineType = dashToLineType(getActiveLineDash());
|
||||
const view = selectionLineStyle();
|
||||
|
||||
return (
|
||||
<div className="cad-ribbon-props">
|
||||
@@ -72,11 +58,13 @@ export const PropertiesWidget: FC = () => {
|
||||
<span>색상</span>
|
||||
<input
|
||||
type="color"
|
||||
value={lineColor}
|
||||
value={view.color || '#000000'}
|
||||
data-mixed={view.color ? undefined : 'true'}
|
||||
onChange={(event) => {
|
||||
const color = event.target.value;
|
||||
setActiveLineColor(color);
|
||||
paintNowCommitLater((entity) => {
|
||||
const lines = selectedLines();
|
||||
if (!lines.length) return setActiveLineColor(color);
|
||||
paintNowCommitLater(lines, (entity) => {
|
||||
entity.lineColor = color;
|
||||
});
|
||||
}}
|
||||
@@ -85,15 +73,17 @@ export const PropertiesWidget: FC = () => {
|
||||
<label className="cad-prop" title="선 두께 (LWEIGHT)">
|
||||
<span>선 두께</span>
|
||||
<select
|
||||
value={lineWidth}
|
||||
value={view.width}
|
||||
onChange={(event) => {
|
||||
const width = Number(event.target.value);
|
||||
setActiveLineWidth(width);
|
||||
applyToSelection((entity) => {
|
||||
const lines = selectedLines();
|
||||
if (!lines.length) return setActiveLineWidth(width);
|
||||
restyle(lines, (entity) => {
|
||||
entity.lineWidth = width;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="" hidden />
|
||||
{LINE_WIDTHS.map((width) => (
|
||||
<option key={width} value={width}>
|
||||
{width}px
|
||||
@@ -104,15 +94,17 @@ export const PropertiesWidget: FC = () => {
|
||||
<label className="cad-prop" title="선종류 (LINETYPE)">
|
||||
<span>선종류</span>
|
||||
<select
|
||||
value={lineType}
|
||||
value={view.lineType}
|
||||
onChange={(event) => {
|
||||
const dash = LINE_TYPES.find((type) => type.value === event.target.value)?.dash;
|
||||
setActiveLineDash(dash ? [...dash] : undefined);
|
||||
applyToSelection((entity) => {
|
||||
const lines = selectedLines();
|
||||
if (!lines.length) return setActiveLineDash(dash ? [...dash] : undefined);
|
||||
restyle(lines, (entity) => {
|
||||
entity.lineDash = dash ? [...dash] : undefined;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="" hidden />
|
||||
{LINE_TYPES.map((type) => (
|
||||
<option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
@@ -163,14 +155,11 @@ export const TextStyleWidget: FC = () => {
|
||||
|
||||
const handle = (patch: Partial<typeof textStyle>, live = false) => {
|
||||
setActiveTextStyle(patch);
|
||||
const mutate = (entity: Entity) => {
|
||||
if (entity.getType() === EntityName.Text) {
|
||||
(entity as TextEntity).setTextOptions(patch);
|
||||
}
|
||||
};
|
||||
const texts = getSelectedEntities().filter((entity) => entity.getType() === EntityName.Text);
|
||||
const mutate = (entity: Entity) => (entity as TextEntity).setTextOptions(patch);
|
||||
// 색만 드래그 중 연발한다 — 나머지 항목은 한 번 고르면 끝이라 바로 싣는다.
|
||||
if (live) paintNowCommitLater(mutate);
|
||||
else applyToSelection(mutate);
|
||||
if (live) paintNowCommitLater(texts, mutate);
|
||||
else restyle(texts, mutate);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* 고른 것의 선 특성(색 · 선 두께 · 선종류) 읽기 · 바꾸기 한 벌 (488 · 리본 특성 · 특성 판 · 특성 명령).
|
||||
*
|
||||
* 읽기 = 고른 것의 선 잎(폴리선 → 자식 선 · 글 뺌)이 모두 같으면 그 값 · 다르거나 목록 밖이면 빈 값 ·
|
||||
* 고른 선이 없으면 그리기 기본값.
|
||||
* 바꾸기 = id 그대로 사본으로 갈아 끼움(폴리선 자식까지) — 되돌리기 한 칸 · Ctrl+Z 한 번에 옛 값.
|
||||
*/
|
||||
import { type Entity, EntityName } from '../entities/Entity';
|
||||
import type { PolyLineEntity } from '../entities/PolyLineEntity';
|
||||
import {
|
||||
getActiveLineColor,
|
||||
getActiveLineDash,
|
||||
getActiveLineWidth,
|
||||
getEntities,
|
||||
getSelectedEntities,
|
||||
notifyWindow,
|
||||
setEntities,
|
||||
} from '../state';
|
||||
import { HtmlEvent } from '../App.types';
|
||||
import { cloneKeepingLook } from '../tools/move-tool.helpers';
|
||||
|
||||
export const LINE_TYPES: { value: string; label: string; dash: number[] | undefined }[] = [
|
||||
{ value: 'solid', label: '실선', dash: undefined },
|
||||
{ value: 'dashed', label: '파선', dash: [10, 5] },
|
||||
{ value: 'dashdot', label: '1점쇄선', dash: [12, 4, 2, 4] },
|
||||
{ value: 'dotted', label: '점선', dash: [2, 4] },
|
||||
];
|
||||
|
||||
export const LINE_WIDTHS = [1, 2, 3, 4, 5];
|
||||
|
||||
/** 점선 → 목록 값 · `[]` · 없음 = 실선 · 목록 밖 = 빈 값 */
|
||||
export const dashToLineType = (dash: number[] | undefined): string =>
|
||||
dash?.length
|
||||
? (LINE_TYPES.find((type) => JSON.stringify(type.dash) === JSON.stringify(dash))?.value ?? '')
|
||||
: 'solid';
|
||||
|
||||
/** 선 메뉴가 보는 것 — 글 뺌(글 색은 문자 스타일 칸) */
|
||||
export const styleTargets = (entities: Entity[]): Entity[] =>
|
||||
entities.filter((entity) => entity.getType() !== EntityName.Text);
|
||||
|
||||
/** 그림에 쓰는 잎 — 폴리선은 자식 선이 제 색 · 두께로 그림 */
|
||||
const leaves = (entity: Entity): Entity[] =>
|
||||
entity.getType() === EntityName.PolyLine ? (entity as PolyLineEntity).getEntities() : [entity];
|
||||
|
||||
/** 모두 같으면 그 값 · 다르거나 없으면 `''`(빈 칸) */
|
||||
export function commonValue(entities: Entity[], read: (entity: Entity) => string): string {
|
||||
const values = new Set(entities.flatMap(leaves).map(read));
|
||||
return values.size === 1 ? [...values][0] : '';
|
||||
}
|
||||
|
||||
export interface LineStyleView {
|
||||
color: string;
|
||||
width: string;
|
||||
lineType: string;
|
||||
}
|
||||
|
||||
/** 리본 · 특성 판이 보일 값 — 고른 선 있으면 그 값(다르면 `''`) · 없으면 그리기 기본값 */
|
||||
export function selectionLineStyle(): LineStyleView {
|
||||
const targets = styleTargets(getSelectedEntities());
|
||||
if (!targets.length) {
|
||||
return {
|
||||
color: getActiveLineColor(),
|
||||
width: String(getActiveLineWidth()),
|
||||
lineType: dashToLineType(getActiveLineDash()),
|
||||
};
|
||||
}
|
||||
const width = commonValue(targets, (entity) => String(entity.lineWidth));
|
||||
return {
|
||||
color: commonValue(targets, (entity) => entity.lineColor.toLowerCase()),
|
||||
width: LINE_WIDTHS.map(String).includes(width) ? width : '',
|
||||
lineType: commonValue(targets, (entity) => dashToLineType(entity.lineDash)),
|
||||
};
|
||||
}
|
||||
|
||||
/** 사본에 바꿈 — 폴리선은 자식까지 */
|
||||
function applyDeep(entity: Entity, change: (entity: Entity) => void): void {
|
||||
change(entity);
|
||||
if (entity.getType() === EntityName.PolyLine) {
|
||||
for (const child of (entity as PolyLineEntity).getEntities()) change(child);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 바꾼 사본(id 그대로)을 제자리에 끼움 · `track` = 되돌리기 한 칸.
|
||||
* 돌려준 사본은 이어서 칠할 수 있음(`repaint` · 색 끌기).
|
||||
*/
|
||||
export function restyle(targets: Entity[], change: (entity: Entity) => void, track = true): Entity[] {
|
||||
if (!targets.length) return [];
|
||||
const copies = targets.map((entity) => {
|
||||
const copy = cloneKeepingLook(entity, true);
|
||||
applyDeep(copy, change);
|
||||
return copy;
|
||||
});
|
||||
const byId = new Map(copies.map((copy) => [copy.id, copy]));
|
||||
setEntities(
|
||||
getEntities().map((entity) => byId.get(entity.id) ?? entity),
|
||||
track
|
||||
);
|
||||
notifyWindow(HtmlEvent.UPDATE_STATE);
|
||||
return copies;
|
||||
}
|
||||
|
||||
/** 이미 끼운 사본을 다시 칠함(화면만 · 되돌리기 없음) */
|
||||
export function repaint(copies: Entity[], change: (entity: Entity) => void): void {
|
||||
for (const copy of copies) applyDeep(copy, change);
|
||||
setEntities([...getEntities()], false);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { Tool } from '../../tools';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
import { findDuplicateEntities, reverseEntity } from './modify.helpers';
|
||||
import { restyle } from '../../helpers/selection-style';
|
||||
|
||||
export const matchPropToolStateMachine = createSequenceTool({
|
||||
tool: Tool.MATCHPROP,
|
||||
@@ -25,12 +26,12 @@ export const matchPropToolStateMachine = createSequenceTool({
|
||||
],
|
||||
commit: (input) => {
|
||||
const source = input.entity(0);
|
||||
const target = input.entity(1);
|
||||
target.lineColor = source.lineColor;
|
||||
target.lineWidth = source.lineWidth;
|
||||
target.lineDash = source.lineDash;
|
||||
target.layerId = source.layerId;
|
||||
setEntities([...getEntities()], true);
|
||||
restyle([input.entity(1)], (target) => {
|
||||
target.lineColor = source.lineColor;
|
||||
target.lineWidth = source.lineWidth;
|
||||
target.lineDash = source.lineDash ? [...source.lineDash] : undefined;
|
||||
target.layerId = source.layerId;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -67,16 +68,15 @@ export const setByLayerToolStateMachine = createSequenceTool({
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: 'ByLayer로 되돌릴 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
let applied = 0;
|
||||
for (const entity of input.entities(0)) {
|
||||
const targets = input.entities(0).filter((entity) => getLayerById(entity.layerId));
|
||||
restyle(targets, (entity) => {
|
||||
const layer = getLayerById(entity.layerId);
|
||||
if (!layer) continue;
|
||||
if (!layer) return;
|
||||
if (layer.color) entity.lineColor = layer.color;
|
||||
if (layer.lineWidth) entity.lineWidth = layer.lineWidth;
|
||||
entity.lineDash = layer.lineDash ? [...layer.lineDash] : undefined;
|
||||
applied += 1;
|
||||
}
|
||||
setEntities([...getEntities()], true);
|
||||
});
|
||||
const applied = targets.length;
|
||||
setSelectedEntityIds([]);
|
||||
toast.success(`${applied}개 객체를 레이어 특성으로 되돌렸습니다.`);
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** 도면층 명령 (조사표 3절 도면층 패널) */
|
||||
import { toast } from 'react-toastify';
|
||||
import { restyle } from '../../helpers/selection-style';
|
||||
import type { Layer } from '../../App.types';
|
||||
import { openInspector } from '../../components/ui-state';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
@@ -186,10 +187,9 @@ export const layerMatchToolStateMachine = createSequenceTool({
|
||||
],
|
||||
commit: (input) => {
|
||||
const layerId = input.entity(1).layerId;
|
||||
for (const entity of input.entities(0)) {
|
||||
restyle(input.entities(0), (entity) => {
|
||||
entity.layerId = layerId;
|
||||
}
|
||||
setEntities([...getEntities()], true);
|
||||
});
|
||||
setSelectedEntityIds([]);
|
||||
toast.success(`${input.entities(0).length}개 객체를 ${layerNameOf(layerId)}(으)로 옮겼습니다.`);
|
||||
},
|
||||
@@ -201,10 +201,9 @@ export const layerToCurrentToolStateMachine = createSequenceTool({
|
||||
steps: [{ kind: 'selection', instructions: '현재 레이어로 옮길 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
const layerId = getActiveLayerId();
|
||||
for (const entity of input.entities(0)) {
|
||||
restyle(input.entities(0), (entity) => {
|
||||
entity.layerId = layerId;
|
||||
}
|
||||
setEntities([...getEntities()], true);
|
||||
});
|
||||
setSelectedEntityIds([]);
|
||||
toast.success(`${input.entities(0).length}개 객체를 현재 레이어로 옮겼습니다.`);
|
||||
},
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
setQuickPropertiesVisible,
|
||||
isQuickPropertiesVisible,
|
||||
} from '../../components/ui-state';
|
||||
import { getEntities, setEntities, setSelectedEntityIds } from '../../state';
|
||||
import { restyle } from '../../helpers/selection-style';
|
||||
import { setSelectedEntityIds } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
|
||||
@@ -60,10 +61,9 @@ export const colorToolStateMachine = createSequenceTool({
|
||||
toast.warn('#rrggbb 형식이나 색 이름을 입력하십시오.');
|
||||
return;
|
||||
}
|
||||
for (const entity of input.entities(0)) {
|
||||
restyle(input.entities(0), (entity) => {
|
||||
entity.lineColor = color;
|
||||
}
|
||||
setEntities([...getEntities()], true);
|
||||
});
|
||||
setSelectedEntityIds([]);
|
||||
toast.success(`색상 ${color}`);
|
||||
},
|
||||
@@ -87,10 +87,9 @@ export const lineTypeToolStateMachine = createSequenceTool({
|
||||
return;
|
||||
}
|
||||
const dash = LINE_DASHES[key] ?? LINE_DASHES[key.toUpperCase()];
|
||||
for (const entity of input.entities(0)) {
|
||||
restyle(input.entities(0), (entity) => {
|
||||
entity.lineDash = dash ? [...dash] : undefined;
|
||||
}
|
||||
setEntities([...getEntities()], true);
|
||||
});
|
||||
setSelectedEntityIds([]);
|
||||
toast.success(`선종류 ${key}`);
|
||||
},
|
||||
@@ -105,10 +104,9 @@ export const lineWeightToolStateMachine = createSequenceTool({
|
||||
],
|
||||
commit: (input) => {
|
||||
const width = Math.max(1, Math.round(input.number(1)));
|
||||
for (const entity of input.entities(0)) {
|
||||
restyle(input.entities(0), (entity) => {
|
||||
entity.lineWidth = width;
|
||||
}
|
||||
setEntities([...getEntities()], true);
|
||||
});
|
||||
setSelectedEntityIds([]);
|
||||
toast.success(`선 두께 ${width}px`);
|
||||
},
|
||||
@@ -123,10 +121,9 @@ export const transparencyToolStateMachine = createSequenceTool({
|
||||
],
|
||||
commit: (input) => {
|
||||
const percent = Math.min(90, Math.max(0, input.number(1)));
|
||||
for (const entity of input.entities(0)) {
|
||||
restyle(input.entities(0), (entity) => {
|
||||
entity.opacity = 1 - percent / 100;
|
||||
}
|
||||
setEntities([...getEntities()], true);
|
||||
});
|
||||
setSelectedEntityIds([]);
|
||||
toast.success(`투명도 ${percent}%를 적용했습니다.`);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 488-2 — 선 특성 메뉴 = 고른 것의 값(다르면 빈 칸) · 바꾸면 고른 것만 · 폴리선 자식까지 · Ctrl+Z 한 번에 옛 값.
|
||||
* 짝: `src/helpers/selection-style.ts`(리본 특성 · 특성 판 · COLOR · LWEIGHT 등 명령 같이 씀).
|
||||
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad selection-style
|
||||
*/
|
||||
// 앱과 같은 flatten 사본(시험 쪽 사본 점은 앱 Segment 가 못 받음)
|
||||
import { Point } from "../../../B07_DesignDetail/openwebcad/node_modules/@flatten-js/core";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { Entity } from "../../../B07_DesignDetail/openwebcad/src/entities/Entity";
|
||||
import { LineEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/LineEntity";
|
||||
import { PolyLineEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/PolyLineEntity";
|
||||
import { TextEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/TextEntity";
|
||||
import {
|
||||
dashToLineType,
|
||||
restyle,
|
||||
selectionLineStyle,
|
||||
styleTargets,
|
||||
} from "../../../B07_DesignDetail/openwebcad/src/helpers/selection-style";
|
||||
import {
|
||||
getActiveLineWidth,
|
||||
getEntities,
|
||||
getSelectedEntities,
|
||||
resetUndoBaseline,
|
||||
setActiveLineWidth,
|
||||
setEntities,
|
||||
setLayers,
|
||||
setSelectedEntityIds,
|
||||
undo,
|
||||
} from "../../../B07_DesignDetail/openwebcad/src/state";
|
||||
|
||||
const line = (color: string, width = 1, dash?: number[]) => {
|
||||
const entity = new LineEntity("L", new Point(0, 0), new Point(10, 0));
|
||||
entity.lineColor = color;
|
||||
entity.lineWidth = width;
|
||||
entity.lineDash = dash;
|
||||
return entity;
|
||||
};
|
||||
/** 엔진 꼴 — 부모 값과 자식 선 값이 따로 */
|
||||
const poly = (childColor: string) => {
|
||||
const kids = [line(childColor), line(childColor)];
|
||||
const entity = new PolyLineEntity("L", kids);
|
||||
entity.lineColor = "#ffffff";
|
||||
return entity;
|
||||
};
|
||||
const leafWidths = (entity: Entity) =>
|
||||
entity instanceof PolyLineEntity ? entity.getEntities().map((kid) => kid.lineWidth) : [entity.lineWidth];
|
||||
const byId = (id: string) => getEntities().find((entity) => entity.id === id) as Entity;
|
||||
|
||||
/** 실은 도면 = 선 a(빨강) · 폴리선 b(자식 파랑) · 글 · 방금 그린 선 d */
|
||||
let a: LineEntity;
|
||||
let b: PolyLineEntity;
|
||||
let d: LineEntity;
|
||||
beforeEach(() => {
|
||||
setLayers([{ id: "L", name: "L", isVisible: true, isLocked: false }]);
|
||||
a = line("#ff0000");
|
||||
b = poly("#0000ff");
|
||||
const text = new TextEntity("L", "글", new Point(0, 0));
|
||||
setEntities([a, b, text], false);
|
||||
resetUndoBaseline();
|
||||
d = line("#00ff00");
|
||||
setEntities([...getEntities(), d], true); // 방금 그린 선
|
||||
setSelectedEntityIds([a.id, b.id, text.id]);
|
||||
setActiveLineWidth(1);
|
||||
});
|
||||
|
||||
describe("selectionLineStyle", () => {
|
||||
it("다른 색 = 빈 칸 · 같은 두께 · 선종류 = 그 값 · 글은 안 봄", () => {
|
||||
expect(styleTargets(getSelectedEntities())).toHaveLength(2);
|
||||
expect(selectionLineStyle()).toEqual({ color: "", width: "1", lineType: "solid" });
|
||||
});
|
||||
|
||||
it("폴리선은 자식 값을 봄 · 목록 밖 두께 · 점선 = 빈 칸", () => {
|
||||
setSelectedEntityIds([b.id]);
|
||||
expect(selectionLineStyle().color).toBe("#0000ff");
|
||||
a.lineWidth = 0.5;
|
||||
a.lineDash = [6, 4]; // 엔진 점선(목록 밖)
|
||||
setSelectedEntityIds([a.id]);
|
||||
expect(selectionLineStyle()).toEqual({ color: "#ff0000", width: "", lineType: "" });
|
||||
expect([dashToLineType([]), dashToLineType(undefined), dashToLineType([10, 5])]).toEqual([
|
||||
"solid",
|
||||
"solid",
|
||||
"dashed",
|
||||
]);
|
||||
});
|
||||
|
||||
it("고른 선 없으면 그리기 기본값", () => {
|
||||
setSelectedEntityIds([]);
|
||||
setActiveLineWidth(4);
|
||||
expect(selectionLineStyle().width).toBe("4");
|
||||
});
|
||||
});
|
||||
|
||||
describe("restyle", () => {
|
||||
it("고른 것만 · 폴리선 자식까지 · 고름 · id 그대로 · 기본값 그대로", () => {
|
||||
restyle(styleTargets(getSelectedEntities()), (entity) => {
|
||||
entity.lineWidth = 3;
|
||||
});
|
||||
expect(leafWidths(byId(a.id))).toEqual([3]);
|
||||
expect(leafWidths(byId(b.id))).toEqual([3, 3]);
|
||||
expect(byId(d.id).lineWidth).toBe(1);
|
||||
expect(getSelectedEntities()).toHaveLength(3);
|
||||
expect(selectionLineStyle().width).toBe("3");
|
||||
expect(getActiveLineWidth()).toBe(1);
|
||||
});
|
||||
|
||||
it("Ctrl+Z 한 번 = 옛 값 · 방금 그린 선 남음", () => {
|
||||
restyle(styleTargets(getSelectedEntities()), (entity) => {
|
||||
entity.lineWidth = 3;
|
||||
});
|
||||
undo();
|
||||
expect(leafWidths(byId(a.id))).toEqual([1]);
|
||||
expect(leafWidths(byId(b.id))).toEqual([1, 1]);
|
||||
expect(getEntities().map((entity) => entity.id)).toContain(d.id);
|
||||
});
|
||||
|
||||
it("고른 것 없으면 아무것도 안 바뀜 · 되돌리기 칸 안 쌓임", () => {
|
||||
expect(restyle([], () => {})).toEqual([]);
|
||||
undo();
|
||||
expect(getEntities().map((entity) => entity.id)).not.toContain(d.id); // 한 칸 전 = 그리기 전
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user