Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_7' into main_laptop_1

This commit is contained in:
2026-10-16 00:26:20 +09:00
16 changed files with 1630 additions and 842 deletions
@@ -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,
});
+299 -282
View File
@@ -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,
});
+8 -2
View File
@@ -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})
+9 -4
View File
@@ -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(" · ");
}
+357
View File
@@ -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);
+17
View File
@@ -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)
+158
View File
@@ -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
-15
View File
@@ -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
+26
View File
@@ -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["맞춤"]["상태"] == "맞음"]
+12 -3
View File
@@ -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"]}