Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_2' and 'origin/sub_laptop_5' into sub_laptop_3
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
/* =============================================================================
|
||||
* design_structure_slope_tier.ts
|
||||
* 사면안정 흙막이 · 옹벽 「추가 단」 상자(178-3) — 배관 유출 흙막이 상자 양식(`design_structure_guard`)
|
||||
* 재사용 · 공용 [추가] · [빼기] · 상자 수 = 추가 단.
|
||||
*
|
||||
* · 흙막이 = 형태 · 높이 · 옹벽 = 높이만(형태 = 콘크리트) · 저장 = structures.json `options` 키 `tier_{n}_*`
|
||||
* · 정의 그룹 종류에 덧붙임 — 좌측 폼 선택 컨테이너만(그룹 상자 · 목록 · 벌룬 · M02 는 정의 그룹 그대로 ·
|
||||
* `STRUCTURE_SPECS` 에 안 넣음)
|
||||
* · 단 그림 = `common_util_structure_walls`(`tierExtras` · 파이썬 짝) → 횡단 다단
|
||||
* import = 같은 폴더 명세뿐 — 시험이 이 폴더 파일만 옮겨 돌림.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { SpecContainer, SpecItem, StructureSpec } from "./design_structure_spec";
|
||||
import { guardItems } from "./design_structure_guard";
|
||||
|
||||
const TIER_ITEMS = guardItems().filter((item) => item.id === "form" || item.id === "height");
|
||||
|
||||
const tierAdd = (items: SpecItem[]): SpecContainer => ({
|
||||
id: "tier",
|
||||
title: "추가 단",
|
||||
required: false,
|
||||
prefix: "tier",
|
||||
link: "gtier",
|
||||
items,
|
||||
});
|
||||
|
||||
export const ADDON_SPECS: Readonly<Record<string, StructureSpec>> = {
|
||||
soil_guard: { typeId: "soil_guard", containers: [tierAdd(TIER_ITEMS)], retired: [] },
|
||||
retaining_wall: {
|
||||
typeId: "retaining_wall",
|
||||
containers: [tierAdd(TIER_ITEMS.filter((item) => item.id === "height"))],
|
||||
retired: [],
|
||||
},
|
||||
};
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type SpecLinks,
|
||||
} from "../A00_Common/design/design_structure_form";
|
||||
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
|
||||
import { ADDON_SPECS } from "../A00_Common/design/design_structure_slope_tier";
|
||||
import { specKeys, type StructureSpec } from "../A00_Common/design/design_structure_spec";
|
||||
import { optionDefaults } from "./B05_Profile_UI_Structures_Fields";
|
||||
import type { WideningBox } from "./B05_Profile_UI_Structures_Widening";
|
||||
@@ -24,9 +25,10 @@ const forms = new WeakMap<HTMLElement, SpecForm>();
|
||||
/** 범위형 명세(노면공 · 87-3)의 「확폭 구간」 상자 — 옵션 칸 짝. */
|
||||
const widenings = new WeakMap<HTMLElement, WideningBox>();
|
||||
|
||||
/** 일반 구조물 명세 — 계곡 통과 시설(관 지점 정본)은 시설 폼 몫이라 뺌. */
|
||||
/** 일반 구조물 명세 — 계곡 통과 시설(관 지점 정본)은 시설 폼 몫이라 뺌 · 정의 그룹 종류 덧붙임(178-3 추가 단)도. */
|
||||
export const structureSpecOf = (type: StructureType | null): StructureSpec | null =>
|
||||
(type && !type.managed_by && STRUCTURE_SPECS[type.type_id]) || null;
|
||||
(type && !type.managed_by && (STRUCTURE_SPECS[type.type_id] ?? ADDON_SPECS[type.type_id])) ||
|
||||
null;
|
||||
|
||||
/** 옵션 칸 짝 폼을 세움 — 값이 바뀌면 `onChange`(패널 즉시 반영). */
|
||||
export function createStructureSpecForm(
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -39,6 +41,31 @@ _FORM_BY_TYPE = {
|
||||
}
|
||||
|
||||
|
||||
# 좌측 폼 「추가 단」 상자 저장 키 머리(178-3) — `tier_{n}_form` · `tier_{n}_height`.
|
||||
_TIER_KEY = re.compile(r"^tier_(\d+)_")
|
||||
|
||||
|
||||
def _num(value: Any) -> float | int | None:
|
||||
"""유한한 수만(TS `num` 짝 — 글 · 참거짓은 None)."""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return None
|
||||
return value if math.isfinite(value) else None
|
||||
|
||||
|
||||
def tier_extras(type_id: str, options: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""「추가 단」 상자 → 추가 단 [{형태 · 높이}](n 차례) — 옹벽 = 콘크리트 · TS `tierExtras` 짝."""
|
||||
ns = sorted({int(m.group(1)) for key in options if (m := _TIER_KEY.match(key))})
|
||||
return [
|
||||
{
|
||||
"form": "콘크리트"
|
||||
if type_id == "retaining_wall"
|
||||
else (options.get(f"tier_{n}_form") or None),
|
||||
"height_m": _num(options.get(f"tier_{n}_height")),
|
||||
}
|
||||
for n in ns
|
||||
]
|
||||
|
||||
|
||||
# 소단 타입 id — 레지스트리와 한 벌이다(C군이지만 벽이 아니다).
|
||||
BERM_TYPE_ID = "berm"
|
||||
|
||||
@@ -60,6 +87,7 @@ def load_wall_structures(project_root: Path) -> list[dict[str, Any]]:
|
||||
if start is None or end is None:
|
||||
continue
|
||||
options = structure.options or {}
|
||||
extras = tier_extras(structure.type_id, options)
|
||||
found.append(
|
||||
{
|
||||
"structure_id": structure.structure_id,
|
||||
@@ -76,7 +104,9 @@ def load_wall_structures(project_root: Path) -> list[dict[str, Any]]:
|
||||
# 기초 축(기초유/기초버림) — 터파기 그림·물량이 같이 쓴다.
|
||||
# 비어 있으면 화면이 터파기를 안 그린다(근거 없음).
|
||||
"foundation": options.get("foundation"),
|
||||
"tiers": options.get("tiers"),
|
||||
# 단 수 = 1 + 추가 단 상자 수 · 상자 없으면 옛 `tiers`(178-3)
|
||||
"tiers": len(extras) + 1 if extras else options.get("tiers"),
|
||||
"extras": extras,
|
||||
"lift_m": options.get("lift_m"),
|
||||
"shift_m": options.get("shift_m"),
|
||||
# 전면 기울기를 B08 과 같은 칸으로 가르는 값 — 저장 원본(2026-09-14 B5 · TS 짝 같은 키).
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* 아래 = B07 횡단 수량표(값 · 칸 배치 모두 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름).
|
||||
* 공용 큰 창(`wide`) · 공용 막(밖 누르면 닫힘) · 스크롤 없음(150-2).
|
||||
* 그림 = 공용 줌 · 팬(164-1 · 휠 = 커서 고정 줌 · 가운데 단추 팬 · 더블클릭 원복 · [+ − ⤢]) —
|
||||
* 복제 자식을 g 하나로 감싸 축 · 눈금째 한 장으로 움직임.
|
||||
* 카드처럼 도형 층만 움직이고 축 · 눈금은 제자리에서 보이는 범위로 다시 그림(179-3).
|
||||
* 모달이 열린 채 카드를 다시 그리면 그림 · 표를 새로 맞춤.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -30,14 +30,19 @@ import {
|
||||
type ZoomPanState,
|
||||
} from "../A00_Common/b_svg_zoom_pan";
|
||||
import { buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom";
|
||||
import { createCrossAxes, IDENTITY_VIEW, type CrossAxesOptions } from "./B06_Section_UI_Cross_Axes";
|
||||
|
||||
/** 페이지가 한 번 걸어 줌(`_UI_Page_Design_Sync`) — 카드 줄은 프로젝트 · 노선을 모름. */
|
||||
export interface CrossDetailHost {
|
||||
projectId: string;
|
||||
routeId: () => number | null;
|
||||
detail: () => SectionDetailResponse | null;
|
||||
/** 칸 크기(W × H px)로 새로 그린 카드 svg(173-4 · 뷰 `detailChart`). */
|
||||
chart: (stationId: string, widthPx: number, heightPx: number) => SVGSVGElement | null;
|
||||
/** 칸 크기(W × H px)로 새로 그린 카드 svg + 축 옵션(173-4 · 179-3 · 뷰 `detailChart`). */
|
||||
chart: (
|
||||
stationId: string,
|
||||
widthPx: number,
|
||||
heightPx: number,
|
||||
) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null;
|
||||
}
|
||||
|
||||
let host: CrossDetailHost | null = null;
|
||||
@@ -121,17 +126,32 @@ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HT
|
||||
|
||||
/* ── 그림 줌 · 팬(164-1) ─────────────────────────────────────────────── */
|
||||
|
||||
/** 복제 자식을 g 하나로 감싸 공용 줌 · 팬을 검(축 · 눈금째 한 장) · 휠 = 커서 고정 줌. */
|
||||
/** 도형 층에만 공용 줌 · 팬(179-3 · 축 · 눈금 · 축선 · clip 제자리) · 휠 = 커서 고정 줌 ·
|
||||
* 바뀔 때마다 축을 보이는 범위로 다시 그림 + 십자 크기 되돌림(카드 `_Cross_View_Frame` 꼴). */
|
||||
export function attachDetailZoom(
|
||||
clone: SVGSVGElement,
|
||||
axes: CrossAxesOptions | null,
|
||||
initial: ZoomPanState | undefined,
|
||||
onChange: (state: ZoomPanState) => void,
|
||||
): ZoomPanHandle {
|
||||
const [, , width, height] = (clone.getAttribute("viewBox") ?? "").split(/[\s,]+/).map(Number);
|
||||
const layer = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
||||
layer.append(...clone.childNodes);
|
||||
): ZoomPanHandle | null {
|
||||
const layer = clone.querySelector<SVGGElement>("g.b06-chart__plot-layer");
|
||||
if (!layer) return null;
|
||||
// 칸 = viewBox(칸 크기로 그림 · 173-4) → 커서 고정 · 팬 속도 정확
|
||||
clone.append(layer);
|
||||
const [, , width, height] = (clone.getAttribute("viewBox") ?? "").split(/[\s,]+/).map(Number);
|
||||
const axisLayer = clone.querySelector<SVGGElement>("g.b06-chart__axes");
|
||||
const renderAxes = axes && axisLayer ? createCrossAxes(axisLayer, axes) : null;
|
||||
const marker = layer.querySelector<SVGGElement>("g.b06-chart__center");
|
||||
const markerLine = marker?.querySelector("line");
|
||||
const cx = Number(markerLine?.getAttribute("x1"));
|
||||
const cy = Number(markerLine?.getAttribute("y1")) + 18;
|
||||
const follow = (state: ZoomPanState): void => {
|
||||
renderAxes?.(state);
|
||||
marker?.setAttribute(
|
||||
"transform",
|
||||
`translate(${cx} ${cy}) scale(${1 / Math.max(state.scale, 1e-6)}) translate(${-cx} ${-cy})`,
|
||||
);
|
||||
};
|
||||
follow(initial ?? IDENTITY_VIEW);
|
||||
return attachSvgZoomPan({
|
||||
svg: clone,
|
||||
layer,
|
||||
@@ -143,7 +163,10 @@ export function attachDetailZoom(
|
||||
content: { x: -width, y: -height, width: 3 * width, height: 3 * height },
|
||||
wheelZoom: true,
|
||||
initial,
|
||||
onChange,
|
||||
onChange: (state) => {
|
||||
follow(state);
|
||||
onChange(state);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -164,12 +187,16 @@ let tableTimer = 0;
|
||||
let drawing = false;
|
||||
|
||||
/** 칸 크기로 새로 그림 → 복제(듣개 없음) · clip id 를 카드와 가름(같은 id 면 카드 clip 을 씀). */
|
||||
function drawChart(section: CrossSection, widthPx: number, heightPx: number): SVGSVGElement | null {
|
||||
function drawChart(
|
||||
section: CrossSection,
|
||||
widthPx: number,
|
||||
heightPx: number,
|
||||
): { clone: SVGSVGElement; axes: CrossAxesOptions | null } | null {
|
||||
drawing = true;
|
||||
try {
|
||||
const svg = host?.chart(section.station_id, widthPx, heightPx);
|
||||
if (!svg) return null;
|
||||
const clone = svg.cloneNode(true) as SVGSVGElement;
|
||||
const drawn = host?.chart(section.station_id, widthPx, heightPx);
|
||||
if (!drawn) return null;
|
||||
const clone = drawn.svg.cloneNode(true) as SVGSVGElement;
|
||||
clone.querySelectorAll("clipPath[id]").forEach((clip) => {
|
||||
const id = clip.id;
|
||||
clip.id = `b06-detail-${id}`;
|
||||
@@ -177,7 +204,7 @@ function drawChart(section: CrossSection, widthPx: number, heightPx: number): SV
|
||||
.querySelectorAll(`[clip-path="url(#${id})"]`)
|
||||
.forEach((el) => el.setAttribute("clip-path", `url(#${clip.id})`));
|
||||
});
|
||||
return clone;
|
||||
return { clone, axes: drawn.axes };
|
||||
} finally {
|
||||
drawing = false;
|
||||
}
|
||||
@@ -246,11 +273,12 @@ export function openCrossDetail(section: CrossSection): void {
|
||||
const size = `${width}x${height}`;
|
||||
if (size !== drawnSize) zoom = undefined;
|
||||
drawnSize = size;
|
||||
const clone = drawChart(state.section, width, height);
|
||||
if (!clone) cad.replaceChildren(document.createTextNode("그림 없음"));
|
||||
const drawn = drawChart(state.section, width, height);
|
||||
if (!drawn) cad.replaceChildren(document.createTextNode("그림 없음"));
|
||||
else {
|
||||
const handle = attachDetailZoom(clone, zoom, (next) => (zoom = next));
|
||||
cad.replaceChildren(clone, buildZoomControls(handle));
|
||||
const { clone, axes } = drawn;
|
||||
const handle = attachDetailZoom(clone, axes, zoom, (next) => (zoom = next));
|
||||
cad.replaceChildren(clone, ...(handle ? [buildZoomControls(handle)] : []));
|
||||
}
|
||||
showPicked();
|
||||
},
|
||||
|
||||
@@ -56,6 +56,8 @@ export interface RevetmentSpec {
|
||||
foundation?: string | null;
|
||||
/** 단 수(다단). 1이면 단일 벽. */
|
||||
tiers?: number | null;
|
||||
/** 추가 단(2단부터) 형태 · 높이 — 좌측 폼 「추가 단」 상자(178-3). 빈 값 = 본 벽 형태 · 기본 높이. */
|
||||
extras?: ReadonlyArray<{ form: string | null; height_m: number | null }> | null;
|
||||
/** (구 모델) 기준 올림·좌우 이동 — 배관식 전환 뒤 자리는 조정창 4축(x·d)이 정한다. */
|
||||
lift_m?: number | null;
|
||||
shift_m?: number | null;
|
||||
@@ -254,7 +256,12 @@ export function computeRevetmentLayout(
|
||||
outward,
|
||||
groundAt,
|
||||
limitOffset: limit,
|
||||
adjusts: Array.from({ length: requestedTiers - 1 }, () => ({ ...ZERO_ADJUST })),
|
||||
// 단마다 상자 형태 · 높이(178-3) — 자리(x · d)는 다단 자동 규칙 그대로
|
||||
adjusts: Array.from({ length: requestedTiers - 1 }, (_, i) => ({
|
||||
...ZERO_ADJUST,
|
||||
m: spec.extras?.[i]?.form ?? null,
|
||||
h: spec.extras?.[i]?.height_m ?? null,
|
||||
})),
|
||||
// 단들도 1단과 같은 형태로 선다 — 안 넘기면 형태가 비어 늘 메쌓기로
|
||||
// 그려졌다(2026-08-30 사용자: 형태별 모양이 누락되는 경우).
|
||||
ownerForm: spec.form,
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
import {
|
||||
type CrossPlotBase,
|
||||
crossPlotBase,
|
||||
crossPlotFromBase,
|
||||
effectiveCardHalfWidth,
|
||||
fitPlotBase,
|
||||
naturalHeightAtWidth,
|
||||
@@ -561,9 +562,23 @@ export function createSectionView(
|
||||
// 카드 줌 · 팬은 카드 몫 — 모달 그림은 1배에서 시작(카드 상태는 되돌려 둠)
|
||||
const cardZoom = cardZoomStates.get(stationId);
|
||||
cardZoomStates.delete(stationId);
|
||||
const card = buildCrossCard(section, heightPx, base && fitPlotBase(base, heightPx), widthPx);
|
||||
const fitted = base && fitPlotBase(base, heightPx);
|
||||
const card = buildCrossCard(section, heightPx, fitted, widthPx);
|
||||
if (cardZoom) cardZoomStates.set(stationId, cardZoom);
|
||||
return card.querySelector<SVGSVGElement>("svg.b06-section__chart");
|
||||
const svg = card.querySelector<SVGSVGElement>("svg.b06-section__chart");
|
||||
if (!svg) return null;
|
||||
// 축 다시 그리기 옵션(179-3) — 카드 `createCrossAxes` 와 같은 기하
|
||||
const plot = fitted && crossPlotFromBase(fitted, heightPx);
|
||||
const axes = plot && {
|
||||
widthPx,
|
||||
heightPx,
|
||||
pixelsPerMeter: plot.pixelsPerMeter,
|
||||
maxOffset: plot.maxOffset,
|
||||
displayMax: plot.displayMax,
|
||||
elevationMid: plot.elevationMid,
|
||||
exaggeration: Math.max(currentExaggeration, 0.1),
|
||||
};
|
||||
return { svg, axes };
|
||||
},
|
||||
focusStation(stationId) {
|
||||
if (stationId === null) clearSelection();
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
} from "./B06_Section_Api_Fetch";
|
||||
import type { SectionStructureEdit } from "./B06_Section_UI_Section_View_Menu";
|
||||
import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import type { CrossAxesOptions } from "./B06_Section_UI_Cross_Axes";
|
||||
|
||||
/**
|
||||
* 상태 행·요약줄·하단 접기 손잡이·테두리가 먹는 세로 공간의 **어림값**.
|
||||
@@ -37,8 +38,13 @@ export interface SectionViewController {
|
||||
refreshCard: (chainageM: number) => void;
|
||||
/** 여러 측점을 한꺼번에 교체한다 — 상단 패널은 **마지막에 한 번만** 다시 그린다. */
|
||||
refreshCards: (chainages: ReadonlyArray<number>) => void;
|
||||
/** [상세] 모달 그림(173-4) — 카드 그리기 함수로 칸 크기(W × H px)에 맞춰 새로 그린 svg(없으면 null). */
|
||||
detailChart: (stationId: string, widthPx: number, heightPx: number) => SVGSVGElement | null;
|
||||
/** [상세] 모달 그림(173-4) — 카드 그리기 함수로 칸 크기(W × H px)에 맞춰 새로 그린 svg(없으면 null)
|
||||
* · 축 다시 그리기 옵션(179-3 · 기하 없으면 null). */
|
||||
detailChart: (
|
||||
stationId: string,
|
||||
widthPx: number,
|
||||
heightPx: number,
|
||||
) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null;
|
||||
/** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음
|
||||
* (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). null = 카드 선택 풂(54-8). */
|
||||
focusStation: (stationId: string | null) => void;
|
||||
|
||||
@@ -54,9 +54,11 @@
|
||||
}
|
||||
|
||||
.b06-detail__qtable {
|
||||
/* 이름 칸 바탕 = 데이터 표 머리색(179-3 · `ui_template_data_table` --dt-head) */
|
||||
--b06-detail-head: color-mix(in srgb, var(--color-accent) 6%, var(--color-surface));
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-caption);
|
||||
font-size: var(--text-body-sm);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -70,7 +72,8 @@
|
||||
|
||||
.b06-detail__qtable th {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--b06-detail-head);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.b06-detail__qtable tr:first-child > th {
|
||||
@@ -100,10 +103,18 @@
|
||||
/* 그림에 대상이 없는 칸(암석 띠 없음 · 면적 0 · 사토장 없음) — 회색 · 누름 없음 */
|
||||
.b06-detail__qtable [data-key][data-empty] {
|
||||
cursor: default;
|
||||
background: none;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 빈 칸 = 값 칸만 바탕 지움 · 이름 칸은 머리색 그대로(글만 회색) */
|
||||
.b06-detail__qtable td[data-key][data-empty] {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.b06-detail__qtable th[data-key][data-empty] {
|
||||
background: var(--b06-detail-head);
|
||||
}
|
||||
|
||||
/* 그림 강조 한 벌 = 붉은 계통(164-2 · 카드 색은 그대로) — 띠 · 판정 선 · 중심 십자 · 사토장 */
|
||||
.b06-detail__cad .b06-chart__area.is-active > polygon {
|
||||
fill: color-mix(in srgb, var(--color-danger) 35%, transparent);
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface WallSpec {
|
||||
/** 기초 축 — "기초유" | "기초버림". 저장 칸과 같은 글자(터파기 그림이 이 값으로 갈린다). */
|
||||
foundation: string | null;
|
||||
tiers: number | null;
|
||||
/** 추가 단(2단부터) 형태 · 높이 — 좌측 폼 「추가 단」 상자(178-3 · `tier_{n}_*`). 없으면 빈 목록. */
|
||||
extras: WallTier[];
|
||||
lift_m: number | null;
|
||||
shift_m: number | null;
|
||||
/** 전면 기울기 판정 칸 — 성토/절토 · 전면 기울기(저장 원본 · B08 과 같은 칸, 2026-09-14 B5). */
|
||||
@@ -42,6 +44,12 @@ export interface WallSpec {
|
||||
face_slope_ratio: number | string | null;
|
||||
}
|
||||
|
||||
/** 추가 단 한 벌 — 형태 빈 값 = 본 벽 형태 · 높이 빈 값 = 기본 단 높이(그리는 쪽). */
|
||||
export interface WallTier {
|
||||
form: string | null;
|
||||
height_m: number | null;
|
||||
}
|
||||
|
||||
/** 구간 경계 측점을 구간 안으로 볼 허용 오차(m) — 파이썬 `_EDGE_TOLERANCE_M`. */
|
||||
const EDGE_TOLERANCE_M = 0.02;
|
||||
|
||||
@@ -67,6 +75,24 @@ export const FORM_BY_TYPE: Record<string, string> = {
|
||||
const num = (value: unknown): number | null =>
|
||||
typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
|
||||
const TIER_KEY = /^tier_(\d+)_/;
|
||||
|
||||
/** 「추가 단」 상자(`tier_{n}_*` · n 차례) → 추가 단 목록 — 옹벽 = 콘크리트(178-3) · 파이썬 `tier_extras` 짝. */
|
||||
export function tierExtras(typeId: string, options: Record<string, unknown>): WallTier[] {
|
||||
const ns = [
|
||||
...new Set(
|
||||
Object.keys(options)
|
||||
.map((key) => TIER_KEY.exec(key)?.[1])
|
||||
.filter((n): n is string => !!n)
|
||||
.map(Number),
|
||||
),
|
||||
].sort((a, b) => a - b);
|
||||
return ns.map((n) => ({
|
||||
form: typeId === "retaining_wall" ? "콘크리트" : (options[`tier_${n}_form`] as string) || null,
|
||||
height_m: num(options[`tier_${n}_height`]),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* C군 벽 구조물을 제원 목록으로 바꾼다. 이름표(`names`)는 타입 레지스트리에서 온다.
|
||||
* 구간(start·end)이 없는 항목은 건너뛴다 — 벽은 구간형이다.
|
||||
@@ -83,6 +109,7 @@ export function wallSpecsFrom(
|
||||
const end = num(structure.end_m);
|
||||
if (start === null || end === null) continue;
|
||||
const options = (structure.options ?? {}) as Record<string, unknown>;
|
||||
const extras = tierExtras(structure.type_id, options);
|
||||
specs.push({
|
||||
structure_id: structure.structure_id ?? null,
|
||||
type_id: structure.type_id,
|
||||
@@ -95,7 +122,9 @@ export function wallSpecsFrom(
|
||||
side: (options.side as string) ?? null,
|
||||
// 기초 축 — 저장 칸과 **같은 글자**. 초안 경로에서 빠지면 터파기가 안 그려진다.
|
||||
foundation: (options.foundation as string) ?? null,
|
||||
tiers: num(options.tiers),
|
||||
// 단 수 = 1 + 추가 단 상자 수 · 상자 없으면 옛 `tiers`(178-3)
|
||||
tiers: extras.length ? extras.length + 1 : num(options.tiers),
|
||||
extras,
|
||||
lift_m: num(options.lift_m),
|
||||
shift_m: num(options.shift_m),
|
||||
face_role: (options.face_role as string) ?? null,
|
||||
|
||||
@@ -11,11 +11,28 @@ const B06 = path.join(ROOT, "B06_Section");
|
||||
const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8");
|
||||
// 카드 여백은 원본에서 읽음(화면과 같은 값)
|
||||
const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)();
|
||||
// 축 눈금 간격도 원본 함수 그대로(179-3 · 축 다시 그리기)
|
||||
const niceSrc = /export function niceTickStep[\s\S]*?\n\}/.exec(common.replace(/\r/g, ""))[0];
|
||||
const niceTickStep = ts
|
||||
.transpileModule(niceSrc.replace("export ", ""), { compilerOptions: {} })
|
||||
.outputText.concat("\nreturn niceTickStep;");
|
||||
const svgElement = (tag, attrs = {}) => {
|
||||
const node = el(tag);
|
||||
for (const [name, value] of Object.entries(attrs)) node.setAttribute(name, value);
|
||||
return node;
|
||||
};
|
||||
const svgText = (text, attrs) => Object.assign(svgElement("text", attrs), { textContent: text });
|
||||
const STUBS = {
|
||||
"@ui/ui_template_modal": { openModal: () => {} },
|
||||
"../A00_Common/b_page_state": {},
|
||||
"./B06_Section_Api_Fetch": {},
|
||||
"./B06_Section_UI_Section_Common": { CROSS_PAD, L: (key) => key },
|
||||
"./B06_Section_UI_Section_Common": {
|
||||
CROSS_PAD,
|
||||
L: (key) => key,
|
||||
niceTickStep: Function(niceTickStep)(),
|
||||
svgElement,
|
||||
svgText,
|
||||
},
|
||||
"./B06_Section_UI_Cross_Detail_Pick": { DETAIL_TARGETS: {} },
|
||||
};
|
||||
const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base);
|
||||
@@ -52,6 +69,24 @@ function el(tag) {
|
||||
node.parent = self;
|
||||
self.childNodes.push(node);
|
||||
}),
|
||||
replaceChildren: (...nodes) => {
|
||||
self.childNodes.splice(0).forEach((node) => (node.parent = null));
|
||||
self.append(...nodes);
|
||||
},
|
||||
// 「tag.class」 · 「tag」 만 — 깊이 우선 첫 것
|
||||
querySelector: (selector) => {
|
||||
const [tagName, cls] = selector.split(".");
|
||||
const walk = (node) => {
|
||||
for (const child of node.childNodes) {
|
||||
const classes = (child.attrs.class ?? "").split(" ");
|
||||
if (child.tag === tagName && (!cls || classes.includes(cls))) return child;
|
||||
const found = walk(child);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return walk(self);
|
||||
},
|
||||
addEventListener: (type, fn) => (listeners[type] ??= []).push(fn),
|
||||
fire: (type, event) => {
|
||||
const full = { preventDefault() {}, stopPropagation() {}, pointerId: 1, ...event };
|
||||
@@ -69,16 +104,42 @@ globalThis.document = { createElementNS: (_ns, tag) => el(tag), createElement: e
|
||||
|
||||
const { attachDetailZoom } = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail.ts"));
|
||||
|
||||
const svg = el("svg");
|
||||
svg.setAttribute("viewBox", "0 0 600 300");
|
||||
svg.append(el("rect"), el("g"), el("g"));
|
||||
// 카드 svg 꼴 — 바탕 · 축 층 · clip 그룹 > 도형 층 > 십자(중심 300,150)
|
||||
const AXES = {
|
||||
widthPx: 600,
|
||||
heightPx: 300,
|
||||
pixelsPerMeter: 10,
|
||||
maxOffset: 24,
|
||||
displayMax: 120,
|
||||
elevationMid: 110,
|
||||
exaggeration: 1,
|
||||
};
|
||||
function chart() {
|
||||
const svg = el("svg");
|
||||
svg.setAttribute("viewBox", "0 0 600 300");
|
||||
const axes = svgElement("g", { class: "b06-chart__axes" });
|
||||
const clipGroup = svgElement("g", { "clip-path": "url(#c)" });
|
||||
const plot = svgElement("g", { class: "b06-chart__plot-layer" });
|
||||
const center = svgElement("g", { class: "b06-chart__center" });
|
||||
center.append(svgElement("line", { x1: 300, y1: 132, x2: 300, y2: 168 }));
|
||||
plot.append(center);
|
||||
clipGroup.append(plot);
|
||||
svg.append(svgElement("rect"), axes, clipGroup, svgElement("line", { class: "b06-chart__axis" }));
|
||||
return { svg, axes, plot, center };
|
||||
}
|
||||
const ticks = (axes) =>
|
||||
axes.childNodes.filter((node) => node.tag === "text").map((node) => node.textContent);
|
||||
const { svg, axes, plot: layer, center } = chart();
|
||||
let saved;
|
||||
const handle = attachDetailZoom(svg, undefined, (next) => (saved = next));
|
||||
const layer = svg.childNodes[0];
|
||||
const handle = attachDetailZoom(svg, AXES, undefined, (next) => (saved = next));
|
||||
const ticksBefore = ticks(axes);
|
||||
const out = {
|
||||
wrapped: svg.childNodes.length === 1 && layer.tag === "g" && layer.childNodes.length === 3,
|
||||
// 179-3 — 감싸지 않음 · 자식 넷 그대로
|
||||
wrapped: svg.childNodes.length === 1 && svg.childNodes[0].tag === "g",
|
||||
children: svg.childNodes.length,
|
||||
ratio: svg.attrs["style:--ratio"],
|
||||
before: layer.getAttribute("transform"),
|
||||
ticksBefore: ticksBefore.length,
|
||||
};
|
||||
// 휠 당김(deltaY > 0) = 확대 · 커서(화면 600,300 = svg 300,150) 고정
|
||||
svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: 100 });
|
||||
@@ -97,19 +158,35 @@ svg.fire("pointerdown", { button: 1, clientX: 100, clientY: 100 });
|
||||
svg.fire("pointermove", { clientX: 300, clientY: 140 });
|
||||
svg.fire("pointerup", {});
|
||||
out.middlePan = { ...saved };
|
||||
// 팬 뒤 — 도형 층만 transform · 축 층 · 바깥 그룹 transform 없음 · 눈금 글 바뀜
|
||||
out.plotTransform = layer.getAttribute("transform");
|
||||
out.axesTransform = axes.getAttribute("transform");
|
||||
out.clipTransform = svg.childNodes[2].getAttribute("transform");
|
||||
out.ticksChanged = JSON.stringify(ticks(axes)) !== JSON.stringify(ticksBefore);
|
||||
out.ticksAfter = ticks(axes).length;
|
||||
out.panClickStopped = svg.fire("click", {
|
||||
stopPropagation() {
|
||||
this.stopped = true;
|
||||
},
|
||||
}).stopped;
|
||||
// 다시 떠도 배율 이어 감
|
||||
const again = el("svg");
|
||||
again.setAttribute("viewBox", "0 0 600 300");
|
||||
attachDetailZoom(again, saved, () => {});
|
||||
out.restored = again.childNodes[0].getAttribute("transform");
|
||||
const again = chart();
|
||||
attachDetailZoom(again.svg, AXES, saved, () => {});
|
||||
out.restored = again.plot.getAttribute("transform");
|
||||
// 다시 떠도 축은 이어 간 범위로(1배 원래 눈금과 다름)
|
||||
out.restoredTicksShifted = JSON.stringify(ticks(again.axes)) !== JSON.stringify(ticksBefore);
|
||||
// 축 옵션 없음 · 도형 층 없음 — 줌은 걸고 축은 그대로 / 줌 없음
|
||||
const bare = chart();
|
||||
out.noAxesHandle = attachDetailZoom(bare.svg, null, undefined, () => {}) !== null;
|
||||
const flat = el("svg");
|
||||
flat.setAttribute("viewBox", "0 0 600 300");
|
||||
out.noLayer = attachDetailZoom(flat, AXES, undefined, () => {});
|
||||
// 더블클릭 원복
|
||||
svg.fire("dblclick", {});
|
||||
out.reset = layer.getAttribute("transform");
|
||||
// 확대 — 십자는 중심에서 배율을 되돌림(크기 그대로)
|
||||
svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: 100 });
|
||||
out.centerTransform = center.getAttribute("transform");
|
||||
out.max = (() => {
|
||||
for (let i = 0; i < 60; i++) svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: 100 });
|
||||
return saved.scale;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/* 178-3 사면안정 흙막이 · 옹벽 「추가 단」 헬퍼 — 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림.
|
||||
* ① 명세 상자 저장 왕복(readState → writeOptions) ② 구조물 정본 → 벽 제원(`wallSpecsFrom`) → 횡단 형상
|
||||
* (`computeRevetmentLayout`) 단 수 · 단마다 형태 · 높이. 입력 측점 = 73-4 픽스처(helper_79 와 같은 180 측점).
|
||||
* 결과 = 표준출력 JSON 한 벌 · 짝 = `test_178_3_slope_tiers.py`.
|
||||
*/
|
||||
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 STUBS = { "@ui/ui_template_toast": { showToast: () => undefined } };
|
||||
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
if (full.endsWith(".json")) {
|
||||
const data = JSON.parse(fs.readFileSync(full, "utf8"));
|
||||
cache.set(full, data);
|
||||
return data;
|
||||
}
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
esModuleInterop: true,
|
||||
},
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" };
|
||||
for (const [prefix, dir] of Object.entries(alias))
|
||||
if (request.startsWith(prefix))
|
||||
return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length))));
|
||||
return loadTs(resolve(path.join(path.dirname(full), request)));
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
const resolve = (base) =>
|
||||
[base, `${base}.ts`, path.join(base, "index.ts")].find(
|
||||
(p) => fs.existsSync(p) && fs.statSync(p).isFile(),
|
||||
) ?? `${base}.ts`;
|
||||
|
||||
const design = (f) => path.join(ROOT, "A00_Common", "design", f);
|
||||
const spec = loadTs(design("design_structure_spec.ts"));
|
||||
const { ADDON_SPECS } = loadTs(design("design_structure_slope_tier.ts"));
|
||||
const { STRUCTURE_SPECS } = loadTs(design("design_structure_pipe.ts"));
|
||||
const { wallSpecsFrom } = loadTs(path.join(ROOT, "common_util", "common_util_structure_walls.ts"));
|
||||
const { computeRevetmentLayout } = loadTs(
|
||||
path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Revetment.ts"),
|
||||
);
|
||||
|
||||
const r3 = (v) => Math.round(v * 1000) / 1000;
|
||||
const out = {};
|
||||
|
||||
// ① 저장 왕복 — 상자 둘(흙막이) · 하나(옹벽) · 빈 상자(값 없이 추가만)
|
||||
const soil = ADDON_SPECS.soil_guard;
|
||||
const wall = ADDON_SPECS.retaining_wall;
|
||||
const soilOptions = {
|
||||
form: "돌쌓기(찰)",
|
||||
height_m: 2,
|
||||
tier_1_form: "콘크리트",
|
||||
tier_1_height: 2,
|
||||
tier_2_form: "돌망태",
|
||||
tier_2_height: 1.2,
|
||||
};
|
||||
const soilState = spec.readState(soil, soilOptions);
|
||||
out.roundTrip = {
|
||||
items: Object.fromEntries(
|
||||
Object.entries(ADDON_SPECS).map(([k, s]) => [k, s.containers[0].items.map((i) => i.id)]),
|
||||
),
|
||||
soilState: soilState.tier,
|
||||
soilBack: spec.writeOptions(soil, soilState),
|
||||
wallBack: spec.writeOptions(wall, { tier: [{ height: "1.8" }] }),
|
||||
emptyBox: spec.writeOptions(soil, { tier: [{}] }),
|
||||
notMainSpecs: Object.keys(ADDON_SPECS).filter((k) => k in STRUCTURE_SPECS),
|
||||
};
|
||||
|
||||
// ② 형상 — 정본 → 벽 제원 → 횡단 단
|
||||
const fixture = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, "fixtures", "b06_73_4_body_guard_sections.json"), "utf8"),
|
||||
);
|
||||
const base = fixture.cross_sections.find((s) => Math.abs(s.chainage_m - 180) < 0.6);
|
||||
const names = new Map([
|
||||
["soil_guard", "흙막이"],
|
||||
["retaining_wall", "옹벽"],
|
||||
]);
|
||||
function shape(typeId, options) {
|
||||
const [wallSpec] = wallSpecsFrom(
|
||||
[{ structure_id: "t", type_id: typeId, chainage_m: 180, start_m: 170, end_m: 190, options }],
|
||||
names,
|
||||
);
|
||||
const section = JSON.parse(JSON.stringify(base));
|
||||
section.revetment = { ...wallSpec, side: "우" };
|
||||
const layout = computeRevetmentLayout(section, null);
|
||||
return {
|
||||
tiers: wallSpec.tiers,
|
||||
extras: wallSpec.extras,
|
||||
requested: layout?.requestedTiers ?? null,
|
||||
walls: (layout?.walls ?? []).map((w) => ({ form: w.form, pure: r3(w.height + 0.5) })),
|
||||
};
|
||||
}
|
||||
out.soil2 = shape("soil_guard", soilOptions);
|
||||
out.wall1 = shape("retaining_wall", { height_m: 2.5, tier_1_height: 1.8 });
|
||||
out.emptyBox = shape("soil_guard", { form: "돌망태", height_m: 2, tier_1_form: "" });
|
||||
out.legacy = shape("soil_guard", { form: "돌망태", height_m: 2, tiers: 2 });
|
||||
out.none = shape("retaining_wall", { height_m: 2.5 });
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -40,10 +40,24 @@ def out() -> dict:
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_복제_자식을_g_하나로_감쌈_비율(out):
|
||||
assert out["wrapped"] is True # 축 · 눈금 · 도형이 한 장으로 움직임
|
||||
def test_감싸지_않음_비율(out):
|
||||
# 179-3 — 복제를 g 하나로 감싸지 않음(축 · 눈금 · 도형 한 장 → 도형 층만)
|
||||
assert out["wrapped"] is False and out["children"] == 4
|
||||
assert out.get("ratio") is None # 173-4 — 칸 = viewBox(칸 크기로 새로 그림) · 비율 변수 없음
|
||||
assert out["before"] is None
|
||||
assert out["ticksBefore"] > 0
|
||||
|
||||
|
||||
def test_축_고정_도형_층만_움직임_눈금_다시(out):
|
||||
# 179-3 — 팬 뒤 도형 층만 transform · 축 층 · clip 그룹 제자리 · 눈금 글은 보이는 범위로
|
||||
assert out["plotTransform"] == "translate(100 20) scale(1)"
|
||||
assert out["axesTransform"] is None and out["clipTransform"] is None
|
||||
assert out["ticksChanged"] is True and out["ticksAfter"] > 0
|
||||
assert out["restoredTicksShifted"] is True # 다시 떠도 이어 간 범위로
|
||||
# 확대 — 십자는 중심(300,150)에서 배율 되돌림
|
||||
assert out["centerTransform"].startswith("translate(300 150) scale(0.869")
|
||||
assert out["noAxesHandle"] is True # 축 옵션 없음 = 줌만
|
||||
assert out["noLayer"] is None # 도형 층 없음 = 줌 없음
|
||||
|
||||
|
||||
def test_휠_당기면_확대_커서_고정(out):
|
||||
@@ -69,8 +83,14 @@ def test_모달_배선_단추_칸_비율():
|
||||
src = _read("B06_Section_UI_Cross_Detail.ts")
|
||||
css = _read("B06_Section_UI_Style_Cross_Detail.css")
|
||||
assert "wheelZoom: true" in src and "attachSvgZoomPan({" in src
|
||||
assert "cad.replaceChildren(clone, buildZoomControls(handle))" in src
|
||||
assert "attachDetailZoom(clone, zoom, (next) => (zoom = next))" in src
|
||||
assert "cad.replaceChildren(clone, ...(handle ? [buildZoomControls(handle)] : []))" in src
|
||||
assert "attachDetailZoom(clone, axes, zoom, (next) => (zoom = next))" in src
|
||||
assert "layer.append(...clone.childNodes)" not in src # 감싸기 지움(179-3)
|
||||
assert 'clone.querySelector<SVGGElement>("g.b06-chart__plot-layer")' in src
|
||||
assert "createCrossAxes(axisLayer, axes)" in src
|
||||
view = _read("B06_Section_UI_Section_View.ts")
|
||||
assert "const plot = fitted && crossPlotFromBase(fitted, heightPx);" in view
|
||||
assert "return { svg, axes };" in view
|
||||
assert "position: relative;" in css
|
||||
# 173-4 — 그림 = 칸 크기로 새로 그림 · svg 칸 가득
|
||||
assert ".b06-detail__cad > svg {\n display: block;\n width: 100%;\n height: 100%;" in css
|
||||
@@ -102,3 +122,27 @@ def test_빈칸_누름_없음_이름_칸_첫_키():
|
||||
assert 'if (!key || el.hasAttribute("data-empty")) return;' in src
|
||||
assert 'cell("th", groupL, { row: 2 }, keyL)' in src
|
||||
assert 'cell("th", groupM, { row: 2 }, keyM)' in src
|
||||
|
||||
|
||||
def test_표_글자_14px_이름_칸_머리색_빈칸_값칸만():
|
||||
# 179-3 — 표 글자 14px · 이름 칸(th) 바탕 = 데이터 표 머리색 · 빈 칸 바탕 지움은 값 칸(td)만
|
||||
css = _read("B06_Section_UI_Style_Cross_Detail.css")
|
||||
table = css.split(".b06-detail__qtable {", 1)[1].split("}", 1)[0]
|
||||
assert "font-size: var(--text-body-sm);" in table
|
||||
assert (
|
||||
"--b06-detail-head: color-mix(in srgb, var(--color-accent) 6%, var(--color-surface));"
|
||||
in table
|
||||
)
|
||||
head = css.split(".b06-detail__qtable th {", 1)[1].split("}", 1)[0]
|
||||
assert "background: var(--b06-detail-head);" in head and "color: var(--color-text);" in head
|
||||
empty = css.split(".b06-detail__qtable [data-key][data-empty] {", 1)[1].split("}", 1)[0]
|
||||
assert "background" not in empty
|
||||
assert ".b06-detail__qtable td[data-key][data-empty] {\n background: none;" in css
|
||||
assert (
|
||||
".b06-detail__qtable th[data-key][data-empty] {\n background: var(--b06-detail-head);"
|
||||
in css
|
||||
)
|
||||
theme = open(
|
||||
os.path.join(ROOT, "ui_template", "ui_template_theme.css"), encoding="utf-8"
|
||||
).read()
|
||||
assert "--text-body-sm: 14px;" in theme
|
||||
|
||||
@@ -87,7 +87,7 @@ def test_상세_테마_토큰_어두운_고정색_없음():
|
||||
def test_상세_칸_크기로_새로_그림_배선():
|
||||
src = _read("B06_Section_UI_Cross_Detail.ts")
|
||||
assert "const { clientWidth: width, clientHeight: height } = cad;" in src
|
||||
assert "const clone = drawChart(state.section, width, height);" in src
|
||||
assert "const drawn = drawChart(state.section, width, height);" in src
|
||||
assert "host?.chart(section.station_id, widthPx, heightPx)" in src
|
||||
assert "new ResizeObserver(() => state.paint())" in src and "resize.disconnect()" in src
|
||||
# 카드 clip id 와 가름 · 그리는 동안 refresh 되먹임 막음
|
||||
@@ -99,7 +99,12 @@ def test_상세_칸_크기로_새로_그림_배선():
|
||||
assert "base && fitPlotBase(base, heightPx)" in view
|
||||
assert "cardZoomStates.delete(stationId);" in view
|
||||
types = _read("B06_Section_UI_Section_View_Types.ts")
|
||||
assert "detailChart: (stationId: string, widthPx: number, heightPx: number) =>" in types
|
||||
# 179-3 — svg + 축 옵션
|
||||
assert " ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null;" in types
|
||||
assert (
|
||||
" detailChart: (\n stationId: string,\n widthPx: number,\n heightPx: number,"
|
||||
in types
|
||||
)
|
||||
sync = _read("B06_Section_UI_Page_Design_Sync.ts")
|
||||
assert "ctx.view().detailChart(stationId, widthPx, heightPx)" in sync
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""178-3 사면안정 흙막이 · 옹벽 「추가 단」 상자 → 여러 단 횡단 형상.
|
||||
|
||||
못박는 것
|
||||
- 상자 = 유출 흙막이 상자 양식(공용 [추가] · [빼기]) · 흙막이 = 형태 · 높이 · 옹벽 = 높이만 · 키 `tier_{n}_*`.
|
||||
- 명세 저장 왕복 그대로 · 빈 상자도 한 단으로 남음 · 정의 그룹 종류 그대로(`STRUCTURE_SPECS` 에 안 넣음).
|
||||
- 서버 저장(structures.json) → 벽 제원(파이썬) 단 수 = 1 + 상자 수 · 단마다 형태 · 높이 · 옹벽 단 = 콘크리트.
|
||||
- 횡단 형상(진짜 `computeRevetmentLayout`) — 단 수 · 단마다 형태 · 높이(빈 값 = 본 벽 형태 · 1.5 m) · 옛 `tiers` 그대로.
|
||||
|
||||
Node 헬퍼 `helper_178_3_slope_tiers.cjs`. 화면은 사용자 확인.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
|
||||
|
||||
SOIL = {
|
||||
"form": "돌쌓기(찰)",
|
||||
"length_m": 20,
|
||||
"height_m": 2,
|
||||
"before_m": 10,
|
||||
"after_m": 10,
|
||||
"tier_1_form": "콘크리트",
|
||||
"tier_1_height": 2,
|
||||
"tier_2_form": "돌망태",
|
||||
"tier_2_height": 1.2,
|
||||
}
|
||||
|
||||
|
||||
def _read(*parts: str) -> str:
|
||||
with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle:
|
||||
return handle.read()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_178_3_slope_tiers.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_상자_칸_흙막이_형태_높이_옹벽_높이(out):
|
||||
assert out["roundTrip"]["items"] == {
|
||||
"soil_guard": ["form", "height"],
|
||||
"retaining_wall": ["height"],
|
||||
}
|
||||
assert out["roundTrip"]["notMainSpecs"] == []
|
||||
|
||||
|
||||
def test_명세_저장_왕복(out):
|
||||
trip = out["roundTrip"]
|
||||
assert trip["soilState"] == [
|
||||
{"form": "콘크리트", "height": "2"},
|
||||
{"form": "돌망태", "height": "1.2"},
|
||||
]
|
||||
assert trip["soilBack"] == {
|
||||
"tier_1_form": "콘크리트",
|
||||
"tier_1_height": 2,
|
||||
"tier_2_form": "돌망태",
|
||||
"tier_2_height": 1.2,
|
||||
}
|
||||
assert trip["wallBack"] == {"tier_1_height": 1.8}
|
||||
assert trip["emptyBox"] == {"tier_1_form": ""} # 값 없이 추가만 해도 단 하나
|
||||
|
||||
|
||||
def test_서버_저장_뒤_벽_제원(tmp_path):
|
||||
from B05_Profile.B05_Profile_Structures_Repository import save_structures
|
||||
from B06_Section import B06_Section_Engine_Structures_Wall as wall
|
||||
|
||||
items = [
|
||||
StructureInstance(
|
||||
type_id="soil_guard",
|
||||
placement="interval",
|
||||
chainage_m=180,
|
||||
start_m=170,
|
||||
end_m=190,
|
||||
options=SOIL,
|
||||
),
|
||||
StructureInstance(
|
||||
type_id="retaining_wall",
|
||||
placement="interval",
|
||||
chainage_m=300,
|
||||
start_m=290,
|
||||
end_m=310,
|
||||
options={"height_m": 2.5, "tier_1_height": 1.8},
|
||||
),
|
||||
]
|
||||
save_structures(str(tmp_path), items, base_revision=0)
|
||||
got = {spec["type_id"]: spec for spec in wall.load_wall_structures(tmp_path)}
|
||||
assert got["soil_guard"]["tiers"] == 3
|
||||
assert got["soil_guard"]["extras"] == [
|
||||
{"form": "콘크리트", "height_m": 2},
|
||||
{"form": "돌망태", "height_m": 1.2},
|
||||
]
|
||||
assert got["retaining_wall"]["tiers"] == 2
|
||||
assert got["retaining_wall"]["extras"] == [{"form": "콘크리트", "height_m": 1.8}]
|
||||
|
||||
|
||||
def test_횡단_형상_단_수_단마다_형태_높이(out):
|
||||
assert out["soil2"]["requested"] == 3
|
||||
assert out["soil2"]["walls"] == [
|
||||
{"form": "돌쌓기(찰)", "pure": 2},
|
||||
{"form": "콘크리트", "pure": 2},
|
||||
{"form": "돌망태", "pure": 1.2},
|
||||
]
|
||||
assert out["wall1"]["walls"] == [
|
||||
{"form": "콘크리트", "pure": 2.5},
|
||||
{"form": "콘크리트", "pure": 1.8},
|
||||
]
|
||||
# 빈 상자 = 본 벽 형태 · 기본 1.5 m · 상자 없으면 옛 tiers 그대로 · 둘 다 없으면 1단
|
||||
assert out["emptyBox"]["walls"][1] == {"form": "돌망태", "pure": 1.5}
|
||||
assert out["legacy"]["requested"] == 2 and out["legacy"]["extras"] == []
|
||||
assert out["none"]["requested"] == 1
|
||||
|
||||
|
||||
def test_배선():
|
||||
spec = _read("B05_Profile", "B05_Profile_UI_Structures_Spec.ts")
|
||||
assert "STRUCTURE_SPECS[type.type_id] ?? ADDON_SPECS[type.type_id]" in spec
|
||||
walls = _read("common_util", "common_util_structure_walls.ts")
|
||||
assert "파이썬 `tier_extras` 짝" in walls
|
||||
engine = _read("B06_Section", "B06_Section_Engine_Structures_Wall.py")
|
||||
assert "TS `tierExtras` 짝" in engine
|
||||
@@ -46,6 +46,24 @@ STRUCTURES = [
|
||||
"end_m": None,
|
||||
"options": {},
|
||||
},
|
||||
{
|
||||
# 178-3 「추가 단」 상자 둘(둘째 상자 높이 빈 값 · 글 값은 수 아님)
|
||||
"structure_id": "tiers",
|
||||
"type_id": "soil_guard",
|
||||
"placement": "interval",
|
||||
"chainage_m": 260.0,
|
||||
"start_m": 250.0,
|
||||
"end_m": 270.0,
|
||||
"options": {
|
||||
"form": "돌망태",
|
||||
"height_m": 2.0,
|
||||
"tiers": 5,
|
||||
"tier_2_form": "",
|
||||
"tier_2_height": "1.5",
|
||||
"tier_1_form": "콘크리트",
|
||||
"tier_1_height": 2.0,
|
||||
},
|
||||
},
|
||||
]
|
||||
NAMES = {"masonry_wet": "돌쌓기(찰)", "retaining_wall": "옹벽", "soil_guard": "흙막이"}
|
||||
CHAINAGES = [20.0, 40.0, 45.02, 60.0, 115.0, 200.0]
|
||||
@@ -62,7 +80,11 @@ writeFileSync(process.argv[3], JSON.stringify({ specs, sections }));
|
||||
|
||||
|
||||
def _python_result() -> dict:
|
||||
from B06_Section.B06_Section_Engine_Structures_Wall import _FORM_BY_TYPE, attach_wall_structures
|
||||
from B06_Section.B06_Section_Engine_Structures_Wall import (
|
||||
_FORM_BY_TYPE,
|
||||
attach_wall_structures,
|
||||
tier_extras,
|
||||
)
|
||||
|
||||
specs = []
|
||||
for item in STRUCTURES:
|
||||
@@ -71,6 +93,7 @@ def _python_result() -> dict:
|
||||
if not name or start is None or end is None:
|
||||
continue
|
||||
options = item["options"] or {}
|
||||
extras = tier_extras(item["type_id"], options)
|
||||
specs.append(
|
||||
{
|
||||
"structure_id": item["structure_id"],
|
||||
@@ -85,7 +108,9 @@ def _python_result() -> dict:
|
||||
# 기초 축(2026-09-09 신설) — 저장 칸과 같은 글자. 초안 경로에서 빠지면
|
||||
# 터파기가 안 그려져 「그림이 값에 안 따라간다」가 된다.
|
||||
"foundation": options.get("foundation"),
|
||||
"tiers": options.get("tiers"),
|
||||
# 178-3 단 수 = 1 + 「추가 단」 상자 수 · 상자 없으면 옛 tiers
|
||||
"tiers": len(extras) + 1 if extras else options.get("tiers"),
|
||||
"extras": extras,
|
||||
"lift_m": options.get("lift_m"),
|
||||
"shift_m": options.get("shift_m"),
|
||||
# 전면 기울기 판정 칸(2026-09-14 B5) — 저장 원본 그대로.
|
||||
@@ -145,8 +170,16 @@ def test_wall_specs_mirror():
|
||||
with tempfile.TemporaryDirectory() as workdir:
|
||||
ts = _ts_result(Path(workdir))
|
||||
py = _python_result()
|
||||
assert len(ts["specs"]) == len(py["specs"]) == 2
|
||||
assert len(ts["specs"]) == len(py["specs"]) == 3
|
||||
assert ts["specs"] == py["specs"]
|
||||
tiered = ts["specs"][2]
|
||||
assert tiered["tiers"] == 3
|
||||
assert tiered["extras"] == [
|
||||
{"form": "콘크리트", "height_m": 2.0},
|
||||
{"form": None, "height_m": None},
|
||||
]
|
||||
assert ts["specs"][1]["extras"] == [] # 상자 없는 옹벽 = 옛 tiers 2 그대로
|
||||
assert ts["specs"][1]["tiers"] == 2
|
||||
ts_marks = [
|
||||
(s["chainage_m"], (s.get("revetment") or {}).get("type_id")) for s in ts["sections"]
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user