Merge remote-tracking branch 'origin/dev' into sub_laptop_5
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -313,7 +313,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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user