feat(m01-sl): 시트 로직 Model · Eval 속 채움 · 시험

- 상자 더하기(종류별 틀 시트 · 칸 A 이름 · B 값 · C 설명) · 지우기 · 같은 이름은 「 (2)」
- 선 잇기 = 받는 입력 알약 칸에 「'시트'!칸」 참조 수식(여러 선은 +) · 끊기 = 그 참조 뺌 · 순환 · 같은 상자 · 두 번 · 없는 알약은 막고 까닭
- 알약 정하기 · 빼기(이름 · 칸이 바뀌면 선이 따라감)
- 바깥 값 칸 목록 · 넣기(표는 칸 덩어리) · 화면 계산 = 우리 스프레드시트 엔진 recalcWorkbook · 결과 노무비 · 재료비 · 경비 · 계
- 검사: 빈 입력 알약 · 안 받은 바깥 값 · 결과 상자 없음 / 선 없음 · 오류 칸 · 순환(#CYCLE!)
- 시험 resources/tester/test_m01_sl_model.py 10개(엔진 그대로 Node)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
This commit is contained in:
2026-10-01 13:04:15 +09:00
co-authored by Claude Opus 5.5
parent ddaff8a6b8
commit 67e4c27658
3 changed files with 728 additions and 44 deletions
+130 -13
View File
@@ -2,9 +2,14 @@
* M01_SL_Eval.ts
* 시트 로직 화면 계산 — 바깥 값 넣기 → 우리 스프레드시트 엔진(브라우저) → 출력 알약 · 결과 읽기 · 검사
* 저장 때는 서버가 같은 엔진을 Node 로(정함 5) · DOM 없음
* 엔진을 정적으로 가져옴 — 화면(Entry)은 SheetLogic 을 import() 로 늦게 불러 M01 첫 묶음에 안 들어가게
* ========================================================================== */
import type { SheetLogic, SlOutside } from "./M01_SL_Model";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { parseA1, toA1 } from "../../A00_Common/spreadsheet/spreadsheet_address";
import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc";
import type { CalcValue, Cell } from "../../A00_Common/spreadsheet/spreadsheet_types";
import { nodeSheet, type SheetLogic, type SlNode, type SlOutside } from "./M01_SL_Model";
/** 알약 값 — 수 · 글 · 참거짓 · 빈 칸 */
export type SlValue = number | string | boolean | null;
@@ -19,31 +24,143 @@ export interface SlOutsideValue {
export interface SlEvalResult {
/** 상자 id → 출력 알약 이름 → 값 */
pills: Record<string, Record<string, SlValue>>;
/** 상자 id → 입력 알약 이름 → 값(화면 알약 위 숫자) */
pillsIn: Record<string, Record<string, SlValue>>;
result: { 노무비: number | null; 재료비: number | null; 경비: number | null; 계: number | null };
/** 쉬운 말 */
errors: string[];
}
const notReady = (): never => {
throw new Error("준비 중");
};
const TEXT = {
/** {box} {cell} {err} */
CellError: [
"「{box}」 {cell} 칸 계산이 막힘 — {err}",
"「{box}」 cell {cell} can't be worked out — {err}",
],
Cycle: ["돌고 도는 참조", "circular reference"],
/** {box} {pill} */
EmptyIn: [
"「{box}」의 입력 알약 「{pill}」이 비어 있음",
"Input pill 「{pill}」 of 「{box}」 is empty",
],
NoOutside: [
"「{box}」의 바깥 값 「{pill}」을 아직 안 받음",
"Outside value 「{pill}」 of 「{box}」 not filled yet",
],
NoResult: ["결과 상자가 없음", "There is no result box"],
NoLine: ["결과 상자에 이어진 선이 없음", "No line reaches the result box"],
} as const;
const tl = (key: keyof typeof TEXT, fill: Record<string, string> = {}): string =>
Object.entries(fill).reduce(
(s, [k, v]) => s.replace(`{${k}}`, v),
TEXT[key][currentLanguageIndex === 1 ? 1 : 0] as string,
);
/* ── 바깥 값 ────────────────────────────────────────────────────────────── */
/** 바깥 값 칸 목록 — 화면 · 서버가 이 칸에 값을 넣음 */
export function outsideCells(_sl: SheetLogic): SlOutside[] {
return notReady();
export function outsideCells(sl: SheetLogic): SlOutside[] {
const kindOf = (n: SlNode): SlOutside["kind"] =>
n.kind === "table" ? "table" : n.kind === "logic" ? "logic" : "price";
return sl.nodes.flatMap((n) =>
[...n.pillsIn, ...n.pillsOut]
.filter((p) => p.outside)
.map((p) => ({ node: n.id, cell: p.cell, kind: kindOf(n), ref: n.ref ?? "" })),
);
}
/** 칸 하나에 값 — null 이면 값을 뺌(서식은 둠) */
function putValue(칸: Record<string, Cell>, cell: string, value: SlValue): void {
const style = 칸[cell]?.서식;
if (value === null) {
if (style) 칸[cell] = { 서식: style };
else delete 칸[cell];
} else 칸[cell] = { 값: value, ...(style ? { 서식: style } : {}) };
}
/** 바깥 값 넣기 — 새 시트 로직(받은 것은 안 바꿈) */
export function applyOutside(_sl: SheetLogic, _values: SlOutsideValue[]): SheetLogic {
return notReady();
export function applyOutside(sl: SheetLogic, values: SlOutsideValue[]): SheetLogic {
const out = structuredClone(sl);
for (const v of values) {
const sheet = nodeSheet(out, v.node);
const at = parseA1(v.cell);
if (!sheet || !at) continue;
if (!Array.isArray(v.value)) {
putValue(sheet.칸, toA1(at.r, at.c), v.value);
continue;
}
v.value.forEach((line, dr) =>
line.forEach((x, dc) => putValue(sheet.칸, toA1(at.r + dr, at.c + dc), x)),
);
}
return out;
}
/* ── 계산 ───────────────────────────────────────────────────────────────── */
const fromCalc = (v: CalcValue | undefined): SlValue => {
if (!v) return null;
if ("수" in v) return Number(v.수);
if ("글" in v) return v.글;
if ("참" in v) return v.참;
return v.오류;
};
const isNum = (v: SlValue): v is number => typeof v === "number" && Number.isFinite(v);
/** 화면 계산 */
export function evalLogic(_sl: SheetLogic): SlEvalResult {
return notReady();
export function evalLogic(sl: SheetLogic): SlEvalResult {
const values = recalcWorkbook(sl.workbook);
const read = (n: SlNode, cell: string): SlValue => {
const c = nodeSheet(sl, n.id)?.칸[cell.toUpperCase()];
if (!c) return null;
if (c.식 !== undefined) return fromCalc(values[n.sheetId]?.[cell.toUpperCase()]);
return c.값 ?? null;
};
const pills: SlEvalResult["pills"] = {};
const pillsIn: SlEvalResult["pillsIn"] = {};
for (const n of sl.nodes) {
pills[n.id] = Object.fromEntries(n.pillsOut.map((p) => [p.name, read(n, p.cell)]));
pillsIn[n.id] = Object.fromEntries(n.pillsIn.map((p) => [p.name, read(n, p.cell)]));
}
const errors: string[] = [];
for (const n of sl.nodes)
for (const [cell, v] of Object.entries(values[n.sheetId] ?? {}))
if ("오류" in v) {
const err = v.오류 === "#CYCLE!" ? `${v.오류} ${tl("Cycle")}` : v.오류;
errors.push(tl("CellError", { box: n.name, cell, err }));
}
const box = sl.nodes.find((n) => n.kind === "result");
const num = (v: SlValue | undefined): number | null => (isNum(v ?? null) ? (v as number) : null);
const result: SlEvalResult["result"] = box
? {
노무비: num(pillsIn[box.id]["노무비"]),
재료비: num(pillsIn[box.id]["재료비"]),
경비: num(pillsIn[box.id]["경비"]),
계: num(pills[box.id]["계"]),
}
: { 노무비: null, 재료비: null, 경비: null, 계: null };
return { pills, pillsIn, result, errors };
}
/** 검사 — 빈 입력 알약 · 순환 · 오류 칸(쉬운 말 목록) */
export function checkLogic(_sl: SheetLogic): string[] {
return notReady();
/** 검사 — 빈 입력 알약 · 받지 않은 바깥 값 · 순환 · 오류 칸 · 결과 상자 없음(쉬운 말 목록) */
export function checkLogic(sl: SheetLogic): string[] {
const out: string[] = [];
for (const n of sl.nodes) {
const sheet = nodeSheet(sl, n.id);
for (const p of [...n.pillsIn, ...n.pillsOut]) {
const c = sheet?.칸[p.cell.toUpperCase()];
const empty = !c || (c.식 === undefined && (c.값 === undefined || c.값 === ""));
if (!empty) continue;
if (p.outside) out.push(tl("NoOutside", { box: n.name, pill: p.name }));
// 결과 상자의 비목 알약은 비어도 0 — 안 쓰는 비목이 늘 있음
else if (n.pillsIn.includes(p) && n.kind !== "result")
out.push(tl("EmptyIn", { box: n.name, pill: p.name }));
}
}
const box = sl.nodes.find((n) => n.kind === "result");
if (!box) out.push(tl("NoResult"));
else if (!sl.edges.some((e) => e.to.node === box.id)) out.push(tl("NoLine"));
return [...out, ...evalLogic(sl).errors];
}
+335 -31
View File
@@ -1,11 +1,15 @@
/* =============================================================================
* M01_SL_Model.ts
* 시트 로직 Model — 로직 한 벌 = 통합문서 하나 · 상자 하나 = 시트 하나 · 알약 = 이름 붙은 칸
* 선 = 받는 입력 알약 칸에 「'시트 이름'!칸」 참조 수식 · DOM 없음
* 선 = 받는 입력 알약 칸에 「'시트 이름'!칸」 참조 수식(여러 선이면 「+」 로 이음) · DOM 없음
* 모양은 `docs/raw/M01_쉬운단가/시트로직_정함.md` 정함 1~5 그대로
*
* 모든 함수는 새 시트 로직을 돌려줌(받은 것은 안 바꿈) · 엔진(A00_Common/spreadsheet)은 가져다 쓰기만
* ========================================================================== */
import type { Workbook } from "../../A00_Common/spreadsheet/spreadsheet_types";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { quoteSheet } from "../../A00_Common/spreadsheet/spreadsheet_address";
import type { Cell, Sheet, Workbook } from "../../A00_Common/spreadsheet/spreadsheet_types";
/** 상자 종류 — 입력값 · 고정값 · 표 · 계산 · 항목 · 다른 로직 · 결과 */
export type SlKind = "input" | "const" | "table" | "calc" | "item" | "logic" | "result";
@@ -73,61 +77,361 @@ export interface SlEnd {
pill: string;
}
const notReady = (): never => {
throw new Error("준비 중");
const TEXT = {
Name: ["이름", "Name"],
Value: ["값", "Value"],
Note: ["설명", "Note"],
Sample: ["예시값", "Sample value"],
Fixed: ["늘 이 값", "Always this value"],
Cond: ["조건", "Condition"],
Found: ["결과", "Result"],
FromTable: ["표에서 찾은 값", "Value found in the table"],
ValueA: ["값1", "Value 1"],
ValueB: ["값2", "Value 2"],
Times: ["값1 × 값2", "Value 1 × Value 2"],
Qty: ["수량", "Quantity"],
Price: ["단가", "Unit price"],
Amount: ["금액", "Amount"],
QtyNote: ["선으로 받음", "Comes in by a line"],
PriceNote: ["바깥 값 — 열 때 넣음", "Outside value — filled on open"],
LogicNote: ["다른 로직 결과 — 열 때 넣음", "Other logic's result — filled on open"],
Sum: ["계", "Total"],
NoPill: ["알약이 없음", "No such pill"],
Self: ["같은 상자끼리는 못 이음", "A box can't link to itself"],
Loop: ["이으면 돌고 도는 선이 됨", "This line would make a loop"],
Twice: ["이미 이어져 있음", "Already linked"],
} as const;
const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
/** 비목 이름(결과 · 항목 칸) — 화면 글 아님 · 알약 이름 열쇠 */
const COSTS = ["노무비", "재료비", "경비"] as const;
/* ── 작은 도움 ──────────────────────────────────────────────────────────── */
const STAGE: Record<SlKind, number> = {
input: 0,
const: 0,
table: 1,
logic: 1,
calc: 2,
item: 3,
result: 4,
};
const clone = <T>(v: T): T => structuredClone(v);
/** 다음 번호 — 있는 id 들(「n3」)보다 하나 큰 것 */
function nextId(prefix: string, ids: string[]): string {
const nums = ids
.filter((x) => x.startsWith(prefix))
.map((x) => Number(x.slice(prefix.length)))
.filter((n) => Number.isInteger(n));
return `${prefix}${Math.max(0, ...nums) + 1}`;
}
/** 시트 이름 — 통합문서 안에서 하나(같으면 「 (2)」) */
function uniqueName(book: Workbook, name: string): string {
const base = name.trim() || "상자";
const used = new Set(book.시트.map((s) => s.이름));
if (!used.has(base)) return base;
for (let n = 2; ; n++) if (!used.has(`${base} (${n})`)) return `${base} (${n})`;
}
const sheetOf = (sl: SheetLogic, node: SlNode): Sheet => {
const sheet = sl.workbook.시트.find((s) => s.id === node.sheetId);
if (!sheet) throw new Error(`없는 시트 ${node.sheetId}`);
return sheet;
};
const nodeOf = (sl: SheetLogic, id: string): SlNode | undefined =>
sl.nodes.find((n) => n.id === id);
const pillOf = (node: SlNode, dir: "in" | "out", name: string): SlPill | undefined =>
(dir === "in" ? node.pillsIn : node.pillsOut).find((p) => p.name === name);
/* ── 상자 틀 ─────────────────────────────────────────────────────────────── */
type Row = [string, Cell | null, string];
/** 종류별 틀 — 1행 머리(이름 · 값 · 설명) · 2행부터 알약 줄 */
function template(kind: SlKind, name: string): { rows: Row[]; ins: SlPill[]; outs: SlPill[] } {
const v = (값: number | string): Cell => ({ 값 });
const f = (식: string): Cell => ({ 식 });
switch (kind) {
case "input":
return { rows: [[name, v(0), tl("Sample")]], ins: [], outs: [{ name, cell: "B2" }] };
case "const":
return { rows: [[name, v(0), tl("Fixed")]], ins: [], outs: [{ name, cell: "B2" }] };
case "table":
return {
rows: [
[tl("Cond"), null, tl("QtyNote")],
[tl("Found"), null, tl("FromTable")],
],
ins: [{ name: tl("Cond"), cell: "B2" }],
outs: [{ name: tl("Found"), cell: "B3" }],
};
case "calc":
return {
rows: [
[tl("ValueA"), null, tl("QtyNote")],
[tl("ValueB"), null, tl("QtyNote")],
[tl("Found"), f("B2*B3"), tl("Times")],
],
ins: [
{ name: tl("ValueA"), cell: "B2" },
{ name: tl("ValueB"), cell: "B3" },
],
outs: [{ name: tl("Found"), cell: "B4" }],
};
case "item":
return {
rows: [
[tl("Qty"), null, tl("QtyNote")],
[tl("Price"), null, tl("PriceNote")],
[tl("Amount"), f("B2*B3"), ""],
["노무비", f("B4"), ""],
["재료비", v(0), ""],
["경비", v(0), ""],
],
ins: [
{ name: tl("Qty"), cell: "B2" },
{ name: tl("Price"), cell: "B3", outside: true },
],
outs: [
{ name: tl("Amount"), cell: "B4" },
...COSTS.map((c, i) => ({ name: c, cell: `B${5 + i}` })),
],
};
case "logic":
return {
rows: [[tl("Found"), null, tl("LogicNote")]],
ins: [],
outs: [{ name: tl("Found"), cell: "B2", outside: true }],
};
case "result":
return {
rows: [
["노무비", null, tl("QtyNote")],
["재료비", null, tl("QtyNote")],
["경비", null, tl("QtyNote")],
[tl("Sum"), f("SUM(B2:B4)"), ""],
],
ins: COSTS.map((c, i) => ({ name: c, cell: `B${2 + i}` })),
outs: [{ name: "계", cell: "B5" }],
};
}
}
/* ── 만들기 · 상자 ───────────────────────────────────────────────────────── */
/** 빈 시트 로직 */
export function newLogic(_name?: string, _resultUnit?: string): SheetLogic {
return notReady();
export function newLogic(name = "", resultUnit = "원/㎡"): SheetLogic {
return {
key: null,
name,
resultUnit,
workbook: { 종류: "통합문서", 판: 1, 열: "SL", 서식: [{}], 시트: [] },
nodes: [],
edges: [],
};
}
/** 상자 더하기 — 그 종류 틀(칸 A 이름 · B 값 · C 설명)로 시트를 만듦 */
export function addNode(
_sl: SheetLogic,
_kind: SlKind,
_name: string,
sl: SheetLogic,
kind: SlKind,
name: string,
): { sl: SheetLogic; node: SlNode } {
return notReady();
const out = clone(sl);
const book = out.workbook;
const sheetId = nextId(
"s",
book.시트.map((s) => s.id),
);
const title = uniqueName(book, name);
const t = template(kind, title);
const 칸: Record<string, Cell> = {
A1: { 값: tl("Name") },
B1: { 값: tl("Value") },
C1: { 값: tl("Note") },
};
t.rows.forEach(([label, cell, note], i) => {
칸[`A${i + 2}`] = { 값: label };
if (cell) 칸[`B${i + 2}`] = cell;
if (note) 칸[`C${i + 2}`] = { 값: note };
});
book.시트.push({ id: sheetId, 이름: title, 칸 });
book.활성 ??= sheetId;
const node: SlNode = {
id: nextId(
"n",
out.nodes.map((n) => n.id),
),
kind,
name: title,
sheetId,
pillsIn: t.ins,
pillsOut: t.outs,
stage: STAGE[kind],
};
out.nodes.push(node);
return { sl: out, node };
}
/** 상자 지우기 — 시트 · 이어진 선 · 받던 참조 수식을 함께 지움 */
export function removeNode(_sl: SheetLogic, _node: string): SheetLogic {
return notReady();
export function removeNode(sl: SheetLogic, id: string): SheetLogic {
const node = nodeOf(sl, id);
if (!node) return sl;
let out = sl;
for (const e of sl.edges.filter((x) => x.from.node === id || x.to.node === id))
out = disconnect(out, e.id);
out = clone(out);
out.nodes = out.nodes.filter((n) => n.id !== id);
out.workbook.시트 = out.workbook.시트.filter((s) => s.id !== node.sheetId);
if (out.workbook.활성 === node.sheetId) out.workbook.활성 = out.workbook.시트[0]?.id;
return out;
}
/* ── 선 ─────────────────────────────────────────────────────────────────── */
/** 받는 알약 칸 수식 = 그 알약으로 오는 선들의 참조를 「+」 로 · 선이 없으면 칸을 비움 */
function rewriteTarget(sl: SheetLogic, to: SlEnd): void {
const node = nodeOf(sl, to.node);
const pill = node && pillOf(node, "in", to.pill);
if (!node || !pill) return;
const refs = sl.edges
.filter((e) => e.to.node === to.node && e.to.pill === to.pill)
.map((e) => {
const src = nodeOf(sl, e.from.node);
const out = src && pillOf(src, "out", e.from.pill);
return src && out ? `${quoteSheet(sheetOf(sl, src).이름)}!${out.cell}` : null;
})
.filter((x): x is string => x !== null);
const sheet = sheetOf(sl, node);
const keepStyle = sheet.칸[pill.cell]?.서식;
if (refs.length)
sheet.칸[pill.cell] = { 식: refs.join("+"), ...(keepStyle ? { 서식: keepStyle } : {}) };
else if (keepStyle) sheet.칸[pill.cell] = { 서식: keepStyle };
else delete sheet.칸[pill.cell];
}
/** from 상자에서 선을 따라 to 상자에 닿나 */
function reaches(sl: SheetLogic, from: string, to: string): boolean {
const seen = new Set<string>();
const walk = (id: string): boolean => {
if (id === to) return true;
if (seen.has(id)) return false;
seen.add(id);
return sl.edges.filter((e) => e.from.node === id).some((e) => walk(e.to.node));
};
return walk(from);
}
/** 선 잇기 — 받는 알약 칸에 참조 수식 · 순환 · 없는 알약이면 막고 까닭(쉬운 말) */
export function connect(
_sl: SheetLogic,
_from: SlEnd,
_to: SlEnd,
sl: SheetLogic,
from: SlEnd,
to: SlEnd,
): { sl: SheetLogic; why: string | null } {
return notReady();
const a = nodeOf(sl, from.node);
const b = nodeOf(sl, to.node);
if (!a || !b || !pillOf(a, "out", from.pill) || !pillOf(b, "in", to.pill))
return { sl, why: tl("NoPill") };
if (a.id === b.id) return { sl, why: tl("Self") };
const same = (e: SlEdge): boolean =>
e.from.node === from.node &&
e.from.pill === from.pill &&
e.to.node === to.node &&
e.to.pill === to.pill;
if (sl.edges.some(same)) return { sl, why: tl("Twice") };
if (reaches(sl, b.id, a.id)) return { sl, why: tl("Loop") };
const out = clone(sl);
out.edges.push({
id: nextId(
"e",
out.edges.map((e) => e.id),
),
from: { ...from },
to: { ...to },
});
rewriteTarget(out, to);
return { sl: out, why: null };
}
/** 선 끊기 — 그 참조를 받는 칸 수식에서 뺌 */
export function disconnect(_sl: SheetLogic, _edge: string): SheetLogic {
return notReady();
export function disconnect(sl: SheetLogic, edgeId: string): SheetLogic {
const edge = sl.edges.find((e) => e.id === edgeId);
if (!edge) return sl;
const out = clone(sl);
out.edges = out.edges.filter((e) => e.id !== edgeId);
rewriteTarget(out, edge.to);
return out;
}
/** 알약 정하기 — 같은 칸이나 같은 이름이 있으면 바꿈 */
/* ── 알약 ───────────────────────────────────────────────────────────────── */
/** 알약 정하기 — 같은 칸이나 같은 이름이 있으면 바꿈(이름이 바뀌면 이어진 선도 따라감) */
export function setPill(
_sl: SheetLogic,
_node: string,
_cell: string,
_dir: "in" | "out",
_name: string,
_extra?: Pick<SlPill, "unit" | "outside">,
sl: SheetLogic,
nodeId: string,
cell: string,
dir: "in" | "out",
name: string,
extra: Pick<SlPill, "unit" | "outside"> = {},
): SheetLogic {
return notReady();
const out = clone(sl);
const node = nodeOf(out, nodeId);
if (!node) return sl;
const list = dir === "in" ? node.pillsIn : node.pillsOut;
const at = list.findIndex((p) => p.cell === cell || p.name === name);
const pill: SlPill = { name, cell: cell.toUpperCase(), ...extra };
if (at < 0) {
list.push(pill);
return out;
}
const old = list[at];
list[at] = pill;
for (const e of out.edges) {
const end = dir === "in" ? e.to : e.from;
if (end.node === nodeId && end.pill === old.name) end.pill = name;
}
// 칸이 바뀌면 선이 가리키던 칸도 새로 씀
if (old.cell !== pill.cell) {
const linked = out.edges.some((e) => e.to.node === nodeId && e.to.pill === name);
if (dir === "in" && linked) {
delete sheetOf(out, node).칸[old.cell];
rewriteTarget(out, { node: nodeId, pill: name });
} else if (dir === "out")
for (const e of out.edges.filter((x) => x.from.node === nodeId && x.from.pill === name))
rewriteTarget(out, e.to);
}
return out;
}
/** 알약 빼기 — 이어진 선도 끊음 */
export function removePill(
_sl: SheetLogic,
_node: string,
_dir: "in" | "out",
_name: string,
sl: SheetLogic,
nodeId: string,
dir: "in" | "out",
name: string,
): SheetLogic {
return notReady();
let out = sl;
for (const e of sl.edges) {
const end = dir === "in" ? e.to : e.from;
if (end.node === nodeId && end.pill === name) out = disconnect(out, e.id);
}
out = clone(out);
const node = nodeOf(out, nodeId);
if (!node) return out;
if (dir === "in") node.pillsIn = node.pillsIn.filter((p) => p.name !== name);
else node.pillsOut = node.pillsOut.filter((p) => p.name !== name);
return out;
}
/** 상자 · 시트 찾기(Eval · 화면이 씀) */
export function nodeSheet(sl: SheetLogic, nodeId: string): Sheet | null {
const node = nodeOf(sl, nodeId);
return node ? sheetOf(sl, node) : null;
}