diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts index 8569f1579..fa25e4f4d 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts @@ -88,6 +88,7 @@ export function structureHashParts(section: CrossSection): Array 0 ? culvert.inlet.basin_height_m : null; // 좌표 규약: +offset = 좌측. 상단측 = 유입(미상이면 좌측 폴백). const uphill = section.uphill_side ?? "left"; @@ -157,7 +159,7 @@ export function computeCulvertLayout( const basinReason = culvert.hidden_pipe ? null : choiceReason; // ㄴ·ㄷ형은 사이즈 유지한 채 통째로 노견 끝점 일치 — 바닥(=관 invert)이 따라 오른다. if (basinReason && basinShape !== "I") { - inlet.elevation = inletInfo.edge.elevation_m - basinAdjust.innerHeightM; + inlet.elevation = inletInfo.edge.elevation_m - (basinHeightM ?? basinAdjust.innerHeightM); } else if (basinReason) { // I형 — 관 시작점(내공 1.0m 자리) invert = 그 x의 원지반(토피 상한 이내). const startOffset = @@ -172,10 +174,10 @@ export function computeCulvertLayout( const adjustOf = (value?: WallAdjust): WallAdjust => ({ ...ZERO_ADJUST, ...(value ?? {}) }); const adjInlet = adjustOf(revetShift?.inlet); const adjOutlet = adjustOf(revetShift?.outlet); - // 전면 기울기 = 표준경사 표(단면유형 × 설치 측 × 형태 × 높이 — B08 과 한 벌, 2026-09-14 B5). + // 전면 기울기 = 표준경사 표(단면유형 × 설치 측 × 높이) — 형태는 안 무늬만(외곽 같음). const pipeWallSpec = (spec: CulvertSideSpec, adjust: WallAdjust) => { const wall = revetWallSpec(spec, adjust, culvert.hidden_pipe === true, diameter); - const lean = leanOf({ + const lean = outlineLeanOf({ form: wall.form, height_m: wall.pureHeight, section_mode: section.design?.section_mode, @@ -343,6 +345,7 @@ export function computeCulvertLayout( groundAt, cutSlopeRatio: section.design?.cut_slope_ratio ?? 1.0, adjust: basinAdjust, + heightM: basinHeightM, }); basin = built.basin; basinPipeEnd = built.pipeEnd; @@ -624,9 +627,9 @@ export function computeCulvertLayout( ) : null; - // 다단 벽 기울기 — 같은 측점·같은 설치 측 규칙으로 형태·높이마다 표준경사(B08 은 다단을 안 셈). + // 다단 벽 기울기 — 같은 측점·같은 설치 측 규칙으로 높이마다 표준경사(형태 무관 · B08 은 다단을 안 셈). const extraLean = (form: string, pureHeightM: number): number => - leanOf({ + outlineLeanOf({ form, height_m: pureHeightM, section_mode: section.design?.section_mode, diff --git a/B06_Section/B06_Section_UI_Cross_Lean.ts b/B06_Section/B06_Section_UI_Cross_Lean.ts index e6daa9d8d..5607cab81 100644 --- a/B06_Section/B06_Section_UI_Cross_Lean.ts +++ b/B06_Section/B06_Section_UI_Cross_Lean.ts @@ -11,8 +11,14 @@ import { type WallLeanInput, wallLeanRatio } from "../common_util/common_util_masonry_slope"; import masonrySlope from "../resources/data_masonry/masonry_slope_2026-01-01.json"; +import { REVET_FORM_DEFAULT } from "./B06_Section_UI_Cross_Culvert_Const"; /** 벽 전면 기울기 n — 표를 못 고르면 종전 0.3. */ export function leanOf(input: WallLeanInput): number { return wallLeanRatio(masonrySlope, input); } + +/** 배관 흙막이 외곽 기울기 — 형태와 무관(형태는 안 무늬만) · 기본 형태의 표준경사. */ +export function outlineLeanOf(input: WallLeanInput): number { + return leanOf({ ...input, form: REVET_FORM_DEFAULT }); +} diff --git a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts index 73970a199..1d3c8d98f 100644 --- a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts +++ b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts @@ -132,6 +132,7 @@ export function applyPipeOptionsToCache( own(culvert.inlet, "basin_length_m", num("inlet_basin_length_m")); own(culvert.inlet, "basin_before_m", num("inlet_basin_before_m")); own(culvert.inlet, "basin_after_m", num("inlet_basin_after_m")); + own(culvert.inlet, "basin_height_m", num("inlet_basin_height_m")); // 연장이 바뀌면 옆 측점 링크도 달라진다 — 그 구조물이 덮는 범위만 다시 그린다. const reach = Math.max( culvert.inlet.revet_before_m ?? 0, diff --git a/common_util/common_util_culvert_sets.ts b/common_util/common_util_culvert_sets.ts index b9fb617e7..296dcf02c 100644 --- a/common_util/common_util_culvert_sets.ts +++ b/common_util/common_util_culvert_sets.ts @@ -108,6 +108,7 @@ function sideSpec( num(defaults.inlet_basin_before_m, null), ); spec.basin_after_m = num(options.inlet_basin_after_m, num(defaults.inlet_basin_after_m, null)); + spec.basin_height_m = num(options.inlet_basin_height_m, null); return spec; } diff --git a/resources/tester/ui_checks/54-3_배관_횡단그림.js b/resources/tester/ui_checks/54-3_배관_횡단그림.js new file mode 100644 index 000000000..71ac4f824 --- /dev/null +++ b/resources/tester/ui_checks/54-3_배관_횡단그림.js @@ -0,0 +1,194 @@ +/* 54-3 — B06 횡단도 배관 그림: 집수정 높이 → 형상 · 흙막이 여섯 형태 = 외곽 같음 · 안 무늬만 다름 + * 시작 화면: B06 횡단설계(#/b06-section) · 검증 프로젝트(sub7_검증) · 목록에 「배관 1」(4+5) + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m543 = "단계";` + * basin → 유입 구조 집수정 · I · L · ㄷ형마다 높이 1.0 → 2.0 → 그림 높이(m, SVG 실좌표 ÷ 세로 눈금) + * soil → 유입 · 유출 흙막이 형태 여섯 → 벽 외곽 꼭짓점(SVG) 같음 · 무늬(해칭 묶음) 서로 다름 + * extra → 추가 흙막이(다단) — 상세의 배관 측점마다 기하를 형태 여섯으로 돌려 외곽 꼭짓점 같음 + * 쓰는 것: 좌측 폼 값(캐시만 · 저장 안 함) — 단계 끝에 원래 값으로 되돌림 + * 기대 수치: basin 형태마다 h1 ≈ 1.0 · h2 ≈ 2.0 · ratio ≈ 2.0 / soil sameOutline true · distinctHatch 6 / + * extra sameLean true · sameOutlineAtSameHeight true(다단이 선 측점 1곳 이상) · heightCapped = 지형 맞춤 키가 + * 형태 높이 한계에 걸린 측점(키만 다름) + */ +(async () => { + const MODE = window.__m543 || "basin"; + 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 spec = () => + [...document.querySelectorAll(".b05-structure__spec")].find((n) => n.offsetParent !== null); + const ctl = (c, item) => + spec() + ?.querySelector(`[data-spec-container="${c}"] [data-spec-item="${c}.${item}"]`) + ?.querySelector("select, input"); + const set = async (c, item, v) => { + const el = ctl(c, item); + el.value = v; + el.dispatchEvent(new Event("change", { bubbles: true })); + await w(900); + }; + const pickPipe = async () => { + await until(() => document.querySelectorAll(".b05-route__irregular-list li").length > 0); + if (spec()) return; + const li = [...document.querySelectorAll(".b05-route__irregular-list li")].find((l) => + /배관 1$/.test(l.textContent.trim()), + ); + (li.querySelector("button") ?? li).click(); + await until(() => !!spec()); + await w(800); + }; + const card = () => document.querySelector(".b06-cross-card--selected svg"); + /** 세로 눈금 — 같은 x 에 놓인 숫자 글(표고)에서 1m 당 픽셀. */ + const pxPerM = () => { + const nums = [...card().querySelectorAll("text")] + .filter((t) => /^-?\d+(\.\d+)?$/.test(t.textContent.trim())) + .map((t) => ({ + v: Number(t.textContent), + x: t.getAttribute("x"), + y: Number(t.getAttribute("y")), + })); + const byX = {}; + for (const n of nums) (byX[n.x] ??= []).push(n); + const col = Object.values(byX) + .filter((g) => new Set(g.map((n) => n.y)).size > 1) + .sort((a, b) => b.length - a.length)[0]; + const a = col[0]; + const b = col[col.length - 1]; + return Math.abs(b.y - a.y) / Math.abs(b.v - a.v); + }; + const pts = (poly) => + poly + .getAttribute("points") + .trim() + .split(/\s+/) + .map((p) => p.split(",").map(Number)); + const minY = (poly) => Math.min(...pts(poly).map((p) => p[1])); + const round = (v) => Math.round(v * 100) / 100; + const out = { mode: MODE }; + await pickPipe(); + + if (MODE === "basin") { + const keep = ["structure", "form", "height"].map((k) => ctl("inlet", k).value); + await set("inlet", "structure", "집수정"); + out.shapes = {}; + for (const shape of ["I", "L", "U"]) { + await set("inlet", "form", shape); + const hs = []; + for (const h of ["1.0", "2.0"]) { + await set("inlet", "height", h); + const k = pxPerM(); + const parts = [...card().querySelectorAll(".b06-chart__culvert-basin")]; + const title = (p) => p.querySelector("title")?.textContent ?? ""; + const wall = parts.find((p) => / 벽 /.test(title(p))); + const floor = parts.find((p) => / 바닥 /.test(title(p))); + const label = [...card().querySelectorAll(".b06-chart__culvert-label")].find((t) => + t.textContent.startsWith("집수정"), + ); + // ㄴ · ㄷ = 바닥 윗면 ~ 벽 상단 · I = 관 유입 기준(라벨 자리 + 0.2) ~ 벽 상단 + const base = floor ? minY(floor) : Number(label.getAttribute("y")) - 11 - 0.2 * k; + hs.push({ name: title(wall).match(/\((.+?)\)/)?.[1], h: round((base - minY(wall)) / k) }); + } + out.shapes[shape] = { + name: hs[0].name, + h1: hs[0].h, + h2: hs[1].h, + ratio: round(hs[1].h / hs[0].h), + }; + } + await set("inlet", "structure", keep[0]); + await set("inlet", "form", keep[1]); + await set("inlet", "height", keep[2]); + out.restored = ["structure", "form", "height"].map((k) => ctl("inlet", k).value); + } + + if (MODE === "soil") { + for (const [side, word] of [ + ["inlet", "유입"], + ["outlet", "유출"], + ]) { + // 구조가 비면 형태 칸이 안 섬 — 흙막이로 고른 뒤 끝에 되돌림 + const keepStructure = ctl(side, "structure").value; + if (keepStructure !== "기슭막이") await set(side, "structure", "기슭막이"); + const forms = [...ctl(side, "form").options].map((o) => o.value).filter(Boolean); + out.forms = forms; + const keep = ctl(side, "form").value; + const rows = []; + for (const form of forms) { + await set(side, "form", form); + const poly = [...card().querySelectorAll(".b06-chart__culvert-revet")].find((p) => + (p.querySelector("title")?.textContent ?? "").startsWith(`${word} 기슭막이`), + ); + const hatch = poly.nextElementSibling?.nextElementSibling; + rows.push({ + form, + title: poly.querySelector("title").textContent.slice(0, 40), + outline: pts(poly) + .map((p) => p.map(round).join(",")) + .join(" "), + hatch: `${hatch?.childElementCount}:${hatch?.innerHTML.length}`, + }); + } + await set(side, "form", keep); + if (keepStructure !== "기슭막이") await set(side, "structure", keepStructure); + out[side] = { + sameOutline: rows.every((r) => r.outline === rows[0].outline), + distinctHatch: new Set(rows.map((r) => r.hatch)).size, + outline: rows[0].outline, + rows: rows.map((r) => [r.form, r.hatch, r.title]), + restored: ctl(side, "structure").value === keepStructure, + }; + } + } + + if (MODE === "extra") { + const modUrl = (part) => + performance + .getEntriesByType("resource") + .map((e) => e.name) + .filter((n) => n.includes(part)) + .pop(); + const geom = await import(modUrl("/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts")); + const key = Object.keys(sessionStorage).find((k) => k.includes("section-detail")); + const detail = JSON.parse(sessionStorage.getItem(key)); + const forms = geom.REVET_FORMS; + out.checked = 0; + out.sameLean = true; + out.sameOutlineAtSameHeight = true; + // 지형 맞춤 키는 형태 높이 한계(메 2.0 · 찰 3.0 · 콘 5.0 — 교본)에서 멈춤 — 같은 키끼리만 외곽 대조 + out.heightCapped = []; + for (const section of detail.cross_sections ?? detail.value?.cross_sections ?? []) { + if (!section.culvert || section.culvert.hidden_pipe) continue; + const rows = forms.map((m) => { + const walls = + geom.computeCulvertLayout(section, section.samples, { extras: [{ m }, { m }] }) + ?.extraWalls ?? []; + return { + m, + lean: walls.map((wall) => wall.lean).join(","), + height: walls.map((wall) => round(wall.height)).join(","), + outline: JSON.stringify( + walls.map((wall) => wall.points.map((p) => [round(p.offset), round(p.elevation)])), + ), + }; + }); + if (rows[0].outline === "[]") continue; + out.checked += 1; + if (!rows.every((r) => r.lean === rows[0].lean)) out.sameLean = false; + for (const r of rows) { + const twin = rows.find((o) => o.height === r.height); + if (twin.outline !== r.outline) out.sameOutlineAtSameHeight = false; + } + const heights = [...new Set(rows.map((r) => r.height))]; + if (heights.length > 1) + out.heightCapped.push({ + chainage: section.chainage_m, + byForm: rows.map((r) => `${r.m}=${r.height}`), + }); + } + } + return JSON.stringify(out); +})();