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

This commit is contained in:
2026-10-06 10:34:37 +09:00
5 changed files with 3928 additions and 3 deletions
@@ -21,6 +21,7 @@ import { mergeStructureOptions } from "./B05_Profile_UI_Structures_Summary";
import { readStructureSpec, specRenderedKeys } from "./B05_Profile_UI_Structures_Spec";
import { asRoad } from "./B05_Profile_UI_Structures_Define";
import type { GroupBoxes } from "./B05_Profile_UI_Structures_Groups";
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
import {
readGroupItems,
readSummaryItem,
@@ -110,8 +111,13 @@ export function loadForm(ctx: StructuresCommitContext, target: StructureInstance
ctx.endFields.write(target.end_m ?? null, step);
ctx.memoField.value = target.memo ?? "";
ctx.renderOptionFields(shown.options);
ctx.setSummary(readSummaryItem(target.summary_item));
ctx.groupBoxes.set(readGroupItems(target.group_items));
const links = readGroupItems(target.group_items);
const summary = readSummaryItem(target.summary_item);
// 정의 그룹 종류는 종류 단위 잇기 줄이 없음 — 옛 종류 단위 값은 첫 그룹 컨테이너 잇기로 읽음(54-6)
const first = type && !STRUCTURE_SPECS[type.type_id] ? type.groups?.[0]?.id : undefined;
const moved = !!(first && summary && !links[first]);
ctx.setSummary(moved ? null : summary);
ctx.groupBoxes.set(moved ? { ...links, [first!]: summary! } : links);
ctx.groupBoxes.setShapes(target.options);
// 구간 직접 입력 시절에 저장된 항목 호환 — 길이·전길이 옵션이 없으면 저장된
// 시작~종료·기준점에서 역산해 채워 범위 표시·재저장이 이어지게 한다(2026-08-19).
+140 -1
View File
@@ -1,5 +1,5 @@
{
"판": 12,
"판": 13,
"schema_version": 1,
"comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다. ★보호공 개편(2026-08-17 사용자 지시): 구 `*_pitching`(있음/없음) + `*_pitching_finish`(찰/메) 두 축을 `*_protection` 한 축(돌붙임(찰)·돌붙임(메)·도수로)으로 합쳤다 — 도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 보호공 선택지로 받는다. \"없음\"은 삭제(물이 흐르는 자리라 보호공 필수). 치수는 배타 — 돌붙임이면 `*_protection_area_m2`(10㎡), 도수로면 `*_protection_width_m`(1m, 지식DB 수치 근거 없음·사용자 확정, 실무 확인 후 개선). 구 저장분은 `common_util_drainage_pipes.parse_pipe_points`가 새 키로 옮긴다.",
"구조물군": {
@@ -3517,6 +3517,145 @@
"옵션": [],
"형상": {}
}
],
"retaining_wall": [
{
"id": "gretaindetail",
"이름": "상세 내용",
"옵션": [
"height_m",
"length_m",
"before_m",
"after_m",
"form",
"side",
"weep_hole_diameter_mm",
"weep_hole_area_m2"
],
"형상": {}
}
],
"masonry_wet": [
{
"id": "gmasonwetdetail",
"이름": "상세 내용",
"옵션": [
"height_m",
"length_m",
"before_m",
"after_m",
"thickness_top_m",
"thickness_bottom_m",
"back_len_cm",
"stone_kind",
"stone_supply",
"stone_coeff_basis",
"fill_concrete_mpa",
"face_slope_ratio",
"face_role",
"foundation",
"side",
"weep_hole_diameter_mm",
"weep_hole_area_m2",
"blinding_concrete"
],
"형상": {}
}
],
"masonry_dry": [
{
"id": "gmasondrydetail",
"이름": "상세 내용",
"옵션": [
"height_m",
"length_m",
"before_m",
"after_m",
"thickness_top_m",
"thickness_bottom_m",
"back_len_cm",
"stone_kind",
"stone_supply",
"stone_coeff_basis",
"fill_concrete_mpa",
"face_slope_ratio",
"face_role",
"foundation",
"side",
"weep_hole_diameter_mm",
"weep_hole_area_m2",
"blinding_concrete"
],
"형상": {}
}
],
"soil_guard": [
{
"id": "gsoilgdetail",
"이름": "상세 내용",
"옵션": [
"height_m",
"length_m",
"before_m",
"after_m",
"form",
"side",
"tiers",
"lift_m",
"shift_m",
"thickness_top_m",
"thickness_bottom_m",
"back_len_cm",
"stone_kind",
"stone_supply",
"stone_coeff_basis",
"fill_concrete_mpa",
"face_slope_ratio",
"foundation",
"weep_hole_diameter_mm",
"weep_hole_area_m2"
],
"형상": {}
}
],
"boulder_masonry": [
{
"id": "gboulderdetail",
"이름": "상세 내용",
"옵션": [
"height_m",
"length_m",
"before_m",
"after_m",
"stone_cm",
"bond",
"stone_supply",
"stone_coeff_basis",
"fill_concrete_mpa",
"face_slope_ratio",
"foundation",
"side",
"weep_hole_diameter_mm",
"weep_hole_area_m2",
"blinding_concrete"
],
"형상": {}
}
],
"berm": [
{
"id": "gbermdetail",
"이름": "상세 내용",
"옵션": [
"length_m",
"before_m",
"after_m",
"width_m",
"interval_m",
"slope_deg"
],
"형상": {}
}
]
},
"배치": {},
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,58 @@
"""PLAN 54-6 — 사면안정(C군) 종류마다 컨테이너 「상세 내용」(정의 그룹) 하나.
· 기준 측점(측점 범위) 밖 옵션 전부를 담음 · 옵션 키 · 차례 그대로
· 잇기 = 그룹 id(`group_items`) — 종류 단위 잇기 줄은 가림(hidesTypeSummary = 그룹 있음)
"""
import json
from pathlib import Path
from B05_Profile.B05_Profile_Structures_Schema import DEFINITION_PATH, load_structure_types
from M02_MasterTemplete.M02_Table_Fill import _with_group_items
from M02_MasterTemplete.StructOptions import M02_SO_Definition
from M02_MasterTemplete.StructOptions.M02_SO_Groups import groups_of
KINDS = {
"retaining_wall": "gretaindetail",
"masonry_wet": "gmasonwetdetail",
"masonry_dry": "gmasondrydetail",
"soil_guard": "gsoilgdetail",
"boulder_masonry": "gboulderdetail",
"berm": "gbermdetail",
}
def test_C군_종류마다_컨테이너_하나_옵션_전부():
types = [t for t in load_structure_types() if t.group == "C"]
assert sorted(t.type_id for t in types) == sorted(KINDS)
groups = groups_of()
for item in types:
assert groups[item.type_id] == [
{
"id": KINDS[item.type_id],
"name": "상세 내용",
"options": [o.key for o in item.options],
}
]
def test_정의_검사_통과_판_이력():
doc = json.loads(Path(DEFINITION_PATH).read_text("utf-8"))
assert M02_SO_Definition.check(doc) == []
assert M02_SO_Definition.history_path(doc["판"] - 1).is_file()
def test_잇기는_컨테이너마다_집계_한_줄():
link = {"layer": "system", "owner": None, "key": "SS000001", "name": "옹벽"}
rows = _with_group_items(
[
{
"type_id": "retaining_wall",
"복사본": "s1",
"summary_item": None,
"group_items": {"gretaindetail": link},
}
]
)
assert [r["복사본"] for r in rows] == ["s1", "s1__gretaindetail"]
assert rows[1]["summary_item"] == link and rows[1]["그룹"] == "상세 내용"
@@ -0,0 +1,198 @@
/* 54-6 — B05 · B06 좌측 「구조물 배치」 사면안정(C군) 종류마다 컨테이너 「상세 내용」 하나
* 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(랩탑_보조)
* 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m546 = "단계";` 를 붙여
* seed → 원래 구조물 목록을 sessionStorage 에 두고 흙막이 · 소단을 심고 옹벽에 옛 종류 단위 잇기를 얹음(PUT) · 그 뒤 orca reload
* check → C군 종류마다 컨테이너 1 · 그 안 칸 수 = 그리는 칸 수 · 밖 칸 0 · 잇기 1 · 종류 단위 줄 가림 ·
* 옹벽 컨테이너 잇기 = 옛 값 — B05 에서 한 번(값을 sessionStorage 에 둠) · B06(#/b06-section) 에서 한 번(B05 = B06)
* save → 옹벽 [수정] · [저장] — 옛 값이 컨테이너 잇기(group_items)로 옮겨 저장 · 그 뒤 orca reload
* verify → 다시 연 옹벽 컨테이너 잇기 = 옛 값 · 파일 = group_items 만
* restore → 원래 구조물 목록으로 되돌림(PUT) · 초안 비움
* 쓰는 것: 검증 프로젝트 structures.json(되돌림 단계가 원래 값으로 다시 씀)
*/
(async () => {
const MODE = window.__m546 || "check";
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (fn, ms = 20000) => {
for (let t = 0; t < ms; t += 200) {
if (fn()) return true;
await w(200);
}
return false;
};
const projectId = localStorage.getItem("frd_current_project_id");
const modUrl = (part) =>
performance
.getEntriesByType("resource")
.map((e) => e.name)
.filter((n) => n.includes(part))
.pop();
const api = await import(modUrl("/B05_Profile/B05_Profile_Api_Structures.ts"));
const OLD = { layer: "system", owner: null, key: "SS000001", name: "54-6 옛 잇기" };
const KINDS = {
retaining_wall: ["gretaindetail", /^10\+0~10\+10옹벽/],
masonry_wet: ["gmasonwetdetail", /^4\+0~4\+10돌쌓기\(찰\)/],
masonry_dry: ["gmasondrydetail", /^7\+0~7\+10돌쌓기\(메\)/],
soil_guard: ["gsoilgdetail", /흙막이/],
boulder_masonry: ["gboulderdetail", /^16\+0~16\+10큰돌쌓기/],
berm: ["gbermdetail", /소단/],
};
const section = () => document.querySelector(".b05-structure-section");
const selects = () => section().querySelectorAll(".b05-structure__grid select");
const visible = (n) => n.offsetParent !== null;
const listItem = (re) =>
[...document.querySelectorAll(".b05-route__irregular-list li")].find((l) =>
re.test(l.textContent.trim()),
);
const open = async (re) => {
await until(() => listItem(re));
const li = listItem(re);
(li.querySelector("button") ?? li).click();
await w(800);
if (!selects()[1].value) (li.querySelector("button") ?? li).click(); // 재선택 = 해제 — 한 번 더
await until(() => !!selects()[1].value);
await w(400);
};
const reset = async () => {
section().querySelector(".b05-route__irregular-actions button:last-child").click();
await w(400);
};
const b06 = location.hash.includes("b06");
const types = Object.fromEntries((await api.fetchStructureTypes()).map((t) => [t.type_id, t]));
/** 그리는 칸 = 정의 옵션 중 이 화면 몫(B05 = b05 · B06 = 상세까지) · 켬 */
const expected = (type) =>
types[type].options
.filter((o) => o.visible !== false && o.enabled !== false)
.filter((o) => b06 || api.isB05Option(o))
.map((o) => o.key);
const shape = (type) => {
const boxes = [...section().querySelectorAll("[data-b05-group]")].filter(visible);
const box = boxes[0];
const inside = box ? [...box.querySelectorAll("[data-option-keys]")] : [];
return {
boxes: boxes.map((b) => `${b.dataset.b05Group}:${b.querySelector("legend")?.textContent}`),
fields: inside.map((n) => n.dataset.optionKeys),
want: expected(type),
loose: [...section().querySelectorAll("[data-option-keys]")].filter(
(n) => !n.closest("[data-b05-group]") && visible(n),
).length,
links: box ? box.querySelectorAll(".b05-structure__summary").length : 0,
typeRows: [...section().querySelectorAll(".b05-structure__summary")].filter(
(n) => !n.closest("[data-b05-group]") && visible(n),
).length,
link: box?.querySelector(".b05-structure__summary-name")?.textContent.trim(),
};
};
const ok = (type, s) =>
s.boxes.length === 1 &&
s.boxes[0] === `${KINDS[type][0]}:상세 내용` &&
JSON.stringify(s.fields) === JSON.stringify(s.want) &&
s.loose === 0 &&
s.links === 1 &&
s.typeRows === 0;
const save = async () => {
document.querySelector('[data-dock-action="save"]').click();
await w(500);
return until(() => !document.querySelector(".ui-overlay.is-active"), 60000);
};
const out = { mode: MODE, page: location.hash };
if (MODE === "seed") {
const now = await api.fetchStructures(projectId);
sessionStorage.setItem("__546_original", JSON.stringify(now.structures));
const add = (type_id, at, options) => ({
structure_id: null,
type_id,
placement: "interval",
chainage_m: at,
start_m: at,
end_m: at + 10,
options: { length_m: 10, before_m: 0, after_m: 10, ...options },
memo: "54-6",
});
const next = now.structures.map((s) =>
s.type_id === "retaining_wall" ? { ...s, summary_item: OLD } : s,
);
next.push(add("soil_guard", 500, { height_m: 1.5 }), add("berm", 560, { width_m: 1 }));
await api.saveStructures(projectId, now.revision, next);
for (const k of Object.keys(sessionStorage))
if (k.includes("draft:structures")) sessionStorage.removeItem(k);
const after = (await api.fetchStructures(projectId)).structures;
out.seeded = after.filter((s) => s.type_id in KINDS).map((s) => [s.type_id, !!s.summary_item]);
}
if (MODE === "check") {
await until(() => document.querySelectorAll(".b05-route__irregular-list li").length > 0);
const r = {};
for (const [type, [, re]] of Object.entries(KINDS)) {
await open(re);
r[type] = shape(type);
r[type].ok = ok(type, r[type]);
}
await reset();
out.allOk = Object.values(r).every((s) => s.ok);
out.oldLink = r.retaining_wall.link;
out.read = r;
if (!b06) sessionStorage.setItem("__546_b05", JSON.stringify(r));
else {
// B06 은 상세 칸까지 그림 — 같은 그리기 판정은 컨테이너 · 잇기 · 밖 칸 · 옛 값
const strip = (x) =>
JSON.stringify(
Object.fromEntries(
Object.entries(x).map(([k, s]) => [
k,
[s.boxes, s.loose, s.links, s.typeRows, s.link, s.ok],
]),
),
);
out.sameDrawing = strip(JSON.parse(sessionStorage.getItem("__546_b05") || "{}")) === strip(r);
}
}
if (MODE === "save") {
await open(KINDS.retaining_wall[1]);
out.before = shape("retaining_wall").link;
section().querySelector(".b05-route__irregular-actions .is-primary").click(); // [수정]
await w(800);
out.saved = await save();
const wall = (await api.fetchStructures(projectId)).structures.find(
(s) => s.type_id === "retaining_wall",
);
out.file = { summary_item: wall.summary_item ?? null, group_items: wall.group_items ?? null };
}
if (MODE === "verify") {
await open(KINDS.retaining_wall[1]);
out.link = shape("retaining_wall").link;
await reset();
const wall = (await api.fetchStructures(projectId)).structures.find(
(s) => s.type_id === "retaining_wall",
);
out.fileOk =
!wall.summary_item &&
JSON.stringify(wall.group_items) === JSON.stringify({ gretaindetail: OLD });
}
if (MODE === "restore") {
const original = JSON.parse(sessionStorage.getItem("__546_original") || "null");
if (!original) return JSON.stringify({ error: "원래 값 없음" });
const now = await api.fetchStructures(projectId);
await api.saveStructures(projectId, now.revision, original);
for (const k of Object.keys(sessionStorage))
if (k.includes("draft:structures") || k.startsWith("__546_")) sessionStorage.removeItem(k);
const after = await api.fetchStructures(projectId);
const key = (list) =>
JSON.stringify(
list.map((s) => [
s.type_id,
s.start_m,
s.chainage_m,
s.end_m,
s.options,
s.summary_item ?? null,
s.group_items ?? null,
]),
);
out.restored = key(after.structures) === key(original);
}
return JSON.stringify(out);
})();