Merge remote-tracking branch 'origin/dev' into sub_laptop_1

This commit is contained in:
2026-10-01 23:24:36 +09:00
10 changed files with 366 additions and 8 deletions
@@ -498,3 +498,11 @@
gap: var(--spacing-8);
pointer-events: none;
}
/* 쉼 — 받는 알약만(상자 흐림은 들어오는 선이 모두 쉼일 때) */
.m02-dfdg__pill.is-in.is-rest {
border-style: dashed;
border-color: var(--color-text-muted);
color: var(--color-text-muted);
opacity: 0.7;
}
@@ -20,6 +20,7 @@ import {
follow,
depLines,
isRest,
restParts,
linkLabel,
midOf,
pillsOf,
@@ -86,11 +87,11 @@ export function renderDiagram(
/** 「!」 는 문제에만 · 쉼(조건이 안 맞아 이번엔 안 지나감)은 회색 점선 선 + 받는 상자 흐리게 */
const breaks = allBreaks.filter((k) => !isRest(k));
const rests = allBreaks.filter(isRest);
const restLinks = new Set(rests.map((k) => k.link).filter(Boolean));
const restBoxes = new Set([
...rests.filter((k) => !k.link).map((k) => k.box),
...lg.links.filter((l) => restLinks.has(l.id)).map((l) => l.to.box),
]);
const {
links: restLinks,
pills: restPills,
boxes: restBoxes,
} = restParts(lg, rests, opts.values ?? {});
const boxName = (id: string): string => lg.boxes.find((b) => b.id === id)?.name ?? "";
const redraw = (): void =>
renderDiagram(host, lg, on, { ...opts, selected: st.selected, selectedPill: st.pill });
@@ -160,6 +161,7 @@ export function renderDiagram(
wired ? "" : "is-empty",
chosen ? "is-chosen" : "",
broken ? "is-broken" : "",
dir === "in" && restPills.has(`${b.id}|${p.name}`) ? "is-rest" : "",
st.linking && dir === "in" && b.id !== st.linking.box ? "is-target" : "",
].join(" ") + markOf((s) => s.some((x) => x.box === b.id && x.port === p.name)),
attrs: {
@@ -306,6 +306,30 @@ export function depPath(a: Pt, b: Pt, side: "left" | "right"): string {
/** 끊긴 곳 갈래 — 쉼(조건이 안 맞아 이번엔 안 지나감 · 문제 아님) · 문제 — Eval 의 level 그대로 */
export const isRest = (k: CfBreak): boolean => k.level === "쉼";
/** 쉼 모양 자리 — 선 · 받는 알약(「상자|알약」) · 흐릴 상자
* 상자 흐림은 들어오는 선이 모두 쉼일 때 · 또는 상자에 걸린 쉼이 있고 그 상자 내보내는 값이 모두 빈 값일 때만 */
export function restParts(
lg: CfLogic,
rests: CfBreak[],
values: Record<string, Record<string, unknown>>,
): { links: Set<string>; pills: Set<string>; boxes: Set<string> } {
const links = new Set(rests.map((k) => k.link).filter((x): x is string => !!x));
const pills = new Set(
lg.links.filter((l) => links.has(l.id)).map((l) => `${l.to.box}|${l.to.name}`),
);
const boxes = new Set<string>();
const empty = (v: unknown): boolean => v === null || v === undefined || v === "";
for (const b of lg.boxes) {
const incoming = lg.links.filter((l) => l.to.box === b.id);
const allRest = incoming.length > 0 && incoming.every((l) => links.has(l.id));
// 내보내는 알약 값만(들어온 값은 빼고) · 쉼이 그 상자에 걸렸나(Eval 쉼 줄은 link 가 늘 있음)
const outs = portsOut(b).map((p) => values[b.id]?.[p.name]);
const hit = rests.some((k) => k.box === b.id);
if (allRest || (hit && outs.every(empty))) boxes.add(b.id);
}
return { links, pills, boxes };
}
/* ── 끌기 손잡이 ─────────────────────────────────────────────────────── */
/** 누른 뒤 5px 넘게 움직이면 끌기 — 끈 뒤 따라오는 click 한 번만 삼킴 */
+17
View File
@@ -0,0 +1,17 @@
{
"종류": "통합문서",
"판": 1,
"열": "item3",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "면적산출",
"칸": {}
}
],
"활성": "s1",
"면적번": "SA-000003"
}
@@ -0,0 +1,96 @@
{
"판": 1,
"줄": [
{
"로직": "GC000297",
"입력": "형식",
"자리": "선택"
},
{
"로직": "GC000297",
"입력": "두께",
"자리": "면적",
"알약": "입력|GC000297|두께"
},
{
"로직": "GF000156",
"입력": "구분",
"자리": "선택"
},
{
"로직": "GF000156",
"입력": "믹서",
"자리": "선택"
},
{
"로직": "GF000156",
"입력": "지역",
"자리": "선택"
},
{
"로직": "GF000156",
"입력": "재료배합",
"자리": "선택"
},
{
"로직": "GF000156",
"입력": "골재최대치수",
"자리": "선택"
},
{
"로직": "GF000156",
"입력": "수중",
"자리": "선택"
},
{
"로직": "GF000156",
"입력": "모래상태",
"자리": "선택"
},
{
"로직": "GF000156",
"입력": "자갈상태",
"자리": "선택"
},
{
"로직": "GF000156",
"입력": "모래단위중량",
"자리": "선택"
},
{
"로직": "GF000156",
"입력": "자갈단위중량",
"자리": "선택"
},
{
"로직": "GF000156",
"입력": "자재지역",
"자리": "선택"
},
{
"로직": "GF000162",
"입력": "포장두께",
"자리": "선택"
},
{
"로직": "GF000162",
"입력": "운반",
"자리": "선택"
},
{
"로직": "GF000162",
"입력": "운반감률",
"자리": "선택"
},
{
"로직": "GF000162",
"입력": "공급감률",
"자리": "선택"
},
{
"로직": "GF000162",
"입력": "종단경사",
"자리": "선택"
}
]
}
@@ -0,0 +1,17 @@
{
"종류": "통합문서",
"판": 1,
"열": "item3",
"서식": [
{}
],
"시트": [
{
"id": "s1",
"이름": "재료산출",
"칸": {}
}
],
"활성": "s1",
"재료번": "SM-000003"
}
@@ -0,0 +1,19 @@
{
"양식": "구조물도면",
"종류": "structure",
"판": 1,
"열": "item3",
"도면": {
"format": 6,
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
},
"도번": "SD-000003"
}
@@ -53,7 +53,7 @@
"id": "item1",
"단위": "m",
"꼴": "수",
"일위대가": null,
"일위대가": "UA000006",
"자재조합": null,
"경로": [
"기슭막이",
@@ -95,6 +95,25 @@
"ss"
]
},
{
"id": "item3",
"단위": null,
"꼴": "수",
"일위대가": null,
"자재조합": null,
"경로": [
"기슭막이",
"ㄴㄴ"
],
"머리": [
"기슭막이",
"ㄴㄴ",
null,
null,
null,
null
]
},
{
"id": "memo",
"머리": [
@@ -2,7 +2,7 @@
"그룹": "구조물집계표조합",
"판": 1,
"비고": "구조물집계표 구조물 열(꼴=수)마다 한 줄 · 서버가 집계표 [저장] 때 맞춤(손으로 고치지 않음) · 키(마스터 SS · 회사 SC{회사}- · 개인 SP{사용자}- + 6자리)는 한 번 주면 안 바뀌고 지운 열의 키는 다시 안 씀 · 옛키 = 옮기기 전 키 · 경로 = 폴더 이름 배열",
"다음": 54,
"다음": 55,
"지운번호": [
"01010100",
"01010101",
@@ -74,7 +74,7 @@
"분류2": "ss",
"분류3": "aaa",
"단위": "m",
"일위대가": null,
"일위대가": "UA000006",
"자재조합": null,
"도번": "SD-000001",
"면적번": "SA-000001",
@@ -106,6 +106,27 @@
"재료번": "SM-000002",
"바인딩": null,
"펼침": null
},
{
"키": "SS000054",
"열": "item3",
"경로": [
"기슭막이",
"ㄴㄴ"
],
"이름": "기슭막이 ㄴㄴ",
"종류": "기슭막이",
"분류1": "ㄴㄴ",
"분류2": null,
"분류3": null,
"단위": null,
"일위대가": null,
"자재조합": null,
"도번": "SD-000003",
"면적번": "SA-000003",
"재료번": "SM-000003",
"바인딩": null,
"펼침": null
}
]
}
@@ -46,6 +46,10 @@ const out = jobs.map((job) => {
if (job.op === "fit") return m.fitView(job.rects, job.view);
if (job.op === "value") return job.values.map((v) => m.showValue(v));
if (job.op === "end") return m.depEnd(job.a, job.tag);
if (job.op === "restParts") {
const r = m.restParts(job.lg, job.rests, job.values ?? {});
return { links: [...r.links].sort(), pills: [...r.pills].sort(), boxes: [...r.boxes].sort() };
}
if (job.op === "path") return m.depPath(job.a, job.b, job.side);
return null;
});
@@ -269,3 +273,134 @@ def test_기댐_선_끝이_알약과_이름표에_붙음():
assert path_right.startswith("M100,50 ") and path_right.endswith(" 130,300")
handle = float(path_right.split(" ")[2].split(",")[0])
assert handle > 130
def test_쉼은_일부면_선과_받는_알약만_모두면_상자도():
two_in = {
**LOGIC,
"links": [
{"id": "a", "from": port("v", "지반"), "to": port("f", "품")},
{"id": "b", "from": port("m", "흄관"), "to": port("f", "흄관")},
],
}
one_rest = [
{"kind": "통과 안 됨", "level": "쉼", "box": "f", "port": "흄관", "link": "b", "text": "쉼"}
]
all_rest = one_rest + [
{"kind": "통과 안 됨", "level": "쉼", "box": "f", "port": "품", "link": "a", "text": "쉼"}
]
box_rest = [{"kind": "통과 안 됨", "level": "쉼", "box": "m", "text": "쉼"}]
part, every, filled, empty = run(
[
{"op": "restParts", "lg": two_in, "rests": one_rest, "values": {"f": {"금액": 2000}}},
{"op": "restParts", "lg": two_in, "rests": all_rest, "values": {"f": {"금액": None}}},
{"op": "restParts", "lg": two_in, "rests": box_rest, "values": {"m": {"흄관": 52000}}},
{"op": "restParts", "lg": two_in, "rests": box_rest, "values": {"m": {"흄관": None}}},
]
)
# 하나만 쉼 — 셈하는 상자는 안 흐림 · 그 선과 받는 알약만
assert part == {"links": ["b"], "pills": ["f|흄관"], "boxes": []}
# 들어오는 선이 모두 쉼 — 상자도 흐림
assert every["boxes"] == ["f"]
# 상자에 걸린 쉼 — 내보내는 값이 있으면 안 흐림 · 모두 빈 값이면 흐림
assert filled["boxes"] == [] and empty["boxes"] == ["m"]
def test_지반_토사_쉼_흐림은_브레이커로_깨기_하나():
def fn(id_, name, ins, out):
return {
"id": id_,
"kind": "function",
"name": name,
"color": "",
"unit": "-",
"x": 0,
"y": 0,
"func": {
"sheet": {},
"inputs": [{"port": p, "cell": f"B{i + 1}"} for i, p in enumerate(ins)],
"variables": [],
"output": {"cell": "B9", "name": out, "unit": "원"},
},
}
gate = {"kind": "gate", "from": port("ig", "지반"), "op": "=", "value": "암"}
ground = {
"key": None,
"name": "지반",
"resultUnit": "원/㎥",
"userElements": [],
"boxes": [
{
"id": "ig",
"kind": "input",
"name": "지반 · 굴착량",
"color": "",
"unit": "-",
"x": 0,
"y": 0,
"input": {
"mode": "variable",
"elements": [],
"variables": [
{"name": "지반", "type": "choice", "unit": "-", "example": "토사"},
{"name": "굴착량", "type": "number", "unit": "㎥", "example": 1},
],
},
},
fn("exc", "굴삭기로 파기", ["굴착량", "시간당 경비"], "굴삭기 경비"),
fn("brk", "브레이커로 깨기", ["굴착량", "시간당 경비"], "브레이커 경비"),
fn("sum", "1㎥ 경비", ["굴삭기 경비", "브레이커 경비", "굴착량"], "1㎥ 경비"),
{
"id": "out",
"kind": "output",
"name": "경비",
"color": "",
"unit": "원",
"x": 0,
"y": 0,
"output": {"cost": "경비", "unit": "원"},
},
],
"links": [
{
"id": "l1",
"from": port("ig", "굴착량"),
"to": port("exc", "굴착량"),
"rule": {**gate, "op": "≠"},
},
{"id": "l2", "from": port("ig", "굴착량"), "to": port("brk", "굴착량"), "rule": gate},
{"id": "l5", "from": port("exc", "굴삭기 경비"), "to": port("sum", "굴삭기 경비")},
{"id": "l6", "from": port("brk", "브레이커 경비"), "to": port("sum", "브레이커 경비")},
{"id": "l7", "from": port("ig", "굴착량"), "to": port("sum", "굴착량")},
{"id": "l8", "from": port("sum", "1㎥ 경비"), "to": port("out", "값")},
],
}
# Eval 쉼 줄은 link 가 늘 있음 — 조건 선 l2(받는 상자 브레이커) · 빈 값이 흘러간 l6(받는 상자 1㎥ 경비)
rests = [
{
"kind": "통과 안 됨",
"level": "쉼",
"box": "brk",
"port": "굴착량",
"link": "l2",
"text": "암이 아님",
},
{
"kind": "통과 안 됨",
"level": "쉼",
"box": "sum",
"port": "브레이커 경비",
"link": "l6",
"text": "쉼",
},
]
values = {
"brk": {"시간당 경비": 80000, "굴착량": None, "브레이커 경비": None},
"sum": {"굴삭기 경비": 2000, "브레이커 경비": None, "굴착량": 1, "1㎥ 경비": 2000},
"out": {"값": 2000},
}
[got] = run([{"op": "restParts", "lg": ground, "rests": rests, "values": values}])
assert got["boxes"] == ["brk"]
assert got["links"] == ["l2", "l6"]
assert got["pills"] == ["brk|굴착량", "sum|브레이커 경비"]