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

This commit is contained in:
2026-10-10 15:38:13 +09:00
57 changed files with 2660 additions and 189 deletions
+3
View File
@@ -97,6 +97,9 @@ export function resolveSelection<T extends SelectableStructure>(
return { pipeChainageM: null, structureId: hit?.structure_id ?? null };
}
/** 측점 하나 고름(횡단 카드 · 종단 측점선)이 덮어 열어도 되는 군 — 노면공(G군)은 뺌(152-5 · B05 = B06 216-1). */
export const coversAtStation = (group: string | undefined): boolean => group !== "G";
/* ── 세션 값 + 알림 ─────────────────────────────────────────────────────── */
interface Stored {
+12 -1
View File
@@ -31,7 +31,13 @@ import {
restoreStructurePick,
wireStructurePick,
} from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
import { NO_SELECTION, nearestWithin, readSelection, selectDesign } from "./design_selection";
import {
coversAtStation,
NO_SELECTION,
nearestWithin,
readSelection,
selectDesign,
} from "./design_selection";
import type { DesignPageHost } from "./design_page";
import {
bindDesignView,
@@ -223,6 +229,11 @@ export async function mountProfileView(
selectMarker: (id) => viewer.markers.selectStation(id),
selectGraph: (id, chainageM) => selectGraphAt(id, chainageM ?? null),
selectSidebar: (chainageM) => panel.structures.selectPipeByChainage(chainageM),
// 측점선 · 3D 띠 고름 = 횡단 카드 고름(B06 showAtChainage)과 같은 규칙 · 같은 함수(216-1)
openAtStation: (chainageM) =>
panel.structures.selectAt({ chainageM, structureId: null }, (entry) =>
coversAtStation(bridge.typeGroup(entry.type_id)),
),
selectBasin: (chainageM) => profilePanel.drainage.selectBasinByChainage(chainageM),
selectPipeForm: (chainageM) => profilePanel.drainage.selectPipeAtChainage(chainageM),
markStation: (chainageM) => profilePanel.drainage.markStation(chainageM),
@@ -91,7 +91,7 @@ def _build(
return detail, carried
# 관 보호공 · 유입 집수정 높이 칸 — 새 관 기본 = 관경 + 0.5(139-1)
# 관 보호공 · 유입 집수정 높이 칸 — 새 관 기본 = 관경 + 0.5(139-1) · 유입 흙막이만 + 0.7(211-1)
PIPE_HEIGHT_KEYS = ("inlet_revet_height_m", "outlet_revet_height_m", "inlet_basin_height_m")
@@ -130,7 +130,10 @@ def _apply_new_defaults(
if diameter_m > 0:
for key in PIPE_HEIGHT_KEYS:
if key in keys:
options.setdefault(key, pipe_wall_min_height_m(diameter_m))
side = (
"inlet" if key == "inlet_revet_height_m" else ""
) # 유입 흙막이 + 0.7(211-1)
options.setdefault(key, pipe_wall_min_height_m(diameter_m, side))
if point.facility == "ford_bridge" and "ford_height_m" not in options:
flow = (flows or {}).get(round(point.chainage_m, 2))
height = ford_height_m(flow, float(options.get("ford_width_m") or 0))
@@ -158,6 +158,62 @@ def tangent_elevation(pvi_s: np.ndarray, pvi_z: np.ndarray, targets: np.ndarray)
return np.interp(targets, pvi_s, pvi_z)
#: 반올림만 다른 같은 측점(m) — 관 측점 0.001m(자동설계) ↔ 0.01m(화면 저장) 차이.
#: 화면 `SAME_STATION_SNAP_M`(B05_Profile_UI_Profile_Alignment.ts) 와 짝.
SAME_STATION_SNAP_M = 0.006
def _nearest_station(value: float, station_s: np.ndarray) -> float | None:
if not station_s.size:
return None
near = float(station_s[int(np.argmin(np.abs(station_s - value)))])
return near if abs(near - value) < SAME_STATION_SNAP_M else None
def snap_to_stations(
base_s: np.ndarray,
base_z: np.ndarray,
edits: dict[str, dict[str, float]],
station_s: np.ndarray,
) -> tuple[np.ndarray, np.ndarray, dict[str, dict[str, float]]]:
"""측점 0.006m 안의 꼭짓점(base_pvi) · 편집 키를 그 측점으로 모은다(213-1).
관 측점이 반올림으로 옮겨도(263.206 → 263.21) 꼭짓점 · 편집 키는 옛 글자로 남아, 새 측점
편집과 0.004m 사이 두 꼭짓점이 됨(−15000% 계단 · 숨은 측점). 겹치면 측점 위 것이 이김.
옮긴 꼭짓점은 옛 선의 측점 높이를 받아 측점 계획고가 안 바뀜. 양 끝 · 측점에서 먼 키는 그대로.
"""
last = len(base_s) - 1
nodes: dict[float, tuple[float, float, float]] = {}
for index, (s, z) in enumerate(zip(base_s.tolist(), base_z.tolist())):
near = None if index in (0, last) else _nearest_station(s, station_s)
at = s if near is None else near
gap = 0.0 if near is None else abs(near - s)
slot = round(at, _KEY_DECIMALS)
if slot not in nodes or gap < nodes[slot][0]:
# 옮긴 꼭짓점 높이 = 옛 선의 그 측점 높이 — 측점 계획고가 그대로
nodes[slot] = (gap, at, z if near is None else float(np.interp(at, base_s, base_z)))
ordered = [nodes[slot] for slot in sorted(nodes)]
snapped: dict[str, dict[str, float]] = {}
for name, values in edits.items():
best: dict[str, tuple[float, float]] = {}
for key, value in values.items():
try:
chainage = float(key)
except (TypeError, ValueError):
chainage = float("nan")
near = _nearest_station(chainage, station_s) if np.isfinite(chainage) else None
target = key if near is None else chainage_key(near)
gap = 0.0 if near is None else abs(near - chainage)
if target not in best or gap < best[target][0]:
best[target] = (gap, value)
snapped[name] = {key: value for key, (_, value) in best.items()}
return (
np.array([item[1] for item in ordered], dtype=np.float64),
np.array([item[2] for item in ordered], dtype=np.float64),
snapped,
)
def resolve_pvi(
base_s: np.ndarray,
base_z: np.ndarray,
@@ -407,6 +463,22 @@ def build_alignment(
if value is not None
}
station_s = np.array(
sorted(
float(station["chainage_m"])
for station in stations
if isinstance(station.get("chainage_m"), (int, float))
),
dtype=np.float64,
)
base_s, base_z, snapped = snap_to_stations(
base_s,
base_z,
{"station_offsets": station_offsets, "curve_radii": curve_radii},
station_s,
)
station_offsets, curve_radii = snapped["station_offsets"], snapped["curve_radii"]
pvi_s, pvi_z, sources = resolve_pvi(base_s, base_z, station_offsets)
curves, warnings = build_curves(
pvi_s, pvi_z, policy, curve_radii, only_explicit=only_explicit_curves
+24 -10
View File
@@ -214,27 +214,41 @@ def _follow_moved_stations(data: dict[str, Any], before: list[float]) -> dict[st
None,
)
moves[chainage_key(old)] = None if near is None else chainage_key(near)
def moved(value: float) -> tuple[bool, str | None]:
"""옛 측점 0.006m 안이면 (참, 옮길 키 · 지우면 None) — 옛 글자(263.206)도 같은 측점(213-1).
남은 측점 위 값은 그대로."""
if chainage_key(value) in after_keys:
return False, None
for old_key, new_key in moves.items():
if abs(float(old_key) - value) < _SAME_STATION_M:
return True, new_key
return False, None
edits = stored.get("edits") or {}
next_edits: dict[str, dict[str, Any]] = {}
changed = False
for name in ("station_offsets", "curve_radii"):
values = dict(edits.get(name) or {})
for old_key, new_key in moves.items():
if old_key in values:
value = values.pop(old_key)
if new_key is not None:
values.setdefault(new_key, value)
changed = True
# 옛 측점 글자 그대로인 키가 먼저 — 반올림 글자보다 이김
for key in sorted(values, key=lambda item: item not in moves):
hit, new_key = moved(float(key))
if not hit:
continue
value = values.pop(key)
if new_key is not None:
values.setdefault(new_key, value)
changed = True
next_edits[name] = values
base: list[dict[str, Any]] = []
for node in stored["base_pvi"]:
key = chainage_key(node["chainage_m"])
if key not in moves:
hit, new_key = moved(float(node["chainage_m"]))
if not hit:
base.append(node)
continue
changed = True
if moves[key] is not None:
base.append({**node, "chainage_m": float(moves[key])})
if new_key is not None:
base.append({**node, "chainage_m": float(new_key)})
if not changed:
return None
stored["base_pvi"] = base
@@ -361,6 +361,8 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
},
/** 그래프·3D가 쓰는 현재 비정규 측점 목록(물넘이포장 제외 — 알약 레인 몫). */
irregularStations: () => withoutFordPavement(irregularStations),
/** 구조물 종류의 군(A~G · 모르면 undefined) — 측점 고름이 덮어 열 구조물을 가름(216-1). */
typeGroup: (typeId: string) => structureTypeMap.get(typeId)?.group,
/** 관 지점 목록이 바뀜 — 측점 투영과 알약 레인을 한 번에 맞춘다. */
setPipes(pipes: PipeProjection[]): void {
pipeStations = pipesToStations(pipes);
@@ -182,6 +182,65 @@ export function toAlignmentBase(alignment: ProfileAlignment): AlignmentBase {
};
}
/** 반올림만 다른 같은 측점(m) — 서버 `SAME_STATION_SNAP_M`(B05_Profile_Engine_Grade_Alignment.py) 와 짝. */
const SAME_STATION_SNAP_M = 0.006;
function nearestStation(value: number, stationS: number[]): number | null {
let near: number | null = null;
stationS.forEach((station) => {
if (near === null || Math.abs(station - value) < Math.abs(near - value)) near = station;
});
return near !== null && Math.abs(near - value) < SAME_STATION_SNAP_M ? near : null;
}
function snapKeys(values: Record<string, number>, stationS: number[]): Record<string, number> {
const best = new Map<string, { gap: number; value: number }>();
Object.entries(values).forEach(([key, value]) => {
const chainage = Number(key);
const near = Number.isFinite(chainage) ? nearestStation(chainage, stationS) : null;
const target = near === null ? key : chainageKey(near);
const gap = near === null ? 0 : Math.abs(near - chainage);
const held = best.get(target);
if (!held || gap < held.gap) best.set(target, { gap, value });
});
return Object.fromEntries([...best].map(([key, entry]) => [key, entry.value]));
}
/**
* 측점 0.006m 안의 꼭짓점(base_pvi) · 편집 키를 그 측점으로 모은다(213-1) — 서버
* `snap_to_stations` 와 같은 규칙. 관 측점이 반올림으로 옮겨도(263.206 → 263.21) 옛 글자로 남아
* 0.004m 사이 두 꼭짓점이 되던 흠. 겹치면 측점 위 것이 이김 · 옮긴 꼭짓점은 옛 선의 측점 높이
* (측점 계획고 그대로) · 양 끝 · 측점에서 먼 키는 그대로.
*/
function snapToStations(
base: AlignmentBase,
edits: AlignmentEdits,
): { basePvi: AlignmentNode[]; edits: AlignmentEdits } {
const stationS = base.stations.map((station) => station.chainage_m);
const baseS = base.basePvi.map((node) => node.chainage_m);
const baseZ = base.basePvi.map((node) => node.elevation_m);
const last = base.basePvi.length - 1;
const nodes = new Map<number, { gap: number; node: AlignmentNode }>();
base.basePvi.forEach((node, index) => {
const near = index === 0 || index === last ? null : nearestStation(node.chainage_m, stationS);
const at = near ?? node.chainage_m;
const gap = near === null ? 0 : Math.abs(near - node.chainage_m);
const slot = Number(at.toFixed(3));
const held = nodes.get(slot);
// 옮긴 꼭짓점 높이 = 옛 선의 그 측점 높이 — 측점 계획고가 그대로
const elevation = near === null ? node.elevation_m : interpolate(baseS, baseZ, at);
if (!held || gap < held.gap)
nodes.set(slot, { gap, node: { chainage_m: at, elevation_m: elevation } });
});
return {
basePvi: [...nodes.keys()].sort((a, b) => a - b).map((slot) => nodes.get(slot)!.node),
edits: {
station_offsets: snapKeys(edits.station_offsets, stationS),
curve_radii: snapKeys(edits.curve_radii, stationS),
},
};
}
/** 자동 변화점에 사용자 편집 측점을 합쳐 최종 변화점 집합을 만든다. */
function resolvePvi(
base: AlignmentBase,
@@ -316,8 +375,10 @@ function normalizeEdits(edits: AlignmentEdits | undefined): AlignmentEdits {
};
}
export function buildAlignment(base: AlignmentBase, input: AlignmentEdits): ProfileAlignment {
const edits = normalizeEdits(input);
export function buildAlignment(raw: AlignmentBase, input: AlignmentEdits): ProfileAlignment {
const snapped = snapToStations(raw, normalizeEdits(input));
const base: AlignmentBase = { ...raw, basePvi: snapped.basePvi };
const edits = snapped.edits;
const warnings: string[] = [];
const nodes = resolvePvi(base, edits.station_offsets);
const pviS = nodes.map((node) => node.chainage_m);
+8 -4
View File
@@ -27,6 +27,9 @@ export interface SelectionSyncPorts {
selectGraph: (stationId: string | null, chainageM?: number | null) => void;
/** 좌측 구조물 폼 선택(누가거리 기준). */
selectSidebar: (chainageM: number | null) => void;
/** 측점 하나 고름(측점선 · 3D 띠) → 좌측 폼 — 횡단 카드 고름과 같은 규칙(관 → 덮는 구조물 · 노면공 뺌 →
* 없으면 폼 비움 · 216-1). 없으면 selectSidebar. */
openAtStation?: (chainageM: number | null) => void;
/** 배수유역도 세부유역 강조(누가거리 기준). */
selectBasin: (chainageM: number | null) => void;
/** 배수유역도 계획선 위 측점 마킹(누가거리 기준). 유역 없는 구조물도 위치를 보여 준다. */
@@ -79,17 +82,18 @@ export function createSelectionSync(ports: SelectionSyncPorts): SelectionSync {
// 측점선 · 3D 띠 · 표 고름도 고름 한 칸에 — 일반 측점을 안 적어 옛 배관 고름이 이겼음(188-1)
const at =
stationId === null ? null : (station?.chainage_m ?? ports.regularChainage?.(stationId));
if (at !== undefined) ports.remember?.(at);
// 규칙 측점을 고른 것이면 구조물 폼은 건드리지 않는다(고를 항목이 없다).
if (stationId !== null && !stationId.startsWith(prefix)) return;
if (at === undefined) return; // 모르는 측점 — 폼 · 고름 한 칸 안 건드림
ports.setSyncing(true);
try {
ports.selectSidebar(station ? station.chainage_m : null);
// 일반 측점도 폼이 따라감 — 일반 측점이면 폼을 안 건드려 옛 폼이 남았음(B06 카드 규칙 · 216-1)
(ports.openAtStation ?? ports.selectSidebar)(at);
ports.selectPipeForm?.(station && isPipeStation(station) ? station.chainage_m : null);
ports.selectStructure3D?.(station ? station.chainage_m : null);
} finally {
ports.setSyncing(false);
}
// 폼이 연 구조물 · 푼 관 알림이 적은 고름을 이 측점으로 덮음 — 마지막 고름 하나(188-1)
ports.remember?.(at);
},
selectStationOfPipe(chainageM) {
if (ports.isSyncing()) return;
@@ -617,8 +617,8 @@ export function createStructuresSection(
loadPipeForm(pipe);
},
resolveAt: (selection) => resolveSelection(selection, pipeFacilities, structures),
selectAt(selection) {
const hit = resolveSelection(selection, pipeFacilities, structures);
selectAt(selection, coverable) {
const hit = resolveSelection(selection, pipeFacilities, structures, coverable);
if (hit.pipeChainageM !== null) this.selectPipeByChainage(hit.pipeChainageM);
else this.selectById(hit.structureId);
return hit;
@@ -174,8 +174,11 @@ export interface StructuresSection {
/** 측점 · 구조물 고름 한 값을 폼이 열 항목으로 푼다 — 열지는 않는다(관 계열이 먼저). */
resolveAt: (selection: DesignSelection) => ResolvedSelection;
/** 고름 한 값으로 폼을 연다(관 → 구조물 순) — 어느 화면에서 골라도 같은 거리 · 같은 순서다.
* 고를 항목이 없으면 폼을 해제한다. 푼 결과를 돌려준다. */
selectAt: (selection: DesignSelection) => ResolvedSelection;
* 고를 항목이 없으면 폼을 해제한다. 푼 결과를 돌려준다. `coverable` = 덮어 열어도 되는 구조물(측점 고름 · 216-1). */
selectAt: (
selection: DesignSelection,
coverable?: (entry: StructureInstance) => boolean,
) => ResolvedSelection;
/** 종단 그래프 우클릭으로 타입을 지정해 추가한다. */
addAt: (chainageM: number, typeId: string) => void;
/** 종단 그래프에서 마크를 끌어 옮긴다. 옮겼으면 true. */
+8
View File
@@ -633,8 +633,16 @@ export interface CrossSectionPatch {
export type CrossCellSource =
| { src: "area"; key: string }
| { src: "length"; start: [number, number]; end: [number, number] }
| { src: "length" | "area"; pieces: CrossPickPiece[] }
| { src: "text"; text: string };
/** [상세] 고른 선 조각(209-1) — 어느 선 · 두 끝 [offset, 표고](m). */
export interface CrossPickPiece {
line: "design" | "ground" | "spoil" | "structure";
start: [number, number];
end: [number, number];
}
/** [상세] 표 손질 — 칸 출처(표 키 →) · 더한 행(`after` 아래 · 없으면 끝) · 더한 열(오른쪽 끝). */
export interface CrossDetailTable {
cells: Record<string, CrossCellSource>;
+10 -5
View File
@@ -78,19 +78,23 @@ REVET_FACE_SLOPE = 0.3
# `test_b06_pipe_wall_height_mirror`.
REVET_HEIGHT_STEP_M = 0.1 # 높이 눈금
REVET_EMBED_DEPTH_M = 0.5 # 근입 깊이
# 관 보호공 **유입** 흙막이 빈 칸 기본 = 관경(올림) + 0.7 — 유출 · 집수정은 근입 0.5 그대로(211-1).
# ⚠ TS 짝 `common_util_culvert_sets.INLET_REVET_ADD_M` · `_Cross_Culvert_Const.INLET_REVET_ADD_M`.
INLET_REVET_ADD_M = 0.7
# 옛 정의 기본 높이(10-6 표 · 관경 무관) — 관경 > 1.0 관에 이 값이면 관 윗면보다 낮은 벽이라
# 읽을 때 관경 기준 값으로 봄(139-1).
OLD_PIPE_WALL_DEFAULT_M = 1.5
def pipe_wall_min_height_m(diameter_m: float) -> float:
def pipe_wall_min_height_m(diameter_m: float, side: str = "") -> float:
"""관 벽 **순수 높이**(근입 포함) 기본값 = 관경(0.1m 로 올림) + 근입 0.5 — 벽 윗면 = 관 윗면(139-1).
관 · 유입 집수정 높이 칸의 새 기본값이자 빈 칸 값.
관 · 유입 집수정 높이 칸의 새 기본값이자 빈 칸 값. `side="inlet"`(유입 흙막이) = + 0.7(211-1).
"""
raw = float(diameter_m)
steps = math.ceil(raw / REVET_HEIGHT_STEP_M - 1e-9)
return round(steps * REVET_HEIGHT_STEP_M + REVET_EMBED_DEPTH_M, 3)
add = INLET_REVET_ADD_M if side == "inlet" else REVET_EMBED_DEPTH_M
return round(steps * REVET_HEIGHT_STEP_M + add, 3)
def pipe_wall_height_m(stored: float | None, diameter_m: float) -> float | None:
@@ -211,10 +215,11 @@ def _side_spec(
spec["basin_form"] = str(options.get("inlet_structure") or "") or None
return spec
# 높이를 안 적었으면 **관경 기준 높이**(관경 + 0.5 · 139-1) · 옛 1.5 도 관경 > 1.0 이면 그 값.
# 높이를 안 적었으면 **관경 기준 높이**(관경 + 0.5 · 유입 + 0.7 · 139-1 · 211-1) ·
# 옛 1.5 도 관경 > 1.0 이면 관경 + 0.5(저장 값 해석 · 안 바뀜).
height = pipe_wall_height_m(_number(options.get(f"{side}_revet_height_m"), None), diameter_m)
if height is None:
height = pipe_wall_min_height_m(diameter_m)
height = pipe_wall_min_height_m(diameter_m, side)
length = _number(
options.get(f"{side}_revet_length_m"),
None,
+1 -1
View File
@@ -50,7 +50,7 @@ def cross_quantity_sheet(source: dict[str, Any], design: dict[str, Any] | None)
key: _format(value) if isinstance(value, (int, float)) else "-"
for key, value in table.items()
}
_values, texts, repick = edited_cells(design)
_values, texts, repick = edited_cells(design, source.get("samples"))
text.update(texts)
return {
"quantity_table": table,
+11 -1
View File
@@ -135,14 +135,24 @@ class BoxAdjustPatch(BaseModel):
right: BoxSideAdjustPatch = Field(default_factory=BoxSideAdjustPatch)
class CrossPickPiece(BaseModel):
"""[상세] 고른 선 조각(209-1) — 어느 선 · 두 끝(m · offset, 표고)."""
line: Literal["design", "ground", "spoil", "structure"]
start: list[float] = Field(..., min_length=2, max_length=2)
end: list[float] = Field(..., min_length=2, max_length=2)
class CrossCellSource(BaseModel):
"""[상세] 표 칸 출처(179-4) — area = 설계 면적 칸 · length = 두 끝(m · offset, 표고) · text."""
"""[상세] 표 칸 출처(179-4) — area = 설계 면적 칸 · length = 두 끝(m · offset, 표고) · text ·
pieces(209-1) = 고른 선 조각 목록(length = 길이 합 · area = 닫힌 넓이)."""
src: Literal["area", "length", "text"]
key: str | None = Field(default=None, max_length=40)
start: list[float] | None = Field(default=None, min_length=2, max_length=2)
end: list[float] | None = Field(default=None, min_length=2, max_length=2)
text: str | None = Field(default=None, max_length=200)
pieces: list[CrossPickPiece] | None = Field(default=None, min_length=1, max_length=200)
class CrossDetailRow(BaseModel):
@@ -71,6 +71,10 @@ export function pipeWallThicknessM(kind: string | null, diameterM: number): numb
*/
export const REVET_EMBED_DEPTH_M = 0.5;
/** 관 보호공 **유입** 흙막이 빈 칸 기본 순수 높이 = 관경(올림) + 0.7(211-1) — 유출은 근입 0.5 그대로.
* ⚠ 파이썬 짝 `INLET_REVET_ADD_M` · 세트 짝 `common_util_culvert_sets.INLET_REVET_ADD_M`(거울 시험). */
export const INLET_REVET_ADD_M = 0.7;
/* 기슭막이 높이는 형태와 무관하게 **사용자 입력 그대로** — 형태별 높이 한계·원지반 맞춤
* 자동 자르기 없음. 형태가 바꾸는 것은 안 무늬뿐. 높이 기준은 **순수 높이**(벽 바닥~상단,
* 근입 0.5m 포함). 단 하나 — 벽 윗면은 노면 위로 올라오지 않음(`buildRevetWallGeometry`). */
@@ -138,7 +142,7 @@ export function revetWallSpec(
// 관경 기준 높이는 기본값만 — 하한은 그림이 서는 값뿐(54-51 · 사용자가 고름).
const preferred = hiddenPipe
? (spec.revet_height_m ?? autoHeightM ?? REVET_EMBED_DEPTH_M)
: (spec.revet_height_m ?? pipeWallMinPureHeight(diameterM)); // 칸 값 → 그림(139-1)
: (spec.revet_height_m ?? pipeWallMinPureHeight(diameterM, spec.role)); // 칸 값 → 그림(139-1)
const pureHeight = Math.max(adjust.h ?? preferred, WALL_MIN_PURE_HEIGHT_M);
return {
form,
@@ -155,10 +159,12 @@ export const PIPE_CONNECT_GRADE = Math.tan((3 * Math.PI) / 180);
/**
* 배관 기슭막이 관 하부 여유(m) — 관 invert 아래 벽 바닥까지의 깊이 = 근입 0.5.
* **순수 높이 기본 = 관경 + 0.5**(땅속 근입 0.5 포함 벽 전체 높이 · 벽 윗면 = 관 윗면 ·
* 10-6 표 Ø1000 = 1.5 · 139-1). 칸을 비우면 이 값.
* 10-6 표 Ø1000 = 1.5 · 139-1 · 유입 흙막이 = 관경 + 0.7 · 211-1). 칸을 비우면 이 값.
*/
export function pipeWallMinPureHeight(diameterM: number): number {
return revetTargetHeight(diameterM) + REVET_EMBED_DEPTH_M;
export function pipeWallMinPureHeight(diameterM: number, side = ""): number {
return (
revetTargetHeight(diameterM) + (side === "inlet" ? INLET_REVET_ADD_M : REVET_EMBED_DEPTH_M)
);
}
/** 벽 순수 높이 하한(m) — 0 이하는 그림이 안 섬. 관경 · 근입 하한 없음(54-51). */
export const WALL_MIN_PURE_HEIGHT_M = 0.1;
+3 -2
View File
@@ -332,6 +332,7 @@ export function openCrossDetail(section: CrossSection): void {
axes = drawn.axes;
const handle = attachDetailZoom(clone, axes, zoom, (next) => (zoom = next));
cad.replaceChildren(clone, ...(handle ? [buildZoomControls(handle)] : []));
tablePick.redraw(); // 선 조각 고르기(209-1)
}
showPicked();
},
@@ -367,11 +368,11 @@ export function openCrossDetail(section: CrossSection): void {
},
stop: () => {
resize.disconnect();
detachTable();
tablePick.detach();
},
};
// 표 손질(179-4) — 우클릭 · 그래프 고르기 · 더한 칸 글 → 세션 초안 → 표 다시 받음
const detachTable = attachDetailTable({
const tablePick = attachDetailTable({
tableBox,
cad,
section: () => state.section,
@@ -0,0 +1,227 @@
/* =============================================================================
* B06_Section_UI_Cross_Detail_Geom.ts
* [상세] 그래프 고르기 기하(209-1) — 선을 만나는 점에서 조각으로 나눔 · 조각 길 · 길이 · 닫힌 넓이.
*
* 짝(① 파이썬 · TS) = `B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py`
* (`piece_path` · `sub_path` · `path_length` · `ring_area` · `ground_runs` — [저장] 뒤 정본 셈) ·
* 거울 시험 `resources/tester/test_209_1_detail_select.py`.
* 나눔(`splitAtCrossings`)은 화면 몫 — 서버는 저장된 조각 두 끝만 검산.
* ========================================================================== */
import type { CrossDesign, CrossPickPiece, SectionSample } from "./B06_Section_Api_Types";
export type Pt = [number, number];
export type LineKind = CrossPickPiece["line"];
/** 고를 수 있는 조각 — 어느 선 · 그 선 위 꺾은선(m). */
export interface SplitPiece {
line: LineKind;
points: Pt[];
}
/** 서버 `LINE_TOLERANCE_M` 과 같은 값 — 두 끝이 선에서 이만큼 안이면 「선 위」 · 고리 끝 맞음. */
export const LINE_TOLERANCE_M = 0.05;
/** 다른 선 끝이 이만큼 안이면 닿음으로 보고 자름(m) — 설계선 표고 0.1 mm 반올림. */
const TOUCH_M = 0.01;
/** 이보다 짧은 조각은 버림(m) — 누를 수도 없음. */
const MIN_PIECE_M = 0.02;
const dist = (a: Pt, b: Pt): number => Math.hypot(a[0] - b[0], a[1] - b[1]);
/* ── 서버 짝(검산 · 값) ─────────────────────────────────────────────────── */
/** 원지반선 — 무효 표고 샘플마다 끊은 꺾은선들(서버 `ground_runs` · 카드 `appendGroundProfile`). */
export function groundRuns(samples: ReadonlyArray<SectionSample> | undefined): Pt[][] {
const runs: Pt[][] = [[]];
for (const sample of samples ?? []) {
const x = sample.offset_m;
const y = sample.elevation_m ?? sample.z;
if (sample.valid === false || typeof x !== "number" || typeof y !== "number") runs.push([]);
else runs[runs.length - 1].push([x, y]);
}
return runs.filter((run) => run.length > 1);
}
const polyline = (raw: ReadonlyArray<{ offset_m: number; elevation_m: number }> | undefined) =>
(raw ?? []).map((p): Pt => [p.offset_m, p.elevation_m]).filter((p) => p.every(Number.isFinite));
/** 서버가 아는 선(서버 `pick_lines`) — 고른 조각 위치 검산 대상. */
export function pickLines(
design: CrossDesign | undefined,
samples: ReadonlyArray<SectionSample> | undefined,
): Record<string, Pt[][]> {
return {
design: [polyline(design?.design_line)],
ground: groundRuns(samples),
spoil: [polyline(design?.spoil_fill_line)],
};
}
/** 선 위 자리(앞 끝에서 잰 길이 m) — 허용 안 가장 가까운 곳 · 없으면 null. */
function locate(point: Pt, run: Pt[]): number | null {
let best: [number, number] | null = null;
let along = 0;
for (let i = 0; i + 1 < run.length; i++) {
const [ax, ay] = run[i];
const [bx, by] = run[i + 1];
const dx = bx - ax;
const dy = by - ay;
const size = Math.hypot(dx, dy);
const raw = size === 0 ? 0 : ((point[0] - ax) * dx + (point[1] - ay) * dy) / size ** 2;
const t = Math.max(0, Math.min(1, raw));
const gap = Math.hypot(point[0] - (ax + t * dx), point[1] - (ay + t * dy));
if (gap <= LINE_TOLERANCE_M && (best === null || gap < best[0])) best = [gap, along + t * size];
along += size;
}
return best ? best[1] : null;
}
function at(run: Pt[], s: number): Pt {
for (let i = 0; i + 1 < run.length; i++) {
const [ax, ay] = run[i];
const [bx, by] = run[i + 1];
const size = Math.hypot(bx - ax, by - ay);
if (s <= size) {
const t = size === 0 ? 0 : s / size;
return [ax + t * (bx - ax), ay + t * (by - ay)];
}
s -= size;
}
return run[run.length - 1];
}
/** 선 위 두 자리 사이 꺾은선(s0 → s1 차례) — 사이 꼭짓점 포함(서버 `sub_path`). */
export function subPath(run: Pt[], s0: number, s1: number): Pt[] {
const lo = Math.min(s0, s1);
const hi = Math.max(s0, s1);
const inner: Pt[] = [];
let along = 0;
for (let i = 0; i + 1 < run.length; i++) {
along += dist(run[i], run[i + 1]);
if (lo < along && along < hi) inner.push(run[i + 1]);
}
const path = [at(run, lo), ...inner, at(run, hi)];
return s0 <= s1 ? path : path.reverse();
}
/** 고른 조각 → 그 선 위 꺾은선 · 두 끝이 선 밖이면 null(구조물 선 = 두 끝 직선 · 검산 없음). */
export function piecePath(piece: CrossPickPiece, lines: Record<string, Pt[][]>): Pt[] | null {
if (piece.line === "structure") return [piece.start, piece.end];
for (const run of lines[piece.line] ?? []) {
const s0 = locate(piece.start, run);
const s1 = locate(piece.end, run);
if (s0 !== null && s1 !== null) return subPath(run, s0, s1);
}
return null;
}
export const pathLength = (path: Pt[]): number =>
path.slice(1).reduce((sum, p, i) => sum + dist(path[i], p), 0);
const near = (a: Pt, b: Pt): boolean => dist(a, b) <= LINE_TOLERANCE_M;
/** 조각 길들을 끝끼리 이어 한 고리가 되면 그 넓이(㎡) · 안 닫힘 · 남는 조각 = null(서버 `ring_area`). */
export function ringArea(paths: Pt[][]): number | null {
if (!paths.length) return null;
const ring = [...paths[0]];
const rest = paths.slice(1).map((path) => [...path]);
while (rest.length) {
const tail = ring[ring.length - 1];
const index = rest.findIndex((p) => near(p[0], tail) || near(p[p.length - 1], tail));
if (index < 0) return null;
const [path] = rest.splice(index, 1);
ring.push(...(near(path[0], tail) ? path : path.reverse()).slice(1));
}
if (ring.length < 4 || !near(ring[0], ring[ring.length - 1])) return null;
ring.push(ring[0]); // 끝이 허용 안에서만 맞음 — 닫는 변을 꼭 넣음
let twice = 0;
for (let i = 0; i + 1 < ring.length; i++)
twice += ring[i][0] * ring[i + 1][1] - ring[i + 1][0] * ring[i][1];
const area = Math.abs(twice) / 2;
return area > 1e-9 ? area : null;
}
/** 고른 조각들 값(서버 `cell_value` 조각 갈래) — 길이 합 · 닫힌 넓이 · 못 셈 = null. */
export function piecesValue(
kind: "length" | "area",
pieces: CrossPickPiece[],
lines: Record<string, Pt[][]>,
): number | null {
const paths = pieces.map((piece) => piecePath(piece, lines));
if (!paths.length || paths.some((path) => path === null)) return null;
const valid = paths as Pt[][];
return kind === "length" ? valid.reduce((sum, p) => sum + pathLength(p), 0) : ringArea(valid);
}
/* ── 나눔(화면 몫) ───────────────────────────────────────────────────────── */
const cross = (a: Pt, b: Pt): number => a[0] * b[1] - a[1] * b[0];
const sub = (a: Pt, b: Pt): Pt => [a[0] - b[0], a[1] - b[1]];
/** 점이 선분 a→b 위 어디(0~1)에 닿나 — `TOUCH_M` 밖이면 null. */
function touchAt(point: Pt, a: Pt, b: Pt): number | null {
const d = sub(b, a);
const size2 = d[0] ** 2 + d[1] ** 2;
if (size2 === 0) return null;
const t = Math.max(0, Math.min(1, ((point[0] - a[0]) * d[0] + (point[1] - a[1]) * d[1]) / size2));
return dist(point, [a[0] + t * d[0], a[1] + t * d[1]]) <= TOUCH_M ? t : null;
}
/**
* 선들을 서로 만나는 점(교차 · 다른 선 끝이 닿는 점)에서 조각으로 나눔.
* 자르는 자리 = 꺾은선 위치(선분 번호 + 0~1). 같은 선끼리는 안 자름.
* ponytail: 겹쳐 나란한 선분(같은 직선 위)은 교점 없음으로 봄 — 끝 닿음만 자름.
*/
export function splitAtCrossings(lines: SplitPiece[]): SplitPiece[] {
const out: SplitPiece[] = [];
lines.forEach((line, li) => {
const cuts: number[] = [];
const pts = line.points;
for (let i = 0; i + 1 < pts.length; i++) {
const p = pts[i];
const r = sub(pts[i + 1], p);
lines.forEach((other, oi) => {
if (oi === li) return;
const q = other.points;
for (let j = 0; j + 1 < q.length; j++) {
const s = sub(q[j + 1], q[j]);
const denom = cross(r, s);
if (Math.abs(denom) > 1e-12) {
const t = cross(sub(q[j], p), s) / denom;
const u = cross(sub(q[j], p), r) / denom;
if (t >= 0 && t <= 1 && u >= 0 && u <= 1) cuts.push(i + t);
}
}
for (const end of [q[0], q[q.length - 1]]) {
const t = touchAt(end, p, pts[i + 1]);
if (t !== null) cuts.push(i + t);
}
});
}
const at = (c: number): Pt => {
const i = Math.min(Math.floor(c), pts.length - 2);
const t = c - i;
return [
pts[i][0] + t * (pts[i + 1][0] - pts[i][0]),
pts[i][1] + t * (pts[i + 1][1] - pts[i][1]),
];
};
const marks = [0, ...cuts.sort((a, b) => a - b), pts.length - 1];
for (let k = 0; k + 1 < marks.length; k++) {
const [c0, c1] = [marks[k], marks[k + 1]];
const inner = pts.slice(Math.floor(c0) + 1, Math.ceil(c1));
const points = [at(c0), ...inner, at(c1)];
if (pathLength(points) >= MIN_PIECE_M) out.push({ line: line.line, points });
}
});
return out;
}
/** 조각 → 저장 꼴(두 끝 · 0.1 mm 반올림). */
export function pieceSource(piece: SplitPiece): CrossPickPiece {
const round = (p: Pt): Pt => [Math.round(p[0] * 1e4) / 1e4, Math.round(p[1] * 1e4) / 1e4];
return {
line: piece.line,
start: round(piece.points[0]),
end: round(piece.points[piece.points.length - 1]),
};
}
@@ -0,0 +1,245 @@
/* =============================================================================
* B06_Section_UI_Cross_Detail_Select.ts
* [상세] 그림 선 고르기(209-1) — 설계선 · 구조물 선 · 원지반선 · 사토장선을 만나는 점에서 조각으로
* 나눠(`_Cross_Detail_Geom.splitAtCrossings`) 조각마다 투명 판정 선 · 누름 = 하나 고름 ·
* Shift/Ctrl+누름 = 더하기/빼기 · 고른 것 값 = [+ − ⤢] 왼쪽 두 줄(카드 179-1 · 195-1 꼴):
* 하나 = 길이 · 경사비율 / 수평 · 높이 · 여럿 = 길이 합 / 닫힌 넓이(안 닫히면 「닫히지 않음」).
* 칸에 넣기 = 표 우클릭 「그래프에서 고르기」 동안만(`_Cross_Detail_Table` 이 [칸에 넣기] · Enter 로 씀).
* ponytail: 그림을 다시 그리면(칸 크기 · 카드 다시 그림) 고름을 지움 — 조각 키 기억은 필요할 때.
* ========================================================================== */
import type { CrossDesign, CrossPickPiece } from "./B06_Section_Api_Types";
import type { CrossAxesOptions } from "./B06_Section_UI_Cross_Axes";
import { CROSS_PAD } from "./B06_Section_UI_Section_Common";
import {
DESIGN_LINE_ATTR,
mergeStraightPieces,
pieceLabel,
} from "./B06_Section_UI_Cross_Design_Pick";
import {
pathLength,
pieceSource,
ringArea,
splitAtCrossings,
type Pt,
type SplitPiece,
} from "./B06_Section_UI_Cross_Detail_Geom";
const SVG_NS = "http://www.w3.org/2000/svg";
export const PIECE_HIT_CLASS = "b06-detail__piece";
/** 화면 px → m(카드 `x` · `toDisplayY` 의 역 · 축 옵션). */
export function pxToM(axes: CrossAxesOptions): (p: number[]) => Pt {
return ([px, py]) => {
const display = axes.displayMax - (py - CROSS_PAD.top) / axes.pixelsPerMeter;
return [
axes.maxOffset - (px - CROSS_PAD.left) / axes.pixelsPerMeter,
axes.elevationMid + (display - axes.elevationMid) / axes.exaggeration,
];
};
}
function mToPx(axes: CrossAxesOptions): (p: Pt) => string {
return ([offset, elevation]) => {
const display = axes.elevationMid + (elevation - axes.elevationMid) * axes.exaggeration;
const x = CROSS_PAD.left + (axes.maxOffset - offset) * axes.pixelsPerMeter;
const y = CROSS_PAD.top + (axes.displayMax - display) * axes.pixelsPerMeter;
return `${x},${y}`;
};
}
function pointsOf(el: Element): number[][] {
if (el.tagName.toLowerCase() === "line") {
const n = (name: string) => Number(el.getAttribute(name));
return [
[n("x1"), n("y1")],
[n("x2"), n("y2")],
];
}
return (el.getAttribute("points") ?? "")
.trim()
.split(/\s+/)
.map((pair) => pair.split(",").map(Number))
.filter((p) => p.length === 2 && p.every(Number.isFinite));
}
/** 그림 도형 층 요소 → 고를 조각(m) — 설계 · 구조물 선은 같은 방향 토막을 직선 하나로 합친 뒤 나눔. */
export function collectPieces(elements: Element[], axes: CrossAxesOptions): SplitPiece[] {
const toM = pxToM(axes);
const runs: Parameters<typeof mergeStraightPieces>[0] = [];
const others: SplitPiece[] = [];
for (const el of elements) {
const points = pointsOf(el).map(toM);
if (points.length < 2) continue;
if (el.classList.contains("b06-chart__design-cross"))
runs.push({
points: points.map(([offset, elevation]) => ({ offset, elevation })),
structure: !el.hasAttribute(DESIGN_LINE_ATTR),
});
else if (el.classList.contains("b06-chart__cross-profile"))
others.push({ line: "ground", points });
else if (el.classList.contains("b06-chart__spoil-fill")) others.push({ line: "spoil", points });
}
const straight = mergeStraightPieces(runs).map((piece): SplitPiece => ({
line: piece.structure ? "structure" : "design",
points: [
[piece.from.offset, piece.from.elevation],
[piece.to.offset, piece.to.elevation],
],
}));
return splitAtCrossings([...straight, ...others]);
}
/** 고름 바꿈 — 더하기(Shift/Ctrl) = 넣고 빼기 · 아니면 그 하나(다시 누르면 비움). */
export function toggleSelection(selected: number[], index: number, additive: boolean): number[] {
if (additive)
return selected.includes(index) ? selected.filter((i) => i !== index) : [...selected, index];
return selected.length === 1 && selected[0] === index ? [] : [index];
}
const LINE_NAME: Record<SplitPiece["line"], string> = {
design: "설계선",
structure: "구조물 선",
ground: "원지반선",
spoil: "사토장선",
};
/** 고른 것 값 두 줄 — 하나 = 길이 · 경사비율 / 수평 · 높이(카드 꼴) · 여럿 = 길이 합 / 넓이. */
export function selectionLabel(design: CrossDesign | undefined, pieces: SplitPiece[]): string {
if (!pieces.length) return "";
if (pieces.length === 1) {
const [piece] = pieces;
const from = piece.points[0];
const to = piece.points[piece.points.length - 1];
if (design && piece.points.length === 2 && piece.line !== "ground" && piece.line !== "spoil")
return pieceLabel(design, {
from: { offset: from[0], elevation: from[1] },
to: { offset: to[0], elevation: to[1] },
structure: piece.line === "structure",
});
return (
`${LINE_NAME[piece.line]} 길이 ${pathLength(piece.points).toFixed(2)}m\n` +
`수평 ${Math.abs(to[0] - from[0]).toFixed(2)}m · 높이 ${Math.abs(to[1] - from[1]).toFixed(2)}m`
);
}
const total = pieces.reduce((sum, piece) => sum + pathLength(piece.points), 0);
const area = ringArea(pieces.map((piece) => piece.points));
return (
`${pieces.length}개 · 길이 합 ${total.toFixed(2)}m\n` +
(area === null ? "면적 닫히지 않음" : `면적 ${area.toFixed(2)}㎡`)
);
}
/** 고른 조각 → 칸 출처(209-1 저장 꼴) — 면적은 닫혀야 · 길이는 하나 이상. */
export function selectionSource(
kind: "area" | "length",
pieces: SplitPiece[],
): { src: "area" | "length"; pieces: CrossPickPiece[] } | null {
if (!pieces.length) return null;
if (kind === "area" && ringArea(pieces.map((piece) => piece.points)) === null) return null;
return { src: kind, pieces: pieces.map(pieceSource) };
}
/* ── 모달 배선 ─────────────────────────────────────────────────────────── */
export interface DetailSelect {
/** 그림을 새로 그린 뒤 — 조각 · 판정 선 · 값 칸 다시(고름 지움). */
redraw: () => void;
/** 고르기 갈래(표 우클릭) — 있으면 [칸에 넣기] 단추 · 면적이면 누름 = 늘 더하기. */
setMode: (kind: "area" | "length" | null) => void;
source: (kind: "area" | "length") => ReturnType<typeof selectionSource>;
clear: () => void;
detach: () => void;
}
export function attachDetailSelect(input: {
cad: HTMLElement;
design: () => CrossDesign | undefined;
axes: () => CrossAxesOptions | null;
/** 조각 누름 뒤(더하기 여부) — 표가 길이 고르기 한 번 누름이면 바로 칸에 넣음. */
picked: (additive: boolean) => void;
/** [칸에 넣기] 단추. */
commit: () => void;
}): DetailSelect {
const { cad } = input;
let pieces: SplitPiece[] = [];
let hits: SVGPolylineElement[] = [];
let selected: number[] = [];
let mode: "area" | "length" | null = null;
const label = document.createElement("span");
label.className = "b06-cross-card__line-dims b06-detail__measure";
const put = document.createElement("button");
put.type = "button";
put.className = "b06-cross-card__zoom-btn b06-detail__commit";
put.textContent = "칸에 넣기";
put.title = "고른 선을 칸에 넣음(Enter)";
put.addEventListener("click", (event) => {
event.stopPropagation();
input.commit();
});
const show = (): void => {
const chosen = selected.map((i) => pieces[i]);
hits.forEach((hit, i) => hit.classList.toggle("is-selected", selected.includes(i)));
label.textContent = selectionLabel(input.design(), chosen);
label.hidden = !chosen.length;
put.hidden = !mode || !selectionSource(mode, chosen);
// [+ − ⤢] 왼쪽(카드 꼴) — 그림을 다시 그리면 막대가 새로 생김
const bar = cad.querySelector(".b06-cross-card__zoom");
if (bar && label.parentElement !== bar) bar.prepend(label, put);
};
const onClick = (event: MouseEvent): void => {
const hit = (event.target as Element).closest?.(`.${PIECE_HIT_CLASS}`);
const additive = event.shiftKey || event.ctrlKey || event.metaKey || mode === "area";
if (!hit) {
if (!additive && !mode && selected.length) {
selected = [];
show();
}
return;
}
event.stopPropagation();
selected = toggleSelection(selected, hits.indexOf(hit as SVGPolylineElement), additive);
show();
input.picked(additive);
};
cad.addEventListener("click", onClick);
return {
redraw() {
selected = [];
const axes = input.axes();
const layer = cad.querySelector<SVGGElement>("g.b06-chart__plot-layer");
pieces = axes && layer ? collectPieces([...layer.querySelectorAll("*")], axes) : [];
const toPx = axes ? mToPx(axes) : null;
hits = toPx
? pieces.map((piece) => {
const hit = document.createElementNS(SVG_NS, "polyline");
hit.setAttribute("points", piece.points.map(toPx).join(" "));
hit.setAttribute("class", `${PIECE_HIT_CLASS} ${PIECE_HIT_CLASS}--${piece.line}`);
layer?.append(hit);
return hit;
})
: [];
show();
},
setMode(kind) {
mode = kind;
show();
},
source: (kind) =>
selectionSource(
kind,
selected.map((i) => pieces[i]),
),
clear() {
selected = [];
show();
},
detach() {
cad.removeEventListener("click", onClick);
},
};
}
@@ -6,7 +6,9 @@
* 값은 서버(`B07_DesignDetail_Engine_Cross_Table_Edit`)가 그릴 때마다 다시 셈 — 모달 · B07 도면 표 한 벌.
* 조작 직후 그 칸 글만 `localCellValue` 로 즉시 — 짝(① 파이썬 · TS) · 거울 시험
* `resources/tester/test_179_4_detail_table.py`.
* 고르기: 면적 = 면적 띠 · 사토장(띠 → 표 키) · 길이 = 보라 판정 선 두 끝(화면 px → m · 축 옵션).
* 고르기: 면적 = 면적 띠 · 사토장(띠 → 표 키) 또는 닫힌 선 조각들 · 길이 = 선 조각(들) —
* 조각 = 선이 만나는 점에서 나눈 것(209-1 · `_Cross_Detail_Select`) · 저장 = 조각 두 끝 목록.
* 길이는 한 번 누름 = 바로 칸에 · Shift/Ctrl+누름 = 여럿 → Enter · [칸에 넣기].
* ========================================================================== */
import { readState, writeState } from "../A00_Common/b_page_state";
@@ -17,8 +19,10 @@ import type {
CrossDetailTable,
CrossSection,
} from "./B06_Section_Api_Fetch";
import type { SectionSample } from "./B06_Section_Api_Types";
import type { CrossAxesOptions } from "./B06_Section_UI_Cross_Axes";
import { CROSS_PAD } from "./B06_Section_UI_Section_Common";
import { pickLines, piecesValue } from "./B06_Section_UI_Cross_Detail_Geom";
import { attachDetailSelect, PIECE_HIT_CLASS } from "./B06_Section_UI_Cross_Detail_Select";
/** 서버 `MAX_ROWS` · `MAX_COLUMNS` · `LINE_TOLERANCE_M` 와 같은 값. */
export const MAX_ROWS = 12;
@@ -162,7 +166,6 @@ const AREA_BANDS: ReadonlyArray<[string, string]> = [
["b06-chart__spoil-fill", "spoil_fill"],
];
export const AREA_PICK_SELECTOR = ".b06-chart__area, .b06-chart__spoil-fill";
export const LENGTH_PICK_SELECTOR = ".b06-chart__design-hit";
export function areaSourceOf(target: Element): CrossCellSource | null {
const hit = target.closest(AREA_PICK_SELECTOR);
@@ -170,27 +173,6 @@ export function areaSourceOf(target: Element): CrossCellSource | null {
return band ? { src: "area", key: band[1] } : null;
}
const round4 = (value: number): number => Math.round(value * 1e4) / 1e4;
/** 판정 선 두 끝 → [offset, 표고](m) — 도형 층 좌표(카드 `x` · `toDisplayY` 의 역). */
export function lengthSourceOf(target: Element, axes: CrossAxesOptions): CrossCellSource | null {
const hit = target.closest(LENGTH_PICK_SELECTOR);
const points = (hit?.getAttribute("points") ?? "")
.trim()
.split(/\s+/)
.map((pair) => pair.split(",").map(Number));
if (points.length < 2 || points.some((p) => p.length !== 2 || !p.every(Number.isFinite)))
return null;
const toM = ([px, py]: number[]): [number, number] => {
const display = axes.displayMax - (py - CROSS_PAD.top) / axes.pixelsPerMeter;
return [
round4(axes.maxOffset - (px - CROSS_PAD.left) / axes.pixelsPerMeter),
round4(axes.elevationMid + (display - axes.elevationMid) / axes.exaggeration),
];
};
return { src: "length", start: toM(points[0]), end: toM(points[points.length - 1]) };
}
/* ── 칸 값(짝 ① · 서버 `cell_value` 거울) ─────────────────────────────────── */
const AREA_DESIGN: Readonly<Record<string, string>> = {
@@ -219,9 +201,15 @@ function onLine([px, py]: [number, number], line: ReadonlyArray<[number, number]
return false;
}
/** 조작 직후 칸 숫자(서버가 [저장] · 다시 받을 때 정본으로 다시 셈) — 글 · 못 셈 = null. */
export function localCellValue(source: CrossCellSource, design: CrossDesign | undefined) {
/** 조작 직후 칸 숫자(서버가 [저장] · 다시 받을 때 정본으로 다시 셈) — 글 · 못 셈 = null.
* `samples` = 측점 원지반(조각 검산 · 209-1). */
export function localCellValue(
source: CrossCellSource,
design: CrossDesign | undefined,
samples?: ReadonlyArray<SectionSample>,
) {
const record = (design ?? {}) as unknown as Record<string, unknown>;
if ("pieces" in source) return piecesValue(source.src, source.pieces, pickLines(design, samples));
if (source.src === "area") return finite(record[AREA_DESIGN[source.key] ?? ""]);
if (source.src !== "length") return null;
const line = (design?.design_line ?? [])
@@ -292,12 +280,16 @@ export interface DetailTableHost {
let menu: ReturnType<typeof createMapContextMenu> | null = null;
const PICK_HINT = {
area: "면적 띠를 누름 · Esc 취소",
length: "보라 선을 누름 · Esc 취소",
area: "면적 띠를 누름 · 또는 닫힌 영역의 선들을 차례로 누름 → Enter · Esc 취소",
length: "선을 누름 · Shift/Ctrl+누름 = 여럿 → Enter · Esc 취소",
} as const;
/** 표 우클릭 · 그래프 고르기 · 더한 칸 글 손질을 검. 돌려준 함수 = 떼기(모달 닫힐 때). */
export function attachDetailTable(host: DetailTableHost): () => void {
/** 표 우클릭 · 그래프 고르기 · 더한 칸 글 손질을 검. detach = 떼기(모달 닫힐 때) ·
* redraw = 그림을 새로 그린 뒤(선 조각 · 판정 선 다시 · 209-1). */
export function attachDetailTable(host: DetailTableHost): {
detach: () => void;
redraw: () => void;
} {
menu ??= createMapContextMenu("b06-detail");
const ctxMenu = menu;
if (!ctxMenu.element.isConnected) {
@@ -318,9 +310,30 @@ export function attachDetailTable(host: DetailTableHost): () => void {
picking = null;
delete cad.dataset.pick;
delete cad.dataset.pickHint;
select.setMode(null);
document.removeEventListener("keydown", onKey, true);
};
/** 고른 출처를 칸에 — 칸 글 즉시(짝 ①) · 세션 초안 → 서버 표 다시 받음. */
const commit = (source: CrossCellSource | null): void => {
if (!picking || !source) return;
const { key } = picking;
stopPick();
select.clear();
const section = host.section();
const shown = localCellValue(source, section.design, section.samples);
const td = tableBox.querySelector<HTMLElement>(`td[data-cell="${CSS.escape(key)}"]`);
if (td) td.textContent = shown === null ? "다시 고름" : shown.toFixed(2);
edit((table) => setCell(table, key, source));
};
const commitSelection = (): void => {
if (picking) commit(select.source(picking.kind));
};
const onKey = (event: KeyboardEvent): void => {
if (event.key === "Enter") {
event.preventDefault();
commitSelection();
return;
}
if (event.key !== "Escape") return;
event.stopPropagation(); // 모달은 그대로 — 고르기만 취소
stopPick();
@@ -329,29 +342,30 @@ export function attachDetailTable(host: DetailTableHost): () => void {
picking = { kind, key };
cad.dataset.pick = kind;
cad.dataset.pickHint = PICK_HINT[kind];
select.setMode(kind);
document.addEventListener("keydown", onKey, true);
};
// 선 조각 고름(209-1) — 길이 한 번 누름(더하기 아님) = 바로 칸에
const select = attachDetailSelect({
cad,
design: () => host.section().design,
axes: host.axes,
picked: (additive) => {
if (picking?.kind === "length" && !additive) commitSelection();
},
commit: commitSelection,
});
// 면적 띠 · 사토장 누름 = 그 띠 면적(179-4 그대로) — 선 조각 누름은 위가 맡음
const onPick = (event: MouseEvent): void => {
if (!picking) return;
const selector = picking.kind === "area" ? AREA_PICK_SELECTOR : LENGTH_PICK_SELECTOR;
// 겹친 요소(띠 위 판정 선 등) 아래까지 — 그 자리 맨 위 대상
if (picking?.kind !== "area" || (event.target as Element).closest(`.${PIECE_HIT_CLASS}`))
return;
const hit = document
.elementsFromPoint(event.clientX, event.clientY)
.find((el) => cad.contains(el) && el.closest(selector));
const axes = host.axes();
const source = !hit
? null
: picking.kind === "area"
? areaSourceOf(hit)
: axes && lengthSourceOf(hit, axes);
.find((el) => cad.contains(el) && el.closest(AREA_PICK_SELECTOR));
const source = hit ? areaSourceOf(hit) : null;
if (!source) return;
event.stopPropagation();
const { key } = picking;
stopPick();
const shown = localCellValue(source, host.section().design);
const td = tableBox.querySelector<HTMLElement>(`td[data-cell="${CSS.escape(key)}"]`);
if (td) td.textContent = shown === null ? "다시 고름" : shown.toFixed(2);
edit((table) => setCell(table, key, source));
commit(source);
};
cad.addEventListener("click", onPick);
@@ -401,12 +415,16 @@ export function attachDetailTable(host: DetailTableHost): () => void {
tableBox.addEventListener("focusout", onBlur);
tableBox.addEventListener("keydown", onEnter);
return () => {
stopPick();
ctxMenu.close();
cad.removeEventListener("click", onPick);
tableBox.removeEventListener("contextmenu", onMenu);
tableBox.removeEventListener("focusout", onBlur);
tableBox.removeEventListener("keydown", onEnter);
return {
detach: () => {
stopPick();
ctxMenu.close();
select.detach();
cad.removeEventListener("click", onPick);
tableBox.removeEventListener("contextmenu", onMenu);
tableBox.removeEventListener("focusout", onBlur);
tableBox.removeEventListener("keydown", onEnter);
},
redraw: () => select.redraw(),
};
}
@@ -38,6 +38,7 @@ import {
} from "../common_util/common_util_summary_item";
import { readStructurePick } from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
import {
coversAtStation,
coversChainage,
NO_SELECTION,
PIPE_MATCH_M,
@@ -163,7 +164,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
let markTypes: StructureType[] = [];
/** 카드 누름이 덮어 여는 구조물 — 노면공(G군)은 뺌(152-5 · 목록 · 알약 고름은 id 길로 그대로). */
const coverable = (entry: StructureInstance): boolean =>
markTypes.find((type) => type.type_id === entry.type_id)?.group !== "G";
coversAtStation(markTypes.find((type) => type.type_id === entry.type_id)?.group);
/** 다음 카드 고름이 열 구조물 — 같은 측점에 관이 있어도 누른 것이 먼저다(52-15). */
let preferredId: string | null = null;
/** 그 구조물로 갈 카드 측점 — 구간형은 범위 안 카드 중 기준점에 가까운 것(경계 기준점이 범위 밖 카드로 안 감 · 165-2). */
@@ -164,11 +164,42 @@
/* 그래프에서 고르기 — 고를 대상만 손가락 · 왼쪽 아래(눈금 자리) 안내 글 */
.b06-detail__cad[data-pick="area"] .b06-chart__area > polygon,
.b06-detail__cad[data-pick="area"] .b06-chart__spoil-fill,
.b06-detail__cad[data-pick="length"] .b06-chart__design-hit {
.b06-detail__cad[data-pick="area"] .b06-chart__spoil-fill {
cursor: pointer;
}
/* 선 조각 판정 선(209-1) — 투명 굵은 선 · 배율 무관 굵기 · 고른 조각 = 붉은 띠 */
.b06-detail__piece {
fill: none;
stroke: transparent;
stroke-width: 10;
stroke-linecap: round;
vector-effect: non-scaling-stroke;
pointer-events: stroke;
cursor: pointer;
}
.b06-detail__piece:hover {
stroke: color-mix(in srgb, var(--color-accent) 25%, transparent);
}
.b06-detail__piece.is-selected {
stroke: color-mix(in srgb, var(--color-danger) 55%, transparent);
stroke-width: 6;
}
/* 고른 것 값(카드 179-1 꼴) · [칸에 넣기] — [+ − ⤢] 왼쪽 */
.b06-detail__commit {
width: auto;
padding: 0 var(--spacing-4);
color: var(--color-accent);
white-space: nowrap;
}
.b06-detail__commit[hidden] {
display: none;
}
.b06-detail__cad[data-pick]::after {
content: attr(data-pick-hint);
position: absolute;
@@ -6,11 +6,17 @@
area = 설계 면적 칸 그대로(`AREA_KEYS` · 띠 키 = 표 키)
length = 두 끝(m · offset · 표고)이 설계선 위면 hypot · 아니면 비움 → 「다시 고름」
text = 글 그대로 — 더한 칸(`r:` 행 · `c:` 열)만
고른 조각(209-1 · `pieces` = [{line, start, end}]) — 교차점에서 나눈 선 조각 목록:
length = 조각 길이 합 · area = 조각이 닫힌 고리를 이루면 그 넓이(안 닫힘 → 「다시 고름」)
조각 길 = 그 선(설계선 · 원지반선 · 사토장선) 위 두 끝 사이 꺾은선
(두 끝이 선 밖 → 「다시 고름」)
· 구조물 선(`structure` · 브라우저 기하)은 위치 검산 없이 두 끝 직선.
행(`rows`) = 묶음 안(이 행 아래 · 묶음이면 하위 줄) 또는 끝에 항목 줄(좌 · 중 · 우 이름 + 값).
열(`columns`) = 오른쪽 끝 · 모든 행(비고 등).
짝(① 파이썬 · TS) = `B06_Section/B06_Section_UI_Cross_Detail_Table.ts` `localCellValue`
(조작 중 즉시 표시) · 거울 시험 `resources/tester/test_179_4_detail_table.py`.
(조작 중 즉시 표시) · 조각 길 · 넓이 = `B06_Section/B06_Section_UI_Cross_Detail_Geom.ts` ·
거울 시험 `resources/tester/test_179_4_detail_table.py` · `test_209_1_detail_select.py`.
"""
from __future__ import annotations
@@ -46,9 +52,9 @@ def _point(value: Any) -> tuple[float, float] | None:
return (x, y) if x is not None and y is not None else None
def _design_line(design: dict[str, Any]) -> list[tuple[float, float]]:
def _polyline(raw: Any) -> list[tuple[float, float]]:
points = []
for point in design.get("design_line") or []:
for point in raw if isinstance(raw, list) else []:
if isinstance(point, dict):
x, y = _num(point.get("offset_m")), _num(point.get("elevation_m"))
if x is not None and y is not None:
@@ -56,6 +62,24 @@ def _design_line(design: dict[str, Any]) -> list[tuple[float, float]]:
return points
def _design_line(design: dict[str, Any]) -> list[tuple[float, float]]:
return _polyline(design.get("design_line"))
def ground_runs(samples: Any) -> list[list[tuple[float, float]]]:
"""원지반선 — 무효 표고 샘플마다 끊은 꺾은선들(카드 `appendGroundProfile` 과 같은 규칙)."""
runs: list[list[tuple[float, float]]] = [[]]
for sample in samples if isinstance(samples, list) else []:
sample = sample if isinstance(sample, dict) else {}
x = _num(sample.get("offset_m"))
y = _num(sample.get("elevation_m", sample.get("z")))
if sample.get("valid") is False or x is None or y is None:
runs.append([])
else:
runs[-1].append((x, y))
return [run for run in runs if len(run) > 1]
def _on_line(point: tuple[float, float], line: list[tuple[float, float]]) -> bool:
"""점이 설계선(꺾은선) 조각 하나에서 `LINE_TOLERANCE_M` 안인가."""
px, py = point
@@ -68,12 +92,119 @@ def _on_line(point: tuple[float, float], line: list[tuple[float, float]]) -> boo
return False
Point = tuple[float, float]
def _locate(point: Point, run: list[Point]) -> float | None:
"""선 위 자리(앞 끝에서 잰 길이 m) — 허용 안 가장 가까운 곳 · 없으면 None."""
best: tuple[float, float] | None = None
along = 0.0
for (ax, ay), (bx, by) in zip(run, run[1:]):
dx, dy = bx - ax, by - ay
size = hypot(dx, dy)
t = 0.0 if size == 0 else ((point[0] - ax) * dx + (point[1] - ay) * dy) / size**2
t = max(0.0, min(1.0, t))
gap = hypot(point[0] - (ax + t * dx), point[1] - (ay + t * dy))
if gap <= LINE_TOLERANCE_M and (best is None or gap < best[0]):
best = (gap, along + t * size)
along += size
return best[1] if best else None
def _at(run: list[Point], s: float) -> Point:
"""선 위 자리 s(m) 의 점."""
for (ax, ay), (bx, by) in zip(run, run[1:]):
size = hypot(bx - ax, by - ay)
if s <= size:
t = 0.0 if size == 0 else s / size
return (ax + t * (bx - ax), ay + t * (by - ay))
s -= size
return run[-1]
def sub_path(run: list[Point], s0: float, s1: float) -> list[Point]:
"""선 위 두 자리 사이 꺾은선(s0 → s1 차례) — 사이 꼭짓점 포함."""
lo, hi = min(s0, s1), max(s0, s1)
inner: list[Point] = []
along = 0.0
for a, b in zip(run, run[1:]):
along += hypot(b[0] - a[0], b[1] - a[1])
if lo < along < hi:
inner.append(b)
path = [_at(run, lo), *inner, _at(run, hi)]
return path if s0 <= s1 else path[::-1]
def piece_path(piece: Any, lines: dict[str, list[list[Point]]]) -> list[Point] | None:
"""고른 조각 하나 → 그 선 위 꺾은선 · 두 끝이 선 밖이면 None(구조물 선 = 두 끝 직선)."""
if not isinstance(piece, dict):
return None
start, end = _point(piece.get("start")), _point(piece.get("end"))
if not start or not end:
return None
if piece.get("line") == "structure":
return [start, end]
for run in lines.get(str(piece.get("line")), []):
s0, s1 = _locate(start, run), _locate(end, run)
if s0 is not None and s1 is not None:
return sub_path(run, s0, s1)
return None
def path_length(path: list[Point]) -> float:
return sum(hypot(b[0] - a[0], b[1] - a[1]) for a, b in zip(path, path[1:]))
def _near(a: Point, b: Point) -> bool:
return hypot(a[0] - b[0], a[1] - b[1]) <= LINE_TOLERANCE_M
def ring_area(paths: list[list[Point]]) -> float | None:
"""조각 길들을 끝끼리 이어 한 고리가 되면 그 넓이(㎡) · 안 닫힘 · 남는 조각 = None."""
if not paths:
return None
ring = list(paths[0])
rest = [list(path) for path in paths[1:]]
while rest:
index = next(
(i for i, p in enumerate(rest) if _near(p[0], ring[-1]) or _near(p[-1], ring[-1])),
None,
)
if index is None:
return None
path = rest.pop(index)
ring += (path if _near(path[0], ring[-1]) else path[::-1])[1:]
if len(ring) < 4 or not _near(ring[0], ring[-1]):
return None
ring.append(ring[0]) # 끝이 허용 안에서만 맞음 — 닫는 변을 꼭 넣음
area = abs(sum(a[0] * b[1] - b[0] * a[1] for a, b in zip(ring, ring[1:]))) / 2
return area if area > 1e-9 else None
def pick_lines(design: dict[str, Any], samples: Any = None) -> dict[str, list[list[Point]]]:
"""서버가 아는 선 — 고른 조각 위치 검산 대상(설계선 · 원지반선 · 사토장선)."""
return {
"design": [_design_line(design)],
"ground": ground_runs(samples),
"spoil": [_polyline(design.get("spoil_fill_line"))],
}
def cell_value(
source: dict[str, Any], design: dict[str, Any] | None
source: dict[str, Any], design: dict[str, Any] | None, samples: Any = None
) -> tuple[float | None, str | None, bool]:
"""출처 하나 → (숫자, 글, 다시 고름)."""
"""출처 하나 → (숫자, 글, 다시 고름). `samples` = 측점 원지반 샘플(조각 검산)."""
design = design if isinstance(design, dict) else {}
kind = source.get("src")
pieces = source.get("pieces")
if kind in ("length", "area") and isinstance(pieces, list):
lines = pick_lines(design, samples)
paths = [piece_path(piece, lines) for piece in pieces]
valid = [path for path in paths if path is not None]
if not valid or len(valid) != len(paths):
return None, None, True
number = sum(map(path_length, valid)) if kind == "length" else ring_area(valid)
return number, None, number is None
if kind == "area":
return _num(design.get(_AREA_DESIGN.get(str(source.get("key")), ""))), None, False
if kind == "length":
@@ -89,9 +220,9 @@ def cell_value(
def edited_cells(
design: dict[str, Any] | None,
design: dict[str, Any] | None, samples: Any = None
) -> tuple[dict[str, float | None], dict[str, str], list[str]]:
"""patch 칸 출처 → (숫자 칸, 글 칸, 다시 고를 칸). 글은 더한 칸만."""
"""patch 칸 출처 → (숫자 칸, 글 칸, 다시 고를 칸). 글은 더한 칸만 · `samples` = 원지반."""
values: dict[str, float | None] = {}
texts: dict[str, str] = {}
repick: list[str] = []
@@ -99,7 +230,7 @@ def edited_cells(
for key, source in (cells if isinstance(cells, dict) else {}).items():
if not isinstance(source, dict):
continue
number, text, again = cell_value(source, design)
number, text, again = cell_value(source, design, samples)
if text is not None:
if key[:2] in ("r:", "c:"):
texts[key] = text
@@ -315,7 +315,7 @@ def _quantity_table(
for key in QUANTITY_VALUE_KEYS:
table.setdefault(key, num(quantities.get(key)))
# [상세] 표 손질(179-4) — 고른 칸 · 더한 칸을 서버가 다시 셈(모달 · B07 도면 한 벌)
table.update(edited_cells(design)[0])
table.update(edited_cells(design, source.get("samples"))[0])
return table
@@ -1,13 +1,30 @@
/** 화면 우하단 탐색 막대 (줌 표시·전체 보기) */
/** 화면 우하단 탐색 막대 (줌 표시·전체 보기) — 제목줄이 없는 화면(M02)은 되돌리기 · 다시 하기도 */
import type { FC } from 'react';
import { runCommandInput } from '../commands/run-command';
import { getScreenCanvasDrawController } from '../state';
import {
getScreenCanvasDrawController,
isDrawingReadOnly,
isHostSave,
isViewOnly,
} from '../state';
export const ViewControls: FC = () => {
const zoom = getScreenCanvasDrawController()?.getScreenScale() ?? 1;
// 제목줄(빠른 막대)이 없는 M02 양식 · 구조물 카드 — 고칠 수 있을 때만
const showUndo = isHostSave() && !isViewOnly() && !isDrawingReadOnly();
return (
<div className="cad-view-controls controls">
{showUndo && (
<>
<button type="button" onClick={() => runCommandInput('UNDO')} title="실행 취소 (Ctrl+Z)">
↶
</button>
<button type="button" onClick={() => runCommandInput('REDO')} title="다시 실행 (Ctrl+Y)">
↷
</button>
</>
)}
<button
type="button"
className="cad-view-controls__fit"
@@ -60,6 +60,7 @@ import {
isPlainSpace,
} from './ime-sink.ts';
import { drawInputField } from './input-field-draw.ts';
import { ctrlShortcut } from './input-shortcut.ts';
import { wheelZoom } from './input-wheel.ts';
import {
ABSOLUTE_POINT_REGEXP,
@@ -339,7 +340,9 @@ export class InputController {
if (target !== this.ime.element && target?.closest('input, textarea, select')) {
return;
}
if (isImeKey(evt)) return; // 한글 조합 중 — 단축키가 먹지 않게 IME 에 넘김
const shortcut = ctrlShortcut(evt);
// 한글 조합 중 — 단축키가 먹지 않게 IME 에 넘김(Ctrl 단축키는 조합 중에도 먹음)
if (isImeKey(evt) && !shortcut) return;
console.log(`key pressed: ${evt.key}`);
if (evt.key === 'F12') {
// F12 => open developer tools
@@ -386,19 +389,16 @@ export class InputController {
}
evt.preventDefault();
evt.stopPropagation();
if (evt.ctrlKey && !evt.shiftKey && evt.key === 'z') {
if (shortcut === 'undo') {
// User wants to undo the last action
this.handleUndo(evt);
} else if (evt.ctrlKey && evt.shiftKey && evt.key === 'z') {
} else if (shortcut === 'redo') {
// User wants to redo the last action
this.handleRedo(evt);
} else if (evt.ctrlKey && evt.key === 'y') {
// User wants to redo the last action
this.handleRedo(evt);
} else if (evt.ctrlKey && evt.key === 's') {
} else if (shortcut === 'save') {
// 부모 페이지가 저장을 맡은 화면(M02 양식)만 — B07 은 예전처럼 아무 일 없음
if (isHostSave()) requestHostSave();
} else if (evt.ctrlKey && evt.key === 'a') {
} else if (shortcut === 'selectAll') {
// User wants to select everything
setSelectedEntityIds(getPickableEntities().map((entity) => entity.id));
} else if (evt.key === 'Backspace') {
@@ -0,0 +1,24 @@
/** Ctrl 단축키 이름 */
export type CtrlShortcut = 'undo' | 'redo' | 'save' | 'selectAll';
/**
* Ctrl 단축키 판정 — 글자(`key`)가 아니라 자판 자리(`code`).
* 한글 자판은 `key` 가 「ㅋ」·「ㅛ」로 오고, Shift 를 누르면 `key` 가 대문자(「Z」)라 글자로는 안 맞는다.
*/
export function ctrlShortcut(
evt: Pick<KeyboardEvent, 'ctrlKey' | 'altKey' | 'shiftKey' | 'code'>
): CtrlShortcut | null {
if (!evt.ctrlKey || evt.altKey) return null;
switch (evt.code) {
case 'KeyZ':
return evt.shiftKey ? 'redo' : 'undo';
case 'KeyY':
return evt.shiftKey ? null : 'redo';
case 'KeyS':
return evt.shiftKey ? null : 'save';
case 'KeyA':
return evt.shiftKey ? null : 'selectAll';
default:
return null;
}
}
+8 -3
View File
@@ -187,11 +187,13 @@ def _revets(
sides: list[tuple[str, str | None]],
empty_height: float | None,
names: dict[str, str],
inlet_empty_height: float | None = None,
) -> None:
"""흙막이 — 유입 · 유출(흙막이일 때 · `sides`) + 흙막이 n(container_rows 가 선 단까지만 냄).
저장 키 = 필수는 `{쪽}_revet_{칸}_m` · 선택 흙막이는 `soil_add_{n}_{칸}`(명세 인스턴스 키) ·
빈 높이 = `empty_height`(None 이면 「미입력」 열) · 머리 글은 관 정의 이름(공용 열).
빈 높이 = `empty_height`(None 이면 「미입력」 열 · 관 유입 쪽만 `inlet_empty_height` · 211-1) ·
머리 글은 관 정의 이름(공용 열).
"""
sources = [(f"{side}_revet_", "_m", side, combo) for side, combo in sides]
sources += [
@@ -203,7 +205,9 @@ def _revets(
form = str(options.get(f"{head}form") or "")
height = _num(options.get(f"{head}height{unit}"))
if height is None:
height = empty_height
height = (
inlet_empty_height if container == "inlet" and inlet_empty_height else empty_height
)
height_text = fill._label("height_m", height)
sheet.add(
at,
@@ -588,7 +592,8 @@ def pipe_summary(
)
wall_options = {**options, **moved}
wall = pipe_wall_min_height_m(diameter / 1000) if diameter else None
_revets(sheet, at, wall_options, links, sources, wall, names)
wall_in = pipe_wall_min_height_m(diameter / 1000, "inlet") if diameter else None
_revets(sheet, at, wall_options, links, sources, wall, names, wall_in)
_chutes(sheet, at, options, links, names)
out = sheet.result()
+5 -10
View File
@@ -26,6 +26,8 @@ import master_keys as mk # noqa: E402
import master_text as mtx # noqa: E402
from M01_MasterData import M01_MasterData_Store_Cache as cache # noqa: E402
from M01_MasterData.M01_MasterData_Store_Find import hit as _hit # noqa: E402
from M01_MasterData.M01_MasterData_Store_Find import squash as _squash # noqa: E402
from M01_MasterData.M01_MasterData_Store_Shape import dump, keep_shape # noqa: E402
_COPY = os.environ.get("AISLO_MASTER_DIR") # 화면 저장 시험 사본 폴더(정함 13) · 없으면 정본
@@ -88,11 +90,6 @@ def chapter_of(file: str, data: dict | None = None) -> str:
return f"{division} {m.group(1)}장 {m.group(2).replace('_', ' ')}".strip()
def _hit(row: dict, q: str) -> bool:
q = _squash(q) # 띄어쓰기 무시 — 「골 함 석」 ↔ 「함석」
return not q or any(q in _squash(row.get(k, "")) for k in ("키", "원문번호", "이름", "옛이름"))
def _book(data: dict) -> str | None:
"""머리 원문 이름 — 한 테이블 파일(기계)은 머리 「원문」 이 갈래 묶음이라 이름이 묶음 안에."""
return data.get("원문") if isinstance(data.get("원문"), str) else None
@@ -367,6 +364,7 @@ def logics(sub: str, detail: str, q: str, blocked: int | None, owner: str = "")
"소유",
)
},
"별칭": row.get("별칭") or "", # 없으면 빈칸(208-1)
"blocked": bool(reasons),
"reasons": reasons,
}
@@ -449,17 +447,14 @@ def elements(group: str, q: str, limit: int) -> dict:
continue
for row in data.get(items_key(data)) or []:
if _hit(row, q):
hits.append({**_brief(str(row.get("키")), row), "file": name})
more = {"별칭": row.get("별칭") or ""} if group == "로직" else {} # 208-1
hits.append({**_brief(str(row.get("키")), row), "file": name, **more})
return {"total": len(hits), "items": hits[: min(max(limit, 1), 200)]}
_PICK = {"price": "재료_자재품목.json", "job": "인력.json"}
def _squash(text) -> str:
return "".join(str(text or "").lower().split())
def _lowest(row: dict) -> object:
"""자재품목 줄 — 값 열 다섯 가운데 낮은 값(시험 계산용) · 값 칸이 있는 줄은 그 값 그대로."""
if "값" in row:
@@ -0,0 +1,20 @@
"""찾기 맞춤 — 낱말(띄어 나눔)마다 키 · 원문번호 · 이름 · 옛이름 · 별칭 가운데 하나에 들면 맞음.
띄어쓰기 · 대소문자 무시 · 별칭 = 설계자가 로직을 찾는 다른 이름(쉼표로 여럿 · 208-1).
짝: `ui_template/ui_template_logic_find.ts` `matchLogic`(화면 쪽) ·
거울 시험 `test_208_1_logic_alias.py`.
"""
from __future__ import annotations
FIELDS = ("키", "원문번호", "이름", "옛이름", "별칭")
def squash(text) -> str:
return "".join(str(text or "").lower().split())
def hit(row: dict, q: str) -> bool:
words = [squash(w) for w in str(q or "").split()]
fields = [squash(row.get(k)) for k in FIELDS]
return all(any(w in f for f in fields) for w in words)
+28 -2
View File
@@ -135,7 +135,10 @@ export async function mountM01LogicLab(
const waiting = el("p", { className: "m01-logic__muted", text: tx("Loading") });
let loaded = false; // 첫 목록이 오기 전에는 「0 줄」 빈 표를 보이지 않음
const pending = el("span", { className: "m01-logic__muted" });
const list = buildList((item) => void open(item.id, item.sub, item.key));
const list = buildList(
(item) => void open(item.id, item.sub, item.key),
(item, alias) => void onAlias(item, alias),
);
const back = createButton({ label: tx("Bar_Back"), variant: "ghost", onClick: () => show(null) });
const persist = (): void => {
@@ -146,8 +149,10 @@ export async function mountM01LogicLab(
}
const marks = new Map<string, ListMark>();
const extra: ListItem[] = [];
const aliases = new Map<string, string>();
for (const [id, d] of Object.entries(drafts)) {
marks.set(id, d.origKey === null ? "new" : d.row === null ? "deleted" : "edited");
if (d.row) aliases.set(id, d.row.별칭 ?? "");
if (d.origKey === null && d.row) {
extra.push({
id,
@@ -156,6 +161,7 @@ export async function mountM01LogicLab(
key: d.row.키,
number: d.row.원문번호,
name: d.row.이름,
alias: d.row.별칭 ?? "",
unit: d.row.결과단위,
source: d.row.출처,
blocked: false,
@@ -163,7 +169,7 @@ export async function mountM01LogicLab(
});
}
}
list.setMarks(marks, extra);
list.setMarks(marks, extra, aliases);
const n = Object.keys(drafts).length;
pending.textContent = n ? tx("Bar_Pending", { n }) : "";
saveButton.disabled = discardButton.disabled = n === 0;
@@ -423,6 +429,26 @@ export async function mountM01LogicLab(
persist();
};
/** 목록 「별칭」 칸 고침(208-1) — 그 줄의 별칭만 바꿔 캐시에 · [저장] 때 다른 고침과 함께 나감 */
const onAlias = async (item: ListItem, alias: string): Promise<void> => {
const draft = drafts[item.id];
if (draft && !draft.row) return; // 지운 줄
try {
const one = draft?.origKey === null ? null : await fetchLogic(item.key);
const row = clone((draft?.row ?? one?.logic) as LogicRow);
if (alias) row.별칭 = alias;
else delete row.별칭;
if (one && JSON.stringify(row) === JSON.stringify(one.logic)) delete drafts[item.id];
else
drafts[item.id] = draft
? { ...draft, row }
: { sub: item.sub, file: one!.file, version: one!.version, origKey: item.key, row };
persist();
} catch (error) {
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
}
};
const onDelete = async (): Promise<void> => {
if (!opened?.row) return;
if (
@@ -42,6 +42,8 @@ export interface LogicRow {
덧줄?: NamedFormula[];
끝수?: string | null;
비고?: string;
/** 설계자가 찾는 다른 이름 — 쉼표로 여럿 · 없으면 칸 없음(208-1) */
별칭?: string;
[extra: string]: unknown;
}
@@ -54,6 +56,8 @@ export interface LogicSummary {
이름: string;
결과단위: string;
출처: string;
/** 없으면 빈칸(208-1) */
별칭?: string;
blocked: boolean;
reasons: string[];
}
@@ -66,6 +70,8 @@ export interface ElementBrief {
단위?: unknown;
값?: unknown;
값칸?: Record<string, string> | null;
/** 로직 그룹만 — 별칭(208-1) */
별칭?: string;
/** 재료 후보 줄 — 값 열 다섯(지역·계약종별별 값) */
값들?: Record<string, unknown>;
}
+56 -16
View File
@@ -2,9 +2,11 @@
* M01_MasterData_UI_Logic_List.ts
* 로직 목록 — 가운데 표 · 왼쪽 「전체」 + 구분 → 상세구분 거름이 고른 범위 + 이름 찾기
* 파일을 가로질러 한 목록 · 쪽 나눔 · 막힘(⛔)·고침(●) 표시 · 거르기는 화면에서(목록은 한 번 받음)
* 「별칭」 열(208-1) — 이름 옆 · 고치면 `onAlias`(캐시에 쌓고 [저장] 때 나감) · 찾기는 공용 `matchLogic`
* ========================================================================== */
import { createInputField, el } from "@ui/ui_template_elements";
import { aliasText, matchLogic } from "@ui/ui_template_logic_find";
import { buildPager } from "./M01_MasterData_UI_Cells";
import type { LogicSummary } from "./M01_MasterData_UI_Logic_Api";
import { tx } from "./M01_MasterData_UI_Logic_Text";
@@ -19,6 +21,8 @@ export interface ListItem {
key: string;
number: string;
name: string;
/** 쉼표로 여럿 · 없으면 빈칸 */
alias: string;
unit: string;
source: string;
blocked: boolean;
@@ -28,8 +32,8 @@ export interface ListItem {
export interface ListHandle {
root: HTMLElement;
setItems: (items: LogicSummary[]) => void;
/** 저장 안 한 새 로직도 목록 맨 위에 */
setMarks: (marks: Map<string, ListMark>, extra: ListItem[]) => void;
/** 저장 안 한 새 로직도 목록 맨 위에 · aliases = 캐시에 고친 별칭(id → 글) */
setMarks: (marks: Map<string, ListMark>, extra: ListItem[], aliases: Map<string, string>) => void;
/** 왼쪽 거름이 고른 범위 — 비면 전체 */
setPick: (sub: string, detail: string) => void;
total: () => number;
@@ -38,12 +42,27 @@ export interface ListHandle {
export const logicId = (sub: string, key: string): string => `${sub}\n${key}`;
const SIZE = 30;
const COLS = ["키", "원문번호", "구분", "상세구분", "이름", "결과 단위", "출처", ""] as const;
const COLS = [
"키",
"원문번호",
"구분",
"상세구분",
"이름",
"별칭",
"결과 단위",
"출처",
"",
] as const;
export function buildList(onOpen: (item: ListItem) => void): ListHandle {
export function buildList(
onOpen: (item: ListItem) => void,
onAlias: (item: ListItem, alias: string) => void,
): ListHandle {
let items: ListItem[] = [];
let extra: ListItem[] = [];
let marks = new Map<string, ListMark>();
let aliases = new Map<string, string>();
const aliasOf = (x: ListItem): string => aliases.get(x.id) ?? x.alias;
let page = 1;
const pick = { sub: "", detail: "" };
const searchField = createInputField({ type: "search", placeholder: tx("List_Search") });
@@ -51,12 +70,19 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle {
const grid = el("div", { className: "m01-master__grid-wrap" });
const draw = (): void => {
const q = search.value.trim().toLowerCase();
const q = search.value;
const shown = [...extra, ...items].filter(
(x) =>
(!pick.sub || x.sub === pick.sub) &&
(!pick.detail || x.detail === pick.detail) &&
(!q || [x.key, x.number, x.name].some((v) => v.toLowerCase().includes(q))),
matchLogic(q, {
키: x.key,
원문번호: x.number,
이름: x.name,
구분: x.sub,
상세구분: x.detail,
별칭: aliasOf(x),
}).ok,
);
page = Math.min(page, Math.max(1, Math.ceil(shown.length / SIZE)));
const rows = shown.slice((page - 1) * SIZE, page * SIZE).map(line);
@@ -87,24 +113,36 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle {
]
.filter(Boolean)
.join(" ");
const cell = (v: string): HTMLElement => el("td", { text: v, attrs: { title: v } });
const tr = el("tr", {
className: `m01-master__table-row${mark ? " is-changed" : ""}`,
children: [
item.key,
item.number,
item.sub,
item.detail,
item.name,
item.unit,
item.source,
state,
].map((v) => el("td", { text: v, attrs: { title: v } })),
...[item.key, item.number, item.sub, item.detail, item.name].map(cell),
aliasCell(item, mark === "deleted"),
...[item.unit, item.source, state].map(cell),
],
});
if (item.blocked) tr.title = item.reasons.join("\n");
tr.addEventListener("click", () => onOpen(item));
return tr;
};
/** 별칭 칸 — 눌러도 로직이 안 열림 · 고친 글은 「a, b」 꼴로 */
const aliasCell = (item: ListItem, locked: boolean): HTMLElement => {
const input = el("input", {
className: "m01-logic__alias",
attrs: { type: "text", value: aliasOf(item), placeholder: "—", title: tx("List_Alias") },
}) as HTMLInputElement;
input.disabled = locked;
input.addEventListener("click", (e) => e.stopPropagation());
input.addEventListener("change", () => {
const next = aliasText(input.value);
input.value = next;
if (next !== aliasOf(item)) onAlias(item, next);
});
return el("td", { children: [input] });
};
search.addEventListener("input", () => go(1));
let pager = buildPager(0, SIZE, 1, go);
@@ -128,6 +166,7 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle {
key: x.키,
number: x.원문번호,
name: x.이름,
alias: x.별칭 ?? "",
unit: x.결과단위,
source: x.출처,
blocked: x.blocked,
@@ -135,9 +174,10 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle {
}));
draw();
},
setMarks: (next, more) => {
setMarks: (next, more, edited) => {
marks = next;
extra = more;
aliases = edited;
draw();
},
setPick: (sub, detail) => {
+12 -2
View File
@@ -2,6 +2,7 @@
* M01_MasterData_UI_Logic_Pick.ts
* 고르기 두 가지
* ① 요소 찾기 창 — 그룹 전체에서 이름·키·원문번호로 찾아 호표 줄에 넣음(`GET /elements`)
* 로직 그룹은 별칭으로도 찾음(서버) · 별칭으로 맞은 줄은 그 별칭을 작게(공용 `matchLogic` · 208-1)
* 표 고르기(소요량·계수)는 값칸 단추를 눌러 `찾기(…).칸` 으로 수량에 넣음
* ② 재료·기계 바꿔 고르기 — 구분 → 상세구분 → 후보 순으로 좁힘
* 바꾼 것은 시험 계산용 복사본에만 · 정본 로직은 그대로
@@ -14,6 +15,7 @@ import {
el,
showToast,
} from "@ui/ui_template_elements";
import { aliasNote, matchLogic } from "@ui/ui_template_logic_find";
import {
fetchMaterials,
searchElements,
@@ -77,7 +79,11 @@ export function openPicker(mode: PickMode, group: string, done: PickDone): void
const found = await searchElements(select.value, search.value.trim());
if (mine !== seq) return; // 최신 응답만
count.textContent = tx("Pick_Total", { n: found.total });
list.replaceChildren(...found.items.map((item) => itemRow(item, mode, choose)));
const note = (item: ElementBrief): string =>
select.value === "로직"
? aliasNote(matchLogic(search.value, { ...item, 키: item.ref, 이름: item.이름 }).alias)
: "";
list.replaceChildren(...found.items.map((item) => itemRow(item, mode, choose, note(item))));
} catch (error) {
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
}
@@ -97,12 +103,16 @@ function itemRow(
item: ElementBrief,
mode: PickMode,
choose: (item: ElementBrief, column?: string) => void,
note = "",
): HTMLElement {
const text = el("div", {
className: "m01-logic__stack",
children: [
el("strong", { text: `${item.이름 ?? ""} ${item.규격 ?? ""}`.trim() }),
el("span", { className: "m01-logic__muted", text: item.ref }),
el("span", {
className: "m01-logic__muted",
text: [item.ref, note].filter(Boolean).join(" · "),
}),
],
});
if (mode === "table") {
@@ -266,6 +266,23 @@ input.m01-logic__input {
font-size: var(--text-caption);
}
/* 목록 「별칭」 칸(208-1) — 칸 안 글 상자 · 테두리는 올렸을 때만 */
.m01-logic__alias {
width: 160px;
padding: 2px var(--spacing-4);
border: 1px solid transparent;
border-radius: var(--radius-buttons);
background: transparent;
color: inherit;
font: inherit;
}
.m01-logic__alias:hover,
.m01-logic__alias:focus {
border-color: var(--color-border);
background: var(--color-canvas);
}
.m01-logic__bad {
color: var(--color-danger);
font-size: var(--text-caption);
@@ -7,7 +7,8 @@ import { currentLanguageIndex } from "@ui/ui_template_locale";
const TEXT = {
Title: ["단가산출 로직", "Unit-price logic"],
List_Search: ["이름 찾기", "Search name"],
List_Search: ["이름 · 별칭 찾기", "Search name or alias"],
List_Alias: ["별칭 — 쉼표로 여럿 · 고친 뒤 [저장]", "Aliases — comma separated · then [Save]"],
List_AllBooks: ["원문 모두", "All sources"],
List_AllChapters: ["장 모두", "All chapters"],
List_BlockedOnly: ["막힘만", "Blocked only"],
@@ -17,6 +17,7 @@ import type {
CfScope,
CfVariable,
} from "./M02_DF_Model";
import { matchLogic } from "@ui/ui_template_logic_find";
/** 입력 상자가 고르는 그룹 — 화면 차례 */
export const GROUPS: CfGroup[] = [
@@ -285,21 +286,21 @@ export interface MasterLogicBrief {
원문번호: string;
이름: string;
결과단위: string;
/** 쉼표로 여럿(208-1) */
별칭?: string;
blocked?: boolean;
reasons?: string[];
}
/** 찾기 — 띄어쓰기로 나눈 말을 모두 포함(이름 · 원문번호 · 키 · 구분 · 상세구분 · 띄어쓰기는 가리지 않음) · 원문(구분) · 장(상세구분) 거름 */
/** 찾기 — 공용 `matchLogic`(띄어 나눈 말마다 이름 · 원문번호 · 키 · 구분 · 상세구분 · 별칭 · 208-1) · 원문(구분) · 장(상세구분) 거름 */
export function filterLogics(
items: MasterLogicBrief[],
q: { text: string; book?: string; chapter?: string },
): MasterLogicBrief[] {
const words = q.text.split(/\s+/).map(squash).filter(Boolean);
return items.filter((i) => {
if (q.book && i.구분 !== q.book) return false;
if (q.chapter && i.상세구분 !== q.chapter) return false;
const hay = squash(`${i.이름} ${i.원문번호} ${i.키} ${i.구분} ${i.상세구분}`);
return words.every((w) => hay.includes(w));
return matchLogic(q.text, i).ok;
});
}
@@ -14,6 +14,7 @@ import {
el,
showToast,
} from "@ui/ui_template_elements";
import { aliasNote, matchLogic } from "@ui/ui_template_logic_find";
import {
fetchAuto,
fetchLogic,
@@ -193,6 +194,11 @@ export function renderLogicInput(
children: [
el("strong", { text: i.이름 }),
el("span", { className: "m02-dfip__muted", text: ` ${i.원문번호}` }),
// 별칭으로 맞았으면 그 별칭을 작게(208-1)
el("span", {
className: "m02-dfip__muted",
text: ` ${aliasNote(matchLogic(q.input.value, i).alias)}`,
}),
el("span", {
className: "m02-dfip__tag",
text: i.blocked ? t("Blocked") : i.상세구분,
@@ -7,6 +7,7 @@
import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements";
import { createFindList } from "@ui/ui_template_find_list";
import { aliasNote, matchLogic } from "@ui/ui_template_logic_find";
import { t as L } from "@ui/ui_template_locale";
import type { LogicSummary, SubBrief } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
@@ -86,7 +87,7 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
(x) =>
(!sub.select.value || x.구분 === sub.select.value) &&
(!detail.select.value || x.상세구분 === detail.select.value) &&
(!q || `${x.키} ${x.이름}`.toLowerCase().includes(q)),
matchLogic(finder.search.value, x).ok, // 이름 · 키 · 별칭 …(공용 · 208-1)
);
const libFound = libRows.filter(
(x) =>
@@ -106,7 +107,9 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
key: x.키,
tag: tc("Kind_Logic"),
name: x.이름,
sub: `${x.키} · ${x.구분} ${x.상세구분}`.trim(),
sub: [`${x.키} · ${x.구분} ${x.상세구분}`.trim(), aliasNote(matchLogic(q, x).alias)]
.filter(Boolean)
.join(" · "),
unit: `${x.결과단위}${x.blocked ? ` · ${tc("Pick_Blocked")}` : ""}`,
onPick: () => {
close();
+25 -11
View File
@@ -51,12 +51,15 @@ export const BOX_COVER_M = 0.5;
export const REVET_HEIGHT_STEP_M = 0.1;
export const REVET_EMBED_DEPTH_M = 0.5;
export const OLD_PIPE_WALL_DEFAULT_M = 1.5;
/** 관 보호공 유입 흙막이 빈 칸 기본 = 관경(올림) + 0.7(211-1) — 유출 · 집수정은 근입 0.5 그대로. */
export const INLET_REVET_ADD_M = 0.7;
/** 관 벽 순수 높이(근입 포함) 기본값 — 파이썬 `pipe_wall_min_height_m`. */
export function pipeWallMinHeightM(diameterM: number): number {
/** 관 벽 순수 높이(근입 포함) 기본값 — 파이썬 `pipe_wall_min_height_m`. `side="inlet"` = + 0.7. */
export function pipeWallMinHeightM(diameterM: number, side = ""): number {
const raw = diameterM;
const steps = Math.ceil(raw / REVET_HEIGHT_STEP_M - 1e-9);
return round(steps * REVET_HEIGHT_STEP_M + REVET_EMBED_DEPTH_M, 3);
const add = side === "inlet" ? INLET_REVET_ADD_M : REVET_EMBED_DEPTH_M;
return round(steps * REVET_HEIGHT_STEP_M + add, 3);
}
/** 관 벽 · 유입 집수정 높이 칸 읽기 — 옛 기본 1.5 이고 관경 > 1.0 이면 관경 기준 값(139-1) ·
@@ -71,7 +74,8 @@ export function pipeWallHeightM(stored: number | null, diameterM: number): numbe
return stored;
}
/** 관 보호공 · 유입 집수정 높이 칸 — 새 관 기본 = 관경 + 0.5(139-1 · 서버 짝 `PIPE_HEIGHT_KEYS`). */
/** 관 보호공 · 유입 집수정 높이 칸 — 새 관 기본 = 관경 + 0.5(139-1) · 유입 흙막이만 + 0.7(211-1)
* (서버 짝 `PIPE_HEIGHT_KEYS`). */
export const PIPE_HEIGHT_KEYS = [
"inlet_revet_height_m",
"outlet_revet_height_m",
@@ -86,12 +90,14 @@ export function fillPipeHeights(
const diameterM = Number(values.pipe_diameter_mm) / 1000;
if (!(diameterM > 0)) return;
for (const key of PIPE_HEIGHT_KEYS)
if (keys.includes(key)) values[key] ??= pipeWallMinHeightM(diameterM);
if (keys.includes(key))
values[key] ??= pipeWallMinHeightM(diameterM, key === "inlet_revet_height_m" ? "inlet" : "");
}
/** 배관 관 보호공 높이 칸(유입 · 유출 · 유입 집수정 = 항목 `height`) — 관경 + 0.5 규칙(139-1).
/** 배관 관 보호공 높이 칸(유입 · 유출 · 유입 집수정 = 항목 `height`) — 관경 + 0.5 규칙(139-1) ·
* 유입 흙막이 + 0.7(211-1).
* `previousMm` 없음 = 읽을 때(옛 1.5 · 관경 > 1.0 옮김) · 있음 = 관경을 바꿈(옛 관경 규칙 값 · 옛 1.5
* 칸만 새 규칙 값으로 · 손 값 · 빈 칸은 그대로). */
* 칸만 새 규칙 값으로 · 손 값 · 빈 칸은 그대로 · 유입은 옛 + 0.5 값도 새 + 0.7 로 따라감). */
export function followPipeHeights(
state: Record<string, Array<Record<string, string>> | undefined>,
diameterMm: string,
@@ -99,7 +105,13 @@ export function followPipeHeights(
): void {
const d = Number(diameterMm) / 1000;
if (!(d > 0)) return;
const prev = previousMm ? pipeWallMinHeightM(Number(previousMm) / 1000) : null;
const prevOf = (id: string): Array<number | null> => {
if (!previousMm) return [];
const p = Number(previousMm) / 1000;
return id === "inlet"
? [pipeWallMinHeightM(p, id), pipeWallMinHeightM(p)]
: [pipeWallMinHeightM(p)];
};
for (const id of ["inlet", "outlet"]) {
const values = state[id]?.[0];
const raw = values?.height ?? "";
@@ -108,8 +120,10 @@ export function followPipeHeights(
const moved =
previousMm === undefined
? pipeWallHeightM(now, d)
: [prev, OLD_PIPE_WALL_DEFAULT_M].some((v) => v !== null && Math.abs(now - v) < 1e-9)
? pipeWallMinHeightM(d)
: [...prevOf(id), OLD_PIPE_WALL_DEFAULT_M].some(
(v) => v !== null && Math.abs(now - v) < 1e-9,
)
? pipeWallMinHeightM(d, id)
: now;
if (moved !== null && moved !== now) values.height = String(moved);
}
@@ -170,7 +184,7 @@ function sideSpec(
const height =
pipeWallHeightM(num(options[`${side}_revet_height_m`], null), diameterM) ??
pipeWallMinHeightM(diameterM);
pipeWallMinHeightM(diameterM, side);
const length = num(options[`${side}_revet_length_m`], null);
const form = text(options[`${side}_revet_form`]);
const before = num(options[`${side}_revet_before_m`], null);
@@ -85,3 +85,16 @@
62. B09의 좌측 패널의 컨테이너를 공용양식을 사용한건지 확인 (확인만)
63. B05/B06의 구조물배치 컨테이너에서 삭제버튼과 리셋버튼이 높이사이즈를 하위 컨테이너의 버튼 높이와 동일하게 구현하고 내부 컨테이너에서 삭제와 리셋버튼이 항상보이게 구현
64. [사진] 현재 검증 서브1 프로젝트의 1+0 측점의 내용임. 이때 원래는 측구가 없어야하는 성토사면에 존재함. 이때 측구버튼과 일반L형 버튼이 없음. 숨기기 결과로 보임. 이런 현상의 원인이 궁금함. 한번 좌우를 바꾸면 재현안됨. 확실히 조사하여 반영할것. (사진: 1+0 · 양측 성토 · 성토사면 좌 18.61m · 우 0.26m · 좌|우 단추 중 「우」 켜짐 · 오른쪽 노견 바깥에 작은 측구 모양 · 측구 · 일반/L형 단추 안 보임)
65. B06의 상단의 성토 5m 이상, 포장 권장, 소단 검토에서 버튼의 형상을 사각 라운드로 변경하고 누를시 나오는 버튼들은 현재를 유지, 그리고 메인 페이지의 스크롤이 제일 상단에 있을경우 상단 버튼으로 내역을 열면 페이지가 아래로 밀림. 안밀리게 변경, 그리고 항목을 선택하면 이동하면서 해당 버튼셋은 접힘으로 그리고 만약 선택된 상태에서 다시 열면 하이라이트 기능, 다시 말해 사용자가 측점 횡단도를 누른상태에서 상단 3버튼을 누르면 하이라이트 되게 변경
66. M01의 단가산출로직들의 항목에 신규열인 별칭 열이 필요함 설계자들이 찾을때 사용하는 명칭과 다른 경우가 있음. 이를 보완하기 위해 별칭열을 추가해줘. 내가 별칭을 있을수도 있고 없을 수도 있지만 값은 내가 넣을거야. 그리고 이때 프로그램 전역에서 단가산출 관련 검색은 별칭도 같이 검색이 되게 해야함.
## 셋째 붙임 (10-10 오후)
67. B06 횡단도 상세 모달 — 셀에 지정된 면적과 길이값을 선택하게 하였는데 안잡히는 경우가 있어 개선이 필요함. 상세 모달창에서 선들이 교차되는 지점들을 기준으로 선들이 분할되어야함. 그리고 선택할때는 다중 선택을 하면 원지반도 가능해야함. 그럼 문제 없어보임. 그럼 면적에서도 폐쇄 영역을 지정하기 좋아보임. 추가로 선택영 지점의 면적과 선들의 길이의 값을 보는 기능이 있었으면 함. 이는 메인페이지의 기능이 있으니 확인, 잘 반영되었다면 메인페이지에도 구현한 생각임.
68. 간혹가다 B06의 상단에 누락된 구조물 측점이라고 측점추가 버튼이 나오는데 이유는? (조사)
69. 관보호공 유입 측 흙막이의 경우에 기본 높이를 배관 + 0.5m로 지정되어 있을것임. 이걸 0.7m로 변경
70. 도수로/돌붙입의 구조물 형상을 잡고 이동하는 경우 토스트 메세지로 '기준측점을 옮겼습니다 ....' 나오는데 나는 도수로와 돌붙임의 이동에 대해 요청한건 없음. 왜그런지 분석하고 알려줘. (조사)
71. B07 (기반영요청사항) 횡단도의 격자에 배치를 옮기기 위해 선을 선택할때 원지반선이 안잡힘
72. B07 (기반영요청사항) 추가로 레이어별로 측점의 원지반선과 설계선을 잡을 수 있는 레이어 자동 생성
73. B07 횡단도의 붉은색 격사선을 하이드쇼할수 있는 버튼 기능은 좋은데 뒤에 검은 격자선이 하나더 있음. 확인 후 삭제
74. B07 횡단도 표준 옵션의 버튼들이 양식화와 일원화 안되어 있음. 확인후 정리할 수 있는 양식으로 적용
76. 다음으로 중요한 문제인데 특정 측점에 2개의 측점이 숨어 있는 경우가 있음. 엄상준_검증 프로젝트에서 13+3 지점에 대해 이유를 확인해줘. 원인파악을 제대로 할것 계속 반복적으로 동일한 문제가 발생됨
@@ -0,0 +1,113 @@
/**
* Ctrl 단축키 — 한글 자판에서도 되돌리기 · 다시 하기가 먹는다(PLAN 214-1).
* 한글 자판은 key 가 「ㅋ」·「ㅛ」 · Shift 는 대문자 — 자판 자리(code)로 판정한다.
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad ctrl-shortcut-hangul
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../../B07_DesignDetail/openwebcad/src/state.ts", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
undo: vi.fn(),
redo: vi.fn(),
}));
import { ctrlShortcut } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-shortcut";
import { InputController } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-controller";
import { redo, undo } from "../../../B07_DesignDetail/openwebcad/src/state";
const press = (controller: InputController, extra: Partial<KeyboardEvent>) => {
const preventDefault = vi.fn();
controller.handleKeyStroke({
key: "",
code: "",
keyCode: 0,
isComposing: false,
shiftKey: false,
ctrlKey: true,
altKey: false,
metaKey: false,
repeat: false,
target: null,
preventDefault,
stopPropagation: vi.fn(),
...extra,
} as unknown as KeyboardEvent);
return preventDefault;
};
describe("ctrlShortcut — 자판 자리(code)로 판정", () => {
const k = (code: string, extra = {}) =>
ctrlShortcut({ ctrlKey: true, altKey: false, shiftKey: false, code, ...extra });
it("KeyZ = 되돌리기 · Shift+KeyZ · KeyY = 다시 하기", () => {
expect(k("KeyZ")).toBe("undo");
expect(k("KeyZ", { shiftKey: true })).toBe("redo");
expect(k("KeyY")).toBe("redo");
});
it("KeyS = 저장 · KeyA = 전부 고르기 · Shift 면 아님", () => {
expect(k("KeyS")).toBe("save");
expect(k("KeyA")).toBe("selectAll");
expect(k("KeyS", { shiftKey: true })).toBeNull();
expect(k("KeyA", { shiftKey: true })).toBeNull();
});
it("Ctrl 없음 · Alt(AltGr) 함께 · 다른 자리는 아님", () => {
expect(k("KeyZ", { ctrlKey: false })).toBeNull();
expect(k("KeyZ", { altKey: true })).toBeNull();
expect(k("KeyX")).toBeNull();
});
});
describe("키 입력 — 한글 자판", () => {
beforeEach(() => {
vi.mocked(undo).mockClear();
vi.mocked(redo).mockClear();
});
it("Ctrl+「ㅋ」(KeyZ) = 되돌리기", () => {
const controller = new InputController();
const prevented = press(controller, { key: "ㅋ", code: "KeyZ" });
expect(undo).toHaveBeenCalledTimes(1);
expect(redo).not.toHaveBeenCalled();
expect(prevented).toHaveBeenCalled();
});
it("Ctrl+「ㅛ」(KeyY) = 다시 하기", () => {
const controller = new InputController();
press(controller, { key: "ㅛ", code: "KeyY" });
expect(redo).toHaveBeenCalledTimes(1);
expect(undo).not.toHaveBeenCalled();
});
it("Ctrl+Shift+「ㅋ」(KeyZ) = 다시 하기", () => {
const controller = new InputController();
press(controller, { key: "ㅋ", code: "KeyZ", shiftKey: true });
expect(redo).toHaveBeenCalledTimes(1);
expect(undo).not.toHaveBeenCalled();
});
it("영문 자판 Ctrl+Shift+Z(key 「Z」) = 다시 하기 · Ctrl+Z = 되돌리기", () => {
const controller = new InputController();
press(controller, { key: "Z", code: "KeyZ", shiftKey: true });
expect(redo).toHaveBeenCalledTimes(1);
press(controller, { key: "z", code: "KeyZ" });
expect(undo).toHaveBeenCalledTimes(1);
});
it("한글 조합 중(keyCode 229 · key Process)에도 Ctrl+Z 는 먹음", () => {
const controller = new InputController();
press(controller, { key: "Process", code: "KeyZ", keyCode: 229, isComposing: true });
expect(undo).toHaveBeenCalledTimes(1);
});
it("조합 중 Ctrl 없는 키는 여전히 IME 에 넘김", () => {
const controller = new InputController();
const prevented = press(controller, {
key: "Process",
code: "KeyZ",
keyCode: 229,
isComposing: true,
ctrlKey: false,
});
expect(undo).not.toHaveBeenCalled();
expect(prevented).not.toHaveBeenCalled();
});
});
@@ -35,6 +35,8 @@ const STUBS = {
svgText,
},
"./B06_Section_UI_Cross_Detail_Pick": { DETAIL_TARGETS: {} },
// 선 조각 고르기(209-1)는 줌과 무관 — 따로 시험(`test_209_1_detail_select.py`)
"./B06_Section_UI_Cross_Detail_Select": { attachDetailSelect: () => ({}), PIECE_HIT_CLASS: "" },
};
const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base);
function loadTs(full) {
@@ -25,6 +25,8 @@ const store = () => {
};
};
globalThis.window = { sessionStorage: store(), localStorage: store() };
// 조정창 공용 부품(Select → Design_Pick → Cross_Panel_Base)이 불러올 때 문서에 귀를 닮
globalThis.document = { addEventListener: () => {}, removeEventListener: () => {} };
const STUBS = {
"@ui/ui_template_context_menu": { createMapContextMenu: () => ({}) },
@@ -38,6 +40,9 @@ function loadTs(full) {
const box = { exports: {} };
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
if (request.endsWith(".css")) return {};
if (request.startsWith("@util/"))
return loadTs(resolve(path.join(ROOT, "common_util", request.slice(6))));
return loadTs(resolve(path.join(path.dirname(full), request)));
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
@@ -85,12 +90,10 @@ const px = ([offset, elevation]) => [
(AXES.displayMax - (AXES.elevationMid + (elevation - AXES.elevationMid) * AXES.exaggeration)) *
AXES.pixelsPerMeter,
];
const points = input.pick
.map(px)
.map((p) => p.join(","))
.join(" ");
out.length = T.lengthSourceOf(fake(["b06-chart__design-hit"], { points }), AXES);
out.lengthBad = T.lengthSourceOf(fake(["b06-chart__design-hit"], { points: "1,2" }), AXES);
// 화면 px → m(209-1 `_Cross_Detail_Select.pxToM`) · 길이 = 옛 저장 꼴(두 끝) 그대로 읽음
const S = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Select.ts"));
out.pxBack = input.pick.map(px).map(S.pxToM(AXES));
out.length = { src: "length", start: input.pick[0], end: input.pick[1] };
out.lengthValue = T.localCellValue(out.length, input.design);
// ③ 우클릭 메뉴 항목
@@ -0,0 +1,117 @@
/* PLAN 209-1 단위검증 헬퍼 — [상세] 선 조각 고르기(`_Cross_Detail_Geom` · `_Cross_Detail_Select`).
* 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림. 입력 = 표준입력 JSON {design, samples, axes} ·
* 결과 = 표준출력 JSON 한 벌. 짝 = `test_209_1_detail_select.py`(파이썬 `cell_value` 거울).
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
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]}`)();
globalThis.window = { sessionStorage: new Map(), localStorage: new Map() };
globalThis.document = { addEventListener: () => {}, removeEventListener: () => {} };
const STUBS = {
"@ui/ui_template_context_menu": { createMapContextMenu: () => ({}) },
"./B06_Section_UI_Section_Common": { CROSS_PAD },
};
const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base);
function loadTs(full) {
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
if (request.endsWith(".css")) return {};
if (request.startsWith("@util/"))
return loadTs(resolve(path.join(ROOT, "common_util", request.slice(6))));
return loadTs(resolve(path.join(path.dirname(full), request)));
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
const G = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Geom.ts"));
const S = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Select.ts"));
const T = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Table.ts"));
const { design, samples, axes } = JSON.parse(fs.readFileSync(0, "utf8"));
const out = {};
// ① 그림 요소(화면 px) → 조각(m) — 설계선(직선 토막 셋) · 원지반선 · 구조물 선 아님
const px = ([offset, elevation]) =>
[
CROSS_PAD.left + (axes.maxOffset - offset) * axes.pixelsPerMeter,
CROSS_PAD.top +
(axes.displayMax -
(axes.elevationMid + (elevation - axes.elevationMid) * axes.exaggeration)) *
axes.pixelsPerMeter,
].join(",");
const el = (classes, points, attrs = {}) => ({
tagName: "polyline",
classList: { contains: (name) => classes.includes(name) },
hasAttribute: (name) => name in attrs,
getAttribute: (name) => (name === "points" ? points.map(px).join(" ") : (attrs[name] ?? null)),
});
const designPts = design.design_line.map((p) => [p.offset_m, p.elevation_m]);
const groundPts = samples.filter((s) => s.valid !== false).map((s) => [s.offset_m, s.elevation_m]);
const pieces = S.collectPieces(
[
el(["b06-chart__design-cross"], designPts, { "data-design-line": "" }),
el(["b06-chart__cross-profile"], groundPts),
el(
["b06-chart__grid"],
[
[0, 0],
[1, 1],
],
),
],
axes,
);
out.pieces = pieces;
// ② 여러 고름 — 더하기 · 빼기 · 하나 · 다시 누르면 비움
out.toggle = {
single: S.toggleSelection([1, 2], 3, false),
again: S.toggleSelection([3], 3, false),
add: S.toggleSelection([1], 3, true),
remove: S.toggleSelection([1, 3], 3, true),
};
// ③ 닫힌 영역 — 땅 아래 설계 골(경사 둘 · 바닥) + 원지반 가운데 조각
const isGround = (p) => p.line === "ground";
const mid = (p) => (p.points[0][0] + p.points[p.points.length - 1][0]) / 2;
const below = pieces.filter(
(p) => !isGround(p) && Math.max(...p.points.map((q) => q[1])) <= 100.0001,
);
const groundMid = pieces.filter((p) => isGround(p) && Math.abs(mid(p)) < 1);
const ring = [...below, ...groundMid];
out.ringCount = ring.length;
out.label = {
ring: S.selectionLabel(design, ring),
open: S.selectionLabel(design, below),
single: S.selectionLabel(design, [below.find((p) => Math.abs(mid(p)) < 0.5)]),
ground: S.selectionLabel(design, groundMid),
};
out.area = G.ringArea(ring.map((p) => p.points));
out.open = G.ringArea(below.map((p) => p.points));
// ④ 칸 넣기 출처(저장 꼴) · 즉시 칸 값(짝 ①)
out.sourceArea = S.selectionSource("area", ring);
out.sourceOpen = S.selectionSource("area", below);
out.sourceLength = S.selectionSource("length", [...below, ...groundMid]);
const withStructure = {
src: "length",
pieces: [...out.sourceLength.pieces, { line: "structure", start: [20, 0], end: [23, 4] }],
};
const offLine = {
src: "length",
pieces: [{ line: "ground", start: [0, 101], end: [1, 100] }],
};
out.sources = [out.sourceArea, out.sourceLength, withStructure, offLine];
out.local = out.sources.map((source) => T.localCellValue(source, design, samples));
out.lengthSum = [...below, ...groundMid].reduce((sum, p) => sum + G.pathLength(p.points), 0);
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,193 @@
/* PLAN 216-1 단위검증 헬퍼 — B05 종단 측점선 고름 → 좌측 구조물 폼이 B06 카드 고름과 같게.
* 화면 TS(`B05_Profile_UI_Selection` · `design_selection`)를 그 자리에서 트랜스파일해 Node 로 돌림 ·
* 결과 = 표준출력 JSON 한 벌. 짝 = `test_216_1_b05_station_panel.py`.
* B05 포트 배선은 `design_view_profile.ts` 와 같은 꼴 · B06 은 `showAtChainage` 와 같은 꼴(resolveSelection + coverable).
*/
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;
// 비정규 측점 모듈은 설정 별칭(@config)을 끌어오므로 쓰는 두 함수만 같은 꼴로 흉내
const STUBS = {
"./B05_Profile_UI_IrregularStations": {
irregularStationId: (id) => `irregular:${id}`,
isPipeStation: (s) => s.origin === "pipe",
},
};
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) =>
STUBS[request] ??
(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 sel = loadTs(path.join(ROOT, "A00_Common", "design", "design_selection.ts"));
const sync = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Selection.ts"));
const P = "proj-216";
// 측점 = 일반(s0~s200) + 관 투영(irregular:p1 · 33.4) + A군 수동 횡단수로(irregular:x1 · 140)
const cards = [0, 20, 40, 60, 80, 100, 120, 160, 180, 200].map((c) => ({
station_id: `s${c}`,
chainage_m: c,
}));
const irregular = [
{ id: "p1", chainage_m: 33.4, origin: "pipe", structure: "배관" },
{ id: "x1", chainage_m: 140, structure: "노출형 횡단수로" },
];
const pipes = [{ chainage_m: 33.4 }];
const types = [
{ type_id: "retaining_wall", group: "C" },
{ type_id: "cross_channel", group: "A" },
{ type_id: "pavement", group: "G" },
];
const structures = [
{ structure_id: "wall", type_id: "retaining_wall", start_m: 60, end_m: 80, chainage_m: 60 },
{ structure_id: "chan", type_id: "cross_channel", chainage_m: 140 },
{ structure_id: "pave", type_id: "pavement", start_m: 150, end_m: 190, chainage_m: 150 },
];
const groupOf = (typeId) => types.find((t) => t.type_id === typeId)?.group;
/** 좌측 폼 흉내 — B05 `_Structures_Panel` 의 selectPipeByChainage · selectById · selectAt 꼴 그대로. */
function makeForm(onSelect) {
const form = { pipe: null, structure: null };
const panel = {
form,
selectPipeByChainage(c) {
if (c === null) {
form.pipe = null;
return;
}
const hit = pipes.find((p) => Math.abs(p.chainage_m - c) < sel.PIPE_MATCH_M);
if (!hit) return;
form.pipe = hit.chainage_m;
form.structure = null;
onSelect?.(null);
},
selectById(id) {
if (id === null) {
form.pipe = null;
form.structure = null;
return;
}
form.pipe = null;
form.structure = id;
onSelect?.(structures.find((s) => s.structure_id === id) ?? null);
},
selectAt(selection, coverable) {
const hit = sel.resolveSelection(selection, pipes, structures, coverable);
if (hit.pipeChainageM !== null) panel.selectPipeByChainage(hit.pipeChainageM);
else panel.selectById(hit.structureId);
return hit;
},
};
return panel;
}
const formOf = (panel) =>
panel.form.pipe !== null ? `pipe-${panel.form.pipe.toFixed(2)}` : panel.form.structure;
// ── B05 종단 보기 — design_view_profile 의 포트 배선 그대로 ─────────────────────
let syncing = false;
const rememberPick = (c, sid) =>
sel.selectDesign(P, c === null ? sel.NO_SELECTION : { chainageM: c, structureId: sid }, {
from: "b05",
});
// 폼이 구조물을 열면 view 의 onStructureSelect 가 그 구조물을 고름 한 칸에 적음(동기화 중이면 그 뒤 return)
const b05Panel = makeForm((structure) => {
if (structure) rememberPick(structure.chainage_m, structure.structure_id);
});
const sb = sync.createSelectionSync({
stations: () => irregular,
isSyncing: () => syncing,
setSyncing: (v) => {
syncing = v;
},
selectMarker: () => undefined,
selectGraph: () => undefined,
selectSidebar: (c) => b05Panel.selectPipeByChainage(c),
openAtStation: (c) =>
b05Panel.selectAt({ chainageM: c, structureId: null }, (entry) =>
sel.coversAtStation(groupOf(entry.type_id)),
),
selectBasin: () => undefined,
regularChainage: (id) => cards.find((c) => c.station_id === id)?.chainage_m,
remember: (c) => rememberPick(c, null),
});
// ── B06 카드 고름 — showAtChainage 와 같은 꼴(카드 = 관 투영은 가까운 카드) ──────────
const b06Panel = makeForm();
const b06Coverable = (entry) => sel.coversAtStation(groupOf(entry.type_id));
const b06Card = (chainageM) => {
const hit = sel.resolveSelection(
{ chainageM, structureId: null },
pipes,
structures,
b06Coverable,
);
if (hit.pipeChainageM !== null) b06Panel.selectPipeByChainage(hit.pipeChainageM);
else b06Panel.selectById(hit.structureId);
};
const chainageOf = (id) =>
irregular.find((s) => `irregular:${s.id}` === id)?.chainage_m ??
cards.find((c) => c.station_id === id)?.chainage_m;
/** 차례대로 고름 — 매 걸음 B05 폼 · B06 폼 · 고름 한 칸. */
function run(ids) {
b05Panel.form.pipe = b05Panel.form.structure = null;
b06Panel.form.pipe = b06Panel.form.structure = null;
sel.selectDesign(P, sel.NO_SELECTION);
return ids.map((id) => {
sb.syncIrregularSelection(id);
const at = id === null ? null : chainageOf(id);
if (at !== undefined) b06Card(at); // 모르는 측점은 B06 카드가 없음
return { id, b05: formOf(b05Panel), b06: formOf(b06Panel), pick: sel.readSelection(P) };
});
}
const out = {
pipe: run(["irregular:p1"]),
// 관 폼이 열린 채 일반 측점 = 폼 비움(새 배치 준비) — 옛 B05 는 관 폼이 남았음
pipeThenPlain: run(["irregular:p1", "s100"]),
// 옹벽 범위 안 일반 측점 = 옹벽 폼 · 고름 한 칸은 그 측점(구조물 id 없음)
wallStation: run(["s20", "s60"]),
wallMiddle: run(["s80"]),
// A군 수동 횡단수로 측점 = 그 구조물 폼
channel: run(["irregular:x1"]),
// 노면공만 덮는 측점 = 노면공 안 엶(189-1 · 152-5) — 연 폼은 풂
pave: run(["s60", "s160"]),
// 같은 측점 다시 = 풂(null)
release: run(["s60", null]),
// 모르는 측점 = 폼 · 고름 한 칸 안 건드림
unknown: run(["s60", "ghost"]),
covers: {
G: sel.coversAtStation("G"),
C: sel.coversAtStation("C"),
none: sel.coversAtStation(undefined),
},
};
process.stdout.write(JSON.stringify(out));
@@ -59,13 +59,16 @@ def test_화면_높이_규칙(tmp_path: Path) -> None:
assert run.returncode == 0, run.stderr[-2000:]
got = json.loads(result.read_text(encoding="utf-8"))
for key in ("inlet_revet_height_m", "outlet_revet_height_m", "inlet_basin_height_m"):
for key in ("outlet_revet_height_m", "inlet_basin_height_m"):
assert got["fresh"][key] == pytest.approx(1.7), key # Φ1200 → 1.7
assert got["fresh"]["inlet_revet_height_m"] == pytest.approx(1.9) # 유입 흙막이 + 0.7(211-1)
assert "inlet_revet_height_m" not in got["noPipe"] # 관경 없음 = 안 채움
assert got["read"]["inlet"][0]["height"] == "1.7" # 옛 1.5 · Φ1200 → 1.7
assert got["read"]["outlet"][0]["height"] == "1.6" # 손 값 그대로
assert got["small"]["inlet"][0]["height"] == "1.5" # Φ800 의 1.5 는 그대로
assert got["change"]["inlet"][0]["height"] == "2" # 옛 규칙 값(1.7) → 새 규칙(2.0)
assert (
got["change"]["inlet"][0]["height"] == "2.2"
) # 옛 + 0.5 값(1.7) → 유입 새 규칙(1.5 + 0.7)
assert got["change"]["outlet"][0]["height"] == "2.2" # 손 값 그대로
assert got["old"]["inlet"][0]["height"] == "1.3" # 옛 1.5 → Φ800 규칙
assert got["old"]["inlet"][0]["height"] == "1.5" # 옛 1.5 → Φ800 유입 규칙(0.8 + 0.7)
assert got["old"]["outlet"][0]["height"] == "" # 빈 칸 그대로
+4 -5
View File
@@ -113,10 +113,8 @@ def test_그래프_고르기_띠_키_판정_선_m(out):
assert area["fill"] == {"src": "area", "key": "embankment"}
assert area["spoil"] == {"src": "area", "key": "spoil_fill"}
assert area["other"] is None
length = out["length"]
assert length["src"] == "length"
assert length["start"] == pytest.approx(PICK[0]) and length["end"] == pytest.approx(PICK[1])
assert out["lengthBad"] is None
# 화면 px → m(209-1 `pxToM`) · 옛 저장 꼴(두 끝) 길이 = hypot 그대로
assert out["pxBack"][0] == pytest.approx(PICK[0]) and out["pxBack"][1] == pytest.approx(PICK[1])
assert out["lengthValue"] == pytest.approx(hypot(4.0, 2.0))
@@ -261,4 +259,5 @@ def test_저장_patch_배선_초안_등록():
assert '"detail-table": { bucket: "draft", scope: "route" }' in state
detail = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Detail.ts").read_text(encoding="utf-8")
assert "designForTable(state.section, projectId, routeId)" in detail
assert "const detachTable = attachDetailTable({" in detail
assert "const tablePick = attachDetailTable({" in detail
assert "tablePick.redraw();" in detail
+185
View File
@@ -0,0 +1,185 @@
# -*- coding: utf-8 -*-
"""PLAN 208-1 — 단가산출 로직 「별칭」 칸 · 로직 찾기는 이름 + 별칭.
① 서버 찾기 맞춤(`M01_MasterData_Store_Find.hit`) ↔ 화면 공용 맞춤(`ui_template_logic_find.ts`) 거울
② 읽기(없으면 빈칸) · 저장 왕복 — 임시 사본 폴더만(정본 마스터 파일은 안 바뀜)
③ M01 목록 「별칭」 열(이름 옆) · 쓰는 곳마다 공용 맞춤을 부름
"""
from __future__ import annotations
import json
import shutil
import subprocess
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from M01_MasterData import M01_MasterData_Router as router_module
from M01_MasterData import M01_MasterData_Store as store
from M01_MasterData.M01_MasterData_Store_Find import hit
ROOT = Path(__file__).resolve().parents[2]
REAL = store.FOLDER
LOGIC_KEY = "GC000091" # 공통 3-3-1 암발파(미진동굴착 TYPE-Ⅰ)
LOGIC_FILE = "로직_건설품셈_03장_토공사.json"
ROW = {
"키": "GF000001",
"원문번호": "9-1-1",
"이름": "흙깎기(보통토사)",
"별칭": "절토, 터파기 기계",
}
# (찾는 글, 맞음, 별칭으로 맞은 것)
CASES = [
("흙깎기", True, ""),
("흙 깎기", True, ""), # 띄어쓰기 무시 · 낱말마다
("절토", True, "절토"),
("터파기", True, "터파기 기계"),
("터파기기계", True, "터파기 기계"), # 별칭 안 띄어쓰기 무시
("절토 기계", True, "절토, 터파기 기계"), # 낱말이 여러 별칭에 나뉘어 듦
("흙깎기 절토", True, "절토"), # 이름 + 별칭
("gf000001", True, ""),
("9-1", True, ""),
("성토", False, ""),
("", True, ""),
]
def _node(src: str) -> str:
if shutil.which("node") is None:
pytest.skip("node 가 없음")
done = subprocess.run(
["node", "--experimental-strip-types", "--input-type=module", "-e", src],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
cwd=ROOT,
)
assert done.returncode == 0, done.stderr
return done.stdout
def test_서버_맞춤_이름_별칭_여러_별칭() -> None:
for q, ok, _ in CASES:
assert hit(ROW, q) is ok, q
assert hit({**ROW, "별칭": ""}, "절토") is False # 별칭 없음 = 빈칸
assert hit({k: v for k, v in ROW.items() if k != "별칭"}, "흙깎기") is True
def test_화면_맞춤_거울_서버와_같음() -> None:
url = (ROOT / "ui_template" / "ui_template_logic_find.ts").as_uri()
src = f"""
import {{ matchLogic, aliasText, aliasesOf, aliasNote }} from "{url}";
const row = {json.dumps(ROW, ensure_ascii=False)};
const cases = {json.dumps([c[0] for c in CASES], ensure_ascii=False)};
console.log(JSON.stringify({{
hits: cases.map((q) => matchLogic(q, row)),
text: aliasText(" 절토 ,, 터파기 기계,"),
none: aliasesOf(undefined),
note: [aliasNote("절토"), aliasNote("")],
chapter: matchLogic("토공", {{ ...row, 상세구분: "09장 토공" }}).ok,
}}));
"""
got = json.loads(_node(src))
for (q, ok, alias), one in zip(CASES, got["hits"]):
assert one == {"ok": ok, "alias": alias}, q
assert one["ok"] is hit(ROW, q), q # 서버와 같은 판정
assert got["text"] == "절토, 터파기 기계"
assert got["none"] == []
assert got["note"] == ["별칭 절토", ""]
assert got["chapter"] is True # 구분 · 상세구분은 화면만 더 봄
@pytest.fixture
def client(tmp_path: Path, monkeypatch) -> TestClient:
for path in REAL.glob("*.json"):
if not path.name.startswith("_") or path.name == store.mk.BOOK.name:
shutil.copy(path, tmp_path / path.name)
monkeypatch.setattr(store, "FOLDER", tmp_path)
app = FastAPI()
app.include_router(router_module.router)
return TestClient(app)
def _logics(client: TestClient, **params) -> list[dict]:
res = client.get("/api/m01/logics", params=params)
assert res.status_code == 200, res.text
return res.json()["logics"]
def _save(client: TestClient, row: dict) -> None:
one = client.get("/api/m01/logic", params={"key": LOGIC_KEY}).json()
part = {
"file": one["file"],
"version": one["version"],
"changes": [{"op": "edit", "key": LOGIC_KEY, "row": row}],
}
res = client.post("/api/m01/save", json={"files": [part]})
assert res.status_code == 200, res.text
def test_읽기_빈칸_저장_왕복_임시_사본만(client: TestClient) -> None:
real = (REAL / LOGIC_FILE).read_bytes()
listed = _logics(client)
assert listed and all(isinstance(x["별칭"], str) for x in listed) # 없으면 빈칸
one = client.get("/api/m01/logic", params={"key": LOGIC_KEY}).json()
base = one["logic"]
assert "별칭" not in base # 마스터 파일에 일괄로 쓰지 않음
_save(client, {**base, "별칭": "미진동 발파, 암깨기"})
got = [x for x in _logics(client, q="암깨기") if x["키"] == LOGIC_KEY]
assert got and got[0]["별칭"] == "미진동 발파, 암깨기"
assert LOGIC_KEY in [x["키"] for x in _logics(client, q="미진동 발파")]
found = client.get("/api/m01/elements", params={"group": "로직", "q": "암깨기"}).json()
assert [x["별칭"] for x in found["items"] if x["ref"] == LOGIC_KEY] == ["미진동 발파, 암깨기"]
saved = json.loads((store.FOLDER / LOGIC_FILE).read_text(encoding="utf-8"))
assert next(r for r in saved["줄"] if r["키"] == LOGIC_KEY)["별칭"] == "미진동 발파, 암깨기"
_save(client, base) # 지우면 칸째 빠짐 · 찾기에도 안 걸림
assert LOGIC_KEY not in [x["키"] for x in _logics(client, q="암깨기")]
after = json.loads((store.FOLDER / LOGIC_FILE).read_text(encoding="utf-8"))
assert after == json.loads(real.decode("utf-8")) # 왕복 뒤 내용 그대로
assert (REAL / LOGIC_FILE).read_bytes() == real # 정본은 안 건드림
def _src(rel: str) -> str:
return (ROOT / rel).read_text(encoding="utf-8")
def test_M01_목록_별칭_열은_이름_옆_고치면_캐시() -> None:
ts = _src("M01_MasterData/M01_MasterData_UI_Logic_List.ts")
cols = ts[ts.index("const COLS") : ts.index("as const")]
names = [x.strip().strip('"') for x in cols[cols.index("[") + 1 :].split(",")]
assert names[names.index("이름") + 1] == "별칭" # 이름 바로 옆
assert "aliasCell(item" in ts and "onAlias(item, next)" in ts
assert "e.stopPropagation()" in ts # 별칭 칸을 눌러도 로직이 안 열림
lab = _src("M01_MasterData/M01_MasterData_UI_LogicLab.ts")
assert "const onAlias = async" in lab and "list.setMarks(marks, extra, aliases)" in lab
# 자동저장 없음 — 캐시(drafts)에만 · [저장] 이 보냄
body = lab[lab.index("const onAlias = async") : lab.index("const onDelete = async")]
assert "saveFiles" not in body and "persist()" in body
@pytest.mark.parametrize(
"rel",
[
"M01_MasterData/M01_MasterData_UI_Logic_List.ts", # M01 로직 목록
"M01_MasterData/M01_MasterData_UI_Logic_Pick.ts", # M01 요소 찾기 창 · 로직 그룹
"M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts", # M02 흐름도 다른 로직 고르기
"M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts", # 〃 결과 별칭 표시
"M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts", # M02 · B09 [로직 더하기]
],
)
def test_쓰는_곳마다_공용_맞춤을_부름(rel: str) -> None:
ts = _src(rel)
assert 'from "@ui/ui_template_logic_find"' in ts
assert "matchLogic(" in ts
def test_서버_찾기도_한_벌() -> None:
py = _src("M01_MasterData/M01_MasterData_Store.py")
assert "import hit as _hit" in py and "def _hit" not in py
@@ -0,0 +1,199 @@
# -*- coding: utf-8 -*-
"""PLAN 209-1 — B06 [상세] 그래프 고르기(교차점 분할 · 여러 고름 · 원지반 포함 · 닫힌 넓이).
정보창(고른 것 길이 · 면적) · 칸 넣기 · 서버 검산 · 옛 꼴 읽기.
못박는 것
- 선(설계선 · 원지반선)이 만나는 점에서 조각으로 나뉨 · 화면 px → m · 격자 등은 안 고름.
- 여러 고름(Shift/Ctrl 더하기 · 빼기) · 원지반 조각 포함 닫힌 영역 넓이 · 안 닫히면 null.
- 정보창 두 줄(하나 = 카드 꼴 길이 · 경사비율 / 여럿 = 길이 합 · 면적 · 「닫히지 않음」).
- 칸 넣기 출처(조각 두 끝 목록) → 스키마 → 서버 `cell_value` 거울(① 파이썬 · TS) ·
서버 검산(선 밖 · 설계선 바뀜 → 다시 고름 · 구조물 선 = 두 끝 직선) · 옛 두 끝 꼴 그대로.
./venv/Scripts/python.exe -m pytest resources/tester/test_209_1_detail_select.py -q
"""
import json
import os
import subprocess
from math import hypot
from pathlib import Path
import pytest
from B06_Section import B06_Section_Router_CrossTable as table_mod
from B06_Section.B06_Section_Schema import CrossSectionPatch
from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Table_Edit import (
cell_value,
ground_runs,
ring_area,
sub_path,
)
ROOT = Path(__file__).resolve().parents[2]
HERE = Path(__file__).resolve().parent
LINE = [(-6.0, 104.0), (-2.0, 98.0), (2.0, 98.0), (6.0, 104.0)]
DESIGN = {
"design_elevation_m": 98.0,
"road_edges": {
"left": {"offset_m": 2.0, "elevation_m": 98.0},
"right": {"offset_m": -2.0, "elevation_m": 98.0},
},
"design_line": [{"offset_m": x, "elevation_m": y} for x, y in LINE],
}
SAMPLES = [{"offset_m": x, "elevation_m": 100.0, "valid": True} for x in (-10, -5, 0, 5, 10)] + [
{"offset_m": 12.0, "elevation_m": None, "valid": False},
{"offset_m": 14.0, "elevation_m": 101.0, "valid": True},
]
AXES = {
"widthPx": 600,
"heightPx": 300,
"pixelsPerMeter": 10,
"maxOffset": 14,
"displayMax": 110,
"elevationMid": 100,
"exaggeration": 2,
}
CROSS_X = 2 + 4 / 3 # 경사(1:0.667)가 원지반 100 m 와 만나는 offset
AREA = (2 * CROSS_X + 4) / 2 * 2 # 사다리꼴(윗변 2·CROSS_X · 아랫변 4 · 높이 2)
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
)
@pytest.fixture(scope="module")
def out() -> dict:
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_209_1_detail_select.cjs")],
input=json.dumps({"design": DESIGN, "samples": SAMPLES, "axes": AXES}),
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
cwd=os.fspath(ROOT),
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def _ends(piece: dict) -> list[tuple[float, float]]:
pts = piece["points"]
return [tuple(round(v, 4) for v in pts[0]), tuple(round(v, 4) for v in pts[-1])]
# ── 분할 · 여러 고름 ─────────────────────────────────────────────────────
def test_교차점에서_조각으로_나뉨(out):
pieces = out["pieces"]
ground = [p for p in pieces if p["line"] == "ground"]
design = [p for p in pieces if p["line"] == "design"]
assert len(ground) == 3 and len(design) == 5 # 원지반 = 두 교점에서 셋 · 설계 경사 둘이 둘씩
c = round(CROSS_X, 4)
assert sorted(_ends(p)[0][0] for p in ground) == pytest.approx([-10.0, -c, c])
# 원지반 가운데 조각은 사이 샘플 꼭짓점(0 m)을 지남 — 꺾은선 그대로
middle = next(p for p in ground if abs(p["points"][0][0]) < 4)
assert [0.0, 100.0] in [[round(v, 6) for v in q] for q in middle["points"]]
# 경사 조각 끝 = 교점(설계선 위 · 원지반 위)
assert any(_ends(p)[1] == (c, 100.0) or _ends(p)[0] == (c, 100.0) for p in design)
def test_여러_고름_더하기_빼기(out):
toggle = out["toggle"]
assert toggle == {"single": [3], "again": [], "add": [1, 3], "remove": [1]}
# ── 닫힌 넓이 · 정보창 ──────────────────────────────────────────────────
def test_원지반_포함_닫힌_영역_넓이(out):
assert out["ringCount"] == 4 # 경사 둘 · 바닥 · 원지반 가운데
assert out["area"] == pytest.approx(AREA)
assert out["open"] is None # 원지반 빼면 안 닫힘
def test_정보창_값(out):
label = out["label"]
length = 2 * hypot(4 / 3, 2) + 4 + 2 * CROSS_X
assert label["ring"] == f"4개 · 길이 합 {length:.2f}m\n면적 {AREA:.2f}㎡"
assert label["open"].endswith("면적 닫히지 않음")
assert label["single"] == "길이 4.00m · 경사비율 0.0%\n수평 4.00m · 높이 0.00m" # 카드 꼴
assert label["ground"] == (
f"원지반선 길이 {2 * CROSS_X:.2f}m\n수평 {2 * CROSS_X:.2f}m · 높이 0.00m"
)
# ── 칸 넣기 · 짝 · 서버 검산 ─────────────────────────────────────────────
def test_칸_넣기_출처_저장_꼴(out):
area = out["sourceArea"]
assert area["src"] == "area" and len(area["pieces"]) == 4
assert {p["line"] for p in area["pieces"]} == {"design", "ground"}
assert out["sourceOpen"] is None # 안 닫힌 면적은 칸에 못 넣음
assert out["sourceLength"]["src"] == "length"
patch = CrossSectionPatch(
chainage_m=40.0,
detail_table={"cells": {"cut_soil": area, "fence": out["sourceLength"]}},
).model_dump()
assert patch["detail_table"]["cells"]["cut_soil"]["pieces"][0]["line"] in ("design", "ground")
def test_칸_값_파이썬_TS_거울(out):
for source, local in zip(out["sources"], out["local"], strict=True):
number, _text, again = cell_value(source, DESIGN, SAMPLES)
if number is None:
assert local is None and again is True, source
else:
assert local == pytest.approx(number, abs=1e-6), source
assert out["local"][0] == pytest.approx(AREA, abs=1e-3)
assert out["local"][1] == pytest.approx(out["lengthSum"], abs=1e-3)
assert out["local"][2] == pytest.approx(out["lengthSum"] + 5.0, abs=1e-3) # 구조물 = 두 끝 직선
assert out["local"][3] is None # 원지반 밖 끝
def test_서버_검산_선_밖이나_설계선_바뀌면_다시_고름(out):
cells = {"cut_soil": out["sourceArea"], "fence": out["sourceLength"]}
stored = CrossSectionPatch(chainage_m=40.0, detail_table={"cells": cells}).model_dump()
design = {**DESIGN, "detail_table": stored["detail_table"]}
source = {"chainage_m": 40.0, "center_z": 100.0, "samples": SAMPLES}
sheet = table_mod.cross_quantity_sheet(source, design)
assert sheet["quantity_table"]["cut_soil"] == pytest.approx(AREA, abs=1e-3)
assert sheet["quantity_table"]["fence"] == pytest.approx(out["lengthSum"], abs=1e-3)
assert sheet["repick"] == []
# 원지반 샘플이 없으면(검산 못 함) 다시 고름 — 브라우저 값 안 받음
blind = table_mod.cross_quantity_sheet({"chainage_m": 40.0}, design)
assert sorted(blind["repick"]) == ["cut_soil", "fence"]
# 설계선이 내려가면 조각 끝이 선 밖 → 다시 고름
moved = {**design, "design_line": [{"offset_m": x, "elevation_m": y - 1} for x, y in LINE]}
again = table_mod.cross_quantity_sheet(source, moved)
assert again["quantity_table"]["cut_soil"] is None
assert sorted(again["repick"]) == ["cut_soil", "fence"]
def test_옛_두_끝_꼴_그대로_읽음():
old = {"src": "length", "start": [2.0, 98.0], "end": [6.0, 104.0]}
assert cell_value(old, DESIGN, SAMPLES)[0] == pytest.approx(hypot(4.0, 6.0))
assert cell_value({"src": "area", "key": "cut_soil"}, {"cut_soil_area_m2": 1.5}) == (
1.5,
None,
False,
)
def test_서버_조각_길_넓이_기본():
assert ground_runs(SAMPLES) == [
[(-10, 100.0), (-5, 100.0), (0, 100.0), (5, 100.0), (10, 100.0)]
]
run = [(0.0, 0.0), (2.0, 0.0), (2.0, 2.0)]
assert sub_path(run, 3.0, 1.0) == [(2.0, 1.0), (2.0, 0.0), (1.0, 0.0)]
square = [
[(0.0, 0.0), (1.0, 0.0)],
[(1.0, 1.0), (1.0, 0.0)],
[(1.0, 1.0), (0.0, 1.0), (0.0, 0.0)],
]
assert ring_area(square) == pytest.approx(1.0)
assert ring_area(square[:2]) is None
assert ring_area([*square, [(5.0, 5.0), (6.0, 6.0)]]) is None # 남는 조각
@@ -0,0 +1,219 @@
"""211-1 관 보호공 **유입** 흙막이 빈 칸 기본 높이 = 관경(0.1 올림) + 0.7 — 유출 · 집수정은 + 0.5 그대로.
못박는 것
- 파이썬 `pipe_wall_min_height_m(d, "inlet")` · 새 관 기본값(`_apply_new_defaults`) · 관 세트 스펙 · B08 집계 빈 높이.
- TS 짝 `buildCulvertSets` ↔ 파이썬 `_culvert_set`(유입 · 유출 스펙 같은 값) · 횡단도 `revetWallSpec`.
- 저장된 높이는 그대로 · 유출 · 집수정 무변 · 흙막이 아닌 시설(숨은 관)은 본체 높이 그대로.
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
from B04_PreProcess import B04_PreProcess_Router_Basins as basins
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from B06_Section import B06_Section_Engine_Culvert as culvert
from B08_Quantity import B08_Quantity_Router_Summary as summary
from common_util.common_util_drainage_pipes import PipePoint
from M02_MasterTemplete import M02_Item_Snapshot as snapshots
from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition
ROOT = Path(__file__).resolve().parents[2]
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
def _revet(side: str, height: float | None = None) -> dict:
found = {
f"{side}_type": "기슭막이",
f"{side}_revet_form": "돌쌓기(메)",
f"{side}_revet_before_m": 5,
f"{side}_revet_after_m": 5,
}
if height is not None:
found[f"{side}_revet_height_m"] = height
return found
def test_파이썬_기본_높이_유입만_0_7():
h = culvert.pipe_wall_min_height_m
assert h(0.8, "inlet") == 1.5 and h(0.8, "outlet") == 1.3
assert h(1.2, "inlet") == 1.9 and h(1.2) == 1.7 # 쪽 안 줌 = 옛 값(집수정)
assert h(1.0, "inlet") == 1.7 and h(1.0, "outlet") == 1.5
assert h(0.45, "inlet") == 1.2 # 0.5 로 올림 + 0.7
def test_새_관_기본값_유입_흙막이만_0_7():
fresh = PipePoint(chainage_m=10.0, source="stream", options={"pipe_diameter_mm": 1200})
basins._apply_new_defaults([fresh])
assert fresh.options["inlet_revet_height_m"] == pytest.approx(1.9)
assert fresh.options["outlet_revet_height_m"] == pytest.approx(1.7)
assert fresh.options["inlet_basin_height_m"] == pytest.approx(1.7)
# 이미 적은 높이(추천 · 손 값)는 그대로 · 나머지 칸만 채움
kept = PipePoint(
chainage_m=20.0,
source="stream",
options={"pipe_diameter_mm": 1200, "inlet_revet_height_m": 1.2},
)
basins._apply_new_defaults([kept])
assert kept.options["inlet_revet_height_m"] == 1.2
assert kept.options["outlet_revet_height_m"] == pytest.approx(1.7)
def test_관_세트_빈_높이_유입_0_7_저장값_그대로():
blank = culvert._culvert_set({"pipe_diameter_mm": 1200, **_revet("inlet"), **_revet("outlet")})
assert blank["inlet"]["revet_height_m"] == pytest.approx(1.9)
assert blank["outlet"]["revet_height_m"] == pytest.approx(1.7)
stored = culvert._culvert_set(
{"pipe_diameter_mm": 1200, **_revet("inlet", 1.2), **_revet("outlet", 2.4)}
)
assert stored["inlet"]["revet_height_m"] == 1.2 and stored["outlet"]["revet_height_m"] == 2.4
# 옛 기본 1.5 · 관경 > 1.0 = 저장 값 해석(139-1) — 관경 + 0.5 그대로
legacy = culvert._culvert_set({"pipe_diameter_mm": 1200, **_revet("inlet", 1.5)})
assert legacy["inlet"]["revet_height_m"] == pytest.approx(1.7)
_RUNNER = """
import { readFileSync, writeFileSync } from "node:fs";
import { buildCulvertSets } from "./common_util_culvert_sets.js";
const input = JSON.parse(readFileSync(process.argv[2], "utf8"));
const sets = buildCulvertSets(input.points, input.registry);
writeFileSync(process.argv[3], JSON.stringify([...sets.entries()]));
"""
_WALL_RUNNER = """
import { writeFileSync } from "node:fs";
import { revetWallSpec } from "./B06_Section_UI_Cross_Culvert_Const.js";
const pick = (spec, hidden, auto) => revetWallSpec(spec, {}, hidden, 1.2, auto).pureHeight;
const base = { structure: "기슭막이" };
writeFileSync(process.argv[2], JSON.stringify({
inlet: pick({ ...base, role: "inlet" }, false),
outlet: pick({ ...base, role: "outlet" }, false),
stored: pick({ ...base, role: "inlet", revet_height_m: 1.2 }, false),
hidden: pick({ ...base, role: "inlet" }, true, 2.0),
}));
"""
def _tsc(source: Path, out: Path) -> None:
subprocess.run( # noqa: S603 — 고정 실행 파일 · 타입 오류가 있어도 JS 는 나옴
[
"node",
str(TSC),
str(source),
"--outDir",
str(out),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--skipLibCheck",
"--ignoreConfig",
],
cwd=str(ROOT),
capture_output=True,
)
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_TS_세트_거울(tmp_path: Path) -> None:
points = [
{
"chainage_m": 20.0,
"facility": "pipe",
"options": {"pipe_diameter_mm": 1200, **_revet("inlet"), **_revet("outlet")},
},
{
"chainage_m": 40.0,
"facility": "pipe",
"options": {"pipe_diameter_mm": "800", **_revet("inlet", 1.2), **_revet("outlet")},
},
{
"chainage_m": 60.0,
"facility": "pipe",
"options": {"pipe_diameter_mm": 1200, **_revet("inlet", 1.5)}, # 옛 1.5
},
]
registry = {
type_id: {o.key: o.default for o in entry.options}
for type_id, entry in structure_type_map().items()
if type_id in ("pipe", "ford_bridge", "ford_pavement", "box_culvert")
}
out = tmp_path / "js"
_tsc(ROOT / "common_util" / "common_util_culvert_sets.ts", out)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
payload, result = tmp_path / "in.json", tmp_path / "out.json"
payload.write_text(json.dumps({"points": points, "registry": registry}), encoding="utf-8")
run = subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(payload), str(result)],
capture_output=True,
text=True,
)
assert run.returncode == 0, run.stderr[-2000:]
got = {at: spec for at, spec in json.loads(result.read_text(encoding="utf-8"))}
for point in points:
want = culvert._culvert_set(point["options"])
spec = got[point["chainage_m"]]
for side in ("inlet", "outlet"):
assert spec[side].get("revet_height_m") == want[side].get("revet_height_m"), point
assert got[20.0]["inlet"]["revet_height_m"] == pytest.approx(1.9)
assert got[20.0]["outlet"]["revet_height_m"] == pytest.approx(1.7)
assert got[40.0]["inlet"]["revet_height_m"] == 1.2 # 저장 값 그대로
assert got[40.0]["outlet"]["revet_height_m"] == pytest.approx(1.3)
assert got[60.0]["inlet"]["revet_height_m"] == pytest.approx(1.7) # 옛 1.5 → 관경 + 0.5
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_횡단도_벽_기본_높이(tmp_path: Path) -> None:
out = tmp_path / "js"
_tsc(ROOT / "B06_Section" / "B06_Section_UI_Cross_Culvert_Const.ts", out)
built = next(
out.rglob("B06_Section_UI_Cross_Culvert_Const.js")
) # 가져온 파일 따라 경로가 깊어짐
runner = built.parent / "runner.mjs"
runner.write_text(_WALL_RUNNER, encoding="utf-8")
result = tmp_path / "out.json"
run = subprocess.run( # noqa: S603
["node", str(runner), str(result)], capture_output=True, text=True
)
assert run.returncode == 0, run.stderr[-2000:]
got = json.loads(result.read_text(encoding="utf-8"))
assert got["inlet"] == pytest.approx(1.9) # Φ1200 + 0.7
assert got["outlet"] == pytest.approx(1.7) # 무변
assert got["stored"] == 1.2 # 칸 값 그대로
assert got["hidden"] == 2.0 # 숨은 관(세월교 · 박스) = 본체 높이 그대로
def test_B08_집계_빈_높이_유입만_0_7(tmp_path: Path) -> None:
combo = {"layer": "system", "owner": None, "key": "SS000011", "name": "횡단배수 배관"}
root = tmp_path / "proj"
edits = root / "B04_PreProcess/drainage/edits"
edits.mkdir(parents=True)
point = {
"chainage_m": 1433.0,
"source": "user",
"options": {"pipe_diameter_mm": 800, **_revet("inlet"), **_revet("outlet")},
"summary_item": combo,
"group_items": {"gnbf9m9": combo, "g9lq9uk": combo},
}
(edits / "pipe_points.json").write_text(
json.dumps({"points": [point]}, ensure_ascii=False), encoding="utf-8"
)
designs = [
{
"chainage_m": 1433.0,
"design": {"pipe_length_m": 18, "inlet_basin": None, "extra_walls": []},
}
]
names = definition.read()["종류"]["pipe"]["이름"]
linked = {owner: {} for owner in snapshots.assignments(root)}
out = summary.pipe_summary(root, designs, names, 6.0, linked=linked)
ids = [c["id"] for c in out["columns"] if c["id"].startswith("pipe:revet")]
assert ids == ["pipe:revet|돌쌓기(메)|1.3", "pipe:revet|돌쌓기(메)|1.5"], (
ids
) # 유출 1.3 · 유입 1.5
@@ -0,0 +1,214 @@
"""213-1 — 한 자리에 측점 둘이 숨는 흠(엄상준_검증 d2245e3b · No.13+3).
까닭: 자동설계 관 측점 263.206(0.001m) → 화면 저장이 관을 0.01m 로 반올림(263.21) → 측점은
263.21 로 다시 섰는데 계획선 꼭짓점(base_pvi) · 편집 키는 옛 글자 「263.206」 그대로 → 새 측점
편집 「263.210」과 0.004m 사이 두 꼭짓점(−15003% 계단).
고침: 계획선 공용 함수(파이썬 `snap_to_stations` ↔ TS `snapToStations`)가 측점 0.006m 안의
꼭짓점 · 편집 키를 그 측점으로 모음(측점 위 것이 이김 · 측점 계획고 그대로) · 192 병합도
옛 글자를 같은 측점으로 봄. 사용자 storage 는 읽기만 — 사본(tmp_path).
"""
from __future__ import annotations
import copy
import json
import shutil
import subprocess
import sys
from pathlib import Path
import numpy as np
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( # noqa: E402
AlignmentPolicy,
build_alignment,
)
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile # noqa: E402
from B05_Profile.B05_Profile_Router_Confirm import _merge_irregular_into_longitudinal # noqa: E402
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
SOURCE = (
PROJECT_ROOT
/ "storage/1/3/d2245e3b-0468-4a90-ac5c-50b2adcb56e7/B06_Section/longitudinal/longitudinal.json"
)
REL = "B06_Section/longitudinal/longitudinal.json"
needs_copy = pytest.mark.skipif(not SOURCE.is_file(), reason="엄상준_검증 storage 없음")
# 합성 — 격자 20m · 관 측점 263.206 → 263.21 반올림 · 끝 꼭짓점 1000.0(끝 측점 999.998)
STATIONS = [0.0, 20.0, 40.0, 263.21, 280.0, 300.0, 999.998]
BASE = [
(0.0, 100.0),
(20.0, 99.0),
(40.0, 98.5),
(263.206, 90.0),
(280.0, 89.0),
(300.0, 88.0),
(1000.0, 60.0),
]
EDITS = {
"station_offsets": {"263.206": 1.5, "263.210": 0.9, "40.000": 0.2, "500.000": 0.3},
"curve_radii": {"263.206": 300.0},
}
def _synthetic() -> dict:
chainage = np.arange(0.0, 1000.0, 1.0)
return {
"base_s": np.array([s for s, _ in BASE]),
"base_z": np.array([z for _, z in BASE]),
"chainage": np.r_[chainage, 1000.0],
"ground": np.r_[100.0 - chainage * 0.04, 60.0],
"stations": [
{"station_id": f"st{index}", "chainage_m": value}
for index, value in enumerate(STATIONS)
],
}
def _python(edits: dict) -> dict:
data = _synthetic()
return build_alignment(**data, policy=AlignmentPolicy.from_dict({}), edits=copy.deepcopy(edits))
def _close_pairs(alignment: dict, limit: float = 0.006) -> list[tuple[float, float]]:
pvi = [node["chainage_m"] for node in alignment["pvi"]]
return [(a, b) for a, b in zip(pvi, pvi[1:], strict=False) if b - a < limit]
def test_rounded_station_keys_collapse_onto_station():
alignment = _python(EDITS)
pvi = {round(node["chainage_m"], 3): node for node in alignment["pvi"]}
assert _close_pairs(alignment) == []
assert 263.206 not in pvi and 263.21 in pvi
# 측점 위 편집(263.210 = 0.9)이 이김 · 반경도 측점으로 옮김 · 측점에서 먼 키(500)는 그대로
assert alignment["edits"]["station_offsets"] == {"263.210": 0.9, "40.000": 0.2, "500.000": 0.3}
assert alignment["edits"]["curve_radii"] == {"263.210": 300.0}
# 양 끝 꼭짓점은 그대로(끝 1000.0 을 측점 999.998 로 당기지 않음)
assert [node["chainage_m"] for node in alignment["base_pvi"]][-1] == 1000.0
assert max(abs(segment["grade_percent"]) for segment in alignment["segments"]) < 100
def test_snapped_base_keeps_station_elevation():
"""편집 없는 측점 — 꼭짓점을 측점으로 옮겨도 그 측점 계획고(탄젠트)는 그대로."""
alignment = _python({"station_offsets": {}, "curve_radii": {}})
node = next(n for n in alignment["base_pvi"] if abs(n["chainage_m"] - 263.21) < 1e-9)
expected = float(np.interp(263.21, [s for s, _ in BASE], [z for _, z in BASE]))
assert node["elevation_m"] == pytest.approx(expected, abs=1e-6)
_RUNNER = """
import { readFileSync, writeFileSync } from "node:fs";
import { buildAlignment } from "./B05_Profile_UI_Profile_Alignment.js";
const input = JSON.parse(readFileSync(process.argv[2], "utf8"));
const result = buildAlignment(input.base, input.edits);
writeFileSync(process.argv[3], JSON.stringify(result));
"""
@pytest.fixture(scope="module")
def ts_alignment(tmp_path_factory):
if not TSC.is_file():
pytest.skip("프론트엔드 의존성 미설치")
tmp = tmp_path_factory.mktemp("align213")
out = tmp / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Alignment.ts"),
"--outDir",
str(out),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--ignoreConfig",
],
cwd=str(PROJECT_ROOT),
check=True,
capture_output=True,
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
data = _synthetic()
base = {
"policy": AlignmentPolicy.from_dict({}).as_dict(),
"onlyExplicitCurves": False,
"basePvi": [{"chainage_m": s, "elevation_m": z} for s, z in BASE],
"chainage": data["chainage"].tolist(),
"ground": data["ground"].tolist(),
"stations": data["stations"],
}
src, dst = tmp / "in.json", tmp / "out.json"
src.write_text(json.dumps({"base": base, "edits": EDITS}), encoding="utf-8")
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(src), str(dst)], check=True, capture_output=True
)
return json.loads(dst.read_text(encoding="utf-8"))
def test_mirror_python_ts(ts_alignment):
py = _python(EDITS)
ts = ts_alignment
assert ts["edits"] == py["edits"]
assert len(ts["base_pvi"]) == len(py["base_pvi"])
for a, b in zip(ts["base_pvi"], py["base_pvi"], strict=True):
assert a["chainage_m"] == pytest.approx(b["chainage_m"], abs=1e-6)
assert a["elevation_m"] == pytest.approx(b["elevation_m"], abs=1e-6)
assert [n["chainage_m"] for n in ts["pvi"]] == pytest.approx(
[n["chainage_m"] for n in py["pvi"]], abs=1e-6
)
assert [n["elevation_m"] for n in ts["pvi"]] == pytest.approx(
[n["elevation_m"] for n in py["pvi"]], abs=1e-6
)
for a, b in zip(ts["stations"], py["stations"], strict=True):
assert a["plan_elevation_m"] == pytest.approx(b["plan_elevation_m"], abs=1e-6)
def _copy(tmp_path: Path) -> Path:
target = tmp_path / REL
target.parent.mkdir(parents=True)
shutil.copyfile(SOURCE, target)
return target
@needs_copy
def test_d2245e3b_no13_3_spike_gone(tmp_path):
data = json.loads(_copy(tmp_path).read_text(encoding="utf-8"))
edits = data["profile_alignment"]["edits"]
assert {"263.206", "263.210"} <= set(edits["station_offsets"]) # 재현 조건
alignment, _ = rebuild_alignment_profile(data, edits)
assert _close_pairs(alignment) == []
assert "263.206" not in alignment["edits"]["station_offsets"]
assert max(abs(segment["grade_percent"]) for segment in alignment["segments"]) < 100
@needs_copy
def test_d2245e3b_station_regen_drops_rounded_key(tmp_path):
"""[측점 만들기] 로 관 측점 263.21 → 263.31 — 옛 글자 263.206 꼭짓점 · 편집도 함께 지워짐."""
path = _copy(tmp_path)
data = json.loads(path.read_text(encoding="utf-8"))
irregular = []
for station in data["stations"]:
if station.get("kind") != "irregular":
continue
item = copy.deepcopy(station)
if abs(item["chainage_m"] - 263.21) < 1e-6:
item["chainage_m"] = 263.31
irregular.append(item)
_merge_irregular_into_longitudinal(tmp_path, REL, irregular)
saved = json.loads(path.read_text(encoding="utf-8"))["profile_alignment"]
leftovers = [
value
for value in [n["chainage_m"] for n in saved["base_pvi"]]
+ [float(key) for key in saved["edits"]["station_offsets"]]
if 263.2 < value < 263.22
]
assert leftovers == []
assert _close_pairs(saved, 0.05) == []
@@ -0,0 +1,98 @@
# -*- coding: utf-8 -*-
"""PLAN 216-1 — B05 종단 측점선 고름 → 좌측 구조물 폼이 B06 카드 고름과 같게.
까닭: `syncIrregularSelection` 이 일반 측점이면 폼을 안 건드리고 끝났고(옛 폼이 남음 · 덮는 옹벽도
안 열림), 비정규 측점도 관 전용 `selectPipeByChainage` 만 불러 A군 수동 구조물은 안 열렸음.
B06 카드 고름은 `showAtChainage` = `resolveSelection`(관 → 덮는 구조물 · 노면공 뺌 → 없으면 비움).
고침: B05 측점 고름도 같은 `resolveSelection` + 같은 `coversAtStation` 으로 폼을 엶(`openAtStation`).
실제 TS 를 `helper_216_1_b05_station_panel.cjs` 가 Node 로 돌린다.
"""
from __future__ import annotations
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
pytestmark = pytest.mark.skipif(
not os.path.isdir(os.path.join(ROOT, "config", "node_modules")),
reason="프론트엔드 의존성 미설치",
)
@pytest.fixture(scope="module")
def out() -> dict:
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", os.path.join(HERE, "helper_216_1_b05_station_panel.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def _read(*parts: str) -> str:
with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle:
return handle.read()
def _forms(steps: list) -> list:
return [(step["b05"], step["b06"]) for step in steps]
def test_관_지점_측점선은_그_관_폼(out) -> None:
assert _forms(out["pipe"]) == [("pipe-33.40", "pipe-33.40")]
def test_관_폼이_열린_채_빈_일반_측점이면_폼을_비움(out) -> None:
assert _forms(out["pipeThenPlain"])[-1] == (None, None)
def test_일반_측점은_그_측점을_덮는_구조물_폼(out) -> None:
assert _forms(out["wallStation"]) == [(None, None), ("wall", "wall")]
assert _forms(out["wallMiddle"]) == [("wall", "wall")]
assert _forms(out["channel"]) == [("chan", "chan")] # A군 수동 구조물 측점
def test_노면공은_측점_고름으로_안_엶(out) -> None:
assert _forms(out["pave"]) == [("wall", "wall"), (None, None)]
assert out["covers"] == {"G": False, "C": True, "none": True}
def test_다시_누름_풂과_모르는_측점(out) -> None:
assert _forms(out["release"])[-1] == (None, None)
assert out["release"][-1]["pick"] is None
assert out["unknown"][-1]["b05"] == "wall" # 모르는 측점은 폼 그대로
assert out["unknown"][-1]["pick"] == {"chainageM": 60, "structureId": None}
def test_고름_한_칸은_측점_구조물_id_없음(out) -> None:
# 폼이 옹벽을 열어도(목록 고름 알림이 구조물을 적어도) 마지막은 그 측점(188-1 · B06 카드와 같음)
for key in ("wallStation", "wallMiddle", "channel", "pave", "pipeThenPlain"):
for step in out[key]:
assert step["pick"]["structureId"] is None, key
assert step["pick"]["chainageM"] in (20, 33.4, 60, 80, 100, 140, 160), key
def test_배선() -> None:
sync = _read("B05_Profile", "B05_Profile_UI_Selection.ts")
assert "(ports.openAtStation ?? ports.selectSidebar)(at);" in sync
assert "if (stationId !== null && !stationId.startsWith(prefix)) return;" not in sync
# 고름 한 칸은 폼 동기화 뒤에 적음
assert sync.index("ports.remember?.(at);") > sync.index("ports.setSyncing(false);")
view = _read("A00_Common", "design", "design_view_profile.ts")
assert "openAtStation: (chainageM) =>" in view
assert "panel.structures.selectAt({ chainageM, structureId: null }, (entry) =>" in view
assert "coversAtStation(bridge.typeGroup(entry.type_id))" in view
b06 = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts")
assert "coversAtStation(markTypes.find((type) => type.type_id === entry.type_id)?.group)" in b06
assert "resolveSelection(selection, pipeFacilities, structures, coverable)" in b06
panel = _read("B05_Profile", "B05_Profile_UI_Structures_Panel.ts")
assert "resolveSelection(selection, pipeFacilities, structures, coverable)" in panel
@@ -34,8 +34,8 @@ def test_새_관만_기본값_빈_키에만():
fresh = PipePoint(chainage_m=10.0, source="stream", options={"pipe_diameter_mm": 800})
basins._apply_new_defaults([fresh])
# 추천(먼저 들어간 값)이 이김 · 집수정 모양 L 도 실제 값(54-39)
# 관 보호공 · 유입 집수정 높이 = 관경 + 0.5(139-1 · Φ800 → 1.3)
heights = dict.fromkeys(basins.PIPE_HEIGHT_KEYS, 1.3)
# 관 보호공 · 유입 집수정 높이 = 관경 + 0.5(139-1 · Φ800 → 1.3) · 유입 흙막이만 + 0.7(211-1 → 1.5)
heights = {**dict.fromkeys(basins.PIPE_HEIGHT_KEYS, 1.3), "inlet_revet_height_m": 1.5}
expected = {**_defaults("pipe"), "pipe_diameter_mm": 800, "inlet_structure": "L", **heights}
assert fresh.options == expected
assert fresh.options["pipe_kind"] == "파형강관" and fresh.options["inlet_type"] == "집수정"
@@ -66,7 +66,8 @@ def test_원본에_있던_관은_빈칸_그대로(monkeypatch):
by = {p.chainage_m: p for p in points}
assert by[100.0].options is None # 옛 빈칸 그대로
# 새로 보충된 관 = 기본값 + 집수정 모양 L(54-39)
heights = dict.fromkeys(basins.PIPE_HEIGHT_KEYS, 1.5) # 기본 관경 1000 → 1.5(139-1)
# 기본 관경 1000 → 1.5(139-1) · 유입 흙막이 1.7(211-1)
heights = {**dict.fromkeys(basins.PIPE_HEIGHT_KEYS, 1.5), "inlet_revet_height_m": 1.7}
assert by[300.0].options == {**_defaults("pipe"), "inlet_structure": "L", **heights}
@@ -26,7 +26,7 @@ def _ts_const(source: str, name: str) -> float:
def test_상수_셋이_TS_와_같다() -> None:
for name in ("REVET_HEIGHT_STEP_M", "REVET_EMBED_DEPTH_M"):
for name in ("REVET_HEIGHT_STEP_M", "REVET_EMBED_DEPTH_M", "INLET_REVET_ADD_M"):
assert getattr(culvert, name) == _ts_const(TS, name) == _ts_const(SETS, name), name
assert culvert.OLD_PIPE_WALL_DEFAULT_M == _ts_const(SETS, "OLD_PIPE_WALL_DEFAULT_M")
@@ -35,9 +35,16 @@ def test_TS_식_모양이_그대로다() -> None:
"""식을 바꾸면 여기서 잡힘 — 파이썬 식도 함께 고칠 것."""
assert "const raw = diameterM;" in TS and "const raw = diameterM;" in SETS
assert "Math.ceil(raw / REVET_HEIGHT_STEP_M - 1e-9) * REVET_HEIGHT_STEP_M" in TS
assert "return revetTargetHeight(diameterM) + REVET_EMBED_DEPTH_M;" in TS
# 그림이 칸 값을 읽음(139-1) — 배관 기본 = 칸 → 관경 기준
assert "(spec.revet_height_m ?? pipeWallMinPureHeight(diameterM))" in TS
assert (
'revetTargetHeight(diameterM) + (side === "inlet" ? INLET_REVET_ADD_M : REVET_EMBED_DEPTH_M)'
in (
" ".join(TS.split()) # 줄바꿈 · 들여쓰기 무시(포맷터가 접음)
)
.replace("( ", "(")
.replace(" )", ")")
)
# 그림이 칸 값을 읽음(139-1) — 배관 기본 = 칸 → 관경 기준(유입 + 0.7 · 211-1)
assert "(spec.revet_height_m ?? pipeWallMinPureHeight(diameterM, spec.role))" in TS
# 옛 1.5 옮김 — TS 세트 짝 식
assert "Math.abs(stored - OLD_PIPE_WALL_DEFAULT_M) < 1e-9 &&" in SETS
assert "diameterM > 1.0 + 1e-9" in SETS
+11 -6
View File
@@ -18,12 +18,17 @@ const fs = require("fs");
const path = require("path");
const [root, file] = process.argv.slice(1);
const ts = require(path.join(root, "config", "node_modules", "typescript"));
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, () => ({}));
const m = box.exports;
const load = (src, req) => {
const js = ts.transpileModule(fs.readFileSync(src, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, req);
return box.exports;
};
// 로직 찾기는 공용 맞춤(208-1) — 그것만 실제로 싣고 나머지 import 는 빈 것
const find = load(path.join(root, "ui_template", "ui_template_logic_find.ts"), () => ({}));
const m = load(file, (p) => (p.endsWith("ui_template_logic_find") ? find : {}));
(async () => {
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
const out = [];
+6
View File
@@ -148,6 +148,12 @@ export function createCadHost<D extends CadHostDrawing, M = unknown, Q = unknown
frame.addEventListener("load", () => {
if (frame.contentWindow) blockNativeMenu(frame.contentWindow, () => true);
});
// 포인터가 CAD 위에 오면 키(Ctrl+Z · Ctrl+Y …)가 CAD 로 가게 초점을 넘김 — 부모 입력 칸에서 치는 중이면 안 뺏음
frame.addEventListener("mouseenter", () => {
const active = document.activeElement;
if (active === frame || active?.closest("input, textarea, select, [contenteditable]")) return;
frame.contentWindow?.focus();
});
const license = document.createElement("a");
license.className = "cad-host__license";
license.href = "/b07-cad/THIRD_PARTY_LICENSES.txt";
+53
View File
@@ -0,0 +1,53 @@
/* =============================================================================
* ui_template_logic_find.ts
* 단가산출 로직 찾기 맞춤(208-1) — 로직을 찾는 화면은 모두 이것 하나를 부름.
* 낱말(띄어 나눔)마다 키 · 원문번호 · 이름 · 구분 · 상세구분 · 별칭(쉼표로 여럿) 가운데 하나에 들면 맞음 ·
* 띄어쓰기 · 대소문자 무시. 별칭으로 맞았으면 그 별칭을 돌려줌(결과에 작게).
* 짝: `M01_MasterData_Store_Find.hit`(서버 찾기 — 키 · 원문번호 · 이름 · 별칭 · 낱말 규칙이 같음 ·
* 구분 · 상세구분은 화면만) · 거울 시험 `test_208_1_logic_alias.py`.
* ========================================================================== */
export interface LogicFindRow {
키?: string;
원문번호?: string;
이름?: string;
구분?: string;
상세구분?: string;
/** 쉼표로 여럿 · 없으면 빈칸 */
별칭?: string | null;
}
export interface LogicFindHit {
ok: boolean;
/** 별칭으로 맞았으면 맞은 별칭(쉼표로 이음) · 아니면 빈 글 */
alias: string;
}
const squash = (v: unknown): string =>
String(v ?? "")
.replace(/\s+/g, "")
.toLowerCase();
/** 별칭 칸 → 낱낱 별칭(쉼표로 나눔 · 빈 것 뺌) */
export const aliasesOf = (v: unknown): string[] =>
String(v ?? "")
.split(",")
.map((x) => x.trim())
.filter(Boolean);
/** 고친 별칭 글을 저장 꼴로 — 「a ,b,, c」 → 「a, b, c」 */
export const aliasText = (v: unknown): string => aliasesOf(v).join(", ");
/** 결과에 작게 붙이는 글 — 별칭으로 안 맞았으면 빈 글 */
export const aliasNote = (alias: string): string => (alias ? `별칭 ${alias}` : "");
export function matchLogic(q: string, row: LogicFindRow): LogicFindHit {
const words = q.split(/\s+/).map(squash).filter(Boolean);
const fields = [row.키, row.원문번호, row.이름, row.구분, row.상세구분].map(squash);
const missed = words.filter((w) => !fields.some((f) => f.includes(w)));
if (!missed.length) return { ok: true, alias: "" };
const aliases = aliasesOf(row.별칭);
const has = (a: string, w: string): boolean => squash(a).includes(w);
if (!missed.every((w) => aliases.some((a) => has(a, w)))) return { ok: false, alias: "" };
return { ok: true, alias: aliases.filter((a) => missed.some((w) => has(a, w))).join(", ") };
}