diff --git a/A00_Common/design/design_structure_widening.ts b/A00_Common/design/design_structure_widening.ts index 6b841ea38..5e73e062b 100644 --- a/A00_Common/design/design_structure_widening.ts +++ b/A00_Common/design/design_structure_widening.ts @@ -63,6 +63,35 @@ export function wideningRuns( return runs; } +/** + * 확폭 면적(㎡ · 138-2 · 표시만) — 확폭(좌 + 우) 측점 사이 사다리꼴 합 Σ(wᵢ + wᵢ₊₁)/2 × Δ. + * 구간 앞뒤 이웃 측점(확폭 0)까지 이어 붙이고 · 노면공 범위 `[start, end]` 밖은 잘라 냄(자른 끝은 직선 보간). + */ +export function wideningArea( + stations: readonly WideningStation[], + run: WideningRun, + range: { start: number; end: number }, +): number { + const sorted = [...stations].sort((a, b) => a.chainage_m - b.chainage_m); + const first = sorted.findIndex((s) => Math.abs(s.chainage_m - run.start_m) <= WIDENING_TOL_M); + const last = sorted.findIndex((s) => Math.abs(s.chainage_m - run.end_m) <= WIDENING_TOL_M); + if (first < 0 || last < 0) return 0; + const points = sorted + .slice(Math.max(first - 1, 0), last + 2) + .map((s) => [s.chainage_m, wideningOf(s)] as const); + let area = 0; + for (let i = 0; i + 1 < points.length; i += 1) { + const [x0, w0] = points[i]; + const [x1, w1] = points[i + 1]; + const a = Math.max(x0, range.start); + const b = Math.min(x1, range.end); + if (b <= a || x1 <= x0) continue; + const at = (x: number): number => w0 + ((w1 - w0) * (x - x0)) / (x1 - x0); + area += ((at(a) + at(b)) / 2) * (b - a); + } + return area; +} + const round = (value: number): number => Number(value.toFixed(2)); /** 저장 글 → 끈 구간 쌍. 틀린 조각은 버림. */ diff --git a/B05_Profile/B05_Profile_UI_Structures_Widening.ts b/B05_Profile/B05_Profile_UI_Structures_Widening.ts index b649eb18f..3ef019b40 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Widening.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Widening.ts @@ -10,6 +10,7 @@ import { isRunOff, offForRuns, toggleRun, + wideningArea, wideningRuns, type WideningRun, } from "../A00_Common/design/design_structure_widening"; @@ -37,18 +38,24 @@ export function createWideningBox(hooks: { let off = ""; /** 지금 범위의 구간들 — 횡단 자료 · 범위가 없으면 null(+ 안내 글). */ - const runs = (): { list: WideningRun[] | null; note: string } => { + const runs = (): { + list: WideningRun[] | null; + note: string; + area?: (run: WideningRun) => number; + } => { const sections = pipeSections(); if (!sections?.length) return { list: null, note: "횡단 자료 없음" }; const { start, end } = hooks.range(); if (start === null || end === null || end <= start) return { list: null, note: "시작 · 종료 측점을 넣으면 보임" }; const list = wideningRuns(sections, start, end); - return { list, note: list.length ? "" : "범위 안 확폭 없음" }; + // 확폭 면적(138-2) — 표시만 · 수량엔 안 넣음 + const area = (run: WideningRun) => wideningArea(sections, run, { start, end }); + return { list, note: list.length ? "" : "범위 안 확폭 없음", area }; }; const paintBody = (): void => { - const { list, note } = runs(); + const { list, note, area } = runs(); if (!list?.length) { const text = document.createElement("span"); text.className = "b05-structure__widening-note"; @@ -68,6 +75,12 @@ export function createWideningBox(hooks: { const from = stationLabel(run.start_m, step); const to = stationLabel(run.end_m, step); button.textContent = `${from === to ? from : `${from} ~ ${to}`} · 확폭 ${Number(run.widening_m.toFixed(2))} m`; + const size = area?.(run); + const areaText = document.createElement("span"); + areaText.className = "b05-structure__widening-area"; + areaText.textContent = + size !== undefined && Number.isFinite(size) ? `${size.toFixed(2)} ㎡` : ""; + button.append(areaText); button.title = on ? "확폭 포함 — 누르면 규격 폭만" : "규격 폭만 — 누르면 확폭 포함"; button.addEventListener("click", () => { off = toggleRun(list, off, run); diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index bb0919662..a322b7cc9 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -606,3 +606,20 @@ .b05-structure__widening [data-widening-run][aria-pressed="true"]::before { content: "✓ "; } + +/* 확폭 면적(138-2) — 단추 안 오른쪽 끝 · 끈 구간 회색 */ +.b05-structure__widening [data-widening-run] { + display: flex; + align-items: center; + column-gap: 4px; +} + +.b05-structure__widening-area { + margin-left: auto; + padding-left: var(--spacing-8, 8px); + white-space: nowrap; +} + +.b05-structure__widening [aria-pressed="false"] .b05-structure__widening-area { + color: var(--color-text-secondary); +} diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 16f1a143b..aafbd6091 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -108,6 +108,14 @@ const PRESERVED_KEYS = [ ...SPOIL_KEYS, ] as const; +/** + * 포장 = 노면공 · 물넘이 범위 안만(138-1) — 측점에 얹힌 제원(`pavement` · `ford_pavement`)이 곧 범위다. + * 서버 짝 = `B06_Section_Router_Design.paved_at` · 저장값 · 카드 선택은 쓰지 않는다. + */ +export function pavedBySpecs(section: Pick): boolean { + return Boolean(section.pavement?.length) || Boolean(section.ford_pavement); +} + function preserveUserFields( next: NonNullable, previous: CrossSection["design"], @@ -293,7 +301,7 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { ditchType: choice?.ditch_type ?? (typeof design.ditch_type === "string" ? design.ditch_type : "standard"), - paved: choice?.paved ?? Boolean(design.paved), + paved: pavedBySpecs(section), standard, berm: bermAt(section.chainage_m), rockBoundaryOffsetM, diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index ee225258d..278c79a05 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -226,26 +226,29 @@ def _side_spec( return spec -def _chute(values: dict[str, Any]) -> dict[str, Any] | None: - """도수로 · 돌붙임(90-2) — 첫 상자(`chute_add_{n}_*`) 구조 · 두께(cm → m · 빈 값 30 cm) · - 구조를 안 고르면 None. 횡단이 유출 성토사면을 따라 띠로 그림. TS 짝 `chute`.""" - head = re.compile(r"^chute_add_(\d+)_structure$") +def _chute(values: dict[str, Any], head: str = "chute_add") -> dict[str, Any] | None: + """도수로 · 돌붙임(90-2) — 첫 상자(`{head}_{n}_*` · 물넘이 포장 = `ford_chute` · 136-2) 구조 · + 두께(cm → m · 빈 값 30 cm) · 구조를 안 고르면 None. 횡단이 유출 성토사면을 따라 띠로 그림. + TS 짝 `chute`.""" + pattern = re.compile(rf"^{head}_(\d+)_structure$") picked = sorted( (int(hit.group(1)), str(value).strip()) for key, value in values.items() - if (hit := head.match(key)) and str(value or "").strip() + if (hit := pattern.match(key)) and str(value or "").strip() ) if not picked: return None n, structure = picked[0] - cm = _number(values.get(f"chute_add_{n}_thickness"), None) + cm = _number(values.get(f"{head}_{n}_thickness"), None) cm = cm if cm and cm > 0 else CHUTE_THICKNESS_CM return {"structure": structure, "thickness_m": round(cm / 100.0, 3)} -def _with_chute(spec: dict[str, Any], values: dict[str, Any]) -> dict[str, Any]: +def _with_chute( + spec: dict[str, Any], values: dict[str, Any], head: str = "chute_add" +) -> dict[str, Any]: """도수로 · 돌붙임이 있으면 세트에 `chute` 로 실음(90-2).""" - chute = _chute(values) + chute = _chute(values, head) if chute: spec["chute"] = chute return spec @@ -502,16 +505,20 @@ def _ford_pavement_set(options: dict[str, Any] | None) -> dict[str, Any]: used = degree if degree is not None else fallback slope = round(math.tan(math.radians(used)) * 100.0, 4) if used is not None else legacy span = width if width and width > 0 else FORD_PAVEMENT_DEFAULT_WIDTH_M - return { - "type": "ford_pavement", - "span_m": span, - # 노선 중심에서 잰 깊이. 없으면 화면이 파임을 그리지 않는다(수치를 지어내지 않는다). - "depth_m": depth if depth and depth > 0 else None, - "slope_pct": slope, - "defaulted": _defaulted( - values, [("ford_width_m", "월류 폭", span), ("ford_slope_deg", "경사각", fallback)] - ), - } + return _with_chute( + { + "type": "ford_pavement", + "span_m": span, + # 노선 중심에서 잰 깊이. 없으면 화면이 파임을 그리지 않는다(수치를 지어내지 않는다). + "depth_m": depth if depth and depth > 0 else None, + "slope_pct": slope, + "defaulted": _defaulted( + values, [("ford_width_m", "월류 폭", span), ("ford_slope_deg", "경사각", fallback)] + ), + }, + values, + "ford_chute", + ) def _box_set(options: dict[str, Any] | None) -> dict[str, Any]: diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index a1963de5a..074fd5e88 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -44,6 +44,8 @@ from B06_Section.B06_Section_Router_Design import ( USER_TOUCHED_KEYS, ford_drop_at, ford_surface_drops, + paved_at, + pavement_ranges, stored_berm, stored_cut_slope, ) @@ -640,7 +642,7 @@ async def compute_cross_section_design( section_mode=request.section_mode, ditch_side=request.ditch_side, ditch_type=request.ditch_type, - paved=request.paved, + paved=paved_at(request.chainage_m, pavement_ranges(project_root)), standard=request.standard_cross_section, rock_boundary_offset_m=request.rock_boundary_offset_m, two_stage_slope=request.two_stage_slope, diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index 7722522ce..b12160741 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -110,12 +110,17 @@ def ford_drop_at(chainage_m: float, drops: list[tuple[float, float]]) -> float: return 0.0 -def paved_at(chainage_m: float, ranges: list[tuple[float, float]], stored: Any = None) -> bool: - """이 측점을 포장으로 볼 것인가 — 구간 안이면 강제, 밖이면 사용자 저장값(기본 비포장).""" - for start, end in ranges: - if start - 1e-9 <= chainage_m <= end + 1e-9: - return True - return bool(stored) if isinstance(stored, bool) else False +# 범위 끝 허용오차(m) — 브라우저 짝(`common_util_structure_pavement` · `_culvert_sets`)과 같은 값. +_PAVED_EDGE_TOLERANCE_M = 0.02 + + +def paved_at(chainage_m: float, ranges: list[tuple[float, float]]) -> bool: + """이 측점을 포장으로 볼 것인가 — 노면공 · 물넘이 범위 안만 포장, 밖은 비포장(저장값 무시). + + 브라우저 짝 = `B06_Section_Cross_Refresh.ts` `pavedBySpecs`(같은 규칙 · 138-1). + """ + tol = _PAVED_EDGE_TOLERANCE_M + return any(start - tol <= chainage_m <= end + tol for start, end in ranges) # 사용자 조작값 — 다시 계산해도 그대로 승계한다. **이 목록이 서버의 유일한 한 벌이다.** @@ -152,7 +157,6 @@ CARRY_INPUT_KEYS = ( "ground_type", "ditch_type", "ditch_choice", - "paved", "two_stage_slope", "rock_boundary_offset_m", "cut_slope_ratio_user", @@ -178,23 +182,23 @@ def enforce_pavement_ranges( project_root: Path, standard: dict[str, Any] | None = None, ) -> int: - """포장 구간·물넘이 범위 안 측점을 포장으로 맞춘다 — 저장분이 비포장이어도 그렇다. + """포장 = 노면공 · 물넘이 범위 안만 — 저장분과 다르면 켬 · 끔 양쪽으로 맞춘다(138-1). 사용자가 구간을 지정하면 그 안 횡단도에 **자동 반영**된다(2026-08-28 사용자 확정). + 범위 밖 옛 `paved=true`(지운 노면공 · 옛 카드 단추)는 비포장으로 되돌린다. 포장 여부는 횡단경사·포장층을 바꾸므로 플래그만 갈아 끼우지 않고 다시 계산한다. 사용자 조작값(반폭·구조물 조정)은 그대로 승계한다. """ ranges = pavement_ranges(project_root) - if not ranges: - return 0 ford_drops = ford_surface_drops(project_root) changed = 0 for section in cross_sections: design = section.get("design") - if not isinstance(design, dict) or design.get("paved"): + if not isinstance(design, dict): continue chainage = float(section.get("chainage_m", 0.0)) - if not paved_at(chainage, ranges): + wanted = paved_at(chainage, ranges) + if bool(design.get("paved")) == wanted: continue try: recomputed = compute_cross_design( @@ -204,7 +208,7 @@ def enforce_pavement_ranges( section_mode=str(design.get("section_mode") or "left_cut"), ditch_side=design.get("ditch_side"), ditch_type=str(design.get("ditch_type") or "standard"), - paved=True, + paved=wanted, standard=standard, rock_boundary_offset_m=design.get( "rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M @@ -449,7 +453,7 @@ def recompute_designs_for_alignment( section_mode=str(stored.get("section_mode") or modes.get(key, "left_cut")), ditch_side=stored.get("ditch_side"), ditch_type=str(stored.get("ditch_type") or "standard"), - paved=paved_at(chainage, paved_ranges, stored.get("paved")), + paved=paved_at(chainage, paved_ranges), standard=standard, rock_boundary_offset_m=session_offsets.get( key, @@ -527,7 +531,7 @@ def compute_default_designs( section_mode=default_modes.get(round(chainage_m, 3), "left_cut"), ditch_type=str(user.get("ditch_type") or "standard"), # 포장은 사용자 구간 지정만 켠다 — 경사 제안은 경고로만 남는다(2026-08-28). - paved=paved_at(chainage_m, paved_ranges, user.get("paved")), + paved=paved_at(chainage_m, paved_ranges), standard=standard, rock_boundary_offset_m=( STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M if rock_offset is None else rock_offset diff --git a/B06_Section/B06_Section_UI_Cross_Design.ts b/B06_Section/B06_Section_UI_Cross_Design.ts index 19e1e8dbc..813759369 100644 --- a/B06_Section/B06_Section_UI_Cross_Design.ts +++ b/B06_Section/B06_Section_UI_Cross_Design.ts @@ -1,6 +1,6 @@ /* ============================================================================= * B06_Section_UI_Cross_Design.ts - * 측점 표준횡단 설계 지정 컨트롤(지반유형·단면유형·측구위치·측구형식·포장)과 + * 측점 표준횡단 설계 지정 컨트롤(지반유형·단면유형·측구위치·측구형식)과 * 설계선·암 경계선·포장층 오버레이. * * 카드 헤더 아래에 세그먼트 버튼을 배치하고, 선택이 바뀌면 onChange로 계산을 @@ -439,25 +439,13 @@ export function buildDesignControls( !rockCut, L("B06_Design_Disabled_RockCut"), ); - // 포장 토글: 라벨 삭제, 버튼명 활성="포장"/비활성="비포장"(컬러 유지) — E-7. - const paved = toggle("", state.paved, L("B06_Design_Paved_On"), L("B06_Design_Paved_Off"), () => { - state.paved = !state.paved; - emit(); - }); - // B05 법정 경사 분석이 포장을 제안한 측점은 근거 문구를 배지·툴팁으로 표기한다. - if (design?.pavement_suggested) { - paved.button.title = L("B06_Design_Paved_Suggested"); - const badge = document.createElement("span"); - badge.className = "b06-design__paved-badge"; - badge.textContent = "⚠"; - badge.title = L("B06_Design_Paved_Suggested"); - paved.wrap.append(badge); - } + // 포장 단추는 없다(138-1) — 포장 = 노면공 · 물넘이 범위만(`pavedBySpecs`) · 「포장 권장」은 알림 줄. + // `state.paved` 는 저장값 그대로 실어 보낸다(재계산이 범위로 다시 정함). // 오버플로 드롭다운(N-2-2): 컨트롤이 카드 폭에서 한 행을 넘치면 뒤쪽부터 우측 "⋯" // 드롭다운(세로 배치)으로 옮겨 다음 행으로 밀리는 것을 막는다. 경사 방향은 항상 인라인, - // 이동 우선순위(뒤에서부터): 포장 → 복합경사 → 측구형식 → 측구 토글. - const moveable = [ditchToggle.wrap, ditchTypeSeg, twoStage.wrap, paved.wrap]; + // 이동 우선순위(뒤에서부터): 복합경사 → 측구형식 → 측구 토글. + const moveable = [ditchToggle.wrap, ditchTypeSeg, twoStage.wrap]; const more = document.createElement("details"); more.className = "b06-design__more"; const moreSummary = document.createElement("summary"); diff --git a/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts b/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts index 6db3cdb63..82f665e0f 100644 --- a/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts +++ b/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts @@ -4,6 +4,7 @@ * (2026-09-14 브레인 승인 (나)). 판정은 `_Cross_FillSlope_Warn`, 여기는 그리기만. * 측점을 누르면 그 카드로 간다. 경고만 — 구조물을 세우거나 값을 바꾸지 않는다. * ⭐ 2026-09-15 — 그 밑에 사면길이 3m 초과 **소단 검토 정보 한 줄**(경고 모양 아님 · 브레인 ③). + * ⭐ 138-1 — 「⚠ 포장 권장 · n측점」 줄(B05 법정 경사 제안 · 포장 범위 밖 측점 · 옛 카드 배지 자리). * ========================================================================== */ import type { CrossSection } from "./B06_Section_Api_Fetch"; @@ -15,6 +16,7 @@ import { type FillSlopeWarning, } from "./B06_Section_UI_Cross_FillSlope_Warn"; import { L, stationLabel } from "./B06_Section_UI_Section_Common"; +import { pavedBySpecs } from "./B06_Section_Cross_Refresh"; export interface FillSlopeNotice { root: HTMLElement; @@ -46,7 +48,8 @@ function noticeBlock(className: string): { const label = L(side === "left" ? "B06_Design_Ditch_Left" : "B06_Design_Ditch_Right"); return `${label} ${open ? "≥" : ""}${lengthM.toFixed(2)}m`; }); - button.textContent = `${stationLabel(section.chainage_m, stationInterval)} ${parts.join(" · ")}`; + button.textContent = + `${stationLabel(section.chainage_m, stationInterval)} ${parts.join(" · ")}`.trim(); button.dataset.stationId = section.station_id; return button; }), @@ -59,7 +62,9 @@ export function createFillSlopeNotice(onPick: (stationId: string) => void): Fill const root = document.createElement("div"); const warn = noticeBlock("b06-section__notice"); const info = noticeBlock("b06-section__notice b06-section__notice--info"); - root.append(warn.root, info.root); + const pave = noticeBlock("b06-section__notice"); + pave.root.title = L("B06_Design_Paved_Suggested"); + root.append(warn.root, pave.root, info.root); root.addEventListener("click", (event) => { const id = (event.target as HTMLElement).closest("button")?.dataset.stationId; if (id) onPick(id); @@ -73,6 +78,13 @@ export function createFillSlopeNotice(onPick: (stationId: string) => void): Fill fillSlopeWarnings(sections, fillSlopeLengths), stationInterval, ); + pave.fill( + `⚠ ${L("B06_Notice_PaveSuggested")}`, + sections + .filter((section) => section.design?.pavement_suggested && !pavedBySpecs(section)) + .map((section) => ({ section, sides: [] })), + stationInterval, + ); info.fill( `ⓘ ${BERM_INFO_TEXT}`, bermReviewStations(sections, slopeRunLengths), diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts index 6f5e86a39..6dc7ab614 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts @@ -15,6 +15,7 @@ import type { CrossDesign } from "./B06_Section_Api_Fetch"; import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types"; +import { appendChuteBand } from "./B06_Section_UI_Cross_Wall_Hatch"; const SVG_NS = "http://www.w3.org/2000/svg"; /** 빗금 패턴 id는 문서 안에서 유일해야 한다 — 카드마다 하나씩 번호를 준다. */ @@ -31,6 +32,8 @@ export interface FordPavementSpec { slope_pct: number | null; /** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */ defaulted?: string[]; + /** 도수로 · 돌붙임(`ford_chute_{n}_*` · 136-2) — 유출측 성토사면 띠. */ + chute?: { structure: string; thickness_m: number }; } interface Edge { @@ -179,9 +182,34 @@ export function appendFordPavementOverlay( "b06-chart__ford-deck", ), ); + if (spec.chute) appendChuteBand(svg, [outletFillLine(design)], spec.chute, x, y); return true; } +/** + * 유출측(`inflowSign` 반대) 노견 바깥 성토사면 선(136-2) — 설계선을 노견에서 바깥으로 따라 + * 내려가는 동안만(오르면 절토라 멈춤). 성토가 없으면 빈 선(띠 안 그림). + */ +export function outletFillLine(design: CrossDesign): OffsetPoint[] { + const edges = design.road_edges; + if (!edges || !design.design_line?.length) return []; + const outward = design.section_mode !== "right_cut" ? -1 : 1; // +offset = 좌측 · 유입 반대 + const edge = + outward > 0 + ? Math.max(edges.left.offset_m, edges.right.offset_m) + : Math.min(edges.left.offset_m, edges.right.offset_m); + const points = design.design_line + .filter((p) => (p.offset_m - edge) * outward >= -1e-6) + .sort((a, b) => (a.offset_m - b.offset_m) * outward); + const line: OffsetPoint[] = []; + for (const p of points) { + const last = line[line.length - 1]; + if (last && p.elevation_m > last.elevation + 1e-6) break; + line.push({ offset: p.offset_m, elevation: p.elevation_m }); + } + return line; +} + /** * 물넘이 자리 노면 · 측구 설계선을 걷는다(66-2 · 66-3) — 노면은 파인 바닥(실선)과 옛 계획고(점선)가 * 대신하고, 측구는 서지 않음. `from` = 설계선 그리기 앞의 자식 수(그 뒤에 붙은 선만 본다). diff --git a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts index 90a9243c5..48b66ddc8 100644 --- a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts +++ b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts @@ -6,7 +6,7 @@ * 캐시(`section.culvert`·조정창 제어기)만 고친다 — 영구저장은 [저장]·[확정] 몫이다. * ========================================================================== */ -import { guardSet, pipeDiameterM } from "@util/common_util_culvert_sets"; +import { chute, guardSet, pipeDiameterM } from "@util/common_util_culvert_sets"; import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; @@ -51,6 +51,7 @@ export function applyPipeOptionsToCache( patch: Record, whole = true, ): void { + syncChute(ctx, chainageM, patch, whole); // 물넘이포장 — 월류 높이 · 경사각을 캐시 제원에 얹음(66-2 · 66-3) · 폭 절반 걸친 옆 카드도 const paved = (ctx.detail()?.cross_sections ?? []).filter( (section) => @@ -163,6 +164,36 @@ export function applyPipeOptionsToCache( } } +/** + * 도수로 · 돌붙임 상자 → 그 관 지점 제원(관 · BOX · 세월교 · 흙막이 · 물넘이 포장)의 `chute` 갈아 끼움 + * (136-1) · 바뀐 카드만 다시 그림. 일부만 온 되얹기는 상자가 있을 때만 얹음(없음 = 모름). + */ +function syncChute( + ctx: PipeOptionsContext, + chainageM: number, + patch: Record, + whole: boolean, +): void { + const found = { chute: chute(patch), pave: chute(patch, "ford_chute") }; + for (const section of ctx.detail()?.cross_sections ?? []) { + let changed = false; + for (const [spec, next] of [ + [section.culvert, found.chute], + [section.box, found.chute], + [section.ford, found.chute], + [section.ford_pavement, found.pave], + ] as const) { + if (!spec || (!whole && !next)) continue; + if (Math.abs((spec.chainage_m ?? section.chainage_m) - chainageM) >= PIPE_MATCH_M) continue; + if (JSON.stringify(spec.chute ?? null) === JSON.stringify(next)) continue; + if (next) spec.chute = { ...next }; + else delete spec.chute; + changed = true; + } + if (changed) ctx.refreshCard(section.chainage_m); + } +} + /** 세월교 · 박스 흙막이 세트를 폼 칸 전부로 다시 지음(73-4) — 흙막이가 없으면 걷음. */ function syncBodyGuard( owner: CrossSection, diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index 6f08716c6..f526528d2 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -404,13 +404,6 @@ align-self: center; } -/* 포장 제안 배지: B05 법정 경사 분석이 포장을 권장한 측점 표시 */ -.b06-design__paved-badge { - font-size: 0.72rem; - color: var(--color-warning); - cursor: help; -} - /* 세월교 구체(2026-08-25) — 바닥판은 콘크리트. 측벽은 ㄴ형 집수정 형상을 그대로 쓰므로 `.b06-chart__culvert-basin` 색을 공유한다. */ .b06-chart__ford-slab { diff --git a/B07_DesignDetail/B07_DesignDetail_UI_PlanManage.ts b/B07_DesignDetail/B07_DesignDetail_UI_PlanManage.ts index 5328ed77f..7cff7b98c 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_PlanManage.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_PlanManage.ts @@ -3,8 +3,8 @@ * [평면도 관리] 모달(PLAN 130-1) — 좌측 패널이 좁아 종류 · 대피소 자리 · 장 보기를 한 창에 모음. * * 왼쪽 = 종류 목록(× 지우기) · 종류 추가 · 업데이트 폼 · 대피소 · 사토장 자리 · 평면도 장 목록 - * 오른쪽 = 고른 장 미리보기(보기 전용 CAD · 장 바꾸기) · [확정 풀기] · [CAD 에서 열기] - * 고치기는 지금 서버 길 그대로(종류 파일 · 확정 풀기) — 고칠 때마다 모달 안을 다시 받고, + * 오른쪽 = 고른 장 미리보기(보기 전용 CAD · 캔버스만 `bare`) · 확정 배지 · [CAD 에서 열기] + * 고치기는 지금 서버 길 그대로(종류 파일) — 고칠 때마다 모달 안을 다시 받고, * 닫을 때 `onClosed(changed)` 로 좌측 목록 · 도면을 다시 읽음. * ========================================================================== */ @@ -16,7 +16,6 @@ import { fetchDesignDrawing, fetchDesignDrawingList, fetchPlanKinds, - invalidateDesignDrawing, type CadDrawing, type DesignDrawingItem, type PlanKindsState, @@ -115,7 +114,7 @@ export function openPlanManager(controls: PlanManageControls): void { .querySelectorAll(".b07-plan-manage__sheets .b07-drawing-button") .forEach((item) => (item.dataset.active = String(item.dataset.drawingId === picked))); - /** 장 하나를 미리보기에 — 보기 전용 · 확정이면 [확정 풀기](지금 [수정]과 같은 서버 길). */ + /** 장 하나를 미리보기에 — 보기 전용 · 확정 풀기는 메인 [수정](140-1). */ const show = async (drawing: DesignDrawingItem, label: string): Promise => { picked = drawing.id; markActive(); @@ -127,12 +126,6 @@ export function openPlanManager(controls: PlanManageControls): void { const badge = el("span", "b07-plan-manage__badge", response.confirmed ? "확정" : "확정 전"); badge.dataset.confirmed = String(response.confirmed); head.append(badge); - if (response.confirmed) - head.append( - button("b07-plan-tools__button", "확정 풀기", () => { - void run(() => invalidateDesignDrawing(projectId, drawing.id), edits); - }), - ); head.append(button("b07-plan-tools__button", "CAD 에서 열기", () => finish(drawing.id))); preview.load( response.drawing, @@ -146,6 +139,7 @@ export function openPlanManager(controls: PlanManageControls): void { hostTitle: "평면도 미리보기", ribbonCollapsed: true, panelsCollapsed: true, + bare: true, }, ); } catch (error) { diff --git a/B07_DesignDetail/openwebcad/src/App.css b/B07_DesignDetail/openwebcad/src/App.css index 847978336..f80bac8e1 100644 --- a/B07_DesignDetail/openwebcad/src/App.css +++ b/B07_DesignDetail/openwebcad/src/App.css @@ -58,6 +58,10 @@ button { .cad-host-save { --cad-title-height: 0px; } +.cad-bare { + --cad-title-height: 0px; + --cad-status-height: 0px; +} .controls { pointer-events: auto; } diff --git a/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx b/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx index 67e6dc26e..ba0b5e184 100644 --- a/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx +++ b/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx @@ -13,7 +13,7 @@ import { Ribbon } from '../ribbon/Ribbon'; import { StatusBar } from './StatusBar'; import { useCadRefresh } from './use-cad-refresh'; import { isInspectorCollapsed, isRibbonCollapsed, setInspectorCollapsed } from './ui-state'; -import { getActiveToolActor } from '../state'; +import { getActiveToolActor, isBare } from '../state'; import type { Tool } from '../tools'; import { ViewControls } from './ViewControls'; import { DropHint } from './DropHint'; @@ -26,21 +26,27 @@ export const Toolbar: FC = () => { const ribbonCollapsed = isRibbonCollapsed(); // 좌측 패널 접힘 — 호스트(M02 미리보기)가 접힌 채로 실을 수 있게 ui-state 에 둔다 const panelCollapsed = isInspectorCollapsed(); + // 캔버스만(B07 평면도 관리 모달) — 겉칸을 안 그리고 높이 · 폭 변수를 0 으로 + const bare = isBare(); useEffect(() => { // 편 상태의 폭은 App.css의 `--cad-panel-width` 기본값을 쓴다. 여기 숫자를 박아 // 두면 스타일에서 넓혀도 이 인라인 값이 덮는다(2026-09-01 실측: 300px 무시됨). - if (panelCollapsed) document.documentElement.style.setProperty('--cad-panel-width', '0px'); + if (panelCollapsed || bare) document.documentElement.style.setProperty('--cad-panel-width', '0px'); else document.documentElement.style.removeProperty('--cad-panel-width'); document.documentElement.style.setProperty( '--cad-command-height', - commandLineVisible ? '86px' : '0px' + commandLineVisible && !bare ? '86px' : '0px' ); // 리본 접힘 — 탭 줄(26px)만 남기고 패널 줄 자리를 걷는다(`Ribbon.tsx` 가 안 그림). - if (ribbonCollapsed) document.documentElement.style.setProperty('--cad-ribbon-height', '26px'); + if (bare) document.documentElement.style.setProperty('--cad-ribbon-height', '0px'); + else if (ribbonCollapsed) + document.documentElement.style.setProperty('--cad-ribbon-height', '26px'); else document.documentElement.style.removeProperty('--cad-ribbon-height'); window.dispatchEvent(new Event('resize')); - }, [panelCollapsed, commandLineVisible, ribbonCollapsed]); + }, [panelCollapsed, commandLineVisible, ribbonCollapsed, bare]); + + if (bare) return null; return ( <> diff --git a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts index 47ecd4f5b..3960d5b73 100644 --- a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts +++ b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts @@ -27,6 +27,7 @@ import { setLayers, setOriginMarker, setViewOnly, + setBare, } from '../state.ts'; import { setInspectorCollapsed, setRibbonCollapsed } from '../components/ui-state.ts'; import { toast } from 'react-toastify'; @@ -68,6 +69,8 @@ interface DrawingLoadMessage { panelsCollapsed?: boolean; /** 미리보기 — 왼쪽 끌기 = 화면 옮기기 · 두 번 누름 = 전체 맞춤 · 선택 · 명령 안내 없음. */ viewOnly?: boolean; + /** 캔버스만 — 제목줄 · 리본 · 패널 · 상태막대를 안 그림(B07 평면도 관리 모달). */ + bare?: boolean; originMarker?: boolean; } @@ -227,6 +230,7 @@ export function registerAisloDrawingBridge() { setRibbonCollapsed(event.data.ribbonCollapsed === true); if (event.data.panelsCollapsed) setInspectorCollapsed(true); setViewOnly(event.data.viewOnly === true); + setBare(event.data.bare === true); setOriginMarker(event.data.originMarker === true); if (event.data.frameEdit) applyFramePreview(); // 앞 도면에서 켜 둔 그리기 도구를 내린다. 안 내리면 **확정한 도면 위에도** diff --git a/B07_DesignDetail/openwebcad/src/state.test.ts b/B07_DesignDetail/openwebcad/src/state.test.ts index a45b4fe21..5a1351e4d 100644 --- a/B07_DesignDetail/openwebcad/src/state.test.ts +++ b/B07_DesignDetail/openwebcad/src/state.test.ts @@ -7,9 +7,11 @@ import { getEntities, getHighlightedEntityIds, isDrawingDirty, + isBare, isDrawingReadOnly, resetUndoBaseline, setActiveLayerId, + setBare, setDesignMeta, setEntities, setHighlightedEntityIds, @@ -120,3 +122,23 @@ describe('isDrawingReadOnly', () => { expect(isDrawingReadOnly()).toBe(false); }); }); + +describe('setBare', () => { + it('캔버스만 — 켜면 뿌리에 cad-bare(제목줄 · 상태막대 0), 끄면 걷힌다', () => { + stubWindow(); + const classes = new Set(); + (globalThis as { document?: unknown }).document = { + documentElement: { + classList: { + toggle: (name: string, on: boolean) => (on ? classes.add(name) : classes.delete(name)), + }, + }, + }; + setBare(true); + expect(isBare()).toBe(true); + expect(classes.has('cad-bare')).toBe(true); + setBare(false); + expect(isBare()).toBe(false); + expect(classes.has('cad-bare')).toBe(false); + }); +}); diff --git a/B07_DesignDetail/openwebcad/src/state.ts b/B07_DesignDetail/openwebcad/src/state.ts index 10b70c21e..559e75731 100644 --- a/B07_DesignDetail/openwebcad/src/state.ts +++ b/B07_DesignDetail/openwebcad/src/state.ts @@ -197,6 +197,8 @@ let viewOnly = false; let originMarker = false; let hostTitle = 'B07 상세 설계'; let hostSave = false; +/** 캔버스만(B07 평면도 관리 모달) — 제목줄 · 리본 · 패널 · 상태막대를 안 그린다. */ +let bare = false; /** * 실은 뒤로 실제 편집이 있었는가. 도면을 바꾸기 전에 부모가 물어보는 근거다 — @@ -512,6 +514,13 @@ export const setHostSave = (enabled: boolean) => { // 제목 줄이 없어지면 그 높이만큼 위로 당긴다(App.css `.cad-host-save`). document.documentElement.classList.toggle('cad-host-save', enabled); }; +export const isBare = (): boolean => bare; +export const setBare = (enabled: boolean) => { + bare = enabled; + // 제목 줄 · 상태막대 높이를 걷는다(App.css `.cad-bare`) — 리본 · 패널은 Toolbar 가 0 으로. + document.documentElement.classList.toggle('cad-bare', enabled); + notifyWindow(HtmlEvent.UPDATE_STATE); +}; export const getHostTitle = (): string => hostTitle; export const setHostTitle = (title: string) => { hostTitle = title; diff --git a/B08_Quantity/B08_Quantity_Router_Summary.py b/B08_Quantity/B08_Quantity_Router_Summary.py index 95e78bd6a..8a97e8c2e 100644 --- a/B08_Quantity/B08_Quantity_Router_Summary.py +++ b/B08_Quantity/B08_Quantity_Router_Summary.py @@ -240,6 +240,10 @@ def _revets( ) +# 도수로/돌붙임 잇기 → 옵션 머리 — 물넘이 포장(`ford_chute` · 136-2)도 같은 열 +CHUTE_HEADS = {"gchuteadd": "chute_add", "gfordpavechute": "ford_chute"} + + def _chutes( sheet: _Sheet, at: float, @@ -249,9 +253,10 @@ def _chutes( ) -> None: """돌붙임 — 붙임 형식 · 면적이 다 있을 때만(설계자가 넣음).""" for link, combo in links.items(): - if not link.startswith("gchuteadd"): + tag = next((t for t in CHUTE_HEADS if link.startswith(t)), None) + if not tag: continue - head = f"chute_add_{link[len('gchuteadd') :]}_" + head = f"{CHUTE_HEADS[tag]}_{link[len(tag) :]}_" bond = str(options.get(f"{head}bond") or "") area = _num(options.get(f"{head}area")) if options.get(f"{head}structure") != "돌붙임" or not bond or not area: @@ -449,6 +454,7 @@ def _drain( combos = {part: links.get(link) or _combo(group.get(link)) for part, link in required.items()} if type_id == "ford_pavement": _pavement(sheet, at, base, combos, names) + _chutes(sheet, at, options, links, pipe_names) return if type_id == "box_culvert": empty_height = _box(sheet, at, base, combos, names, box_length) diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index 1eef8d95f..5c99a203a 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -1,7 +1,7 @@ """M02 일위대가 문서 `labor/<열id>.json` — 정리 · 검사 · 계산 · 알약(PLAN 39-1a · 설계_정함 1장). 문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, -담은로직: [{로직, 별칭, 메모, 입력값, 수량, 켜쌓기율?, 고름?} | {참조: {층, 주인, 키}, 별칭, 메모, 수량}], +담은로직: [{로직, 별칭, 메모, 입력값, 수량, 켜쌓기율?, 고름?, 승격?} | {참조: {층, 주인, 키}, 별칭, 메모, 수량}], 자재: [{코드, 이름, 규격, 단위, 수량}], 계산값}` — 이름은 구조물 열 이름(따로 안 둠). 수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}` (손이 칸보다 앞섬) · 같은 로직 두 번 금지 · 입력값 = 그 로직 입력 이름만(빈 값 = 자동값). @@ -17,6 +17,7 @@ 로직 자재(103-1) = 담은 로직 맨 위 층 호표 재료 줄(`logic_mats` · 줄마다 `재료`) · 고름 = `{호표 차례: MT키}`(엔진 `choosing` 그대로) · 금액은 로직 재료비 안이라 합 · 알약에 안 더함(보기만) · 사용자 자재와 같은 품목 두 번은 [저장] 때 400. +승격(134-1) = 로직 줄 「단가승격」 표시(`true` 만 · 없음 = 단가산출) · 금액 무관 · B09 호표 몫. """ from __future__ import annotations @@ -124,6 +125,7 @@ def clean(doc: Any) -> dict[str, Any]: "수량": _qty(one.get("수량"), where), **({"켜쌓기율": rate} if rate is not None else {}), **({"고름": picks} if picks else {}), + **({"승격": True} if one.get("승격") is True else {}), } ) mats, codes = [], set() diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index 25c01e014..550e2ec7b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -204,6 +204,16 @@ color: var(--color-text-secondary); } +/* 구분 드롭다운(134-1) — 칸 너비만 · 품셈 책 글은 아래 */ +.m02-labrow__kind .ui-field { + display: block; +} + +.m02-labrow__kind .ui-select { + width: auto; + min-width: 0; +} + /* 줄 접고 폄 표(51-2) · 고른 줄 하위 행 — 이름 들여쓰기 · 흐린 바탕 · 위 줄보다 촘촘(51-7) */ .m02-labrow .m02-labrow__sub td { padding: var(--spacing-4) var(--spacing-8); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index 47275de84..27205c10b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -133,6 +133,8 @@ export interface LaborRow { 켜쌓기율?: number | null; /** 고른 로직 자재(103-1) — {호표 차례: MT키} · 없으면 엔진 기본 */ 고름?: Record; + /** 단가승격(134-1 · 로직 줄 구분 드롭다운) — 없음 = 단가산출 · 금액 무관 · B09 호표 몫 */ + 승격?: boolean; } export interface LaborMat { 코드: string; @@ -170,6 +172,7 @@ export function normalizeLabor(doc: unknown, column: string): LaborDoc { ...(r.참조 ? { 참조: r.참조 } : {}), ...(r.켜쌓기율 != null ? { 켜쌓기율: r.켜쌓기율 } : {}), ...(r.고름 && Object.keys(r.고름).length ? { 고름: { ...r.고름 } } : {}), + ...(r.승격 === true && !r.참조 ? { 승격: true } : {}), })), 자재: (d.자재 ?? []).map((m) => ({ 코드: m.코드, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index 098950a16..33911a524 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -204,6 +204,8 @@ export function mountLaborDetail( move: reorder, remove: removeRow, alias: setAlias, + // 134-1 — 구조물 일위대가 로직 줄만(사전조합 안 로직 줄엔 없음) + ...(lib ? {} : { promote: setPromote }), } : { open: pick, option: openRow, ref: openRef }, ), @@ -361,6 +363,12 @@ export function mountLaborDetail( cur.담은로직[i].별칭 = value; opt.onChanged(dirty()); } + /** 구분 드롭다운(134-1) — 화면 캐시만 · 금액 무관이라 다시 셈 없음 */ + function setPromote(i: number, on: boolean): void { + if (on) cur.담은로직[i].승격 = true; + else delete cur.담은로직[i].승격; + opt.onChanged(dirty()); + } function removeMat(i: number): void { cur.자재.splice(i, 1); changed(); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index 6184cf511..d000ea116 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -12,7 +12,7 @@ * 상세는 줄 누름 = 고름(`selected` 줄 하이라이트 · 그 줄 바로 아래 하위 행 · 51-2 · 화살표 없음 51-7) · 넣을 값 모달은 [옵션](47-5). * ========================================================================== */ -import { createButton, el } from "@ui/ui_template_elements"; +import { createButton, createSelectField, el } from "@ui/ui_template_elements"; import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money"; import type { ItemLogic } from "./M02_MasterTemplete_Api_Fetch"; import type { @@ -41,14 +41,36 @@ const HEAD: Record<(typeof COSTS)[number], Parameters[0]> = { /** 옵션 값 줄이 걸치는 칸 수 — 구분 · 이름 · 별칭(113-1 · 나머지 칸은 위 줄에서 rowspan 2) */ const SPAN = 3; -/** 구분(50-4 · 저장 안 함) — 줄 모양(참조 키 머리)에서 읽음 · SP- = 단가승격 · SC- = 일위조합(50-3) */ -export const kindOf = (row: Pick): string => { +/** 구분(50-4) — 로직 줄 = `승격`(134-1 · 단가승격 · 없음 = 단가산출) · 참조 줄 = 키 머리(SC- = 일위조합 · + * 옛 SP- = 단가승격 글 고정 · 134-2) */ +export const kindOf = (row: Pick): string => { const key = row.참조?.키 ?? row.로직; return tc( - key.startsWith("SP-") ? "Kind_Promote" : key.startsWith("SC-") ? "Kind_Combo" : "Kind_Logic", + row.승격 || key.startsWith("SP-") + ? "Kind_Promote" + : key.startsWith("SC-") + ? "Kind_Combo" + : "Kind_Logic", ); }; +/** 구분 드롭다운(134-1) — 로직 줄만 [단가산출 | 단가승격] · 줄 누름과 따로 */ +function kindPick(on: boolean, set: (on: boolean) => void): HTMLElement { + const pick = createSelectField({ + options: [ + { value: "", text: tc("Kind_Logic") }, + { value: "1", text: tc("Kind_Promote") }, + ], + value: on ? "1" : "", + compact: true, + ariaLabel: tc("Sub"), + onChange: (v) => set(v === "1"), + }); + pick.select.dataset.laborKind = ""; + pick.root.addEventListener("click", (e) => e.stopPropagation()); // 줄 누름(고름)과 따로 + return pick.root; +} + /** 품셈 책(102-3) — 출처 첫 낱말 */ const BOOK = /^(건설|산림)품셈/; @@ -189,6 +211,8 @@ export interface RowHandlers { remove?: (i: number) => void; /** 별칭 칸 고침(44-6 · 글자마다 · 빈 글 = null) — 없으면 글만 보임 */ alias?: (i: number, value: string | null) => void; + /** 구분 드롭다운(134-1 · 로직 줄 단가산출 ↔ 단가승격) — 없으면 글만 */ + promote?: (i: number, on: boolean) => void; } /** 별칭 칸(44-6) — 고칠 수 있으면 칸 안 글상자(누르면 바로 고침 · 줄 누름과 따로) · 아니면 글 · 비면 빈 칸 */ @@ -361,7 +385,9 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement el("td", { className: "m02-labrow__kind", children: [ - kindOf(row), + on?.promote && !row.참조 + ? kindPick(!!row.승격, (v) => on.promote?.(i, v)) + : kindOf(row), // 102-3 — 단가산출 줄만 품셈 책(출처 첫 낱말) ...(book ? [el("small", { className: "m02-labrow__book", text: book })] : []), ], diff --git a/common_util/common_util_culvert_sets.ts b/common_util/common_util_culvert_sets.ts index 8c7fef06b..64e167b35 100644 --- a/common_util/common_util_culvert_sets.ts +++ b/common_util/common_util_culvert_sets.ts @@ -126,27 +126,33 @@ function sideSpec( return spec; } -/** 도수로 · 돌붙임(90-2) — 첫 상자(`chute_add_{n}_*`) 구조 · 두께(cm → m · 빈 값 30 cm) · 구조를 안 - * 고르면 null. 횡단이 유출 성토사면을 따라 띠로 그림. 파이썬 짝 `_chute`. */ +/** 도수로 · 돌붙임(90-2) — 첫 상자(`{head}_{n}_*` · 물넘이 포장 = `ford_chute` · 136-2) 구조 · 두께 + * (cm → m · 빈 값 30 cm) · 구조를 안 고르면 null. 횡단이 유출 성토사면을 따라 띠로 그림. 파이썬 짝 `_chute`. */ export function chute( values: Record, + head = "chute_add", ): { structure: string; thickness_m: number } | null { + const pattern = new RegExp(`^${head}_(\\d+)_structure$`); const picked = Object.entries(values) .flatMap(([key, value]) => { - const hit = /^chute_add_(\d+)_structure$/.exec(key); + const hit = pattern.exec(key); const structure = String(value ?? "").trim(); return hit && structure ? [[Number(hit[1]), structure] as const] : []; }) .sort((a, b) => a[0] - b[0]); if (!picked.length) return null; const [n, structure] = picked[0]; - const cm = num(values[`chute_add_${n}_thickness`], null); + const cm = num(values[`${head}_${n}_thickness`], null); return { structure, thickness_m: round((cm && cm > 0 ? cm : CHUTE_THICKNESS_CM) / 100, 3) }; } /** 도수로 · 돌붙임이 있으면 세트에 `chute` 로 실음(90-2). */ -function withChute(spec: CulvertSetSpec, values: Record): CulvertSetSpec { - const found = chute(values); +function withChute( + spec: CulvertSetSpec, + values: Record, + head = "chute_add", +): CulvertSetSpec { + const found = chute(values, head); if (found) spec.chute = found; return spec; } @@ -417,17 +423,21 @@ function fordPavementSet( : null; const used = degree ?? fallback; const span = width && width > 0 ? width : FORD_PAVEMENT_DEFAULT_WIDTH_M; - return { - type: "ford_pavement", - span_m: span, - // 노선 중심에서 잰 깊이. 없으면 화면이 파임을 그리지 않는다(수치를 지어내지 않는다). - depth_m: depth && depth > 0 ? depth : null, - slope_pct: used !== null ? round(Math.tan((used * Math.PI) / 180) * 100, 4) : legacy, - defaulted: defaulted(values, [ - ["ford_width_m", "월류 폭", span], - ["ford_slope_deg", "경사각", fallback], - ]), - }; + return withChute( + { + type: "ford_pavement", + span_m: span, + // 노선 중심에서 잰 깊이. 없으면 화면이 파임을 그리지 않는다(수치를 지어내지 않는다). + depth_m: depth && depth > 0 ? depth : null, + slope_pct: used !== null ? round(Math.tan((used * Math.PI) / 180) * 100, 4) : legacy, + defaulted: defaulted(values, [ + ["ford_width_m", "월류 폭", span], + ["ford_slope_deg", "경사각", fallback], + ]), + }, + values, + "ford_chute", + ); } /** BOX암거 1개소의 세트 제원(구체 + 날개벽 연장). */ diff --git a/resources/tester/helper_130_b07_plan_manage.cjs b/resources/tester/helper_130_b07_plan_manage.cjs index 5b6aea7ba..dddea378f 100644 --- a/resources/tester/helper_130_b07_plan_manage.cjs +++ b/resources/tester/helper_130_b07_plan_manage.cjs @@ -320,7 +320,7 @@ const { buildDrawingSidePanel } = loadTs(path.join(B07, "B07_DesignDetail_UI_Pan confirms: confirms.length, }; - // 미리보기 장 바꾸기 — 「지형 2장」 → 「노선배치도」(확정 → [확정 풀기]) + // 미리보기 장 바꾸기 — 「지형 2장」 → 「노선배치도」(확정 배지만 · [확정 풀기] 없음 140-1) const pickSheet = async (id) => { modal.body .querySelectorAll(".b07-plan-manage__sheets .b07-drawing-button") @@ -340,14 +340,6 @@ const { buildDrawingSidePanel } = loadTs(path.join(B07, "B07_DesignDetail_UI_Pan }; await pickSheet("plan_k_route"); out.routeHead = head().textContent; - byText(head(), "BUTTON", "확정 풀기")[0].click(); - await settle(); - await settle(); - out.release = { - call: calls.find((c) => c[0] === "invalidate"), - head: head().textContent, - preview: cad.loads.at(-1).drawing.entities[0].id, - }; // 닫기 — 고친 게 있으니 changed = true · 미리보기 듣기 멈춤 modal.opts.beforeClose(); diff --git a/resources/tester/helper_136_chute_form_cache.cjs b/resources/tester/helper_136_chute_form_cache.cjs new file mode 100644 index 000000000..c06e7abc0 --- /dev/null +++ b/resources/tester/helper_136_chute_form_cache.cjs @@ -0,0 +1,96 @@ +/* 136-1 폼 → 캐시 chute 헬퍼 — `applyPipeOptionsToCache`(B06_Section_UI_Page_Pipe_Options.ts)를 그 자리에서 + * 트랜스파일해 돌림 · 도수로/돌붙임 상자가 관 · BOX · 세월교 · 물넘이 포장 제원 `chute` 에 바로 얹히는지 · + * 다시 그린 카드. 짝 = `test_136_chute_form_cache.py`. 로더는 `helper_b06_draft_pipe_replay.cjs` 와 같음. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +const session = new Map(); +const fakeStorage = { + getItem: (k) => session.get(k) ?? null, + setItem: (k, v) => session.set(k, String(v)), + removeItem: (k) => session.delete(k), +}; +globalThis.window = { sessionStorage: fakeStorage, localStorage: fakeStorage }; +globalThis.sessionStorage = fakeStorage; +globalThis.localStorage = fakeStorage; + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (request.startsWith("@ui/")) return new Proxy({}, { get: () => () => {} }); + if (request.startsWith("@util/")) + return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`)); + if (request.startsWith("@config/")) + return loadTs(path.join(ROOT, "config", `${request.slice(8)}.ts`)); + return request.startsWith(".") + ? loadTs(path.join(path.dirname(full), `${request}.ts`)) + : require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const options = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Pipe_Options.ts")); + +const box = (at) => ({ + chainage_m: 120, + top_thickness_m: 0.3, + inner_height_m: 2, + slab_thickness_m: 0.3, + wing_in: {}, + wing_out: {}, + _at: at, +}); +const detail = { + cross_sections: [ + { chainage_m: 85, culvert: { chainage_m: 85.05, diameter_m: 1, inlet: {}, outlet: {} } }, + { chainage_m: 118, box: box(118) }, + { chainage_m: 120, box: box(120) }, + { chainage_m: 160, ford: { chainage_m: 160, diameter_m: 1, wing_in: {}, wing_out: {} } }, + { chainage_m: 196, ford_pavement: { chainage_m: 200, span_m: 9, depth_m: 0.3 } }, + { chainage_m: 200, ford_pavement: { chainage_m: 200, span_m: 9, depth_m: 0.3 } }, + ], +}; +let refreshed = []; +const ctx = { + detail: () => detail, + ford: { setPipe: () => {}, setWing: () => {} }, + box: { setBody: () => {}, setWing: () => {} }, + revetOffset: { update: () => {}, adjustFor: () => ({ h: null, m: null }) }, + overrideOptions: () => {}, + refreshCard: (at) => refreshed.push(at), +}; +const at = (i) => detail.cross_sections[i]; +const step = (chainage, patch, whole = true) => { + refreshed = []; + options.applyPipeOptionsToCache(ctx, chainage, patch, whole); + return { + pipe: at(0).culvert.chute ?? null, + box: [at(1).box.chute ?? null, at(2).box.chute ?? null], + ford: at(3).ford.chute ?? null, + pave: [at(4).ford_pavement.chute ?? null, at(5).ford_pavement.chute ?? null], + refreshed: [...new Set(refreshed)].sort((a, b) => a - b), + }; +}; + +const out = { + pipeAdd: step(85.05, { chute_add_1_structure: "돌붙임", chute_add_1_thickness: 40 }), + pipeSame: step(85.05, { chute_add_1_structure: "돌붙임", chute_add_1_thickness: 40 }), + replayNoChute: step(85.05, { pipe_diameter_mm: 1000 }, false), + pipeRemove: step(85.05, { pipe_diameter_mm: 1000 }), + boxAdd: step(120, { chute_add_1_structure: "도수로" }), + fordAdd: step(160, { chute_add_1_structure: "돌붙임", chute_add_1_thickness: 50 }), + paveAdd: step(200, { ford_chute_1_structure: "돌붙임", chute_add_1_structure: "도수로" }), +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_87_widening.cjs b/resources/tester/helper_87_widening.cjs index 4aea3aeda..5f7a53262 100644 --- a/resources/tester/helper_87_widening.cjs +++ b/resources/tester/helper_87_widening.cjs @@ -50,5 +50,18 @@ const out = { toggledOn: w.toggleRun(runs, off, runs[0]), stale: w.offForRuns(runs, "20-60,0-5,100-130"), empty: w.offForRuns([], "20-60"), + // 확폭 면적(138-2) — 앞뒤 확폭 0 측점까지 사다리꼴 · 범위로 자름 + area: runs.map((r) => w.wideningArea(stations, r, { start: 0, end: 140 })), + areaClipped: w.wideningArea(stations, runs[0], { start: 30, end: 140 }), + areaSingle: w.wideningArea( + [at(0), at(20, 1, 0), at(40)], + { start_m: 20, end_m: 20, widening_m: 1 }, + { start: 0, end: 40 }, + ), + areaMissing: w.wideningArea( + stations, + { start_m: 999, end_m: 999, widening_m: 1 }, + { start: 0, end: 140 }, + ), }; process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_m02_labor_normalize.cjs b/resources/tester/helper_m02_labor_normalize.cjs new file mode 100644 index 000000000..753edd03b --- /dev/null +++ b/resources/tester/helper_m02_labor_normalize.cjs @@ -0,0 +1,22 @@ +/* M02 일위대가 문서 화면 정리(`M02_MasterTemplete_Labor_Api.ts` `normalizeLabor`) 헬퍼(134-1) — + * 그 자리에서 트랜스파일해 Node 로 돌림 · 가져오는 길은 빈 대역 · stdin = {doc, column} · stdout = 정리한 문서 JSON. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const file = path.join( + __dirname, + "..", + "..", + "M02_MasterTemplete", + "M02_MasterTemplete_Labor_Api.ts", +); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const stub = new Proxy({}, { get: () => function () {} }); +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, () => stub); + +const input = JSON.parse(fs.readFileSync(0, "utf8")); +process.stdout.write(JSON.stringify(box.exports.normalizeLabor(input.doc, input.column))); diff --git a/resources/tester/test_120_b08_drain_summary.py b/resources/tester/test_120_b08_drain_summary.py index ec364dfaf..fe920b3ac 100644 --- a/resources/tester/test_120_b08_drain_summary.py +++ b/resources/tester/test_120_b08_drain_summary.py @@ -82,7 +82,14 @@ POINTS = [ { "chainage_m": 500.0, "facility": "ford_pavement", - "options": {"ford_width_m": 8, "ford_height_m": 1.0}, + "options": { + "ford_width_m": 8, + "ford_height_m": 1.0, + # 물넘이 포장 도수로/돌붙임(136-2) — 관 · 박스 · 세월교와 같은 열 + "ford_chute_1_structure": "돌붙임", + "ford_chute_1_bond": "찰붙임", + "ford_chute_1_area": 12, + }, }, ] STRUCTURES = [ @@ -177,6 +184,7 @@ def test_물넘이_H별_월류폭(out): pave = _col(out, "pipe:pave|1.0") assert pave["group"] == ["물넘이포장", "월류 폭", "H=1.0"] and pave["label"] == "m" assert _cell(out, 500, "pipe:pave|1.0") == 8 + assert _cell(out, 500, "pipe:chute|찰붙임") == 12 def test_개거_노출형_맹암거_개소(out): diff --git a/resources/tester/test_130_b07_plan_manage.py b/resources/tester/test_130_b07_plan_manage.py index 457190f4f..23542bb27 100644 --- a/resources/tester/test_130_b07_plan_manage.py +++ b/resources/tester/test_130_b07_plan_manage.py @@ -5,7 +5,7 @@ - 좌측 「평면도」 = 장 단추 + [평면도 관리] 하나 · 종류 폼 · 대피소 칸 · × 없음. - 단추 → 공용 모달(큰 창) · 안에 종류 목록 · 종류 폼 · 대피소 · 사토장 자리 · 장 목록 · 첫 장 미리보기(보기 전용). - 종류 추가 · 업데이트(축척 포함) · 지우기 = 지금 서버 길 · 고칠 때마다 모달 안을 다시 받음. - - 미리보기 장 바꾸기 · 확정이면 [확정 풀기](invalidate) · 닫으면 changed=True · [CAD 에서 열기] = 그 장 id. + - 미리보기 장 바꾸기 · 캔버스만(bare) · 확정 배지만(확정 풀기 없음 140-1) · 닫으면 changed=True · [CAD 에서 열기] = 그 장 id. Node 헬퍼 `helper_130_b07_plan_manage.cjs` 가 화면 그릇 흉내로 돌림. 화면은 사용자 확인. """ @@ -54,6 +54,7 @@ def test_첫_장_미리보기는_보기_전용(out): assert out["open"]["preview"] == "plan_k_terrain" extra = out["open"]["extra"] assert extra["readOnly"] is True and extra["viewOnly"] is True + assert extra["bare"] is True # 140-1 캔버스만(제목줄 · 리본 · 상태막대 없음) assert extra["recoveryScope"] == "b07-plan-preview" # 본 CAD 자동백업 칸과 안 겹침 @@ -82,12 +83,10 @@ def test_미리보기_장_바꾸기(out): assert "확정 전" in s2["head"] and "확정 풀기" not in s2["head"] -def test_확정이면_확정_풀기(out): - assert "확정 풀기" in out["routeHead"] - rel = out["release"] - assert rel["call"] == ["invalidate", "plan_k_route"] - assert "확정 풀기" not in rel["head"] - assert rel["preview"] == "plan_k_route" # 보던 장 그대로 +def test_확정이면_배지만(out): + # 140-1 확정 풀기는 메인 [수정] — 모달엔 배지 · [CAD 에서 열기]만 + assert "확정" in out["routeHead"] and "CAD 에서 열기" in out["routeHead"] + assert "확정 풀기" not in out["routeHead"] def test_닫으면_좌측_다시_읽기_신호(out): diff --git a/resources/tester/test_136_chute_form_cache.py b/resources/tester/test_136_chute_form_cache.py new file mode 100644 index 000000000..775499efd --- /dev/null +++ b/resources/tester/test_136_chute_form_cache.py @@ -0,0 +1,55 @@ +"""136-1 폼 → 캐시 chute — 좌측 패널 도수로/돌붙임 상자가 [저장] 전에 횡단 제원에 바로 얹힘. + +관 · BOX(폭 걸친 측점 둘) · 세월교 · 물넘이 포장(`ford_chute_*`) · 지움 · 일부만 온 되얹기. +화면 TS 를 그 자리에서 돌린 값(`helper_136_chute_form_cache.cjs`). +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).parent +ROOT = HERE.parents[1] +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +@pytest.fixture(scope="module") +def out() -> dict: + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_136_chute_form_cache.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + ) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +def test_관_상자_넣으면_바로_chute_와_카드(out: dict) -> None: + assert out["pipeAdd"]["pipe"] == {"structure": "돌붙임", "thickness_m": 0.4} + assert 85 in out["pipeAdd"]["refreshed"] + # 같은 값이면 chute 때문에 다시 그리진 않음(관 길의 카드 갱신만) + assert out["pipeSame"]["pipe"] == out["pipeAdd"]["pipe"] + + +def test_되얹기는_상자_없으면_그대로_칸_전부면_지움(out: dict) -> None: + assert out["replayNoChute"]["pipe"] == {"structure": "돌붙임", "thickness_m": 0.4} + assert out["pipeRemove"]["pipe"] is None + + +def test_박스는_걸친_측점_모두_세월교도(out: dict) -> None: + assert out["boxAdd"]["box"] == [{"structure": "도수로", "thickness_m": 0.3}] * 2 + assert {118, 120} <= set(out["boxAdd"]["refreshed"]) + assert out["fordAdd"]["ford"] == {"structure": "돌붙임", "thickness_m": 0.5} + assert out["fordAdd"]["refreshed"] == [160] + # 다른 관 지점은 안 건드림 + assert out["fordAdd"]["pipe"] is None and out["fordAdd"]["box"][0] is not None + + +def test_물넘이_포장은_ford_chute_만(out: dict) -> None: + assert out["paveAdd"]["pave"] == [{"structure": "돌붙임", "thickness_m": 0.3}] * 2 + assert out["paveAdd"]["refreshed"] == [196, 200] diff --git a/resources/tester/test_52_3_structure_spec_ford_pavement.py b/resources/tester/test_52_3_structure_spec_ford_pavement.py index e183dd48c..e5a020a1e 100644 --- a/resources/tester/test_52_3_structure_spec_ford_pavement.py +++ b/resources/tester/test_52_3_structure_spec_ford_pavement.py @@ -131,3 +131,70 @@ def test_정의_그룹_경사각도(): ) as f: groups = json.load(f)["그룹"]["ford_pavement"] assert [(g["id"], g["이름"]) for g in groups] == [("gfordpaveslope", "경사각도")] + + +OVERLAY = r""" +const fs = require("fs"), path = require("path"); +const ROOT = process.argv[1]; +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); +const cache = new Map(); +const load = (file) => { + if (cache.has(file)) return cache.get(file); + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(file, box.exports); + const req = (r) => (r.startsWith(".") ? load(path.join(path.dirname(file), `${r}.ts`)) : ({})); + new Function("exports", "module", "require", js)(box.exports, box, req); + return box.exports; +}; +class Node { + constructor(tag) { this.tag = tag; this.attrs = {}; this.children = []; } + setAttribute(k, v) { this.attrs[k] = String(v); } + getAttribute(k) { return this.attrs[k] ?? null; } + append(...kids) { this.children.push(...kids); } + set textContent(v) { this.text = v; } +} +globalThis.document = { createElementNS: (_ns, tag) => new Node(tag) }; +const pave = load(path.join(ROOT, "B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts")); +// +offset = 좌측 · 좌 성토 1:1.5 · 우 절토 오름 — 유입 = 좌(left_cut 아님) → 유출 = 우 +const design = (mode) => ({ + section_mode: mode, cross_slope_pct: 2, design_elevation_m: 100, pavement_thickness_m: 0.2, + road_edges: { left: { offset_m: 2.5, elevation_m: 100 }, right: { offset_m: -2.5, elevation_m: 100 } }, + design_line: [ + { offset_m: 7, elevation_m: 97 }, { offset_m: 2.5, elevation_m: 100 }, + { offset_m: -2.5, elevation_m: 100 }, { offset_m: -5.5, elevation_m: 98 }, + { offset_m: -6.5, elevation_m: 98 }, { offset_m: -8, elevation_m: 99 }, + ], +}); +const spec = { span_m: 9, depth_m: 0.3, slope_pct: 2, chute: { structure: "돌붙임", thickness_m: 0.3 } }; +const run = (mode, withChute = true) => { + const svg = new Node("g"); + pave.appendFordPavementOverlay(svg, withChute ? spec : { ...spec, chute: undefined }, design(mode), (o) => o, (e) => e); + const band = svg.children.find((k) => k.attrs.class === "b06-chart__culvert-pitching"); + return band ? band.attrs.points : null; +}; +process.stdout.write(JSON.stringify({ + outLeft: pave.outletFillLine(design("left_cut")), outRight: pave.outletFillLine(design("right_cut")), + band: run("left_cut"), none: run("left_cut", false), +})); +""" + + +def test_유출측_성토사면_띠_136_2(): + proc = subprocess.run( + ["node", "-e", OVERLAY, ROOT], capture_output=True, text=True, encoding="utf-8", timeout=60 + ) + assert proc.returncode == 0, proc.stderr + got = json.loads(proc.stdout) + # 유입 = 좌 → 유출 = 우(− 바깥) · 노견에서 내려가는 동안만(평지 · 오름에서 멈춤) + assert got["outLeft"] == [ + {"offset": -2.5, "elevation": 100}, + {"offset": -5.5, "elevation": 98}, + {"offset": -6.5, "elevation": 98}, + ] + # right_cut = 유입 우 → 유출 좌 성토 1:1.5 + assert got["outRight"] == [{"offset": 2.5, "elevation": 100}, {"offset": 7, "elevation": 97}] + assert got["band"] and got["band"].startswith("-2.5,100 -5.5,98") + assert got["none"] is None diff --git a/resources/tester/test_74_route_confirm_initial.py b/resources/tester/test_74_route_confirm_initial.py index 0211134d8..1c79e715b 100644 --- a/resources/tester/test_74_route_confirm_initial.py +++ b/resources/tester/test_74_route_confirm_initial.py @@ -25,7 +25,8 @@ def test_이월은_사용자_입력만() -> None: "ditch_side", ): assert result_key not in CARRY_INPUT_KEYS - assert {"ground_type", "paved", "ditch_choice", "cut_slope_ratio_user"} <= set(CARRY_INPUT_KEYS) + assert {"ground_type", "ditch_choice", "cut_slope_ratio_user"} <= set(CARRY_INPUT_KEYS) + assert "paved" not in CARRY_INPUT_KEYS # 포장 = 노면공 범위로 새로 정함(138-1) def test_체인이_옛_설계를_통째로_옮기지_않는다() -> None: diff --git a/resources/tester/test_87_widening_runs.py b/resources/tester/test_87_widening_runs.py index 05a91b85d..e0bdf026f 100644 --- a/resources/tester/test_87_widening_runs.py +++ b/resources/tester/test_87_widening_runs.py @@ -58,3 +58,19 @@ def test_끈_구간_글_왕복(out): def test_안_맞는_끈_값은_무시_저장_때_빠짐(out): assert out["stale"] == "20-60" assert out["empty"] == "" + + +def test_확폭_면적_사다리꼴_앞뒤_0_측점까지(out): + # 0~20: (0+0.3)/2×20=3 · 20~40: 10.5 · 40~60: 11.5 · 60~80: 4 → 29 + # 80~100: 5 · 100~120: 11 · 120~140: 6 → 22 + assert [round(a, 6) for a in out["area"]] == [29.0, 22.0] + + +def test_확폭_면적_노면공_범위로_자름(out): + # 30 에서 자름: w(30)=0.525 → (0.525+0.75)/2×10=6.375 + 11.5 + 4 = 21.875 + assert round(out["areaClipped"], 6) == 21.875 + + +def test_한_측점_구간도_면적_자료_없으면_0(out): + assert round(out["areaSingle"], 6) == 20.0 # (0+1)/2×20 × 2 + assert out["areaMissing"] == 0 diff --git a/resources/tester/test_b06_culvert_sets_mirror.py b/resources/tester/test_b06_culvert_sets_mirror.py index 58f858823..c46295fcc 100644 --- a/resources/tester/test_b06_culvert_sets_mirror.py +++ b/resources/tester/test_b06_culvert_sets_mirror.py @@ -100,7 +100,14 @@ POINTS = [ { "chainage_m": 140.0, "facility": "ford_pavement", - "options": {"ford_height_m": 0.3, "ford_slope_deg": 5}, + "options": { + "ford_height_m": 0.3, + "ford_slope_deg": 5, + # 물넘이 포장 도수로/돌붙임(136-2) — `ford_chute_*` · 관 키(`chute_add_*`)는 안 읽음 + "ford_chute_1_structure": "돌붙임", + "ford_chute_1_thickness": 40, + "chute_add_1_structure": "도수로", + }, }, {"chainage_m": 160.0, "facility": "ford_pavement", "options": {"ford_height_m": 0.2}}, ] @@ -213,3 +220,5 @@ def test_ford_box_guard_attached_as_culvert() -> None: assert sections[60.0]["ford"]["chute"] == {"structure": "도수로", "thickness_m": 0.3} assert box["chute"] == {"structure": "돌붙임", "thickness_m": 0.45} assert "chute" not in sections[40.0]["culvert"] + assert sections[140.0]["ford_pavement"]["chute"] == {"structure": "돌붙임", "thickness_m": 0.4} + assert "chute" not in sections[160.0]["ford_pavement"] diff --git a/resources/tester/test_b06_pavement_and_ford.py b/resources/tester/test_b06_pavement_and_ford.py index 975cc3fc3..584c0c472 100644 --- a/resources/tester/test_b06_pavement_and_ford.py +++ b/resources/tester/test_b06_pavement_and_ford.py @@ -126,17 +126,42 @@ def test_missing_truth_files_are_not_an_error(tmp_path): # ── 포장 판정 규칙 ───────────────────────────────────────────────────────── -def test_outside_range_keeps_user_value_and_defaults_unpaved(): - """경사 제안은 더 이상 포장을 켜지 않는다 — 구간 밖은 사용자 값(없으면 비포장).""" +def test_outside_range_is_unpaved(): + """포장 = 노면공 · 물넘이 범위만(138-1) — 밖은 비포장(옛 저장값 인자 없음).""" ranges = [(10.0, 20.0)] assert paved_at(30.0, ranges) is False - assert paved_at(30.0, ranges, True) is True - assert paved_at(30.0, ranges, False) is False + assert paved_at(15.0, ranges) is True + assert paved_at(20.02, ranges) is True # 끝 허용오차 = 브라우저 짝 0.02 m + assert paved_at(20.03, ranges) is False + assert paved_at(15.0, []) is False -def test_inside_range_overrides_user_off_switch(): - """구간 안은 강제 포장 — 물넘이는 콘크리트 노면이고 지정 구간은 자동 반영이다.""" - assert paved_at(15.0, [(10.0, 20.0)], False) is True +def test_enforce_turns_paved_on_and_off(monkeypatch): + """재계산은 켬↔끔 양쪽 — 범위 밖 옛 `paved=true` 는 비포장으로 되돌림(138-1).""" + from pathlib import Path + + from B06_Section import B06_Section_Router_Design as design_mod + + monkeypatch.setattr(design_mod, "pavement_ranges", lambda root: [(0.0, 50.0)]) + monkeypatch.setattr(design_mod, "ford_surface_drops", lambda root: []) + monkeypatch.setattr(design_mod, "design_elevation_from_longitudinal", lambda lon, ch: 100.0) + monkeypatch.setattr(design_mod, "curve_widening_args", lambda section: {}) + monkeypatch.setattr( + design_mod, "compute_cross_design", lambda samples, elev, **kw: {"paved": kw["paved"]} + ) + sections = [ + {"chainage_m": 20.0, "design": {"paved": False}}, # 범위 안 · 끔 → 켬 + {"chainage_m": 80.0, "design": {"paved": True}}, # 범위 밖 · 켬 → 끔 + {"chainage_m": 30.0, "design": {"paved": True}}, # 그대로 + {"chainage_m": 90.0, "design": {"paved": False}}, # 그대로 + ] + assert design_mod.enforce_pavement_ranges({}, sections, Path("."), None) == 2 + assert [s["design"]["paved"] for s in sections] == [True, False, True, False] + + # 노면공을 다 지워도(범위 없음) 옛 켬은 꺼진다 + monkeypatch.setattr(design_mod, "pavement_ranges", lambda root: []) + assert design_mod.enforce_pavement_ranges({}, sections, Path("."), None) == 2 + assert not any(s["design"]["paved"] for s in sections) # ── 독립 기슭막이 ───────────────────────────────────────────────────────── diff --git a/resources/tester/test_m02_labor_promote_flag.py b/resources/tester/test_m02_labor_promote_flag.py new file mode 100644 index 000000000..cbb3ac888 --- /dev/null +++ b/resources/tester/test_m02_labor_promote_flag.py @@ -0,0 +1,90 @@ +"""일위대가 로직 줄 단가승격 표시(PLAN 134-1) — 줄에 `승격: true` · 금액 무관. + +- 서버 `clean` = `true` 만 남김(거짓 · 글 · 참조 줄은 버림) · 저장 왕복에 남음 · 금액 그대로 +- 사전조합 안 로직 줄엔 승격 없음(정리 때 버림) +- 화면 `normalizeLabor` 도 `true` 만 통과(Node 헬퍼) +""" + +import json +import os +import subprocess + +import pytest + +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from resources.tester.test_m02_structure import write_sample_table + +HERE = os.path.dirname(os.path.abspath(__file__)) +NET = "GC000337" +BARE = "GC000994" + + +def _doc(lines): + return {"열": "rv", "담은로직": lines, "자재": []} + + +def test_clean_은_참만_남김(): + got = labor.clean(_doc([{"로직": NET, "승격": True}, {"로직": BARE, "승격": "yes"}]))[ + "담은로직" + ] + assert got[0]["승격"] is True and "승격" not in got[1] + + +def test_승격은_금액을_안_바꿈(): + plain = labor.calc(labor.clean(_doc([{"로직": NET, "수량": {"손": 2}}])), {}) + flag = labor.calc(labor.clean(_doc([{"로직": NET, "수량": {"손": 2}, "승격": True}])), {}) + assert flag["반영합"] == plain["반영합"] and flag["기준합"] == plain["기준합"] + + +def test_사전조합_안_로직_줄엔_승격_없음(monkeypatch): + monkeypatch.setattr(refs, "here", lambda: ("system", None)) + lines = refs._combo_lines({"담은로직": [{"로직": NET, "승격": True}]}, "SC-000009") + assert "승격" not in lines[0] + + +@pytest.fixture() +def system(tmp_path, monkeypatch): + write_sample_table(tmp_path) + monkeypatch.setattr(store, "FOLDER", tmp_path) + store.create_structure("rv") + return tmp_path + + +def test_저장_왕복에_승격_남음(system): + have = store.read("labor", "rv") + sent = { + **have["문서"], + "담은로직": [ + {"로직": NET, "수량": None, "승격": True}, + {"로직": BARE, "수량": None}, + ], + } + store.write("labor", "rv", have["판"], sent) + rows = store.read("labor", "rv")["문서"]["담은로직"] + assert rows[0]["승격"] is True and "승격" not in rows[1] + # 내리면(드롭다운 단가산출) 칸째 사라짐 + have = store.read("labor", "rv") + store.write("labor", "rv", have["판"], {**have["문서"], "담은로직": [{"로직": NET}]}) + assert "승격" not in store.read("labor", "rv")["문서"]["담은로직"][0] + + +def test_화면_정리도_참만_통과(): + doc = { + "담은로직": [ + {"로직": NET, "승격": True}, + {"로직": BARE, "승격": False}, + {"로직": "", "참조": {"층": "system", "주인": None, "키": "SC-000001"}, "승격": True}, + ] + } + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_m02_labor_normalize.cjs")], + input=json.dumps({"doc": doc, "column": "rv"}, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + rows = json.loads(proc.stdout)["담은로직"] + assert [r.get("승격") for r in rows] == [True, None, None] diff --git a/ui_template/cad_host/cad_host.ts b/ui_template/cad_host/cad_host.ts index 2931d1e82..cd0f3d601 100644 --- a/ui_template/cad_host/cad_host.ts +++ b/ui_template/cad_host/cad_host.ts @@ -77,6 +77,8 @@ export interface CadLoadExtra { panelsCollapsed?: boolean; /** 미리보기 — 왼쪽 끌기 = 화면 옮기기 · 두 번 누름 = 전체 맞춤 · 선택 · 명령 안내 없음. */ viewOnly?: boolean; + /** 캔버스만 — 제목줄 · 리본 · 패널 · 상태막대를 안 그린다(B07 평면도 관리 모달). */ + bare?: boolean; /** 좌표 0,0 에 기준점 십자를 그린다(그리기 겹판 · 저장 안 됨) — 안 보내면 없음. */ originMarker?: boolean; } diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index bc98b3039..b23da94cb 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -447,9 +447,6 @@ export const ui_locales_b2 = { B06_Design_DitchType_Legend: ["측구형식", "Ditch type"], B06_Design_DitchType_Standard: ["일반", "Standard"], B06_Design_DitchType_LType: ["L형", "L-type"], - B06_Design_Paved_Legend: ["포장", "Pavement"], - B06_Design_Paved_On: ["포장", "Paved"], - B06_Design_Paved_Off: ["비포장", "Unpaved"], B06_Design_TwoStage_Legend: ["2단계 경사", "Two-stage slope"], B06_Design_TwoStage_On: ["복합경사", "Compound slope"], B06_Design_TwoStage_Off: ["단경사", "Single slope"], @@ -475,6 +472,7 @@ export const ui_locales_b2 = { "측구를 생성한 경우에만 형식을 선택할 수 있습니다.", "Available only when a ditch is created.", ], + B06_Notice_PaveSuggested: ["포장 권장", "Pavement recommended"], B06_Design_Paved_Suggested: [ "종단경사 법정 상한 초과 — 포장 권장 (임도설치 및 관리 등에 관한 규정 별표 1-2)", "Grade exceeds legal limit — pavement recommended (Forest Road Regulation, Annex 1-2)",