Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_7' into main_laptop_1
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
/** ARRAY 계산 · 물음(337 라 · 설계 1-3) — 직사각형 열 × 행 · 원형 항목 · 채울 각도 · 항목 회전 */
|
||||
import type { Box, Point } from '@flatten-js/core';
|
||||
import type { OptionSpec, PromptSpec } from '../commands/prompt';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
import { cloneKeepingLook } from './move-tool.helpers';
|
||||
import type { PickedContext } from './transform.helpers';
|
||||
|
||||
export interface ArrayValues extends PickedContext {
|
||||
columns: number;
|
||||
rows: number;
|
||||
columnSpacing: number;
|
||||
rowSpacing: number;
|
||||
center: Point | null;
|
||||
items: number;
|
||||
/** 채울 각도(도 · + 반시계) */
|
||||
fill: number;
|
||||
rotateItems: boolean;
|
||||
}
|
||||
|
||||
const opt = (key: string, kw: string, label: string, en = key): OptionSpec => ({
|
||||
key,
|
||||
kw,
|
||||
en,
|
||||
label,
|
||||
});
|
||||
|
||||
export const ARRAY_OPT = {
|
||||
RECTANGULAR: opt('RECTANGULAR', 'R', '직사각형'),
|
||||
POLAR: opt('POLAR', 'PO', '원형'),
|
||||
PATH: opt('PATH', 'PA', '경로'),
|
||||
COUNT: opt('COUNT', 'COU', '개수'),
|
||||
SPACING: opt('SPACING', 'S', '간격두기'),
|
||||
COLUMNS: opt('COLUMNS', 'COL', '열'),
|
||||
ROWS: opt('ROWS', 'R', '행'),
|
||||
POLAR_ROWS: opt('ROWS', 'ROW', '행'),
|
||||
ITEMS: opt('ITEMS', 'I', '항목'),
|
||||
ANGLE: opt('ANGLE', 'A', '사이의 각도'),
|
||||
FILL: opt('FILL', 'F', '채울 각도'),
|
||||
ROTATE: opt('ROTATE', 'ROT', '항목 회전'),
|
||||
EXIT: opt('EXIT', 'X', '종료'),
|
||||
ASSOCIATIVE: opt('ASSOCIATIVE', 'AS', '연관'),
|
||||
BASE: opt('BASE', 'B', '기준점'),
|
||||
LEVELS: opt('LEVELS', 'L', '수준'),
|
||||
AXIS: opt('AXIS', 'A', '회전축'),
|
||||
};
|
||||
|
||||
const O = ARRAY_OPT;
|
||||
const COUNT_RULE = { int: true, min: 1, max: 32767 } as const;
|
||||
|
||||
/** 항목 사이 각 — 채울 각이 한 바퀴면 항목 수로 나눔 · 아니면 사이 수로 */
|
||||
export const angleBetween = (values: Pick<ArrayValues, 'items' | 'fill'>): number => {
|
||||
if (Math.abs(values.fill) >= 360) return values.fill / values.items;
|
||||
return values.items > 1 ? values.fill / (values.items - 1) : 0;
|
||||
};
|
||||
|
||||
const value = (text: string, remember: string, initial: number, format?: 'int' | 'ang') => ({
|
||||
text,
|
||||
default: { kind: 'value' as const, remember, initial, ...(format ? { format } : {}) },
|
||||
});
|
||||
|
||||
/** 물음 — 함수 꼴은 지금 값(빈 context 면 처음 값) */
|
||||
export const ARRAY_PROMPT = {
|
||||
type: (fallback: string): PromptSpec => ({
|
||||
text: '배열 유형 입력',
|
||||
options: [O.RECTANGULAR, O.POLAR],
|
||||
absent: [O.PATH],
|
||||
default: { kind: 'option', key: fallback },
|
||||
}),
|
||||
center: { text: '배열의 중심점 지정', absent: [O.BASE, O.AXIS] } satisfies PromptSpec,
|
||||
editRect: {
|
||||
text: '그립을 선택하여 배열을 편집하거나',
|
||||
options: [O.COUNT, O.SPACING, O.COLUMNS, O.ROWS, O.EXIT],
|
||||
absent: [O.ASSOCIATIVE, O.BASE, O.LEVELS],
|
||||
default: { kind: 'option', key: O.EXIT.key },
|
||||
} satisfies PromptSpec,
|
||||
editPolar: {
|
||||
text: '그립을 선택하여 배열을 편집하거나',
|
||||
options: [O.ITEMS, O.ANGLE, O.FILL, O.ROTATE, O.EXIT],
|
||||
absent: [O.ASSOCIATIVE, O.BASE, O.POLAR_ROWS, O.LEVELS],
|
||||
default: { kind: 'option', key: O.EXIT.key },
|
||||
} satisfies PromptSpec,
|
||||
columns: (v: Partial<ArrayValues>): PromptSpec => ({
|
||||
...value('열 수 입력', 'ARRAY.COLS', v.columns ?? 4, 'int'),
|
||||
number: COUNT_RULE,
|
||||
}),
|
||||
rows: (v: Partial<ArrayValues>): PromptSpec => ({
|
||||
...value('행 수 입력', 'ARRAY.ROWS', v.rows ?? 3, 'int'),
|
||||
number: COUNT_RULE,
|
||||
}),
|
||||
columnSpacing: (v: Partial<ArrayValues>): PromptSpec =>
|
||||
value('열 사이의 거리 지정', 'ARRAY.COLDIST', v.columnSpacing ?? 1),
|
||||
rowSpacing: (v: Partial<ArrayValues>): PromptSpec =>
|
||||
value('행 사이의 거리 지정', 'ARRAY.ROWDIST', v.rowSpacing ?? 1),
|
||||
items: (v: Partial<ArrayValues>): PromptSpec => ({
|
||||
...value('배열의 항목 수 입력', 'ARRAY.ITEMS', v.items ?? 6, 'int'),
|
||||
number: COUNT_RULE,
|
||||
}),
|
||||
angle: (v: Partial<ArrayValues>): PromptSpec =>
|
||||
value(
|
||||
'항목 사이의 각도 지정',
|
||||
'ARRAY.ANGLE',
|
||||
angleBetween({ items: v.items ?? 6, fill: v.fill ?? 360 }),
|
||||
'ang'
|
||||
),
|
||||
fill: (v: Partial<ArrayValues>): PromptSpec =>
|
||||
value('채울 각도 지정(+=ccw, -=cw)', 'ARRAY.FILL', v.fill ?? 360, 'ang'),
|
||||
rotateItems: (v: Partial<ArrayValues>): PromptSpec => ({
|
||||
text: '배열된 항목을 회전하시겠습니까?',
|
||||
options: [opt('YES', 'Y', '예'), opt('NO', 'N', '아니오')],
|
||||
default: { kind: 'option', key: v.rotateItems === false ? 'NO' : 'YES' },
|
||||
}),
|
||||
};
|
||||
|
||||
/** 고른 것 범위 — 처음 간격 = 폭 · 높이의 1.5 배(0 이면 다른 쪽 · 1) */
|
||||
export function initialSpacing(originals: Entity[]): { column: number; row: number } {
|
||||
const box = originals.reduce<Box | null>((all, entity) => {
|
||||
const next = entity.getBoundingBox();
|
||||
return all ? all.merge(next) : next;
|
||||
}, null);
|
||||
const width = box ? box.xmax - box.xmin : 0;
|
||||
const height = box ? box.ymax - box.ymin : 0;
|
||||
const fallback = Math.max(width, height) || 1;
|
||||
return { column: 1.5 * (width || fallback), row: 1.5 * (height || fallback) };
|
||||
}
|
||||
|
||||
/** 고른 것 범위 가운데 — 항목 회전 끔일 때 옮김 기준 */
|
||||
function selectionCenter(originals: Entity[]): Point | null {
|
||||
const boxes = originals.map((entity) => entity.getBoundingBox());
|
||||
if (!boxes.length) return null;
|
||||
return boxes.reduce((all, next) => all.merge(next)).center;
|
||||
}
|
||||
|
||||
/** 직사각형 — (0,0) 원본 빼고 열 × 행 사본 */
|
||||
export function rectangularCopies(values: ArrayValues): Entity[] {
|
||||
const copies: Entity[] = [];
|
||||
for (let row = 0; row < values.rows; row++) {
|
||||
for (let column = 0; column < values.columns; column++) {
|
||||
if (!row && !column) continue;
|
||||
for (const entity of values.originals) {
|
||||
const copy = cloneKeepingLook(entity);
|
||||
copy.move(column * values.columnSpacing, row * values.rowSpacing);
|
||||
copies.push(copy);
|
||||
}
|
||||
}
|
||||
}
|
||||
return copies;
|
||||
}
|
||||
|
||||
/** 원형 — 중심 둘레 항목 수 − 1 사본 · 항목 회전 끔이면 돌지 않고 자리만 */
|
||||
export function polarCopies(values: ArrayValues, center = values.center): Entity[] {
|
||||
if (!center) return [];
|
||||
const step = (angleBetween(values) * Math.PI) / 180;
|
||||
const anchor = selectionCenter(values.originals);
|
||||
const copies: Entity[] = [];
|
||||
for (let index = 1; index < values.items; index++) {
|
||||
const radians = step * index;
|
||||
for (const entity of values.originals) {
|
||||
const copy = cloneKeepingLook(entity);
|
||||
if (values.rotateItems || !anchor) {
|
||||
copy.rotate(center, radians);
|
||||
} else {
|
||||
const moved = anchor.rotate(radians, center);
|
||||
copy.move(moved.x - anchor.x, moved.y - anchor.y);
|
||||
}
|
||||
copies.push(copy);
|
||||
}
|
||||
}
|
||||
return copies;
|
||||
}
|
||||
@@ -1,369 +1,293 @@
|
||||
import { type Point, Vector } from '@flatten-js/core';
|
||||
import { assign, createMachine, sendTo } from 'xstate';
|
||||
import { GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH, TO_RADIANS } from '../App.consts.ts';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
import { LineEntity } from '../entities/LineEntity.ts';
|
||||
import { getPointFromEvent } from '../helpers/get-point-from-event.ts';
|
||||
import {
|
||||
addEntities,
|
||||
getActiveLayerId,
|
||||
getSelectedEntities,
|
||||
getSelectedEntityIds,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
} from '../state';
|
||||
/**
|
||||
* ARRAY(337 라 · 설계 1-3) — 「객체 선택:」 → 「배열 유형 입력 [직사각형(R)/원형(PO)] <직사각형>:」(ARRAYTYPE 기억) →
|
||||
* 직사각형: 미리보기 4열 × 3행 → 「그립을 선택하여 배열을 편집하거나 [개수(COU)/간격두기(S)/열(COL)/행(R)/종료(X)] <종료>:」 되풀이 ·
|
||||
* 원형: 「배열의 중심점 지정:」 → 미리보기 6개 360° → 「… [항목(I)/사이의 각도(A)/채울 각도(F)/항목 회전(ROT)/종료(X)] <종료>:」 되풀이.
|
||||
* 종료(X) · 빈 Enter = 만듦 · Esc = 안 만듦 · 처음 간격 = 고른 것 범위 폭 · 높이의 1.5 배.
|
||||
*/
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import { assign, createMachine } from 'xstate';
|
||||
import { recall, remember } from '../commands/prompt';
|
||||
import { getPointFromEvent } from '../helpers/get-point-from-event';
|
||||
import { addEntities, setGhostHelperEntities, setShouldDrawHelpers } 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,
|
||||
DrawEvent,
|
||||
MouseClickEvent,
|
||||
NumberInputEvent,
|
||||
StateEvent,
|
||||
TextInputEvent,
|
||||
ToolContext,
|
||||
import {
|
||||
ARRAY_OPT,
|
||||
ARRAY_PROMPT,
|
||||
type ArrayValues,
|
||||
angleBetween,
|
||||
initialSpacing,
|
||||
polarCopies,
|
||||
rectangularCopies,
|
||||
} from './array-tool.helpers';
|
||||
import {
|
||||
type DrawEvent,
|
||||
END_STATE,
|
||||
type NumberInputEvent,
|
||||
type PointInputEvent,
|
||||
type StateEvent,
|
||||
type ToolContext,
|
||||
} from './tool.types';
|
||||
import { endState, selectionState, startState } from './transform.helpers';
|
||||
|
||||
export enum ArrayState {
|
||||
INIT = 'INIT',
|
||||
WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION',
|
||||
CHECK_SELECTION = 'CHECK_SELECTION',
|
||||
ASK_COPY_MODE = 'ASK_COPY_MODE',
|
||||
ASK_NUMBER_OF_COPIES = 'ASK_NUMBER_OF_COPIES',
|
||||
LINEAR_WAITING_FOR_START_POINT = 'LINEAR_WAITING_FOR_START_POINT',
|
||||
LINEAR_WAITING_FOR_END_POINT = 'LINEAR_WAITING_FOR_END_POINT',
|
||||
RADIAL_WAITING_FOR_PIVOT_POINT = 'RADIAL_WAITING_FOR_PIVOT_POINT',
|
||||
RADIAL_WAITING_FOR_ANGLE = 'RADIAL_WAITING_FOR_ANGLE',
|
||||
EXECUTE_COPY = 'EXECUTE_COPY',
|
||||
WAITING_FOR_TYPE = 'WAITING_FOR_TYPE',
|
||||
WAITING_FOR_CENTER = 'WAITING_FOR_CENTER',
|
||||
EDIT_RECTANGULAR = 'EDIT_RECTANGULAR',
|
||||
EDIT_POLAR = 'EDIT_POLAR',
|
||||
WAITING_FOR_COLUMNS = 'WAITING_FOR_COLUMNS',
|
||||
WAITING_FOR_ROWS = 'WAITING_FOR_ROWS',
|
||||
WAITING_FOR_COLUMN_SPACING = 'WAITING_FOR_COLUMN_SPACING',
|
||||
WAITING_FOR_ROW_SPACING = 'WAITING_FOR_ROW_SPACING',
|
||||
WAITING_FOR_ITEMS = 'WAITING_FOR_ITEMS',
|
||||
WAITING_FOR_ANGLE = 'WAITING_FOR_ANGLE',
|
||||
WAITING_FOR_FILL = 'WAITING_FOR_FILL',
|
||||
WAITING_FOR_ROTATE_ITEMS = 'WAITING_FOR_ROTATE_ITEMS',
|
||||
}
|
||||
|
||||
export enum ArrayAction {
|
||||
INIT_ARRAY_TOOL = 'INIT_ARRAY_TOOL',
|
||||
PERFORM_COPY = 'PERFORM_COPY',
|
||||
DESELECT_ENTITIES = 'DESELECT_ENTITIES',
|
||||
DRAW_TEMP_DISTANCE_LINE = 'DRAW_TEMP_DISTANCE_LINE',
|
||||
/** 편집 옵션이 이어 묻는 차례 — 개수 = 열 → 행 · 간격두기 = 열 거리 → 행 거리 · 열 = 수 → 거리 · 행 = 수 → 거리 */
|
||||
type Flow = 'COUNT' | 'SPACING' | 'COLUMNS' | 'ROWS' | null;
|
||||
|
||||
export interface ArrayContext extends ToolContext, ArrayValues {
|
||||
flow: Flow;
|
||||
}
|
||||
|
||||
export interface ArrayContext extends ToolContext {
|
||||
copyMode: CopyMode | null;
|
||||
startDistanceVector: Point | null;
|
||||
endDistanceVector: Point | null;
|
||||
pivotPoint: Point | null;
|
||||
angleStep: number | null;
|
||||
numberOfCopies: number;
|
||||
}
|
||||
type Ctx = { context: ArrayContext; event: StateEvent };
|
||||
|
||||
enum CopyMode {
|
||||
LINEAR = 'LINEAR',
|
||||
RADIAL = 'RADIAL',
|
||||
}
|
||||
const S = ArrayState;
|
||||
const O = ARRAY_OPT;
|
||||
const esc = { ESC: { target: END_STATE } };
|
||||
const numberOf = (event: StateEvent) => (event as NumberInputEvent).value;
|
||||
|
||||
const initialArrayContext: ArrayContext = {
|
||||
copyMode: null,
|
||||
startDistanceVector: null,
|
||||
endDistanceVector: null,
|
||||
pivotPoint: null,
|
||||
angleStep: null,
|
||||
numberOfCopies: 0,
|
||||
type: Tool.ARRAY,
|
||||
/** 물음 기본값 = 지금 값(빈 Enter 가 지금 값을 다시 보냄) */
|
||||
const syncRect = (values: ArrayValues) => {
|
||||
remember('ARRAY.COLS', values.columns);
|
||||
remember('ARRAY.ROWS', values.rows);
|
||||
remember('ARRAY.COLDIST', values.columnSpacing);
|
||||
remember('ARRAY.ROWDIST', values.rowSpacing);
|
||||
};
|
||||
const syncPolar = (values: ArrayValues) => {
|
||||
remember('ARRAY.ITEMS', values.items);
|
||||
remember('ARRAY.FILL', values.fill);
|
||||
remember('ARRAY.ANGLE', angleBetween(values));
|
||||
};
|
||||
|
||||
export const arrayToolStateMachine = createMachine(
|
||||
{
|
||||
types: {} as {
|
||||
context: ArrayContext;
|
||||
events: StateEvent;
|
||||
const showRect = ({ context }: Ctx) => {
|
||||
syncRect(context);
|
||||
setGhostHelperEntities(rectangularCopies(context));
|
||||
};
|
||||
const showPolar = ({ context }: Ctx) => {
|
||||
syncPolar(context);
|
||||
setGhostHelperEntities(polarCopies(context));
|
||||
};
|
||||
const create = (copies: (values: ArrayValues) => unknown[]) => ({
|
||||
actions: ({ context }: Ctx) => {
|
||||
const made = copies(context) as Parameters<typeof addEntities>[0];
|
||||
if (made.length) addEntities(made, true);
|
||||
},
|
||||
target: END_STATE,
|
||||
});
|
||||
|
||||
/** 숫자 물음 — 받아서 assign · 흐름 따라 다음 */
|
||||
const numberStep = (
|
||||
prompt: unknown,
|
||||
take: (value: number, context: ArrayContext) => Partial<ArrayContext> | null,
|
||||
next: { flow?: Flow; target: string }[]
|
||||
) => ({
|
||||
meta: { kind: 'number', prompt },
|
||||
on: {
|
||||
NUMBER_INPUT: [
|
||||
...next.map(({ flow, target }) => ({
|
||||
guard: ({ context, event }: Ctx) =>
|
||||
!!take(numberOf(event), context) && (flow === undefined || context.flow === flow),
|
||||
actions: assign(({ context, event }: Ctx) => take(numberOf(event), context) ?? {}),
|
||||
target,
|
||||
})),
|
||||
{ actions: () => noticeLater('값이 범위를 벗어남.') },
|
||||
],
|
||||
...esc,
|
||||
},
|
||||
});
|
||||
|
||||
const noticeLater = (text: string) => {
|
||||
void import('../inputController/prompt-input').then(({ notice }) => notice(text));
|
||||
};
|
||||
|
||||
const fillOk = (fill: number) => fill !== 0 && Math.abs(fill) <= 360;
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 공용 상태 조각(여러 도구 공통)을 섞어 만든다
|
||||
const states: Record<string, any> = {
|
||||
[S.INIT]: startState(S.WAITING_FOR_TYPE),
|
||||
[S.WAITING_FOR_SELECTION]: selectionState(S.WAITING_FOR_TYPE),
|
||||
[S.WAITING_FOR_TYPE]: {
|
||||
meta: {
|
||||
kind: 'keyword',
|
||||
prompt: () => ARRAY_PROMPT.type(recall<string>('ARRAYTYPE') ?? O.RECTANGULAR.key),
|
||||
},
|
||||
context: initialArrayContext,
|
||||
initial: ArrayState.INIT,
|
||||
states: {
|
||||
[ArrayState.INIT]: {
|
||||
description: 'Initializing the array copy tool',
|
||||
always: {
|
||||
actions: ArrayState.INIT,
|
||||
target: ArrayState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
[ArrayState.WAITING_FOR_SELECTION]: {
|
||||
description: 'Select what you want to copy',
|
||||
meta: {
|
||||
instructions: '배열할 객체 선택 뒤 Enter',
|
||||
},
|
||||
invoke: {
|
||||
id: 'selectToolInsideTheCopyTool',
|
||||
src: selectToolStateMachine,
|
||||
onDone: {
|
||||
actions: assign(() => {
|
||||
return initialArrayContext;
|
||||
}),
|
||||
target: ArrayState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
ESC: {
|
||||
actions: [ArrayAction.DESELECT_ENTITIES, ArrayAction.INIT_ARRAY_TOOL],
|
||||
},
|
||||
ENTER: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
DRAW: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
[ArrayState.CHECK_SELECTION]: {
|
||||
description: 'Check if there is something selected',
|
||||
always: [
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length > 0;
|
||||
},
|
||||
target: ArrayState.ASK_COPY_MODE,
|
||||
},
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length === 0;
|
||||
},
|
||||
target: ArrayState.WAITING_FOR_SELECTION,
|
||||
},
|
||||
on: {
|
||||
'OPTION.RECTANGULAR': {
|
||||
actions: [
|
||||
() => remember('ARRAYTYPE', O.RECTANGULAR.key),
|
||||
assign(({ context }: Ctx) => {
|
||||
const spacing = initialSpacing(context.originals);
|
||||
return {
|
||||
columns: recall<number>('ARRAY.COLS') ?? 4,
|
||||
rows: recall<number>('ARRAY.ROWS') ?? 3,
|
||||
columnSpacing: spacing.column,
|
||||
rowSpacing: spacing.row,
|
||||
};
|
||||
}),
|
||||
],
|
||||
target: S.EDIT_RECTANGULAR,
|
||||
},
|
||||
[ArrayState.ASK_COPY_MODE]: {
|
||||
meta: { instructions: '배열 방식 [직선(L)/원형(R)] <직선>:' },
|
||||
on: {
|
||||
TEXT_INPUT: {
|
||||
actions: assign(({ event }) => {
|
||||
return {
|
||||
copyMode:
|
||||
(event as TextInputEvent).value === 'R' ? CopyMode.RADIAL : CopyMode.LINEAR,
|
||||
};
|
||||
}),
|
||||
target: ArrayState.ASK_NUMBER_OF_COPIES,
|
||||
},
|
||||
ENTER: {
|
||||
actions: assign(() => ({
|
||||
copyMode: CopyMode.LINEAR,
|
||||
})),
|
||||
target: ArrayState.ASK_NUMBER_OF_COPIES,
|
||||
},
|
||||
ESC: {
|
||||
actions: ArrayAction.INIT_ARRAY_TOOL,
|
||||
},
|
||||
},
|
||||
},
|
||||
[ArrayState.ASK_NUMBER_OF_COPIES]: {
|
||||
meta: { instructions: '복사 개수 입력:' },
|
||||
on: {
|
||||
NUMBER_INPUT: [
|
||||
{
|
||||
guard: ({ context }) => {
|
||||
return context.copyMode === CopyMode.LINEAR;
|
||||
},
|
||||
actions: assign(({ event }) => ({
|
||||
numberOfCopies: (event as NumberInputEvent).value,
|
||||
})),
|
||||
target: ArrayState.LINEAR_WAITING_FOR_START_POINT,
|
||||
},
|
||||
{
|
||||
guard: ({ context }) => {
|
||||
return context.copyMode === CopyMode.RADIAL;
|
||||
},
|
||||
actions: assign(({ event }) => ({
|
||||
numberOfCopies: (event as NumberInputEvent).value,
|
||||
})),
|
||||
target: ArrayState.RADIAL_WAITING_FOR_PIVOT_POINT,
|
||||
},
|
||||
],
|
||||
ESC: {
|
||||
actions: ArrayAction.INIT_ARRAY_TOOL,
|
||||
},
|
||||
},
|
||||
},
|
||||
[ArrayState.LINEAR_WAITING_FOR_START_POINT]: {
|
||||
meta: { instructions: '간격 시작점 지정:' },
|
||||
on: {
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: assign(({ event }) => ({
|
||||
startDistanceVector: (event as AbsolutePointInputEvent).value,
|
||||
})),
|
||||
target: ArrayState.EXECUTE_COPY,
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: assign(({ event }) => ({
|
||||
startDistanceVector: (event as MouseClickEvent).worldMouseLocation,
|
||||
})),
|
||||
target: ArrayState.LINEAR_WAITING_FOR_END_POINT,
|
||||
},
|
||||
ESC: {
|
||||
actions: ArrayAction.INIT_ARRAY_TOOL,
|
||||
},
|
||||
},
|
||||
},
|
||||
[ArrayState.LINEAR_WAITING_FOR_END_POINT]: {
|
||||
meta: { instructions: '간격 끝점 지정:' },
|
||||
on: {
|
||||
RELATIVE_POINT_INPUT: {
|
||||
actions: assign(({ event, context }) => ({
|
||||
endDistanceVector: getPointFromEvent(context.startDistanceVector, event),
|
||||
})),
|
||||
target: ArrayState.EXECUTE_COPY,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: assign(({ event, context }) => ({
|
||||
endDistanceVector: getPointFromEvent(context.startDistanceVector, event),
|
||||
})),
|
||||
target: ArrayState.EXECUTE_COPY,
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: assign(({ event, context }) => ({
|
||||
endDistanceVector: getPointFromEvent(context.startDistanceVector, event),
|
||||
})),
|
||||
target: ArrayState.EXECUTE_COPY,
|
||||
},
|
||||
ESC: {
|
||||
actions: ArrayAction.INIT_ARRAY_TOOL,
|
||||
},
|
||||
DRAW: {
|
||||
actions: ArrayAction.DRAW_TEMP_DISTANCE_LINE,
|
||||
},
|
||||
},
|
||||
},
|
||||
[ArrayState.RADIAL_WAITING_FOR_PIVOT_POINT]: {
|
||||
meta: { instructions: '회전 중심점 지정:' },
|
||||
on: {
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: assign(({ event }) => ({
|
||||
pivotPoint: (event as AbsolutePointInputEvent).value,
|
||||
})),
|
||||
target: ArrayState.RADIAL_WAITING_FOR_ANGLE,
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: assign(({ event }) => ({
|
||||
pivotPoint: (event as MouseClickEvent).worldMouseLocation,
|
||||
})),
|
||||
target: ArrayState.RADIAL_WAITING_FOR_ANGLE,
|
||||
},
|
||||
ESC: {
|
||||
actions: ArrayAction.INIT_ARRAY_TOOL,
|
||||
},
|
||||
},
|
||||
},
|
||||
[ArrayState.RADIAL_WAITING_FOR_ANGLE]: {
|
||||
meta: { instructions: '한 칸 각도(도) 입력:' },
|
||||
on: {
|
||||
NUMBER_INPUT: {
|
||||
target: ArrayState.EXECUTE_COPY,
|
||||
actions: assign(({ event }) => ({
|
||||
angleStep: (event as NumberInputEvent).value,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
[ArrayState.EXECUTE_COPY]: {
|
||||
entry: ArrayAction.PERFORM_COPY,
|
||||
always: ArrayState.INIT,
|
||||
'OPTION.POLAR': {
|
||||
actions: [
|
||||
() => remember('ARRAYTYPE', O.POLAR.key),
|
||||
assign(() => ({
|
||||
items: recall<number>('ARRAY.ITEMS') ?? 6,
|
||||
fill: recall<number>('ARRAY.FILL') ?? 360,
|
||||
rotateItems: true,
|
||||
})),
|
||||
],
|
||||
target: S.WAITING_FOR_CENTER,
|
||||
},
|
||||
...esc,
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
[ArrayAction.INIT_ARRAY_TOOL]: assign(() => {
|
||||
setShouldDrawHelpers(true);
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
return {
|
||||
copyMode: null,
|
||||
distanceVector: null,
|
||||
pivotPoint: null,
|
||||
angleStep: null,
|
||||
numberOfCopies: 0,
|
||||
selectedEntities: [],
|
||||
};
|
||||
}),
|
||||
|
||||
[ArrayAction.DRAW_TEMP_DISTANCE_LINE]: ({ context, event }) => {
|
||||
if (!context.startDistanceVector) {
|
||||
throw new Error('[ARRAY] Calling draw temp distance line without a start point');
|
||||
}
|
||||
|
||||
const endPointTemp = (event as DrawEvent).drawController.getWorldMouseLocation();
|
||||
|
||||
// Draw the array entities to show the result
|
||||
// Draw all selected entities according to distance vector and number of copies, so the user gets visual feedback of where the entities will be copied;
|
||||
// TODO
|
||||
|
||||
// // Draw a dashed line between the start move point and the current mouse location
|
||||
const activeDistanceLine = new LineEntity(
|
||||
getActiveLayerId(),
|
||||
context.startDistanceVector,
|
||||
endPointTemp
|
||||
);
|
||||
activeDistanceLine.lineColor = GUIDE_LINE_COLOR;
|
||||
activeDistanceLine.lineWidth = GUIDE_LINE_WIDTH;
|
||||
activeDistanceLine.lineDash = GUIDE_LINE_STYLE;
|
||||
setGhostHelperEntities([activeDistanceLine]);
|
||||
[S.WAITING_FOR_CENTER]: {
|
||||
meta: { kind: 'point', prompt: ARRAY_PROMPT.center },
|
||||
entry: () => setShouldDrawHelpers(true),
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: ({ context, event }: Ctx) =>
|
||||
setGhostHelperEntities(polarCopies(context, getPointFromEvent(null, event as DrawEvent))),
|
||||
},
|
||||
|
||||
[ArrayAction.PERFORM_COPY]: ({ context }) => {
|
||||
const resultEntities: Entity[] = [];
|
||||
|
||||
for (let i = 1; i <= context.numberOfCopies; i++) {
|
||||
if (
|
||||
context.copyMode === 'LINEAR' &&
|
||||
context.startDistanceVector &&
|
||||
context.endDistanceVector
|
||||
) {
|
||||
const distanceVector = new Vector(
|
||||
context.endDistanceVector.x - context.startDistanceVector.x,
|
||||
context.endDistanceVector.y - context.startDistanceVector.y
|
||||
);
|
||||
const x = distanceVector.x * i;
|
||||
const y = distanceVector.y * i;
|
||||
resultEntities.push(
|
||||
...getSelectedEntities().map((entity) => {
|
||||
const clone = cloneKeepingLook(entity);
|
||||
clone.move(x, y);
|
||||
return clone;
|
||||
})
|
||||
);
|
||||
} else if (context.copyMode === 'RADIAL' && context.pivotPoint && context.angleStep) {
|
||||
const angleRad = context.angleStep * i * TO_RADIANS;
|
||||
resultEntities.push(
|
||||
...getSelectedEntities().map((entity) => {
|
||||
const clone = cloneKeepingLook(entity);
|
||||
clone.rotate(context.pivotPoint as Point, angleRad);
|
||||
return clone;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
addEntities(resultEntities, true);
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
[CopyAction.DESELECT_ENTITIES]: assign(() => {
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
return initialArrayContext;
|
||||
}),
|
||||
...Object.fromEntries(
|
||||
['MOUSE_CLICK', 'ABSOLUTE_POINT_INPUT'].map((type) => [
|
||||
type,
|
||||
{
|
||||
actions: assign(({ event }: Ctx) => ({
|
||||
center: getPointFromEvent(null, event as PointInputEvent) as Point,
|
||||
})),
|
||||
target: S.EDIT_POLAR,
|
||||
},
|
||||
])
|
||||
),
|
||||
...esc,
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
[S.EDIT_RECTANGULAR]: {
|
||||
meta: { kind: 'keyword', prompt: ARRAY_PROMPT.editRect },
|
||||
entry: [() => setShouldDrawHelpers(false), assign({ flow: null }), showRect],
|
||||
on: {
|
||||
'OPTION.COUNT': {
|
||||
actions: assign({ flow: 'COUNT' as Flow }),
|
||||
target: S.WAITING_FOR_COLUMNS,
|
||||
},
|
||||
'OPTION.SPACING': {
|
||||
actions: assign({ flow: 'SPACING' as Flow }),
|
||||
target: S.WAITING_FOR_COLUMN_SPACING,
|
||||
},
|
||||
'OPTION.COLUMNS': {
|
||||
actions: assign({ flow: 'COLUMNS' as Flow }),
|
||||
target: S.WAITING_FOR_COLUMNS,
|
||||
},
|
||||
'OPTION.ROWS': { actions: assign({ flow: 'ROWS' as Flow }), target: S.WAITING_FOR_ROWS },
|
||||
'OPTION.EXIT': create(rectangularCopies),
|
||||
...esc,
|
||||
},
|
||||
},
|
||||
[S.WAITING_FOR_COLUMNS]: numberStep(
|
||||
ARRAY_PROMPT.columns,
|
||||
(columns) => {
|
||||
remember('ARRAY.COLS', columns);
|
||||
return { columns };
|
||||
},
|
||||
[{ flow: 'COUNT', target: S.WAITING_FOR_ROWS }, { target: S.WAITING_FOR_COLUMN_SPACING }]
|
||||
),
|
||||
[S.WAITING_FOR_ROWS]: numberStep(
|
||||
ARRAY_PROMPT.rows,
|
||||
(rows) => {
|
||||
remember('ARRAY.ROWS', rows);
|
||||
return { rows };
|
||||
},
|
||||
[{ flow: 'ROWS', target: S.WAITING_FOR_ROW_SPACING }, { target: S.EDIT_RECTANGULAR }]
|
||||
),
|
||||
[S.WAITING_FOR_COLUMN_SPACING]: numberStep(
|
||||
ARRAY_PROMPT.columnSpacing,
|
||||
(columnSpacing) => ({ columnSpacing }),
|
||||
[{ flow: 'SPACING', target: S.WAITING_FOR_ROW_SPACING }, { target: S.EDIT_RECTANGULAR }]
|
||||
),
|
||||
[S.WAITING_FOR_ROW_SPACING]: numberStep(
|
||||
ARRAY_PROMPT.rowSpacing,
|
||||
(rowSpacing) => ({ rowSpacing }),
|
||||
[{ target: S.EDIT_RECTANGULAR }]
|
||||
),
|
||||
[S.EDIT_POLAR]: {
|
||||
meta: { kind: 'keyword', prompt: ARRAY_PROMPT.editPolar },
|
||||
entry: [() => setShouldDrawHelpers(false), showPolar],
|
||||
on: {
|
||||
'OPTION.ITEMS': { target: S.WAITING_FOR_ITEMS },
|
||||
'OPTION.ANGLE': { target: S.WAITING_FOR_ANGLE },
|
||||
'OPTION.FILL': { target: S.WAITING_FOR_FILL },
|
||||
'OPTION.ROTATE': { target: S.WAITING_FOR_ROTATE_ITEMS },
|
||||
'OPTION.EXIT': create(polarCopies),
|
||||
...esc,
|
||||
},
|
||||
},
|
||||
[S.WAITING_FOR_ITEMS]: numberStep(
|
||||
ARRAY_PROMPT.items,
|
||||
(items) => {
|
||||
remember('ARRAY.ITEMS', items);
|
||||
return { items };
|
||||
},
|
||||
[{ target: S.EDIT_POLAR }]
|
||||
),
|
||||
// 사이 각 → 채울 각 = 사이 각 × (항목 − 1)(같은 자리)
|
||||
[S.WAITING_FOR_ANGLE]: numberStep(
|
||||
ARRAY_PROMPT.angle,
|
||||
(angle, context) => {
|
||||
const fill = angle * Math.max(context.items - 1, 1);
|
||||
return fillOk(fill) ? { fill } : null;
|
||||
},
|
||||
[{ target: S.EDIT_POLAR }]
|
||||
),
|
||||
[S.WAITING_FOR_FILL]: numberStep(
|
||||
ARRAY_PROMPT.fill,
|
||||
(fill) => {
|
||||
if (!fillOk(fill)) return null;
|
||||
remember('ARRAY.FILL', fill);
|
||||
return { fill };
|
||||
},
|
||||
[{ target: S.EDIT_POLAR }]
|
||||
),
|
||||
[S.WAITING_FOR_ROTATE_ITEMS]: {
|
||||
meta: { kind: 'keyword', prompt: ARRAY_PROMPT.rotateItems },
|
||||
on: {
|
||||
'OPTION.YES': { actions: assign({ rotateItems: true }), target: S.EDIT_POLAR },
|
||||
'OPTION.NO': { actions: assign({ rotateItems: false }), target: S.EDIT_POLAR },
|
||||
...esc,
|
||||
},
|
||||
},
|
||||
[END_STATE]: endState,
|
||||
};
|
||||
|
||||
export const arrayToolStateMachine = createMachine({
|
||||
types: {} as { context: ArrayContext; events: StateEvent },
|
||||
context: {
|
||||
originals: [],
|
||||
columns: 4,
|
||||
rows: 3,
|
||||
columnSpacing: 1,
|
||||
rowSpacing: 1,
|
||||
center: null,
|
||||
items: 6,
|
||||
fill: 360,
|
||||
rotateItems: true,
|
||||
flow: null,
|
||||
type: Tool.ARRAY,
|
||||
},
|
||||
initial: S.INIT,
|
||||
states,
|
||||
});
|
||||
|
||||
@@ -1,304 +1,321 @@
|
||||
import type { Point } from '@flatten-js/core';
|
||||
/**
|
||||
* COPY(337 라 · 설계 1-3) — 「객체 선택:」 → 「기준점 지정 또는 [변위(D)/모드(O)/다중(M)] <변위>:」 →
|
||||
* 「두 번째 점 지정 또는 [배열(A)] <첫 번째 점을 변위로 사용>:」 → 첫 사본 뒤
|
||||
* 「두 번째 점 지정 또는 [배열(A)/종료(E)/명령 취소(U)] <종료>:」 되풀이(빈 Enter · Esc = 끝 · 만든 사본 남음).
|
||||
* O → 「복사 모드 옵션 입력 [단일(S)/다중(M)] <다중>:」(COPYMODE 기억) · M = 이번만 되풀이(단일 때만 보임) ·
|
||||
* A → 「배열할 항목 수 입력:」 → 「두 번째 점 지정 또는 [맞춤(F)]:」 · U = 마지막 사본 되돌림.
|
||||
*/
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { assign, createMachine } from 'xstate';
|
||||
import { OPT, type OptionSpec, type PromptSpec, recall, remember } from '../commands/prompt';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
import { getPointFromEvent } from '../helpers/get-point-from-event';
|
||||
import {
|
||||
addEntities,
|
||||
getActiveLayerId,
|
||||
getSelectedEntities,
|
||||
getSelectedEntityIds,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
undo,
|
||||
} from '../state';
|
||||
import { Tool } from '../tools';
|
||||
import type { DrawEvent, PointInputEvent, StateEvent, ToolContext } from './tool.types';
|
||||
import { getPointFromEvent } from '../helpers/get-point-from-event';
|
||||
import { assign, createMachine, sendTo } from 'xstate';
|
||||
import { selectToolStateMachine } from './select-tool';
|
||||
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 { cloneKeepingLook, moveEntities } from './move-tool.helpers';
|
||||
|
||||
export interface CopyContext extends ToolContext {
|
||||
startPoint: Point | null;
|
||||
originalSelectedEntities: Entity[];
|
||||
copiedEntities: Entity[];
|
||||
lastDrawLocation: Point | null;
|
||||
}
|
||||
import { cloneKeepingLook } from './move-tool.helpers';
|
||||
import {
|
||||
type DrawEvent,
|
||||
END_STATE,
|
||||
type NumberInputEvent,
|
||||
type PointInputEvent,
|
||||
type StateEvent,
|
||||
type ToolContext,
|
||||
} from './tool.types';
|
||||
import {
|
||||
type PickedContext,
|
||||
endState,
|
||||
guideLine,
|
||||
selectionState,
|
||||
startState,
|
||||
} from './transform.helpers';
|
||||
|
||||
export enum CopyState {
|
||||
INIT = 'INIT',
|
||||
CHECK_SELECTION = 'CHECK_SELECTION',
|
||||
WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION',
|
||||
WAITING_FOR_START_COPY_POINT = 'WAITING_FOR_START_COPY_POINT',
|
||||
WAITING_FOR_END_COPY_POINT = 'WAITING_FOR_END_COPY_POINT',
|
||||
WAITING_FOR_BASE_POINT = 'WAITING_FOR_BASE_POINT',
|
||||
WAITING_FOR_COPY_MODE = 'WAITING_FOR_COPY_MODE',
|
||||
WAITING_FOR_SECOND_POINT = 'WAITING_FOR_SECOND_POINT',
|
||||
WAITING_FOR_ARRAY_COUNT = 'WAITING_FOR_ARRAY_COUNT',
|
||||
WAITING_FOR_ARRAY_POINT = 'WAITING_FOR_ARRAY_POINT',
|
||||
WAITING_FOR_DISPLACEMENT = 'WAITING_FOR_DISPLACEMENT',
|
||||
}
|
||||
|
||||
export enum CopyAction {
|
||||
INIT_COPY_TOOL = 'INIT_COPY_TOOL',
|
||||
ENABLE_HELPERS = 'ENABLE_HELPERS',
|
||||
RECORD_START_POINT = 'RECORD_START_POINT',
|
||||
COPY_SELECTION_BEFORE_COPY = 'COPY_SELECTION_BEFORE_COPY',
|
||||
DRAW_TEMP_COPY_ENTITIES = 'DRAW_TEMP_COPY_ENTITIES',
|
||||
COPY_SELECTION = 'COPY_SELECTION',
|
||||
DESELECT_ENTITIES = 'DESELECT_ENTITIES',
|
||||
RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES',
|
||||
export interface CopyContext extends ToolContext, PickedContext {
|
||||
base: Point | null;
|
||||
/** COPYMODE 단일 */
|
||||
single: boolean;
|
||||
/** 다중(M) — 이번만 되풀이 */
|
||||
multiple: boolean;
|
||||
/** 만든 사본 묶음 수(U 되돌림 · 물음 바뀜) */
|
||||
made: number;
|
||||
/** 배열(A) 항목 수(원본 포함) */
|
||||
items: number;
|
||||
/** 맞춤(F) — 두 번째 점 = 마지막 항목 */
|
||||
fit: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy tool state machine
|
||||
* This state machine is responsible for copying entities around the canvas
|
||||
* It uses the select tool state machine to select entities to copy
|
||||
* When the user presses enter, the selected entities are marked for copying
|
||||
* The user can then select a start point
|
||||
* When the user moves their mouse, the selected entities will be updated to move according to the vector: startpoint => mouse location
|
||||
* When the user clicks again, the end point is selected and the entities are copied to the new location
|
||||
*/
|
||||
export const copyToolStateMachine = createMachine(
|
||||
const MODE: OptionSpec = { key: 'MODE', kw: 'O', en: 'MODE', label: '모드' };
|
||||
const SINGLE: OptionSpec = { key: 'SINGLE', kw: 'S', en: 'SINGLE', label: '단일' };
|
||||
const ARRAY: OptionSpec = { key: 'ARRAY', kw: 'A', en: 'ARRAY', label: '배열' };
|
||||
const FIT: OptionSpec = { key: 'FIT', kw: 'F', en: 'FIT', label: '맞춤' };
|
||||
|
||||
/** 한 번만 만들고 끝 — 단일 모드이고 다중(M) 안 고름 */
|
||||
const once = (context: Partial<CopyContext>) => !!context.single && !context.multiple;
|
||||
|
||||
const PROMPT = {
|
||||
base: (context: Partial<CopyContext>): PromptSpec => ({
|
||||
text: '기준점 지정',
|
||||
options: [OPT.DISPLACEMENT, MODE, ...(once(context) ? [OPT.MULTIPLE] : [])],
|
||||
default: { kind: 'option', key: OPT.DISPLACEMENT.key },
|
||||
}),
|
||||
mode: (context: Partial<CopyContext>): PromptSpec => ({
|
||||
text: '복사 모드 옵션 입력',
|
||||
options: [SINGLE, OPT.MULTIPLE],
|
||||
default: { kind: 'option', key: context.single ? SINGLE.key : OPT.MULTIPLE.key },
|
||||
}),
|
||||
second: (context: Partial<CopyContext>): PromptSpec =>
|
||||
context.made
|
||||
? {
|
||||
text: '두 번째 점 지정',
|
||||
options: [ARRAY, OPT.EXIT, OPT.UNDO],
|
||||
default: { kind: 'enter', label: '종료' },
|
||||
}
|
||||
: {
|
||||
text: '두 번째 점 지정',
|
||||
options: [ARRAY],
|
||||
default: { kind: 'enter', label: '첫 번째 점을 변위로 사용' },
|
||||
},
|
||||
count: {
|
||||
text: '배열할 항목 수 입력',
|
||||
number: { int: true, min: 2, max: 32767 },
|
||||
} satisfies PromptSpec,
|
||||
arrayPoint: (context: Partial<CopyContext>): PromptSpec => ({
|
||||
text: '두 번째 점 지정',
|
||||
...(context.fit ? {} : { options: [FIT] }),
|
||||
}),
|
||||
displacement: {
|
||||
text: '변위 지정',
|
||||
default: { kind: 'value', remember: 'COPY.DISP', initial: { x: 0, y: 0 } },
|
||||
} satisfies PromptSpec,
|
||||
};
|
||||
|
||||
type Ctx = { context: CopyContext; event: StateEvent };
|
||||
|
||||
/** 변위 (dx, dy) 를 1 · 2 · … 배 한 사본들 — 배열(A)이면 항목 수 − 1 벌 */
|
||||
function copiesAt(context: CopyContext, dx: number, dy: number, sets: number): Entity[] {
|
||||
const copies: Entity[] = [];
|
||||
for (let index = 1; index <= sets; index++) {
|
||||
for (const entity of context.originals) {
|
||||
const copy = cloneKeepingLook(entity);
|
||||
copy.move(dx * index, dy * index);
|
||||
copies.push(copy);
|
||||
}
|
||||
}
|
||||
return copies;
|
||||
}
|
||||
|
||||
/** 배열(A) — 맞춤이면 두 번째 점이 마지막 항목 */
|
||||
function arrayCopies(context: CopyContext, dx: number, dy: number): Entity[] {
|
||||
const sets = context.items - 1;
|
||||
const scale = context.fit ? 1 / sets : 1;
|
||||
return copiesAt(context, dx * scale, dy * scale, sets);
|
||||
}
|
||||
|
||||
const vectorTo = ({ context, event }: Ctx) => {
|
||||
const base = context.base as Point;
|
||||
const dest = getPointFromEvent(base, event as PointInputEvent);
|
||||
return new Point(dest.x - base.x, dest.y - base.y);
|
||||
};
|
||||
|
||||
const place = (copies: Entity[]) => {
|
||||
if (copies.length) addEntities(copies, true);
|
||||
setGhostHelperEntities([]);
|
||||
};
|
||||
const bump = assign(({ context }: Ctx) => ({ made: context.made + 1, fit: false }));
|
||||
|
||||
const onPoints = (handler: unknown) => ({
|
||||
MOUSE_CLICK: handler,
|
||||
ABSOLUTE_POINT_INPUT: handler,
|
||||
RELATIVE_POINT_INPUT: handler,
|
||||
NUMBER_INPUT: handler,
|
||||
});
|
||||
const esc = { ESC: { target: END_STATE } };
|
||||
|
||||
/** 사본 놓음 → 단일이면 끝 · 아니면 다시 두 번째 점 */
|
||||
const placeThen = (copies: (args: Ctx) => Entity[]) => [
|
||||
{
|
||||
types: {} as {
|
||||
context: CopyContext;
|
||||
events: StateEvent;
|
||||
},
|
||||
context: {
|
||||
startPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
copiedEntities: [],
|
||||
lastDrawLocation: null,
|
||||
type: Tool.COPY,
|
||||
},
|
||||
initial: CopyState.INIT,
|
||||
states: {
|
||||
[CopyState.INIT]: {
|
||||
description: 'Initializing the copy tool',
|
||||
always: {
|
||||
actions: CopyAction.INIT_COPY_TOOL,
|
||||
target: CopyState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
[CopyState.CHECK_SELECTION]: {
|
||||
description: 'Check if there is something selected',
|
||||
always: [
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length > 0;
|
||||
},
|
||||
target: CopyState.WAITING_FOR_START_COPY_POINT,
|
||||
},
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length === 0;
|
||||
},
|
||||
target: CopyState.WAITING_FOR_SELECTION,
|
||||
},
|
||||
],
|
||||
},
|
||||
[CopyState.WAITING_FOR_SELECTION]: {
|
||||
description: 'Select what you want to copy',
|
||||
meta: {
|
||||
instructions: '복사할 객체 선택 뒤 Enter',
|
||||
},
|
||||
invoke: {
|
||||
id: 'selectToolInsideTheCopyTool',
|
||||
src: selectToolStateMachine,
|
||||
onDone: {
|
||||
actions: assign(() => {
|
||||
return {
|
||||
startPoint: null,
|
||||
};
|
||||
}),
|
||||
target: CopyState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
ESC: {
|
||||
actions: [CopyAction.DESELECT_ENTITIES, CopyAction.INIT_COPY_TOOL],
|
||||
},
|
||||
ENTER: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
DRAW: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
[CopyState.WAITING_FOR_START_COPY_POINT]: {
|
||||
description: '기준점 지정 (x,y):',
|
||||
meta: {
|
||||
instructions: '기준점 지정 (x,y):',
|
||||
},
|
||||
always: {
|
||||
actions: CopyAction.ENABLE_HELPERS,
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: [CopyAction.RECORD_START_POINT, CopyAction.COPY_SELECTION_BEFORE_COPY],
|
||||
target: CopyState.WAITING_FOR_END_COPY_POINT,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: [CopyAction.RECORD_START_POINT, CopyAction.COPY_SELECTION_BEFORE_COPY],
|
||||
target: CopyState.WAITING_FOR_END_COPY_POINT,
|
||||
},
|
||||
ESC: {
|
||||
actions: CopyAction.DESELECT_ENTITIES,
|
||||
target: CopyState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
[CopyState.WAITING_FOR_END_COPY_POINT]: {
|
||||
description: '둘째 점 지정 (길이 · <각도 · @x,y):',
|
||||
meta: {
|
||||
instructions: '둘째 점 지정 (길이 · <각도 · @x,y):',
|
||||
},
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: [CopyAction.DRAW_TEMP_COPY_ENTITIES],
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: [CopyAction.COPY_SELECTION],
|
||||
target: CopyState.WAITING_FOR_END_COPY_POINT,
|
||||
},
|
||||
NUMBER_INPUT: {
|
||||
actions: [CopyAction.COPY_SELECTION],
|
||||
target: CopyState.WAITING_FOR_END_COPY_POINT,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: [CopyAction.COPY_SELECTION],
|
||||
target: CopyState.WAITING_FOR_END_COPY_POINT,
|
||||
},
|
||||
RELATIVE_POINT_INPUT: {
|
||||
actions: [CopyAction.COPY_SELECTION],
|
||||
target: CopyState.WAITING_FOR_END_COPY_POINT,
|
||||
},
|
||||
ESC: {
|
||||
actions: CopyAction.DESELECT_ENTITIES,
|
||||
target: CopyState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
guard: ({ context }: Ctx) => once(context),
|
||||
actions: (args: Ctx) => place(copies(args)),
|
||||
target: END_STATE,
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
[CopyAction.INIT_COPY_TOOL]: assign(() => {
|
||||
setShouldDrawHelpers(false);
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
return {
|
||||
startPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
copiedEntities: [],
|
||||
lastDrawLocation: null,
|
||||
};
|
||||
}),
|
||||
[CopyAction.ENABLE_HELPERS]: () => {
|
||||
setShouldDrawHelpers(true);
|
||||
},
|
||||
[CopyAction.RECORD_START_POINT]: assign(({ event }) => {
|
||||
const startPoint = getPointFromEvent(null, event as PointInputEvent);
|
||||
setAngleGuideOriginPoint(startPoint);
|
||||
return { startPoint };
|
||||
}),
|
||||
[CopyAction.COPY_SELECTION_BEFORE_COPY]: assign(({ context }) => {
|
||||
const selectedEntities = getSelectedEntities();
|
||||
actions: [(args: Ctx) => place(copies(args)), bump],
|
||||
target: CopyState.WAITING_FOR_SECOND_POINT,
|
||||
},
|
||||
];
|
||||
|
||||
// Copy the selected entities to the ghost helper entities, so they are drawn on the canvas, but do not interact with the snap points / angle guides
|
||||
setGhostHelperEntities(selectedEntities);
|
||||
const recordBase = {
|
||||
actions: assign(({ event }: Ctx) => {
|
||||
const base = getPointFromEvent(null, event as PointInputEvent);
|
||||
setAngleGuideOriginPoint(base);
|
||||
return { base };
|
||||
}),
|
||||
target: CopyState.WAITING_FOR_SECOND_POINT,
|
||||
};
|
||||
|
||||
// TODO keep a copy of the original entities in the entities list, but set their line color to grey, so the user can see where the entities were before being copied and the original entities also are used for snap points / angle guides
|
||||
const S = CopyState;
|
||||
|
||||
setSelectedEntityIds([]);
|
||||
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) => cloneKeepingLook(entity))
|
||||
),
|
||||
copiedEntities: selectedEntities,
|
||||
};
|
||||
}),
|
||||
[CopyAction.DRAW_TEMP_COPY_ENTITIES]: ({ context, event }) => {
|
||||
if (!context.startPoint) {
|
||||
throw new Error('[COPY] Calling draw temp copy line without a start point');
|
||||
}
|
||||
|
||||
const endPointTemp = getPointFromEvent(context.startPoint, event as DrawEvent);
|
||||
|
||||
// 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) =>
|
||||
cloneKeepingLook(entity)
|
||||
);
|
||||
moveEntities(
|
||||
movedEntities,
|
||||
endPointTemp.x - context.startPoint.x,
|
||||
endPointTemp.y - context.startPoint.y
|
||||
);
|
||||
|
||||
// // Draw a dashed line between the start copy point and the current mouse location
|
||||
const activeCopyLine = new LineEntity(
|
||||
getActiveLayerId(),
|
||||
context.startPoint as Point,
|
||||
endPointTemp
|
||||
);
|
||||
activeCopyLine.lineColor = GUIDE_LINE_COLOR;
|
||||
activeCopyLine.lineWidth = GUIDE_LINE_WIDTH;
|
||||
activeCopyLine.lineDash = GUIDE_LINE_STYLE;
|
||||
setGhostHelperEntities([activeCopyLine, ...movedEntities]);
|
||||
},
|
||||
[CopyAction.COPY_SELECTION]: ({ context, event }) => {
|
||||
if (!context.startPoint) {
|
||||
throw new Error('[COPY] Calling copy selection without a start point');
|
||||
}
|
||||
|
||||
// Copy the entities one final time
|
||||
// 클릭 · 거리 · 절대 · 상대 좌표(키보드)
|
||||
const currentEndPoint = getPointFromEvent(context.startPoint, event as PointInputEvent);
|
||||
const copiedEntities = context.originalSelectedEntities.map((entity) =>
|
||||
cloneKeepingLook(entity)
|
||||
);
|
||||
moveEntities(
|
||||
copiedEntities,
|
||||
currentEndPoint.x - context.startPoint.x,
|
||||
currentEndPoint.y - context.startPoint.y
|
||||
);
|
||||
|
||||
// 원본은 목록에 그대로 있음 — 복사본만 더함(원본까지 더하면 누를 때마다 원본이 겹겹이 쌓임).
|
||||
addEntities(copiedEntities, true);
|
||||
},
|
||||
[CopyAction.DESELECT_ENTITIES]: assign(() => {
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
return {
|
||||
startPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
lastDrawLocation: null,
|
||||
};
|
||||
}),
|
||||
[CopyAction.RESTORE_ORIGINAL_ENTITIES]: assign(({ context }) => {
|
||||
addEntities(context.originalSelectedEntities, false); // This should already be the last state of the undo stack
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
return {
|
||||
startPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
lastDrawLocation: null,
|
||||
};
|
||||
}),
|
||||
...selectToolStateMachine.implementations.actions,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 공용 상태 조각(여러 도구 공통)을 섞어 만든다
|
||||
const states: Record<string, any> = {
|
||||
[S.INIT]: startState(S.WAITING_FOR_BASE_POINT),
|
||||
[S.WAITING_FOR_SELECTION]: selectionState(S.WAITING_FOR_BASE_POINT),
|
||||
[S.WAITING_FOR_BASE_POINT]: {
|
||||
meta: { kind: 'point', prompt: PROMPT.base },
|
||||
entry: () => setShouldDrawHelpers(true),
|
||||
on: {
|
||||
MOUSE_CLICK: recordBase,
|
||||
ABSOLUTE_POINT_INPUT: recordBase,
|
||||
'OPTION.DISPLACEMENT': { target: S.WAITING_FOR_DISPLACEMENT },
|
||||
'OPTION.MODE': { target: S.WAITING_FOR_COPY_MODE },
|
||||
'OPTION.MULTIPLE': { actions: assign({ multiple: true }) },
|
||||
...esc,
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
[S.WAITING_FOR_COPY_MODE]: {
|
||||
meta: { kind: 'keyword', prompt: PROMPT.mode },
|
||||
on: {
|
||||
'OPTION.SINGLE': {
|
||||
actions: [assign({ single: true }), () => remember('COPYMODE', SINGLE.key)],
|
||||
target: S.WAITING_FOR_BASE_POINT,
|
||||
},
|
||||
'OPTION.MULTIPLE': {
|
||||
actions: [assign({ single: false }), () => remember('COPYMODE', OPT.MULTIPLE.key)],
|
||||
target: S.WAITING_FOR_BASE_POINT,
|
||||
},
|
||||
...esc,
|
||||
},
|
||||
},
|
||||
[S.WAITING_FOR_SECOND_POINT]: {
|
||||
meta: { kind: 'point', prompt: PROMPT.second },
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: ({ context, event }: Ctx) => {
|
||||
const base = context.base as Point;
|
||||
const dest = getPointFromEvent(base, event as DrawEvent);
|
||||
setGhostHelperEntities([
|
||||
guideLine(base, dest),
|
||||
...copiesAt(context, dest.x - base.x, dest.y - base.y, 1),
|
||||
]);
|
||||
},
|
||||
},
|
||||
...onPoints(
|
||||
placeThen((args) => {
|
||||
const vector = vectorTo(args);
|
||||
return copiesAt(args.context, vector.x, vector.y, 1);
|
||||
})
|
||||
),
|
||||
// 첫 물음 빈 Enter = 기준점 좌표가 변위(한 벌 뒤 끝) · 그 뒤 = <종료>
|
||||
ENTER: [
|
||||
{
|
||||
guard: ({ context }: Ctx) => context.made === 0,
|
||||
actions: ({ context }: Ctx) => {
|
||||
const base = context.base as Point;
|
||||
place(copiesAt(context, base.x, base.y, 1));
|
||||
},
|
||||
target: END_STATE,
|
||||
},
|
||||
{ target: END_STATE },
|
||||
],
|
||||
'OPTION.ARRAY': { target: S.WAITING_FOR_ARRAY_COUNT },
|
||||
'OPTION.EXIT': { target: END_STATE },
|
||||
'OPTION.UNDO': {
|
||||
guard: ({ context }: Ctx) => context.made > 0,
|
||||
actions: [
|
||||
() => {
|
||||
undo();
|
||||
setGhostHelperEntities([]);
|
||||
},
|
||||
assign(({ context }: Ctx) => ({ made: context.made - 1 })),
|
||||
],
|
||||
},
|
||||
...esc,
|
||||
},
|
||||
},
|
||||
[S.WAITING_FOR_ARRAY_COUNT]: {
|
||||
meta: { kind: 'number', prompt: PROMPT.count },
|
||||
entry: () => setGhostHelperEntities([]),
|
||||
on: {
|
||||
NUMBER_INPUT: {
|
||||
actions: assign(({ event }: Ctx) => ({ items: (event as NumberInputEvent).value })),
|
||||
target: S.WAITING_FOR_ARRAY_POINT,
|
||||
},
|
||||
...esc,
|
||||
},
|
||||
},
|
||||
[S.WAITING_FOR_ARRAY_POINT]: {
|
||||
meta: { kind: 'point', prompt: PROMPT.arrayPoint },
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: ({ context, event }: Ctx) => {
|
||||
const base = context.base as Point;
|
||||
const dest = getPointFromEvent(base, event as DrawEvent);
|
||||
setGhostHelperEntities([
|
||||
guideLine(base, dest),
|
||||
...arrayCopies(context, dest.x - base.x, dest.y - base.y),
|
||||
]);
|
||||
},
|
||||
},
|
||||
...onPoints(
|
||||
placeThen((args) => {
|
||||
const vector = vectorTo(args);
|
||||
return arrayCopies(args.context, vector.x, vector.y);
|
||||
})
|
||||
),
|
||||
'OPTION.FIT': { actions: assign({ fit: true }) },
|
||||
...esc,
|
||||
},
|
||||
},
|
||||
[S.WAITING_FOR_DISPLACEMENT]: {
|
||||
meta: { kind: 'point', prompt: PROMPT.displacement },
|
||||
entry: () => setAngleGuideOriginPoint(null),
|
||||
on: {
|
||||
...Object.fromEntries(
|
||||
['MOUSE_CLICK', 'ABSOLUTE_POINT_INPUT'].map((type) => [
|
||||
type,
|
||||
{
|
||||
actions: ({ context, event }: Ctx) => {
|
||||
const vector = getPointFromEvent(null, event as PointInputEvent);
|
||||
remember('COPY.DISP', { x: vector.x, y: vector.y });
|
||||
place(copiesAt(context, vector.x, vector.y, 1));
|
||||
},
|
||||
target: END_STATE,
|
||||
},
|
||||
])
|
||||
),
|
||||
...esc,
|
||||
},
|
||||
},
|
||||
[END_STATE]: endState,
|
||||
};
|
||||
|
||||
export const copyToolStateMachine = createMachine({
|
||||
types: {} as { context: CopyContext; events: StateEvent },
|
||||
context: () => ({
|
||||
originals: [],
|
||||
base: null,
|
||||
single: recall('COPYMODE') === SINGLE.key,
|
||||
multiple: false,
|
||||
made: 0,
|
||||
items: 2,
|
||||
fit: false,
|
||||
type: Tool.COPY,
|
||||
}),
|
||||
initial: S.INIT,
|
||||
states,
|
||||
});
|
||||
|
||||
@@ -1,37 +1,80 @@
|
||||
/** 대칭·정렬·간격띄우기·신축·길이조정 (조사표 2절) */
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { Box, Point, Polygon } from '@flatten-js/core';
|
||||
import { toast } from 'react-toastify';
|
||||
import { type AnyActorRef, assign } from 'xstate';
|
||||
import { OPT } from '../../commands/prompt';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { LineEntity } from '../../entities/LineEntity';
|
||||
import { addEntities, deleteEntities, getActiveLayerId, setSelectedEntityIds } from '../../state';
|
||||
import { PolyLineEntity } from '../../entities/PolyLineEntity';
|
||||
import { RectangleEntity } from '../../entities/RectangleEntity';
|
||||
import {
|
||||
addEntities,
|
||||
deleteEntities,
|
||||
getActiveLayerId,
|
||||
getEntities,
|
||||
setEntities,
|
||||
setSelectedEntityIds,
|
||||
} from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
import { createDisplaceTool } from '../move-tool';
|
||||
import { SelectState } from '../select-tool';
|
||||
import type { MouseClickEvent, StateEvent } from '../tool.types';
|
||||
import { SELECT_CHILD } from '../transform.helpers';
|
||||
import { copyStyle, lengthenLine, offsetEntity } from './modify.helpers';
|
||||
|
||||
/** 원본 특성을 유지한 복사본 */
|
||||
const cloneStyled = (entity: Entity): Entity => copyStyle(entity, entity.clone());
|
||||
|
||||
/** 대칭선 — 두 점이 같으면 없음 */
|
||||
const mirrorAxis = (first: Point, second: Point): LineEntity | null =>
|
||||
first.distanceTo(second)[0] < 1e-9 ? null : new LineEntity(getActiveLayerId(), first, second);
|
||||
|
||||
const mirrored = (originals: Entity[], axis: LineEntity): Entity[] =>
|
||||
originals.map((entity) => {
|
||||
const copy = cloneStyled(entity);
|
||||
copy.mirror(axis);
|
||||
return copy;
|
||||
});
|
||||
|
||||
/**
|
||||
* MIRROR(337 라 · 설계 1-3) — 「객체 선택:」 → 「대칭선의 첫 번째 점 지정:」 → 「대칭선의 두 번째 점 지정:」 →
|
||||
* 「원본 객체를 지우시겠습니까? [예(Y)/아니오(N)] <아니오>:」 · Esc = 안 만듦 · 한 번 뒤 끝.
|
||||
*/
|
||||
export const mirrorToolStateMachine = createSequenceTool({
|
||||
tool: Tool.MIRROR,
|
||||
steps: [
|
||||
{ kind: 'selection', instructions: '대칭 복사할 객체를 선택한 뒤 ENTER.' },
|
||||
{ kind: 'point', instructions: '대칭축의 첫 점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '대칭축의 두 번째 점을 지정하십시오.' },
|
||||
{ kind: 'selection' },
|
||||
{ kind: 'point', id: 'first', prompt: { text: '대칭선의 첫 번째 점 지정' } },
|
||||
{ kind: 'point', id: 'second', prompt: { text: '대칭선의 두 번째 점 지정' } },
|
||||
{
|
||||
kind: 'keyword',
|
||||
id: 'erase',
|
||||
prompt: {
|
||||
text: '원본 객체를 지우시겠습니까?',
|
||||
options: [OPT.YES, OPT.NO],
|
||||
default: { kind: 'option', key: OPT.NO.key },
|
||||
},
|
||||
},
|
||||
],
|
||||
preview: (input) => {
|
||||
const points = input.points();
|
||||
if (points.length !== 1) return [];
|
||||
return [new LineEntity(getActiveLayerId(), points[0], input.cursor)];
|
||||
const [first, second] = input.points();
|
||||
const axis = first ? mirrorAxis(first, second ?? input.cursor) : null;
|
||||
if (!axis) return [];
|
||||
return [...(second ? [] : [axis]), ...mirrored(input.entities(0), axis)];
|
||||
},
|
||||
commit: (input) => {
|
||||
const [first, second] = input.points();
|
||||
const axis = new LineEntity(getActiveLayerId(), first, second);
|
||||
const mirrored = input.entities(0).map((entity) => {
|
||||
const copy = cloneStyled(entity);
|
||||
copy.mirror(axis);
|
||||
return copy;
|
||||
});
|
||||
if (mirrored.length) addEntities(mirrored, true);
|
||||
const axis = mirrorAxis(first, second);
|
||||
const originals = input.entities(0);
|
||||
if (!axis || !originals.length) return;
|
||||
const copies = mirrored(originals, axis);
|
||||
if (input.get('erase') === OPT.YES.key) {
|
||||
const gone = new Set(originals.map((entity) => entity.id));
|
||||
setEntities([...getEntities().filter((entity) => !gone.has(entity.id)), ...copies], true);
|
||||
} else {
|
||||
addEntities(copies, true);
|
||||
}
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
@@ -84,44 +127,85 @@ export const offsetToolStateMachine = createSequenceTool({
|
||||
},
|
||||
});
|
||||
|
||||
export const stretchToolStateMachine = createSequenceTool({
|
||||
tool: Tool.STRETCH,
|
||||
steps: [
|
||||
{ kind: 'selection', instructions: '신축할 객체를 선택한 뒤 ENTER.' },
|
||||
{ kind: 'point', instructions: '기준점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '이동할 위치를 지정하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const [base, destination] = input.points();
|
||||
const dx = destination.x - base.x;
|
||||
const dy = destination.y - base.y;
|
||||
const replaced: Entity[] = [];
|
||||
const removed: Entity[] = [];
|
||||
/** 바꾼 것을 원본 자리에 — id · 묶음 · 꼴 그대로 */
|
||||
const inPlaceOf = <T extends Entity>(source: Entity, made: T): T => {
|
||||
copyStyle(source, made);
|
||||
made.id = source.id;
|
||||
made.groupId = source.groupId;
|
||||
made.opacity = source.opacity;
|
||||
return made;
|
||||
};
|
||||
|
||||
for (const entity of input.entities(0)) {
|
||||
const shape = entity.getShape();
|
||||
// 선은 기준점에 가까운 끝점만 끌어당긴다. 그 밖의 객체는 통째로 옮긴다.
|
||||
if (shape && 'start' in shape && 'end' in shape) {
|
||||
const start = (shape as { start: Point }).start;
|
||||
const end = (shape as { end: Point }).end;
|
||||
const moveStart = start.distanceTo(base)[0] < end.distanceTo(base)[0];
|
||||
const newLine = new LineEntity(
|
||||
entity.layerId,
|
||||
moveStart ? new Point(start.x + dx, start.y + dy) : start,
|
||||
moveStart ? end : new Point(end.x + dx, end.y + dy)
|
||||
);
|
||||
replaced.push(copyStyle(entity, newLine));
|
||||
removed.push(entity);
|
||||
} else {
|
||||
const copy = cloneStyled(entity);
|
||||
copy.move(dx, dy);
|
||||
replaced.push(copy);
|
||||
removed.push(entity);
|
||||
}
|
||||
}
|
||||
if (removed.length) deleteEntities(removed, false);
|
||||
addEntities(replaced, true);
|
||||
setSelectedEntityIds([]);
|
||||
/**
|
||||
* 걸침 창 신축 — 창 안 꼭짓점만 (dx, dy) · 창 안에 통째 든 것 · 하나씩 집은 것(창 밖) = 이동 · 그 밖 = 그대로(null).
|
||||
* ponytail: 선 · 폴리선(선 토막) · 사각형만 꼭짓점 신축 — 원 · 호 · 글은 걸치면 그대로(오토캐드는 중심 · 끝점 기준).
|
||||
*/
|
||||
function stretchEntity(entity: Entity, box: Box | null, dx: number, dy: number): Entity | null {
|
||||
const inside = (point: Point) =>
|
||||
!!box &&
|
||||
point.x >= box.xmin &&
|
||||
point.x <= box.xmax &&
|
||||
point.y >= box.ymin &&
|
||||
point.y <= box.ymax;
|
||||
const shift = (point: Point) => (inside(point) ? new Point(point.x + dx, point.y + dy) : point);
|
||||
if (!box || entity.isContainedInBox(box) || !entity.intersectsWithBox(box)) {
|
||||
const copy = cloneStyled(entity);
|
||||
copy.move(dx, dy);
|
||||
return inPlaceOf(entity, copy);
|
||||
}
|
||||
if (entity instanceof LineEntity) {
|
||||
const start = entity.getStartPoint();
|
||||
const end = entity.getEndPoint();
|
||||
if (!inside(start) && !inside(end)) return null;
|
||||
return inPlaceOf(entity, new LineEntity(entity.layerId, shift(start), shift(end)));
|
||||
}
|
||||
if (entity instanceof PolyLineEntity) {
|
||||
let changed = false;
|
||||
const parts = entity.getEntities().map((part) => {
|
||||
if (!(part instanceof LineEntity)) return cloneStyled(part);
|
||||
const start = part.getStartPoint();
|
||||
const end = part.getEndPoint();
|
||||
if (inside(start) || inside(end)) changed = true;
|
||||
return copyStyle(part, new LineEntity(part.layerId, shift(start), shift(end)));
|
||||
});
|
||||
return changed ? inPlaceOf(entity, new PolyLineEntity(entity.layerId, parts)) : null;
|
||||
}
|
||||
if (entity instanceof RectangleEntity) {
|
||||
const corners = (entity.getShape() as Polygon).vertices;
|
||||
if (!corners.some(inside)) return null;
|
||||
return inPlaceOf(entity, new RectangleEntity(entity.layerId, new Polygon(corners.map(shift))));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 고르기 중 마지막 걸침 창 — 선택 도구가 두 번째 구석을 기다릴 때의 누름 */
|
||||
const watchWindow = assign(({ event, self }: { event: StateEvent; self: AnyActorRef }) => {
|
||||
const snapshot = self.getSnapshot().children[SELECT_CHILD]?.getSnapshot();
|
||||
const start = snapshot?.context?.startPoint as Point | null | undefined;
|
||||
if (snapshot?.value !== SelectState.WAITING_FOR_SECOND_SELECT_POINT || !start) return {};
|
||||
return { window: [start, (event as MouseClickEvent).worldMouseLocation] as [Point, Point] };
|
||||
});
|
||||
|
||||
/**
|
||||
* STRETCH(337 라 · 설계 1-3) — 기록 「걸치기 윈도우 또는 걸치기 폴리곤만큼 신축할 객체 선택...」 → 「객체 선택:」 →
|
||||
* MOVE 와 같은 기준점 [변위(D)] · 두 번째 점 · 변위 기억(STRETCH.DISP) · 걸침 창 안 꼭짓점만 옮김.
|
||||
*/
|
||||
export const stretchToolStateMachine = createDisplaceTool({
|
||||
tool: Tool.STRETCH,
|
||||
remember: 'STRETCH.DISP',
|
||||
intro: '걸치기 윈도우 또는 걸치기 폴리곤만큼 신축할 객체 선택...',
|
||||
watch: watchWindow,
|
||||
change: (context, dx, dy) => {
|
||||
const window = context.window;
|
||||
const box = window
|
||||
? new Box(
|
||||
Math.min(window[0].x, window[1].x),
|
||||
Math.min(window[0].y, window[1].y),
|
||||
Math.max(window[0].x, window[1].x),
|
||||
Math.max(window[0].y, window[1].y)
|
||||
)
|
||||
: null;
|
||||
return context.originals.flatMap((entity) => stretchEntity(entity, box, dx, dy) ?? []);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,138 +1,48 @@
|
||||
/**
|
||||
* PEDIT — 고른 선 · 호를 폴리선 하나로 묶음. 고르기 물음 = 오토캐드 「객체 선택:」(337 라 · 설계 2-14 ④) ·
|
||||
* 고른 것 있으면 바로 · 빈 고르기 Enter · Esc = 끝 · 묶은 뒤 끝(쉬는 상태).
|
||||
*/
|
||||
import { toast } from 'react-toastify';
|
||||
import { assign, createMachine, sendTo } from 'xstate';
|
||||
import { createMachine } from 'xstate';
|
||||
import { PolyLineEntity } from '../entities/PolyLineEntity.ts';
|
||||
import {
|
||||
getActiveLayerId,
|
||||
getNotSelectedEntities,
|
||||
getSelectedEntities,
|
||||
getSelectedEntityIds,
|
||||
setAngleGuideOriginPoint,
|
||||
setEntities,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
} from '../state';
|
||||
import { Tool } from '../tools';
|
||||
import { selectToolStateMachine } from './select-tool';
|
||||
import type { StateEvent, ToolContext } from './tool.types';
|
||||
import { END_STATE, type StateEvent, type ToolContext } from './tool.types';
|
||||
import { endState, selectionState, startState } from './transform.helpers';
|
||||
|
||||
export interface PeditContext extends ToolContext {}
|
||||
|
||||
export enum PeditState {
|
||||
INIT = 'INIT',
|
||||
CHECK_SELECTION = 'CHECK_SELECTION',
|
||||
WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION',
|
||||
CONVERT_SELECTION_TO_POLYLINE = 'CONVERT_SELECTION_TO_POLYLINE',
|
||||
}
|
||||
|
||||
export enum PeditAction {
|
||||
INIT_PEDIT_TOOL = 'INIT_PEDIT_TOOL',
|
||||
DESELECT_ENTITIES = 'DESELECT_ENTITIES',
|
||||
CONVERT_SELECTION_TO_POLYLINE = 'CONVERT_SELECTION_TO_POLYLINE',
|
||||
}
|
||||
const convert = () => {
|
||||
const newPolyLine = new PolyLineEntity(getActiveLayerId(), getSelectedEntities());
|
||||
setEntities([...getNotSelectedEntities(), newPolyLine], true);
|
||||
toast.success(`Created polyline with ${newPolyLine.numberOfSegments()} segments`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Pedit tool state machine
|
||||
* This state machine is responsible for combining entities into a polyline
|
||||
* It uses the select tool state machine to select entities to combine
|
||||
* When the user presses enter, the selected entities are combined into one polyline
|
||||
*/
|
||||
export const peditToolStateMachine = createMachine(
|
||||
{
|
||||
types: {} as {
|
||||
context: PeditContext;
|
||||
events: StateEvent;
|
||||
},
|
||||
context: {
|
||||
type: Tool.PEDIT,
|
||||
},
|
||||
initial: PeditState.INIT,
|
||||
states: {
|
||||
[PeditState.INIT]: {
|
||||
description: 'Initializing the pedit tool',
|
||||
always: {
|
||||
actions: PeditAction.INIT_PEDIT_TOOL,
|
||||
target: PeditState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
[PeditState.CHECK_SELECTION]: {
|
||||
description: 'Check if there is something selected',
|
||||
always: [
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length > 0;
|
||||
},
|
||||
target: PeditState.CONVERT_SELECTION_TO_POLYLINE,
|
||||
},
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length === 0;
|
||||
},
|
||||
target: PeditState.WAITING_FOR_SELECTION,
|
||||
},
|
||||
],
|
||||
},
|
||||
[PeditState.WAITING_FOR_SELECTION]: {
|
||||
description: 'Select the entities that you want to combine into a polyline',
|
||||
meta: {
|
||||
instructions: '폴리선으로 합칠 객체 선택 뒤 Enter',
|
||||
},
|
||||
invoke: {
|
||||
id: 'selectToolInsideThePeditTool',
|
||||
src: selectToolStateMachine,
|
||||
onDone: {
|
||||
target: PeditState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideThePeditTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
ESC: {
|
||||
actions: [PeditAction.DESELECT_ENTITIES, PeditAction.INIT_PEDIT_TOOL],
|
||||
},
|
||||
ENTER: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideThePeditTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
DRAW: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideThePeditTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
[PeditState.CONVERT_SELECTION_TO_POLYLINE]: {
|
||||
always: {
|
||||
actions: PeditAction.CONVERT_SELECTION_TO_POLYLINE,
|
||||
target: PeditState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 공용 상태 조각(여러 도구 공통)을 섞어 만든다
|
||||
const states: Record<string, any> = {
|
||||
[PeditState.INIT]: startState(PeditState.CONVERT_SELECTION_TO_POLYLINE),
|
||||
[PeditState.WAITING_FOR_SELECTION]: selectionState(PeditState.CONVERT_SELECTION_TO_POLYLINE),
|
||||
[PeditState.CONVERT_SELECTION_TO_POLYLINE]: {
|
||||
always: { actions: convert, target: END_STATE },
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
[PeditAction.INIT_PEDIT_TOOL]: assign(() => {
|
||||
setShouldDrawHelpers(false);
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
return {};
|
||||
}),
|
||||
[PeditAction.CONVERT_SELECTION_TO_POLYLINE]: assign(() => {
|
||||
const newPolyLine = new PolyLineEntity(getActiveLayerId(), getSelectedEntities());
|
||||
const newEntities = [...getNotSelectedEntities(), newPolyLine];
|
||||
setEntities(newEntities, true);
|
||||
setSelectedEntityIds([]);
|
||||
toast.success(`Created polyline with ${newPolyLine.numberOfSegments()} segments`);
|
||||
return {};
|
||||
}),
|
||||
...selectToolStateMachine.implementations.actions,
|
||||
},
|
||||
}
|
||||
);
|
||||
[END_STATE]: endState,
|
||||
};
|
||||
|
||||
/** 고른 객체를 폴리선 하나로 */
|
||||
export const peditToolStateMachine = createMachine({
|
||||
types: {} as { context: PeditContext & { originals: unknown[] }; events: StateEvent },
|
||||
context: { originals: [], type: Tool.PEDIT },
|
||||
initial: PeditState.INIT,
|
||||
states,
|
||||
});
|
||||
|
||||
@@ -102,7 +102,8 @@ def _qty(entry: dict, found: dict | None) -> dict:
|
||||
for m in ex["장비"]:
|
||||
mine = [x["시간"] for x in us["장비"] if set(x["갈래"]) & set(m["갈래"])]
|
||||
hit = any(option.near(m["시간"], q, fit.TOL[0], option.ru(m["시간"])) for q in mine)
|
||||
same["·".join(m["갈래"])] = hit or same.get("·".join(m["갈래"]), False)
|
||||
name = "+".join(m["갈래"]) # 화면 품 비교 열쇠와 같음(갈래 + 로 묶음)
|
||||
same[name] = hit or same.get(name, False)
|
||||
return {"엑셀": ex, "우리": us, "같음": same}
|
||||
|
||||
|
||||
@@ -123,7 +124,10 @@ def _numeric(line: dict, ctx: Ctx, entry: dict) -> dict:
|
||||
for c in combo
|
||||
]
|
||||
else:
|
||||
targets = [_target(top, line, ctx, found, use)] if top else []
|
||||
part = next((c for c in entry.get("조합") or [] if c["로직"] == top), {})
|
||||
share = part.get("몫") if part.get("몫") not in (None, 1, 1.0) else None # 섞인 줄 인력 몫
|
||||
extra = {"몫": share, "어디": part["어디"]} if share else {}
|
||||
targets = [_target(top, line, ctx, found, use, **extra)] if top else []
|
||||
tags = list(judged.get("꼬리표") or [])
|
||||
tags += [f"조합 — {' + '.join(c['로직'] for c in combo)}"] if combo else []
|
||||
for c in entry.get("조합") or []:
|
||||
@@ -304,6 +308,8 @@ def guess(
|
||||
raise store.StoreError(400, "돈 로직 · 우리 자재가 아님")
|
||||
given = t.get("입력값")
|
||||
one = _target(t["로직"], line, ctx, None, False, 몫=t.get("몫"))
|
||||
if isinstance(t.get("배"), (int, float)) and t["배"] > 0:
|
||||
one["배"] = t["배"] # 사용자가 정한 배는 그대로
|
||||
if isinstance(given, dict):
|
||||
one["입력값"] = {**one["입력값"], **given}
|
||||
one["출처"].update({k: "고름" for k in given})
|
||||
|
||||
@@ -295,10 +295,10 @@ def hopyo_one(
|
||||
|
||||
|
||||
class GuessBody(BaseModel):
|
||||
"""`차례` = 호표 안 줄(0부터) · `대상` = {로직} | {자재}."""
|
||||
"""`차례` = 호표 안 줄(0부터) · `대상` = [{로직 | 자재, 입력값?, 몫?}](낱개도 받음)."""
|
||||
|
||||
차례: int
|
||||
대상: dict[str, Any]
|
||||
대상: list[dict[str, Any]] | dict[str, Any]
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/hopyo/{key}/{no}/guess")
|
||||
@@ -308,11 +308,16 @@ def hopyo_guess(
|
||||
no: int,
|
||||
body: GuessBody,
|
||||
owner: str | None = None,
|
||||
to: Layer | None = None,
|
||||
to_owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""고른 대상 미리 채움(저장 안 함) — 규격 → 입력값 · 환산."""
|
||||
"""미리 셈(저장 안 함 · 설계 3-4) — 고른 대상 · 옵션으로 엔진 한 번 → `{대상, 품, 글}` ·
|
||||
`to` = 만들 층(없으면 파일 층)."""
|
||||
folder = _root(session, layer, owner)
|
||||
return _call(folder, hopyo_match.guess, folder, key, no, body.차례, body.대상)
|
||||
target = _root(session, to, to_owner) if to is not None else None
|
||||
args = (folder, key, no, body.차례, body.대상)
|
||||
return _call(folder, lambda: hopyo_match.guess(*args, to=target))
|
||||
|
||||
|
||||
class _Strict(BaseModel):
|
||||
|
||||
@@ -152,7 +152,7 @@ export function compareText(q: HopyoMatch["품"] | undefined): string {
|
||||
const name = (g: string | string[]): string => [g].flat().join("+");
|
||||
for (const x of q.엑셀.장비 ?? []) {
|
||||
const o = (q.우리.장비 ?? []).find((y) => name(y.갈래) === name(x.갈래));
|
||||
out.push(`${name(x.갈래)} ${n4(x.시간)} → ${n4(o?.시간)} ${mark([x.갈래].flat()[0])}`);
|
||||
out.push(`${name(x.갈래)} ${n4(x.시간)} → ${n4(o?.시간)} ${mark(name(x.갈래))}`);
|
||||
}
|
||||
return out.join(" · ");
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ vi.mock("react-toastify", () => ({
|
||||
import { remember } from "../../../B07_DesignDetail/openwebcad/src/commands/prompt";
|
||||
import { runCommandInput } from "../../../B07_DesignDetail/openwebcad/src/commands/run-command";
|
||||
import type { Entity } from "../../../B07_DesignDetail/openwebcad/src/entities/Entity";
|
||||
import { selectToolStateMachine } from "../../../B07_DesignDetail/openwebcad/src/tools/select-tool";
|
||||
import {
|
||||
getGhostHelperEntities,
|
||||
getSelectedEntityIds,
|
||||
setSelectedEntityIds,
|
||||
} from "../../../B07_DesignDetail/openwebcad/src/state";
|
||||
@@ -316,3 +318,358 @@ describe("SCALE(1-3)", () => {
|
||||
expect(lineEnds()).toEqual([[0, 0, 30, 0]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("COPY(1-3)", () => {
|
||||
it("물음 차례 · 되풀이 · U 마지막 사본 되돌림 · 빈 Enter = 끝(사본 남음)", () => {
|
||||
lineSelected();
|
||||
type("co");
|
||||
expect(promptLine()).toBe("기준점 지정 또는 [변위(D)/모드(O)] <변위>:");
|
||||
type("0,0");
|
||||
expect(promptLine()).toBe("두 번째 점 지정 또는 [배열(A)] <첫 번째 점을 변위로 사용>:");
|
||||
type("0,10");
|
||||
expect(promptLine()).toBe("두 번째 점 지정 또는 [배열(A)/종료(E)/명령 취소(U)] <종료>:");
|
||||
type("0,20");
|
||||
expect(entities("Line")).toHaveLength(3);
|
||||
type("u");
|
||||
expect(entities("Line")).toHaveLength(2);
|
||||
expect(activeTool()).toBe(Tool.COPY);
|
||||
enter();
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
expect(lineEnds()).toEqual([
|
||||
[0, 0, 20, 0],
|
||||
[0, 10, 20, 10],
|
||||
]);
|
||||
});
|
||||
|
||||
it("첫 물음 빈 Enter = 기준점 좌표가 변위(한 벌 뒤 끝) · E = 끝 · Esc = 끝(사본 남음)", () => {
|
||||
lineSelected();
|
||||
type("co");
|
||||
type("5,5");
|
||||
enter();
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
expect(lineEnds()).toEqual([
|
||||
[0, 0, 20, 0],
|
||||
[5, 5, 25, 5],
|
||||
]);
|
||||
setSelectedEntityIds([entities()[0].id]);
|
||||
type("co");
|
||||
type("0,0");
|
||||
type("0,-5");
|
||||
type("e");
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
setSelectedEntityIds([entities()[0].id]);
|
||||
type("co");
|
||||
type("0,0");
|
||||
type("0,-9");
|
||||
esc();
|
||||
expect(entities("Line")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("O 단일(COPYMODE 기억) · 단일 땐 M 보임 · M = 이번만 되풀이", () => {
|
||||
lineSelected();
|
||||
type("co");
|
||||
type("o");
|
||||
expect(promptLine()).toBe("복사 모드 옵션 입력 [단일(S)/다중(M)] <다중>:");
|
||||
type("s");
|
||||
expect(promptLine()).toBe("기준점 지정 또는 [변위(D)/모드(O)/다중(M)] <변위>:");
|
||||
type("0,0");
|
||||
type("0,10");
|
||||
expect(activeTool()).toBe(Tool.SELECT); // 단일 = 한 벌 뒤 끝
|
||||
expect(entities("Line")).toHaveLength(2);
|
||||
setSelectedEntityIds([entities()[0].id]);
|
||||
type("co");
|
||||
expect(promptLine()).toBe("기준점 지정 또는 [변위(D)/모드(O)/다중(M)] <변위>:");
|
||||
type("m");
|
||||
type("0,0");
|
||||
type("0,20");
|
||||
type("0,30");
|
||||
expect(activeTool()).toBe(Tool.COPY);
|
||||
expect(entities("Line")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("A 배열 · F 맞춤 · D 변위", () => {
|
||||
lineSelected();
|
||||
type("co");
|
||||
type("0,0");
|
||||
type("a");
|
||||
expect(promptLine()).toBe("배열할 항목 수 입력:");
|
||||
type("1.5");
|
||||
expect(noticeLine()).toBe("정수 값을 요구함.");
|
||||
type("3");
|
||||
expect(promptLine()).toBe("두 번째 점 지정 또는 [맞춤(F)]:");
|
||||
type("0,10");
|
||||
expect(lineEnds().map((ends) => ends[1])).toEqual([0, 10, 20]);
|
||||
type("a");
|
||||
type("3");
|
||||
type("f");
|
||||
type("0,-10"); // 맞춤 — 마지막 항목이 −10
|
||||
expect(lineEnds().map((ends) => ends[1])).toEqual([-10, -5, 0, 10, 20]);
|
||||
esc();
|
||||
setSelectedEntityIds([entities()[0].id]);
|
||||
type("co");
|
||||
type("d");
|
||||
expect(promptLine()).toBe("변위 지정 <0.0000, 0.0000>:");
|
||||
type("100,0");
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
expect(entities("Line")).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MIRROR(1-3)", () => {
|
||||
it("물음 차례 · 빈 Enter = 아니오(원본 둠) · Y = 원본 지움 · N", () => {
|
||||
lineSelected("0,10", "20,10");
|
||||
type("mi");
|
||||
expect(promptLine()).toBe("대칭선의 첫 번째 점 지정:");
|
||||
type("0,0");
|
||||
expect(promptLine()).toBe("대칭선의 두 번째 점 지정:");
|
||||
type("10,0");
|
||||
expect(promptLine()).toBe("원본 객체를 지우시겠습니까? [예(Y)/아니오(N)] <아니오>:");
|
||||
enter();
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
expect(lineEnds()).toEqual([
|
||||
[0, -10, 20, -10],
|
||||
[0, 10, 20, 10],
|
||||
]);
|
||||
setSelectedEntityIds([entities()[0].id]);
|
||||
type("mi");
|
||||
type("0,0");
|
||||
type("0,10");
|
||||
type("y");
|
||||
expect(lineEnds()).toEqual([
|
||||
[0, -10, 20, -10],
|
||||
[0, 10, -20, 10],
|
||||
]);
|
||||
setSelectedEntityIds([entities()[0].id]);
|
||||
type("mi");
|
||||
type("0,0");
|
||||
type("0,10");
|
||||
type("n");
|
||||
expect(entities("Line")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("Esc = 안 만듦(예/아니오 물음에서도)", () => {
|
||||
lineSelected("0,10", "20,10");
|
||||
type("mi");
|
||||
type("0,0");
|
||||
type("10,0");
|
||||
esc();
|
||||
expect(lineEnds()).toEqual([[0, 10, 20, 10]]);
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("STRETCH(1-3)", () => {
|
||||
it("걸침 창 안 꼭짓점만 옮김 · 창 밖 객체 그대로", () => {
|
||||
line("0,0", "20,0");
|
||||
line("100,0", "120,0");
|
||||
type("s");
|
||||
expect(promptLine()).toBe("객체 선택:");
|
||||
click(30, 5); // 걸침(오→왼)
|
||||
click(15, -5);
|
||||
enter();
|
||||
expect(promptLine()).toBe("기준점 지정 또는 [변위(D)] <변위>:");
|
||||
type("0,0");
|
||||
type("@10,0");
|
||||
expect(lineEnds()).toEqual([
|
||||
[0, 0, 30, 0],
|
||||
[100, 0, 120, 0],
|
||||
]);
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
});
|
||||
|
||||
it("창 안에 통째 든 것 = 이동 · D 변위 · 하나씩 고른 것(창 없음) = 이동", () => {
|
||||
line("0,0", "20,0");
|
||||
type("s");
|
||||
click(-5, 5);
|
||||
click(25, -5);
|
||||
enter();
|
||||
type("d");
|
||||
type("0,7");
|
||||
expect(lineEnds()).toEqual([[0, 7, 20, 7]]);
|
||||
setSelectedEntityIds([entities()[0].id]);
|
||||
type("s");
|
||||
type("0,0");
|
||||
type("3,0");
|
||||
expect(lineEnds()).toEqual([[3, 7, 23, 7]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ARRAY(1-3)", () => {
|
||||
it("R = 직사각형 · 미리보기 4 × 3 · 처음 간격 1.5 배 · 빈 Enter = 만듦", () => {
|
||||
lineSelected("0,0", "10,0");
|
||||
type("ar");
|
||||
expect(promptLine()).toBe("배열 유형 입력 [직사각형(R)/원형(PO)] <직사각형>:");
|
||||
type("r");
|
||||
expect(promptLine()).toBe(
|
||||
"그립을 선택하여 배열을 편집하거나 [개수(COU)/간격두기(S)/열(COL)/행(R)/종료(X)] <종료>:",
|
||||
);
|
||||
expect(getGhostHelperEntities()).toHaveLength(11);
|
||||
enter();
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
expect(entities("Line")).toHaveLength(12);
|
||||
expect(lineEnds()[11]).toEqual([45, 30, 55, 30]);
|
||||
});
|
||||
|
||||
it("COU · S · COL · R · X", () => {
|
||||
lineSelected("0,0", "10,0");
|
||||
type("ar");
|
||||
enter(); // <직사각형>
|
||||
type("cou");
|
||||
expect(promptLine()).toBe("열 수 입력 <4>:");
|
||||
type("2");
|
||||
expect(promptLine()).toBe("행 수 입력 <3>:");
|
||||
type("2");
|
||||
type("s");
|
||||
expect(promptLine()).toBe("열 사이의 거리 지정 <15.0000>:");
|
||||
type("20");
|
||||
expect(promptLine()).toBe("행 사이의 거리 지정 <15.0000>:");
|
||||
type("30");
|
||||
type("col");
|
||||
type("3");
|
||||
expect(promptLine()).toBe("열 사이의 거리 지정 <20.0000>:");
|
||||
enter();
|
||||
type("r");
|
||||
expect(promptLine()).toBe("행 수 입력 <2>:");
|
||||
type("1");
|
||||
expect(promptLine()).toBe("행 사이의 거리 지정 <30.0000>:");
|
||||
enter();
|
||||
type("x");
|
||||
expect(lineEnds()).toEqual([
|
||||
[0, 0, 10, 0],
|
||||
[20, 0, 30, 0],
|
||||
[40, 0, 50, 0],
|
||||
]);
|
||||
});
|
||||
|
||||
it("PO — 중심 · I · F · A · ROT · 유형 기억", () => {
|
||||
lineSelected("10,0", "20,0");
|
||||
type("ar");
|
||||
type("po");
|
||||
expect(promptLine()).toBe("배열의 중심점 지정:");
|
||||
type("0,0");
|
||||
expect(promptLine()).toBe(
|
||||
"그립을 선택하여 배열을 편집하거나 [항목(I)/사이의 각도(A)/채울 각도(F)/항목 회전(ROT)/종료(X)] <종료>:",
|
||||
);
|
||||
expect(getGhostHelperEntities()).toHaveLength(5); // 6 개 360°
|
||||
type("i");
|
||||
expect(promptLine()).toBe("배열의 항목 수 입력 <6>:");
|
||||
type("4");
|
||||
type("x");
|
||||
expect(lineEnds()).toEqual([
|
||||
[-10, 0, -20, 0],
|
||||
[0, -10, 0, -20],
|
||||
[0, 10, 0, 20],
|
||||
[10, 0, 20, 0],
|
||||
]);
|
||||
|
||||
setSelectedEntityIds([entities()[0].id]);
|
||||
type("ar");
|
||||
expect(promptLine()).toBe("배열 유형 입력 [직사각형(R)/원형(PO)] <원형>:");
|
||||
enter();
|
||||
type("0,0");
|
||||
type("i");
|
||||
type("3");
|
||||
type("f");
|
||||
expect(promptLine()).toBe("채울 각도 지정(+=ccw, -=cw) <360>:");
|
||||
type("180");
|
||||
type("a");
|
||||
expect(promptLine()).toBe("항목 사이의 각도 지정 <90>:");
|
||||
type("45");
|
||||
type("rot");
|
||||
expect(promptLine()).toBe("배열된 항목을 회전하시겠습니까? [예(Y)/아니오(N)] <예>:");
|
||||
type("n");
|
||||
type("x");
|
||||
// 원본 (10,0)-(20,0) · 가운데 (15,0) 을 45° · 90° 자리로 옮김(돌지 않음)
|
||||
const c45 = 15 / Math.SQRT2;
|
||||
expect(lineEnds()).toContainEqual([r(c45 - 5), r(c45), r(c45 + 5), r(c45)]);
|
||||
expect(lineEnds()).toContainEqual([-5, 15, 5, 15]);
|
||||
});
|
||||
|
||||
it("Esc = 안 만듦(편집 물음 · 숫자 물음)", () => {
|
||||
lineSelected("0,0", "10,0");
|
||||
type("ar");
|
||||
type("r");
|
||||
esc();
|
||||
expect(entities("Line")).toHaveLength(1);
|
||||
expect(getGhostHelperEntities()).toHaveLength(0);
|
||||
setSelectedEntityIds([entities()[0].id]);
|
||||
type("ar");
|
||||
type("po");
|
||||
type("0,0");
|
||||
type("i");
|
||||
esc();
|
||||
expect(entities("Line")).toHaveLength(1);
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PEDIT 고르기 · 고르기 공용(2-14 ④ · 2-15)", () => {
|
||||
it("PEDIT — 고른 것 바로 묶음 · 없으면 「객체 선택:」 · 빈 Enter · Esc = 끝", () => {
|
||||
line("0,0", "10,0");
|
||||
line("10,0", "10,10");
|
||||
type("pe");
|
||||
expect(promptLine()).toBe("객체 선택:");
|
||||
enter();
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
type("pe");
|
||||
esc();
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
setSelectedEntityIds(entities().map((entity) => entity.id));
|
||||
type("pe");
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
expect(entities("Line")).toHaveLength(0);
|
||||
expect(entities()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("고르기 물음의 ALL · L · P 는 거절 없이 선택 도구로 넘어감", () => {
|
||||
line("0,0", "10,0");
|
||||
type("m");
|
||||
for (const word of ["all", "l", "p"]) {
|
||||
type(word);
|
||||
expect(noticeLine()).toBeNull();
|
||||
expect(activeTool()).toBe(Tool.MOVE);
|
||||
}
|
||||
});
|
||||
|
||||
const selectTakesKeywords = !!(
|
||||
selectToolStateMachine.states.WAITING_FOR_FIRST_SELECT_POINT.config.on as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
)?.["OPTION.ALL"];
|
||||
|
||||
it.skipIf(!selectTakesKeywords)(
|
||||
"ALL · P(앞 고름) · L(마지막 객체) — 마 선택 도구 처리 뒤",
|
||||
() => {
|
||||
line("0,0", "10,0");
|
||||
line("0,20", "10,20");
|
||||
type("m");
|
||||
type("all");
|
||||
enter();
|
||||
type("0,0");
|
||||
type("0,5");
|
||||
expect(lineEnds()).toEqual([
|
||||
[0, 5, 10, 5],
|
||||
[0, 25, 10, 25],
|
||||
]);
|
||||
type("m");
|
||||
type("p");
|
||||
enter();
|
||||
type("0,0");
|
||||
type("5,0");
|
||||
expect(lineEnds()).toEqual([
|
||||
[5, 5, 15, 5],
|
||||
[5, 25, 15, 25],
|
||||
]);
|
||||
type("m");
|
||||
type("l");
|
||||
enter();
|
||||
type("0,0");
|
||||
type("0,100");
|
||||
expect(lineEnds()).toEqual([
|
||||
[5, 5, 15, 5],
|
||||
[5, 125, 15, 125],
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"_설명": "338 맞춤 응답 대역(설계 3-3 · 3-4) — 실물(북부청 임도 제1 · 4호표) 응답 · 가 창 1 단계 엔진",
|
||||
"_설명": "338 맞춤 응답 대역(설계 3-3 · 3-4) — 실물(북부청 임도 제1 · 4호표) 응답",
|
||||
"길": {
|
||||
"파일": "GET /layers/{층}/hopyo/{키}",
|
||||
"호표": "GET /layers/{층}/hopyo/{키}/{번}?to=&to_owner=",
|
||||
@@ -8,7 +8,7 @@
|
||||
"파일": {
|
||||
"키": "HP-000001",
|
||||
"파일": "2026년_북부지방산림청_임도사업_설계내역서(공통단가).xls",
|
||||
"올린때": "2026-10-15T23:51:45",
|
||||
"올린때": "2026-10-16T00:18:16",
|
||||
"올린이": {
|
||||
"id": 0,
|
||||
"이름": "시험"
|
||||
@@ -75,7 +75,7 @@
|
||||
"호표": {
|
||||
"키": "HP-000001",
|
||||
"파일": "2026년_북부지방산림청_임도사업_설계내역서(공통단가).xls",
|
||||
"올린때": "2026-10-15T23:51:45",
|
||||
"올린때": "2026-10-16T00:18:16",
|
||||
"올린이": {
|
||||
"id": 0,
|
||||
"이름": "시험"
|
||||
@@ -250,7 +250,12 @@
|
||||
"조항": [
|
||||
"건설품셈 공통 6-1-1"
|
||||
],
|
||||
"까닭": "조항 같음"
|
||||
"직종": [
|
||||
"콘크리트공"
|
||||
],
|
||||
"단위": true,
|
||||
"점수": 1.75,
|
||||
"까닭": "조항 같음 · 건설 공통 · 직종 콘크리트공 · 단위 맞음"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -262,6 +267,9 @@
|
||||
"글": "후보",
|
||||
"근거": {
|
||||
"조항": [],
|
||||
"직종": [],
|
||||
"단위": true,
|
||||
"점수": 0.0,
|
||||
"까닭": ""
|
||||
}
|
||||
},
|
||||
@@ -276,7 +284,12 @@
|
||||
"조항": [
|
||||
"건설품셈 공통 6-1-2"
|
||||
],
|
||||
"까닭": "앞 마디 같음"
|
||||
"직종": [
|
||||
"콘크리트공"
|
||||
],
|
||||
"단위": true,
|
||||
"점수": 1.25,
|
||||
"까닭": "앞 마디 같음 · 건설 공통 · 직종 콘크리트공 · 단위 맞음"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -288,6 +301,9 @@
|
||||
"글": "후보",
|
||||
"근거": {
|
||||
"조항": [],
|
||||
"직종": [],
|
||||
"단위": true,
|
||||
"점수": 0.0,
|
||||
"까닭": ""
|
||||
}
|
||||
},
|
||||
@@ -302,7 +318,12 @@
|
||||
"조항": [
|
||||
"건설품셈 공통 6-1-4"
|
||||
],
|
||||
"까닭": "앞 마디 같음"
|
||||
"직종": [
|
||||
"콘크리트공"
|
||||
],
|
||||
"단위": true,
|
||||
"점수": 1.25,
|
||||
"까닭": "앞 마디 같음 · 건설 공통 · 직종 콘크리트공 · 단위 맞음"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -439,7 +460,12 @@
|
||||
"조항": [
|
||||
"건설품셈 공통 6-3-1"
|
||||
],
|
||||
"까닭": "조항 같음"
|
||||
"직종": [
|
||||
"형틀목공"
|
||||
],
|
||||
"단위": true,
|
||||
"점수": 1.75,
|
||||
"까닭": "조항 같음 · 건설 공통 · 직종 형틀목공 · 단위 맞음"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -451,6 +477,9 @@
|
||||
"글": "후보",
|
||||
"근거": {
|
||||
"조항": [],
|
||||
"직종": [],
|
||||
"단위": true,
|
||||
"점수": 0.0,
|
||||
"까닭": ""
|
||||
}
|
||||
},
|
||||
@@ -465,7 +494,12 @@
|
||||
"조항": [
|
||||
"건설품셈 공통 6-3-3"
|
||||
],
|
||||
"까닭": "앞 마디 같음"
|
||||
"직종": [
|
||||
"형틀목공"
|
||||
],
|
||||
"단위": true,
|
||||
"점수": 1.25,
|
||||
"까닭": "앞 마디 같음 · 건설 공통 · 직종 형틀목공 · 단위 맞음"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -479,7 +513,12 @@
|
||||
"조항": [
|
||||
"건설품셈 공통 6-3-7"
|
||||
],
|
||||
"까닭": "앞 마디 같음"
|
||||
"직종": [
|
||||
"형틀목공"
|
||||
],
|
||||
"단위": true,
|
||||
"점수": 1.25,
|
||||
"까닭": "앞 마디 같음 · 건설 공통 · 직종 형틀목공 · 단위 맞음"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -493,7 +532,12 @@
|
||||
"조항": [
|
||||
"건설품셈 공통 6-3-4"
|
||||
],
|
||||
"까닭": "앞 마디 같음"
|
||||
"직종": [
|
||||
"형틀목공"
|
||||
],
|
||||
"단위": true,
|
||||
"점수": 1.25,
|
||||
"까닭": "앞 마디 같음 · 건설 공통 · 직종 형틀목공 · 단위 맞음"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -634,7 +678,12 @@
|
||||
"조항": [
|
||||
"건설품셈 공통 3-4-8"
|
||||
],
|
||||
"까닭": "조항 같음"
|
||||
"직종": [
|
||||
"특별인부"
|
||||
],
|
||||
"단위": true,
|
||||
"점수": 1.75,
|
||||
"까닭": "조항 같음 · 건설 공통 · 직종 특별인부 · 단위 맞음"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -648,7 +697,12 @@
|
||||
"조항": [
|
||||
"건설품셈 공통 3-4-4"
|
||||
],
|
||||
"까닭": "앞 마디 같음"
|
||||
"직종": [
|
||||
"특별인부"
|
||||
],
|
||||
"단위": true,
|
||||
"점수": 1.25,
|
||||
"까닭": "앞 마디 같음 · 건설 공통 · 직종 특별인부 · 단위 맞음"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -662,7 +716,12 @@
|
||||
"조항": [
|
||||
"건설품셈 공통 3-4-6"
|
||||
],
|
||||
"까닭": "앞 마디 같음"
|
||||
"직종": [
|
||||
"특별인부"
|
||||
],
|
||||
"단위": true,
|
||||
"점수": 1.25,
|
||||
"까닭": "앞 마디 같음 · 건설 공통 · 직종 특별인부 · 단위 맞음"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -676,7 +735,12 @@
|
||||
"조항": [
|
||||
"건설품셈 공통 3-4-5"
|
||||
],
|
||||
"까닭": "앞 마디 같음"
|
||||
"직종": [
|
||||
"특별인부"
|
||||
],
|
||||
"단위": true,
|
||||
"점수": 1.25,
|
||||
"까닭": "앞 마디 같음 · 건설 공통 · 직종 특별인부 · 단위 맞음"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -690,7 +754,12 @@
|
||||
"조항": [
|
||||
"건설품셈 공통 3-4-7"
|
||||
],
|
||||
"까닭": "앞 마디 같음"
|
||||
"직종": [
|
||||
"특별인부"
|
||||
],
|
||||
"단위": true,
|
||||
"점수": 1.25,
|
||||
"까닭": "앞 마디 같음 · 건설 공통 · 직종 특별인부 · 단위 맞음"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -947,7 +1016,12 @@
|
||||
"조항": [
|
||||
"건설품셈 공통 6-2-3"
|
||||
],
|
||||
"까닭": "윗마디 같음"
|
||||
"직종": [
|
||||
"철근공"
|
||||
],
|
||||
"단위": true,
|
||||
"점수": 1.65,
|
||||
"까닭": "윗마디 같음 · 건설 공통 · 직종 철근공 · 단위 맞음"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -961,7 +1035,12 @@
|
||||
"조항": [
|
||||
"건설품셈 공통 6-2-2"
|
||||
],
|
||||
"까닭": "윗마디 같음"
|
||||
"직종": [
|
||||
"철근공"
|
||||
],
|
||||
"단위": true,
|
||||
"점수": 1.65,
|
||||
"까닭": "윗마디 같음 · 건설 공통 · 직종 철근공 · 단위 맞음"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -973,6 +1052,9 @@
|
||||
"글": "후보",
|
||||
"근거": {
|
||||
"조항": [],
|
||||
"직종": [],
|
||||
"단위": true,
|
||||
"점수": 0.0,
|
||||
"까닭": ""
|
||||
}
|
||||
},
|
||||
@@ -985,6 +1067,9 @@
|
||||
"글": "후보",
|
||||
"근거": {
|
||||
"조항": [],
|
||||
"직종": [],
|
||||
"단위": true,
|
||||
"점수": 0.0,
|
||||
"까닭": ""
|
||||
}
|
||||
},
|
||||
@@ -999,7 +1084,12 @@
|
||||
"조항": [
|
||||
"건설품셈 공통 6-2-4"
|
||||
],
|
||||
"까닭": "윗마디 같음"
|
||||
"직종": [
|
||||
"철근공"
|
||||
],
|
||||
"단위": true,
|
||||
"점수": 1.65,
|
||||
"까닭": "윗마디 같음 · 건설 공통 · 직종 철근공 · 단위 맞음"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -54,6 +54,7 @@ global.sessionStorage = {
|
||||
removeItem: (k) => store.delete(k),
|
||||
};
|
||||
|
||||
let out0 = null;
|
||||
(async () => {
|
||||
const input = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
const calls = [];
|
||||
@@ -73,6 +74,36 @@ global.sessionStorage = {
|
||||
const sheet = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Sheet.ts"));
|
||||
const api = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Api.ts"));
|
||||
const pick = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Pick.ts"));
|
||||
if (input.real) {
|
||||
// 실물 맞춤 응답(338-4) — 서버 줄을 그대로 먹여 체크 · 몸 · 빠질 줄 · 품 비교 · 글
|
||||
const lines = input.real;
|
||||
const d = check.loadDraft("real");
|
||||
out0 = {
|
||||
body: check.bodyOf(lines, d),
|
||||
leftOut: check.leftOut(lines, d),
|
||||
sure: check.sureLines(lines, d).map((l) => l.차례),
|
||||
lines: lines.map((l) => {
|
||||
const p = check.partsOf(l, d)[0];
|
||||
return {
|
||||
이름: l.이름,
|
||||
상태: l.맞춤.상태,
|
||||
checkable: check.checkable(l, d),
|
||||
checked: check.isChecked(l, d),
|
||||
compare: check.compareText(l.맞춤.품),
|
||||
qty: choose.qtyText(l.맞춤.품?.엑셀),
|
||||
target: choose.targetText(p),
|
||||
inputs: p ? choose.inputsText(p.입력값 ?? {}, p.못읽음 ?? [], p.출처 ?? {}) : "",
|
||||
cands: (l.맞춤.후보 ?? []).map((c) => [
|
||||
choose.targetText(c),
|
||||
c.판정 ?? "",
|
||||
choose.basisTag(c.근거),
|
||||
]),
|
||||
};
|
||||
}),
|
||||
};
|
||||
process.stdout.write(JSON.stringify(out0));
|
||||
return;
|
||||
}
|
||||
const { lines } = input;
|
||||
const scope = { layer: "personal", owner: null };
|
||||
const key = check.draftKey(scope, "HP-000001", 3);
|
||||
|
||||
@@ -9,6 +9,7 @@ master_data 원본 그대로(sha256). 층은 임시 폴더.
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -18,6 +19,8 @@ from fastapi.testclient import TestClient
|
||||
from common_util.common_util_auth import verify_session
|
||||
from config import config_system
|
||||
from M02_MasterTemplete import M02_Hopyo_Excel as hx
|
||||
from M02_MasterTemplete import M02_Hopyo_Fit as fit
|
||||
from M02_MasterTemplete import M02_Hopyo_Ours as ours
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hs
|
||||
from M02_MasterTemplete import M02_Labor_Router as labor_router
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
@@ -120,6 +123,19 @@ def test_xlsx_길():
|
||||
|
||||
|
||||
# ── 저장 · 길 ──────────────────────────────────────────────────────────
|
||||
def calc_cache(tmp_path, monkeypatch) -> Path:
|
||||
"""맞춤셈(338) — 캐시 = 임시 폴더(우리 지문 사본 · 시험 끝에 같이 지움) · 뒤 셈 안 돌림
|
||||
(`ensure` = 상태만) — 숫자 판정이 필요한 시험은 `fit.run_job` 을 바로 부름."""
|
||||
if not ours.ready():
|
||||
ours.build()
|
||||
cache = tmp_path / "hopyo_cache"
|
||||
cache.mkdir(exist_ok=True)
|
||||
shutil.copy(ours.path_of(), cache / ours.path_of().name)
|
||||
monkeypatch.setenv("AISLO_HOPYO_CACHE", str(cache))
|
||||
monkeypatch.setattr(fit, "ensure", fit.status)
|
||||
return cache
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path, monkeypatch):
|
||||
system = tmp_path / "system"
|
||||
@@ -129,6 +145,7 @@ def client(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
|
||||
(tmp_path / "storage").mkdir()
|
||||
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage"))
|
||||
calc_cache(tmp_path, monkeypatch)
|
||||
app = FastAPI()
|
||||
app.include_router(labor_router.router)
|
||||
who = dict(USER)
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""338-4 이음매 — 끝까지(시험 클라이언트 · 임시 층): 북부청 임도 올림 → 셈 끝 → 호표 보기(맞음 ·
|
||||
다른 점 ·
|
||||
후보) → 화면 도우미(`helper_m02_hopyo_check.cjs` · 실물 응답)가 만든 몸(체크한 줄만) →
|
||||
[확인하고 사전조합 만들기] → 사전조합 줄(로직 여럿 같은 별칭 · 배 · 몫) · 별칭 장부 → 다시 올림 =
|
||||
별칭 체크 ·
|
||||
몸 없는 만들기 400 · 옛 hopyo-memory 404 · STmate 영월 올림 → 상태 · master_data 원본 그대로.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Fit as fit
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
from resources.tester.helper_m02_hopyo_golden import FILES
|
||||
from resources.tester.test_m02_hopyo_excel import URL, _post, client, master_hash # noqa: F401
|
||||
|
||||
# ruff: noqa: F811
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
FOLDER = (7, 42) # USER 의 개인 층
|
||||
|
||||
|
||||
def _node(payload: dict) -> dict:
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_m02_hopyo_check.cjs")],
|
||||
input=json.dumps(payload, ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=False,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def _up(client, key: str, name: str | None = None, data: bytes | None = None) -> None:
|
||||
res = _post(client, data=data, **({"name": name} if name else {}))
|
||||
assert res.status_code == 200, res.text
|
||||
assert res.json()["키"] == key
|
||||
fit.run_job(layers.personal_dir(*FOLDER), key) # 셈 끝(뒤 셈 대신 바로)
|
||||
|
||||
|
||||
def _one(client, key: str, no: int) -> dict:
|
||||
res = client.get(f"{URL.format('personal')}/{key}/{no}")
|
||||
assert res.status_code == 200, res.text
|
||||
return res.json()["호표"]
|
||||
|
||||
|
||||
def _find(client, key: str, name: str) -> tuple[int, dict]:
|
||||
whole = client.get(f"{URL.format('personal')}/{key}").json()
|
||||
assert whole["셈"]["끝"]
|
||||
for h in whole["호표"]:
|
||||
one = _one(client, key, h["번"])
|
||||
if any(x["이름"] == name for x in one["줄"]):
|
||||
return h["번"], one
|
||||
raise KeyError(name)
|
||||
|
||||
|
||||
def _build(client, key: str, no: int, lines: list, **extra) -> dict:
|
||||
body = {"층": "personal", "키": key, "번": no, "줄": lines, **extra}
|
||||
res = client.post("/api/m02/layers/personal/hopyo-build", json=body)
|
||||
assert res.status_code == 200, res.text
|
||||
return res.json()
|
||||
|
||||
|
||||
def _doc(ref: dict) -> dict:
|
||||
path = layers.personal_dir(*FOLDER) / "precombo" / f"{ref['키']}.json"
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_끝까지_확인한_줄만_별칭(client):
|
||||
before = master_hash()
|
||||
_up(client, "HP-000001")
|
||||
no, hopyo = _find(client, "HP-000001", "레미콘타설(장비사용)")
|
||||
by = {x["이름"]: x for x in hopyo["줄"]}
|
||||
assert by["레미콘타설(장비사용)"]["맞춤"]["상태"] == "맞음"
|
||||
screen = _node({"real": hopyo["줄"]})
|
||||
seen = {x["이름"]: x for x in screen["lines"]}
|
||||
remicon = seen["레미콘타설(장비사용)"]
|
||||
assert remicon["checked"] and remicon["checkable"]
|
||||
assert remicon["compare"].startswith("콘크리트공 0.1091 → 0.1091 ✓")
|
||||
assert "굴착기 0.29 → 0.2909 ✓" in remicon["compare"]
|
||||
assert "소량감률=50(블록)" in remicon["inputs"] and remicon["target"].endswith("[㎥]")
|
||||
states = {x["상태"] for x in screen["lines"]}
|
||||
assert {"맞음", "다른 점", "후보"} <= states
|
||||
body = screen["body"] # 화면이 만든 몸 = 서버 체크 제안 줄만
|
||||
suggested = [x["차례"] for x in hopyo["줄"] if x["맞춤"]["체크"]]
|
||||
assert [x["차례"] for x in body] == suggested and screen["leftOut"]["확인 안 함"] > 0
|
||||
rebar = next(x for x in hopyo["줄"] if x["이름"] == "철근가공조립(현장)")
|
||||
two = [{k: p[k] for k in ("로직", "입력값", "배")} for p in rebar["맞춤"]["대상"]]
|
||||
assert {p["로직"] for p in two} == {"GC000206", "GC000207"} # 다른 점 → 사용자가 체크
|
||||
body = body + [{"차례": rebar["차례"], "대상": two}]
|
||||
made = _build(client, "HP-000001", no, body)
|
||||
assert made["다시씀"] is False and made["장부"]["씀"] == len(body)
|
||||
assert all(m["까닭"] for m in made["빠진줄"]) # 확인 안 함 · % 줄
|
||||
doc = _doc(made["참조"])
|
||||
logics = [x for x in doc["담은로직"] if x.get("로직")]
|
||||
alias = {x["로직"]: x["별칭"] for x in logics}
|
||||
assert alias["GC000206"] == alias["GC000207"] == "철근가공조립(현장)" # 로직 여럿 · 같은 별칭
|
||||
assert doc["본뜬"]["확인"] and doc["본뜬"]["키"] == f"HP-000001 제{no}호표"
|
||||
rows = client.get("/api/m02/layers/personal/hopyo-ledger").json()["줄"]
|
||||
keys = {r["열쇠"]: r for r in rows}
|
||||
assert {p["로직"] for p in keys[rebar["맞춤"]["열쇠"]]["짝"]} == {"GC000206", "GC000207"}
|
||||
# 다시 올림 = 확인한 줄은 별칭 · 체크 · 만든 것이 보임
|
||||
_up(client, "HP-000002")
|
||||
again = {x["이름"]: x["맞춤"] for x in _one(client, "HP-000002", no)["줄"]}
|
||||
assert again["레미콘타설(장비사용)"]["상태"] == "별칭" and again["레미콘타설(장비사용)"]["체크"]
|
||||
assert again["철근가공조립(현장)"]["상태"] == "별칭"
|
||||
assert again["철근가공조립(현장)"]["대상"][0]["출처"] == {
|
||||
k: "별칭" for k in again["철근가공조립(현장)"]["대상"][0]["입력값"]
|
||||
}
|
||||
first = _one(client, "HP-000001", no)
|
||||
assert first["만든"] == {"키": made["참조"]["키"], "확인": True}
|
||||
assert master_hash() == before
|
||||
|
||||
|
||||
def test_섞인_줄_몫_배(client):
|
||||
_up(client, "HP-000001")
|
||||
no, hopyo = _find(client, "HP-000001", "고임돌 채집 및 운반")
|
||||
line = next(x for x in hopyo["줄"] if x["이름"] == "고임돌 채집 및 운반")
|
||||
part = line["맞춤"]["대상"][0]
|
||||
assert (part["로직"], part["몫"], part["배"]) == ("GF000211", 0.1, 0.1)
|
||||
screen = _node({"real": [line]})
|
||||
assert screen["lines"][0]["checkable"] and not screen["lines"][0]["checked"]
|
||||
one = {k: part[k] for k in ("로직", "입력값", "배", "몫")}
|
||||
made = _build(client, "HP-000001", no, [{"차례": line["차례"], "대상": [one]}])
|
||||
row = next(x for x in _doc(made["참조"])["담은로직"] if x.get("로직") == "GF000211")
|
||||
assert row["수량"]["손"] == round(line["수량"] * 0.1, 6)
|
||||
|
||||
|
||||
def test_막는_길(client):
|
||||
_up(client, "HP-000001")
|
||||
empty = client.post(
|
||||
"/api/m02/layers/personal/hopyo-build",
|
||||
json={"층": "personal", "키": "HP-000001", "번": 1, "줄": []},
|
||||
)
|
||||
assert empty.status_code == 400 and "확인한 줄이 없음" in empty.text
|
||||
money = client.post(
|
||||
"/api/m02/layers/personal/hopyo-build",
|
||||
json={"층": "personal", "키": "HP-000001", "번": 1, "줄": [{"차례": 1, "금액": 1}]},
|
||||
)
|
||||
assert money.status_code in (400, 422)
|
||||
gone = client.post("/api/m02/layers/personal/hopyo-memory", json={"줄": {}})
|
||||
assert gone.status_code in (404, 405)
|
||||
url = f"{URL.format('personal')}/HP-000001/1/guess"
|
||||
many = client.post(url, json={"차례": 1, "대상": [{"로직": "GC000198", "입력값": {}}]})
|
||||
assert many.status_code == 200 and many.json()["대상"][0]["로직"] == "GC000198"
|
||||
|
||||
|
||||
def test_STmate_영월(client):
|
||||
path = FILES["동부청영월"]
|
||||
_up(client, "HP-000001", path.name, path.read_bytes())
|
||||
whole = client.get(f"{URL.format('personal')}/HP-000001").json()
|
||||
assert whole["셈"]["끝"] and len(whole["호표"]) == 23 and whole["맞춤"][0] > 0
|
||||
states = set()
|
||||
for h in whole["호표"]:
|
||||
states |= {x["맞춤"]["상태"] for x in _one(client, "HP-000001", h["번"])["줄"]}
|
||||
assert {"맞음", "후보"} <= states
|
||||
@@ -2,13 +2,10 @@
|
||||
맞춤 차례 · 기억 우선 · 길(보기 · 미리 채움 · 확정) · master_data 원본 그대로. 층은 임시 폴더.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
|
||||
import pytest
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Fit as fit
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as hm
|
||||
from M02_MasterTemplete import M02_Hopyo_Ours as ours
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
|
||||
# 올리기 길 시험의 `client` 마당을 같이 씀
|
||||
@@ -91,18 +88,6 @@ def test_규격에서_입력값():
|
||||
|
||||
|
||||
# ── 맞춤 응답(338 · 설계 3-3) ─────────────────────────────────────────
|
||||
@pytest.fixture(autouse=True)
|
||||
def calc(tmp_path, monkeypatch):
|
||||
"""맞춤셈 캐시 = 임시 폴더(우리 지문 사본) · 뒤 셈은 시험이 바로 돌림(`run_job`)."""
|
||||
if not ours.ready():
|
||||
ours.build()
|
||||
cache = tmp_path / "hopyo_cache"
|
||||
cache.mkdir()
|
||||
shutil.copy(ours.path_of(), cache / ours.path_of().name)
|
||||
monkeypatch.setenv("AISLO_HOPYO_CACHE", str(cache))
|
||||
monkeypatch.setattr(fit, "ensure", fit.status)
|
||||
|
||||
|
||||
def _hopyo(client, no, layer="personal"):
|
||||
res = client.get(f"{URL.format(layer)}/HP-000001/{no}")
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
@@ -342,3 +342,29 @@ def test_엑셀_보기_창_여는_자리와_늦게_불러옴():
|
||||
r'^import \{[^}]*\} from "\.\./A00_Common/spreadsheet/spreadsheet"', sheet, re.M
|
||||
)
|
||||
assert "readOnly: true" in sheet and "wide: true" in sheet
|
||||
|
||||
|
||||
def test_실물_응답_대역_확인_창():
|
||||
"""338-4 — 가 창 실물 응답(`fixtures/m02_hopyo_view_sample.json`)을 확인 창 · 고르기 창 도우미에
|
||||
그대로 먹임 — 체크 = 서버 제안 · 몸 꼴 · 품 비교 · 옵션 출처 · 후보 판정 꼬리표."""
|
||||
sample = json.loads((Path(HERE) / "fixtures" / "m02_hopyo_view_sample.json").read_bytes())
|
||||
lines = sample["호표"]["호표"]["줄"]
|
||||
got = _node("helper_m02_hopyo_check.cjs", {"real": lines})
|
||||
by = {x["이름"]: x for x in got["lines"]}
|
||||
remicon = by["레미콘타설(장비사용)"]
|
||||
assert remicon["상태"] == "맞음" and remicon["checked"] and remicon["checkable"]
|
||||
assert (
|
||||
remicon["compare"]
|
||||
== "콘크리트공 0.1091 → 0.1091 ✓ · 보통인부 0.0364 → 0.0364 ✓ · 굴착기 0.29 → 0.2909 ✓"
|
||||
)
|
||||
assert (
|
||||
remicon["inputs"]
|
||||
== "타설방법=장비사용타설(규격), 구조물=철근구조물(규격), 소량감률=50(블록)"
|
||||
)
|
||||
assert remicon["qty"] == "콘크리트공 0.1091 · 보통인부 0.0364 · 굴착기 0.29hr"
|
||||
assert remicon["cands"][0][1] == "맞음" and remicon["cands"][0][2].startswith("조항 건설품셈")
|
||||
rebar = by["철근가공조립(현장)"]
|
||||
assert rebar["상태"] == "다른 점" and not rebar["checked"] and rebar["checkable"]
|
||||
assert [x["차례"] for x in got["body"]] == [x["차례"] for x in lines if x["맞춤"]["체크"]]
|
||||
assert all({"로직", "입력값", "배"} <= set(t) for x in got["body"] for t in x["대상"])
|
||||
assert got["sure"] == [x["차례"] for x in lines if x["맞춤"]["상태"] == "맞음"]
|
||||
|
||||
@@ -2,14 +2,18 @@
|
||||
화면 도우미(`helper_m02_hopyo_seam.cjs` → Hopyo_Sheet · Hopyo_View)에 먹임.
|
||||
|
||||
행 22 → 제3호표 · 25 빈 줄 · 26 → 제4호표 · 영역 B20:O24 · 단산93 행 4229:4244 ·
|
||||
엑셀 근거 줄 · 근거 꼬리표 · 근거 맞음 · 북부청 「후보」 줄 1순위 근거 맞음 = 서버 기준 123
|
||||
(338 — 근거 맞음 모두 확정 단추 없앰 · 도우미는 고르기 창 `Hopyo_Choose`).
|
||||
엑셀 근거 줄 · 근거 꼬리표 · 근거 맞음 · 도우미는 고르기 창 `Hopyo_Choose`.
|
||||
338 — 같음 상태 없앰 · 숫자 판정(셈 끝)으로 북부청 단가산출 268 줄 = 맞음 122 · 다른 점 86 · 후보 60
|
||||
·
|
||||
「후보」 줄 1순위 근거 맞음 123 → 9(근거 맞는 줄은 거의 맞음 · 다른 점으로 옮겨 감).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Fit as fit
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
from resources.tester import test_m02_hopyo_excel as base
|
||||
from resources.tester.test_m02_hopyo_excel import URL, _post
|
||||
|
||||
@@ -32,6 +36,7 @@ def _node(payload: dict) -> dict:
|
||||
|
||||
def test_실제_응답_화면_도우미(client):
|
||||
assert _post(client).status_code == 200
|
||||
fit.run_job(layers.personal_dir(7, 42), "HP-000001") # 셈 끝(338 숫자 판정)
|
||||
grid = client.get("/api/m02/layers/personal/hopyo-grid/HP-000001").json()
|
||||
lines = []
|
||||
for no in (3, 12, 38, 58):
|
||||
@@ -42,7 +47,11 @@ def test_실제_응답_화면_도우미(client):
|
||||
res = client.get(f"{URL.format('personal')}/HP-000001/{one['번']}").json()
|
||||
every += [x for x in res["호표"]["줄"] if x["갈래"] == "단가산출"]
|
||||
got = _node({"grid": grid, "lines": lines, "all": every})
|
||||
assert got["count"]["top"] == 123
|
||||
states = {
|
||||
k: sum(1 for x in every if x["맞춤"]["상태"] == k) for k in ("맞음", "다른 점", "후보")
|
||||
}
|
||||
assert states == {"맞음": 122, "다른 점": 86, "후보": 60} # 268 줄 · 셈 끝
|
||||
assert got["count"]["top"] == 9 # 「후보」 줄 1순위 근거 맞음(근거 맞는 줄은 거의 숫자로 풀림)
|
||||
assert got["at"] == [None, 3, 3, 3, None, 4] # 25행 = 호표 사이 빈 줄
|
||||
assert got["span"] == "B20:O24" and got["rows"] == "4229:4244"
|
||||
by = {x["이름"]: x for x in got["lines"]}
|
||||
|
||||
Reference in New Issue
Block a user