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

This commit is contained in:
2026-10-18 23:18:02 +09:00
40 changed files with 2011 additions and 200 deletions
+1
View File
@@ -11,6 +11,7 @@ const ROOT = path.join(__dirname, "..", "..");
const DATA = { pipes: [], structures: [], types: [] };
const STUBS = {
"@ui/cad_host/cad_host": { createCadHost: () => ({}) },
"@ui/ui_template_elements": { showToast: () => undefined },
"../A00_Common/b_page_state": { readState: () => null, writeState: () => undefined },
"../A00_Common/design/design_selection": { PIPE_MATCH_M: 0.51 },
"../B04_PreProcess/B04_PreProcess_Api_Fetch": {
@@ -0,0 +1,306 @@
/* 271-1~3 종단 직선화 측점 고름 · 끝 R 기본 L · 실시간 띠 단위검증 헬퍼 — `_Straighten.ts` ·
* `_Tools.ts` · `design_profile_free.ts` · `design_profile_range_band.ts` 를 그 자리에서 트랜스파일해
* 돌리고 파이썬 거울 입력(base · edits · TS 곡선)을 JSON 으로 낸다.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
// 화면 틀 파일(@ui 별칭)은 Node 가 못 읽음 — 도구줄 그림만 쓰므로 빈 그림으로 갈음
const STUBS = { "./B05_Profile_UI_RouteEdit_Chrome": { HISTORY_ICON: { undo: "", redo: "" } } };
const cache = new Map();
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 near = (request) => {
if (STUBS[request]) return STUBS[request];
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
return require(request);
};
new Function("exports", "module", "require", js)(box.exports, box, near);
return box.exports;
}
const LOGIC = path.join(ROOT, "B05_Profile", "logic");
const align = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Alignment.ts"));
const st = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Straighten.ts"));
const law = loadTs(path.join(LOGIC, "B05_Profile_Profile_CurveLaw.ts"));
const tools = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Tools.ts"));
const DESIGN = path.join(ROOT, "A00_Common", "design");
const free = loadTs(path.join(DESIGN, "logic", "design_profile_free.ts"));
const band = loadTs(path.join(DESIGN, "design_profile_range_band.ts"));
const policy = {
station_interval_m: 20,
curve_radius_ratio: 1,
curve_tangent_max_ratio: 0.5,
curve_skip_legal_exception: false,
default_curve_length_m: 20,
default_curve_radius_m: 300,
balance_tolerance_percent: 10,
edit_step_m: 0.1,
grade_violation_policy: "warn",
max_grade_pct: 14,
curve_skip_delta_pct: 0,
paved: true,
min_vertical_radius_m: 100,
min_curve_length_m: 20,
};
// 0~300 m · 20 m 측점 — 마디마다 기울기가 다른 전체 측점 폴리라인(서버와 같이 지정 없는 꺾임 = 기본 L)
const S = Array.from({ length: 16 }, (_, i) => i * 20);
const g = (s) => 100 + 0.03 * s + 0.6 * Math.sin(s / 13);
const base = {
policy,
onlyExplicitCurves: false,
basePvi: S.map((s) => ({ chainage_m: s, elevation_m: +g(s).toFixed(6) })),
chainage: S,
ground: S.map((s) => +g(s).toFixed(6)),
stations: S.map((s) => ({ station_id: `S${s}`, chainage_m: s })),
};
const tidy = (e) => st.tidyEdits(base, e);
const build = (e) => align.buildAlignment(base, e);
const curves = (e) => build(e).curves.map((c) => ({ at: c.chainage_m, l: c.l_m, r: c.r_m }));
const shortfalls = (e) => build(e).warnings.filter((w) => w.includes("미달"));
const out = {};
const mirror = [];
const keep = (name, edits) => {
mirror.push({ name, edits, curves: curves(edits).map((c) => [c.at, c.r, c.l]) });
return edits;
};
// ① 직선화 40~200 — 사이 곡선 없음 · 양 끝 기본 L 20
const e1 = keep("straight", tidy(st.straightenBetween(base, align.emptyEdits(), 40, 200)));
const a1 = build(e1);
out.straight = { curves: curves(e1), radii: Object.keys(e1.curve_radii) };
// ② 이미 직선인 범위 = 부분 구간 · 반대 방향 같음 · 꺾인 · 섞인 범위 = null
out.sub = st.straightSubRun(a1, 80, 160);
out.subReverse = st.straightSubRun(a1, 160, 80);
out.subBent = st.straightSubRun(build(align.emptyEdits()), 40, 200);
out.subMixed = st.straightSubRun(a1, 100, 260);
out.subOneSpan = st.straightSubRun(a1, 80, 100);
// ③ 부분 구간 ▲(틸트) = 가운데 + 양 끝 L 20 · 거짓 미달 없음
const e3 = keep("tiltSub", tidy(st.tiltStraightRun(base, e1, out.sub, 0.5)));
out.tiltSub = { curves: curves(e3), shortfalls: shortfalls(e3) };
// 판정 오차 짝(1e-3) — 옛 1e-6 이면 L 19.99998 이 미달로 읽힘
out.tolerance = law.SHORTFALL_TOLERANCE_M;
out.nearMiss = law.verticalCurveShortfalls(
[{ chainage_m: 120, l_m: 19.99998, r_m: 800, omitted: false, skip_allowed: false }],
policy,
);
out.realMiss = law.verticalCurveShortfalls(
[{ chainage_m: 120, l_m: 19.9, r_m: 800, omitted: false, skip_allowed: false }],
policy,
);
// ④ 끝에 저장 자동 R → 지움(기본 L 20) · 사용자 R 은 둠
const saved = { "40.000": 5330, "200.000": 1084 };
const savedEdits = { station_offsets: {}, curve_radii: { ...saved } };
const e4 = keep("autoEnds", tidy(st.straightenBetween(base, savedEdits, 40, 200, saved)));
out.autoEnds = { radii: Object.keys(e4.curve_radii), curves: curves(e4) };
const userEdits = { station_offsets: {}, curve_radii: { "40.000": 600, "200.000": 1084 } };
const e4u = tidy(st.straightenBetween(base, userEdits, 40, 200, saved));
out.userEnd = { radii: e4u.curve_radii };
// 자동 R 기준 없이(옛 부름) = 그대로 남음
out.noAuto = Object.keys(tidy(st.straightenBetween(base, savedEdits, 40, 200)).curve_radii);
// ⑤ BP · EP — 시종점 곡선 없음
const e5 = keep("bpEp", tidy(st.straightenBetween(base, align.emptyEdits(), 0, 300)));
out.bpEp = curves(e5);
const eBP = tidy(st.straightenBetween(base, align.emptyEdits(), 0, 200));
const subBP = st.straightSubRun(build(eBP), 0, 80);
const e5t = keep("tiltBp", tidy(st.tiltStraightRun(base, eBP, subBP, 0.5)));
out.tiltBp = curves(e5t).map((c) => c.at);
// ⑥ 곡선 섞인 범위 = 안쪽 R 지움 · 양 끝 L 20
const mixedIn = { station_offsets: {}, curve_radii: { "120.000": 900, "140.000": 700 } };
const e6 = keep("mixed", tidy(st.straightenBetween(base, mixedIn, 100, 260, {})));
out.mixed = { radii: Object.keys(e6.curve_radii), curves: curves(e6) };
// ⑦ 자유지정 끝 자동 R 지움 · 사용자 R 둠
const z = (s) => align.controlElevationAt(build(savedEdits), s);
const vertices = [
{ chainage_m: 40, elevation_m: z(40) },
{ chainage_m: 120, elevation_m: z(120) + 1 },
{ chainage_m: 200, elevation_m: z(200) },
];
const e7 = keep("freeAuto", tidy(free.buildFreeEdits(base, savedEdits, vertices, saved)));
out.freeAuto = Object.keys(e7.curve_radii);
out.freeUser = Object.keys(tidy(free.buildFreeEdits(base, userEdits, vertices, saved)).curve_radii);
// ⑧ 자유지정 range 단계 실시간 띠 · Esc = 시작만 취소
{
const stations = S;
let edits = align.emptyEdits();
const log = { closed: 0 };
const draw = free.createFreeDraw({
base: () => base,
alignment: () => build(edits),
edits: () => edits,
apply: (next) => (edits = next),
close: () => (log.closed += 1),
changed: () => undefined,
});
draw.click(41, 0, stations);
draw.hover({ chainageM: 117, elevationM: 0, stations });
out.liveBand = draw.marks().band;
out.liveEdges = draw.marks().edges;
draw.hover({ chainageM: 13, elevationM: 0, stations });
out.liveReverse = draw.marks().band;
draw.hover(null);
out.outside = { band: draw.marks().band, edges: draw.marks().edges };
out.hintPending = draw.hint();
out.escTaken = draw.key("Escape");
out.afterEsc = { phase: draw.phase(), edges: draw.marks().edges, closed: log.closed };
draw.click(41, 0, stations);
draw.click(158, 0, stations);
draw.hover({ chainageM: 260, elevationM: 0, stations });
out.fixedBand = draw.marks().band;
out.armedPhase = draw.phase();
out.escArmed = draw.key("Escape");
out.afterEscArmed = { phase: draw.phase(), closed: log.closed };
}
// ⑩ 범위 띠 그림 — 가짜 DOM
class El {
constructor(tag) {
this.tag = tag;
this.attrs = {};
this.children = [];
this.parent = null;
this.textContent = "";
this.listeners = {};
this.isConnected = true;
this.classList = { toggle: () => undefined, add: () => undefined };
}
setAttribute(name, value) {
this.attrs[name] = value;
}
append(...items) {
items.forEach((item) => {
item.parent = this;
this.children.push(item);
});
}
remove() {
if (!this.parent) return;
this.parent.children = this.parent.children.filter((c) => c !== this);
this.parent = null;
}
addEventListener(type, fn, opts) {
(this.listeners[type] ??= []).push(fn);
opts?.signal?.addEventListener("abort", () => {
this.listeners[type] = this.listeners[type].filter((f) => f !== fn);
});
}
fire(type, event = {}) {
(this.listeners[type] ?? []).forEach((fn) => fn(event));
}
getBoundingClientRect() {
return { left: 0, top: 0 };
}
}
global.document = {
createElementNS: (_ns, tag) => new El(tag),
createElement: (tag) => new El(tag),
};
const shape = (svg) =>
svg.children.map((c) =>
c.tag === "text"
? `text:${c.textContent}`
: c.tag === "rect"
? `rect:${c.attrs.x}+${c.attrs.width}`
: `line:${c.attrs.x1}`,
);
{
const x = (m) => m * 2;
const svg = new El("svg");
band.appendRangeBand(svg, x, 100, 120, 40);
out.bandShape = shape(svg);
const one = new El("svg");
band.appendRangeBand(one, x, 100, 40, null);
out.bandStart = shape(one);
const chart = new El("div");
const scroller = new El("div");
const geom = {
chart,
scroller,
x,
toChainage: (px) => px / 2,
widthPx: 640,
heightPx: 100,
stations: S,
};
let start = null;
const view = band.createRangeBandView((hover) => (start === null ? null : [start, hover]));
view.mount(geom, true);
out.viewIdle = chart.children.length;
start = 40;
view.redraw();
out.viewStart = shape(chart.children[0]);
chart.fire("mousemove", { clientX: 2 * 117 });
out.viewHover = shape(chart.children[0]);
chart.fire("mouseleave");
out.viewLeave = shape(chart.children[0]);
chart.fire("mousemove", { clientX: 2 * 61 });
view.mount(geom, true); // 다시 그림(줌 · 스크롤 · 계획선 바뀜) 뒤에도 커서 자리 이어짐
out.viewRemount = shape(chart.children[0]);
view.mount(geom, false);
out.viewOff = chart.children.length;
}
// ⑨ 도구 상태 — [직선화] 부분 구간은 붉은 강조(selectedRuns) 아님 · Esc 취소 · 모드 유지
{
const picked = [];
const t = tools.createProfileTools({
onStraighten: (from, to) => picked.push([from, to]),
onShift: () => undefined,
onTilt: (run, delta) => picked.push(["tilt", run.fromM, run.toM, delta]),
onResetToSaved: () => undefined,
canResetToSaved: () => false,
onUndo: () => undefined,
onRedo: () => undefined,
canUndo: () => false,
canRedo: () => false,
hasStraightRuns: () => true,
onChanged: () => undefined,
onModeChange: () => undefined,
freeHint: () => "",
mountOverlay: () => undefined,
});
const press = (label) => {
const button = t.render().children.find((c) => c.textContent === label);
button.fire("click", { stopPropagation: () => undefined });
};
const hintOf = () => t.render().children.find((c) => c.tag === "em")?.textContent ?? null;
out.toolsNone = t.handleStationPick(40);
press("직선화");
t.handleStationPick(40);
out.pending = { at: t.pendingStation(), hint: hintOf() };
out.cancel = { taken: t.cancelPick(), pending: t.pendingStation(), mode: t.mode() };
out.cancelAgain = t.cancelPick();
t.handleRunPick({ fromM: 80, toM: 160, nodes: [80, 100, 120, 140, 160] });
out.subPicked = { sub: t.subRun(), red: t.selectedRuns(), hint: hintOf() };
press("▲");
out.tiltCall = picked.at(-1);
t.handleStationPick(60); // 새 시작 = 부분 구간 풂
out.newStart = { pending: t.pendingStation(), sub: t.subRun() };
t.handleStationPick(180);
out.straightenCall = picked.at(-1);
press("직선화"); // 끔
press("쉬프트");
t.handleRunPick({ fromM: 40, toM: 200, nodes: [40, 200] });
out.shiftRed = { red: t.selectedRuns().length, sub: t.subRun(), cancel: t.cancelPick() };
}
out.mirror = { base, cases: mirror };
process.stdout.write(JSON.stringify(out));
@@ -76,6 +76,7 @@ const drafts = loadTs("B06_Section/B06_Section_Section_Drafts.ts", {
const hit = sections.find((s) => Math.abs(s.chainage_m - pipe.chainage_m) < 0.005);
if (hit) hit.culvert = true;
}
return { outside: [], far: [] };
},
},
// 측점 열쇠 세우기(344-3) — 이 시험 몫 아님
+1
View File
@@ -142,6 +142,7 @@ function loadTs(target) {
const full = path.resolve(target);
if (STUBS.has(full)) return STUBS.get(full);
if (cache.has(full)) return cache.get(full);
if (full.endsWith(".css")) return {}; // 화면 꼴(vite 몫)
if (full.endsWith(".json")) {
const data = JSON.parse(fs.readFileSync(full, "utf8"));
cache.set(full, data);
+2 -2
View File
@@ -41,9 +41,9 @@ const hash = load(
);
catalog.useCatalogDefinition({ types: input.types, ...input.definition });
const out = { cases: {} };
const out = { cases: {}, misses: {} };
for (const [name, { sections, pipes, structures }] of Object.entries(input.cases)) {
attach.attachShapes(sections, pipes, structures, input.types);
out.misses[name] = attach.attachShapes(sections, pipes, structures, input.types);
out.cases[name] = sections;
}
@@ -514,7 +514,10 @@ async function run() {
st("pipe:p28", 28, "pipe"),
st("end:EP", 40, "end"),
];
const source = crossMod.pipelineCrossSource(C, screen, savedList, 7);
// 초안 자리 관 측점(27 m) = 등록 열쇠(주인 id)로 저장 관 측점과 짝(457 · 차례 짝 없음)
const source = crossMod.pipelineCrossSource(C, screen, savedList, 7, () =>
savedList.map((s) => s.key),
);
const bare = crossMod.pipelineCrossSource(
C,
{ ...screen, cross_sections: screen.cross_sections.filter((x) => x.chainage_m !== 27) },
@@ -534,6 +537,7 @@ async function run() {
const viewFree = {
grid20: source.input.byKey["grid:1"],
pipeSpec: source.specs.get("pipe:p28") ?? null,
pipeCarried: Boolean(source.input.byKey["pipe:p28"]),
specsDiffer: source.input.specsKey !== bare.input.specsKey,
firstStored: firstPvi(planlineOf(() => savedBase.basePvi)),
firstShaken: firstPvi(planlineOf(undefined)),
+2
View File
@@ -9,6 +9,7 @@ const STUBS = {
B05_Profile_Pipeline_Grade: { stationIdOf: idOf },
B05_Profile_UI_Profile_Data: { toDesignProfile: () => ({}) },
};
const CATALOG = require("./helper_322_catalog_module.cjs");
const cache = new Map();
function load(full) {
if (cache.has(full)) return cache.get(full).exports;
@@ -18,6 +19,7 @@ function load(full) {
const box = { exports: {} };
cache.set(full, box);
const req = (r) => {
if (CATALOG.isCatalogRequest(r)) return CATALOG; // 형상 얹기(Pipeline_Detail · 457)가 끌어감
const name = r.split("/").pop();
if (STUBS[name]) return STUBS[name];
if (r.startsWith("@util/")) return load(path.join(ROOT, "common_util", `${r.slice(6)}.ts`));
+14 -1
View File
@@ -6,7 +6,8 @@ const ROOT = path.resolve(__dirname, "..", "..");
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
const idOf = (m) => `station_${String(Math.round(m * 1000)).padStart(12, "0")}`;
const REAL =
/(B05_Profile_Pipeline_Detail|B06_Section_Station_Swap|design_pipeline_cross|common_util_station_key)$/;
/(B05_Profile_Pipeline_Detail|B06_Section_Station_Swap|design_pipeline_cross|common_util_\w+)$/;
const CATALOG = require("./helper_322_catalog_module.cjs");
const STUBS = {
B05_Profile_Pipeline_Grade: { stationIdOf: idOf },
common_util_station: { stationLabel: (m) => String(m) },
@@ -27,6 +28,9 @@ function load(full) {
const box = { exports: {} };
cache.set(full, box);
const req = (r) => {
if (CATALOG.isCatalogRequest(r)) return CATALOG;
if (r.endsWith(".json"))
return JSON.parse(fs.readFileSync(path.resolve(path.dirname(full), r)));
const name = r.split("/").pop();
if (STUBS[name]) return STUBS[name];
if (REAL.test(r)) return load(path.resolve(path.dirname(full), r + ".ts"));
@@ -83,6 +87,13 @@ const draftStations = [
const oldKeys = savedStations.map((s) => s.key);
const source = crossMod.pipelineCrossSource("p1", saved, savedStations, null);
// 그림 자료 = 지금 현황(따라간 관 · 457) — 관 p35100 이 354 로
const types = [{ type_id: "pipe", name: "배수관", options: [] }];
const status = detailMod.shapeStatus(
[{ id: "p35100", facility: "pipe", chainage_m: 354, options: { pipe_diameter_mm: "800" } }],
[],
types,
);
const draft = detailMod.draftSectionDetail(
saved,
{ stations: draftStations, longitudinal: { chainageM: [0, 400], z: [0, 0] } },
@@ -90,6 +101,7 @@ const draft = detailMod.draftSectionDetail(
null,
source.specs,
20,
status,
);
const swapped = swapMod.swapCrossSections(saved, draftStations, oldKeys, 20).detail;
const ids = (detail) => detail.cross_sections.map((s) => s.station_id);
@@ -106,6 +118,7 @@ const added = detailMod.draftSectionDetail(
null,
source.specs,
20,
status,
);
console.log(
JSON.stringify({
+275
View File
@@ -0,0 +1,275 @@
/* 457 헬퍼 — 노선 바뀜 뒤 초안 카드(묶음 새 상세) 그림 · 이름 · 시설 맥락 = 지금 현황을 새 측점에 다시 얹은 것.
* 진짜 TS(`design_pipeline_cross` · `B05_Profile_Pipeline_Detail` · 측점 · 따라감 · 형상 얹기) · 세션 창구만 가짜.
* 재현 E · F(시점 당김) · G(세션에서 놓은 BOX) · H(관 둘 엇갈려 옮김) — 결과 = 표준출력 JSON. 짝 = `test_457_draft_card.py`. */
const fs = require("fs");
const path = require("path");
const ROOT = path.resolve(__dirname, "..", "..");
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
const CATALOG = require("./helper_322_catalog_module.cjs");
const STUBS = {
b_page_state: { readState: () => null },
B06_Section_Section_Canon: { savedDesigns: () => null },
B06_Section_UI_Standard_Panel: {
effectiveStandardCross: () => ({ road_width_m: 3 }),
readRockBoundaryDefault: () => null,
},
B05_Profile_UI_Profile_Data: { toDesignProfile: () => ({}) },
};
const cache = new Map();
function resolveTs(base) {
for (const c of [`${base}.ts`, base]) if (fs.existsSync(c) && fs.statSync(c).isFile()) return c;
throw new Error(`없음: ${base}`);
}
function load(full) {
if (cache.has(full)) return cache.get(full).exports;
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);
const req = (r0) => {
if (CATALOG.isCatalogRequest(r0)) return CATALOG;
const r = r0.replace(/\.js$/, "");
const stub = STUBS[r.split("/").pop()];
if (stub) return stub;
if (r.endsWith(".json"))
return JSON.parse(fs.readFileSync(path.join(path.dirname(full), r), "utf8"));
if (r.startsWith("@util/")) return load(resolveTs(path.join(ROOT, "common_util", r.slice(6))));
if (r.startsWith(".")) return load(resolveTs(path.join(path.dirname(full), r)));
return require(r);
};
new Function("exports", "module", "require", js)(box.exports, box, req);
return box.exports;
}
const P = (...a) => path.join(ROOT, ...a);
const follow = load(P("common_util", "common_util_route_follow.ts"));
const SS = load(P("B05_Profile", "logic", "B05_Profile_Sections_Stations.ts"));
const AT = load(P("common_util", "common_util_structure_attach.ts"));
const ST = load(P("common_util", "common_util_structure_station.ts"));
const CS = load(P("common_util", "common_util_culvert_sets.ts"));
const PD = load(P("B05_Profile", "logic", "B05_Profile_Pipeline_Detail.ts"));
const CR = load(P("A00_Common", "design", "design_pipeline_cross.ts"));
const def = JSON.parse(
fs.readFileSync(P("resources", "master_template", "구조물정의.json"), "utf8"),
);
const types = def.types
.filter((t) => t.enabled !== false)
.map((t) => ({
type_id: t.type_id,
group: t.group,
placement: t.placement,
managed_by: t.managed_by,
name: t.name,
options: (t.options || []).map((o) => ({ key: o.key, label: o.label, default: o.default })),
}));
const SHAPES = AT.SHAPE_KEYS;
const idOf = (m) => `station_${String(Math.round(m * 1000)).padStart(12, "0")}`;
const kindOf = (s) =>
s.kind === "end"
? s.key.endsWith("EP")
? "ep"
: "bp"
: s.kind === "grid"
? "regular"
: s.kind === "curve"
? "curve"
: "irregular";
const ground = (s) => ({
...s,
ground: { centerZ: 100, left: [{ offsetM: 5, z: 101 }], right: [{ offsetM: 5, z: 99 }] },
});
const sectionsFrom = (S) =>
S.map((s) => ({
station_id: idOf(s.chainageM),
chainage_m: s.chainageM,
label: "",
kind: kindOf(s),
center_x: s.x,
center_y: s.y,
samples: [],
design: { design_elevation_m: 101 },
}));
const xy = (L) => L.map(([x, y]) => ({ x, y }));
function at(L, ch) {
let walked = 0;
for (let i = 1; i < L.length; i += 1) {
const [a, b] = [L[i - 1], L[i]];
const span = Math.hypot(b[0] - a[0], b[1] - a[1]);
if (walked + span >= ch) {
const t = (ch - walked) / span;
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
}
walked += span;
}
return L[L.length - 1];
}
const pipe = (id, facility, ch, L, options) => {
const [x, y] = at(L, ch);
return { id, facility, chainage_m: ch, x, y, options };
};
const wall = (L) => {
const [x, y] = at(L, 70);
return {
structure_id: "w1",
type_id: "retaining_wall",
chainage_m: 70,
start_m: 60,
end_m: 80,
x,
y,
options: { height_m: 2, side: "left" },
};
};
/** 측점 그림 지문 — 얹힌 형상 열쇠 · 그 누가거리(구간이면 시작~끝). */
const shapesOf = (s) =>
SHAPES.filter((k) => s[k] !== undefined)
.map((k) => {
const v = s[k];
const c = Array.isArray(v)
? v.map((x) => `${x.start_m ?? x.chainage_m ?? ""}~${x.end_m ?? ""}`).join("/")
: v && (v.chainage_m ?? `${v.start_m ?? ""}~${v.end_m ?? ""}`);
return `${k}@${c}`;
})
.join(" ");
/** 정답 = 새 측점(구조물 없는 꼴)에 지금 현황(따라간 관 · 구조물)을 얹은 그림 · 측점 계획 이름(캐시 `withDraftShapes`). */
function truthOf(S2, pipes, structures) {
const bare = S2.map((s) => ({ chainage_m: s.chainageM }));
AT.attachShapes(bare, pipes, structures, types);
const planned = ST.planStations(pipes, structures, types, CS.registryDefaults(types));
return bare.map((s) => ({
shapes: shapesOf(s),
name: planned.find((p) => Math.abs(p.chainage_m - s.chainage_m) < 0.005)?.title ?? null,
}));
}
function scenario(L1, L2, saved, now, opts = {}) {
const S1 = SS.buildStations(L1, [], saved.pipes, saved.structures, { types, intervalM: 20 });
// 저장 상세(서버) = 저장 측점에 저장 현황 그림 · 초안 둔 채 열기면 화면 상세가 초안 자리(`canon`)
const Scanon = opts.canon ?? S1;
const detail = {
longitudinal: { stations: [], design_profiles: [], length_m: 0, samples: [] },
cross_sections: sectionsFrom(Scanon),
};
AT.attachShapes(
detail.cross_sections,
opts.canonPipes ?? saved.pipes,
opts.canonStructures ?? saved.structures,
types,
);
const pF = follow.projectPipePoints(now.pipes, xy(L2));
const sF = follow.followStructures(now.structures, xy(L2), undefined, 20).map((m) => m.item);
const S2 = SS.buildStations(L2, [], pF, sF, { types, intervalM: 20 }).map(ground);
const keysOf = opts.keys ? () => opts.keys : undefined;
const source = CR.pipelineCrossSource("p457", detail, S1, 7, keysOf);
// 노선 초안 동안 등록 목록이 새 노선 것으로 바뀜 — 처음 본 열쇠 그대로여야 함
const again = opts.keys
? CR.pipelineCrossSource("p457", detail, S1, 7, () => [...opts.keys].reverse())
: source;
const status = PD.shapeStatus
? PD.shapeStatus(pF, sF, types)
: { pipes: pF, structures: sF, types };
const card = PD.draftSectionDetail(
detail,
{ stations: S2, longitudinal: { chainageM: [0], z: [0] } },
{},
null,
again.specs,
20,
status,
);
const truth = truthOf(S2, pF, sF);
const shaped = PD.crossWithShapes ? PD.crossWithShapes(source.input, S2, status) : source.input;
const ctx = (key) => {
const e = shaped.byKey[key] ?? {};
return [Boolean(e.paved), Boolean(e.tilted), e.tiltPct ?? null, e.surfaceDropM ?? 0];
};
return {
rows: S2.map((s, i) => ({
key: s.key,
m: s.chainageM,
card: shapesOf(card.cross_sections[i]),
truth: truth[i].shapes,
name: card.cross_sections[i].structure ?? null,
truthName: truth[i].name,
id: card.cross_sections[i].station_id,
ctx: ctx(s.key),
})),
canonIds: Object.fromEntries(
Scanon.map((s, i) => [s.key, detail.cross_sections[i].station_id]),
),
sourceKeys: Object.fromEntries([...source.specs].map(([k, v]) => [k, v.station_id])),
specFields: [...new Set([...source.specs.values()].flatMap((v) => Object.keys(v)))],
};
}
const L1 = [
[0, 0],
[100, 0],
[200, 60],
];
const P1 = [
pipe("p1", "pipe", 47, L1, { pipe_diameter_mm: "800" }),
pipe("fp", "ford_pavement", 125, L1, { ford_width_m: 12 }),
];
const facilities = { pipes: P1, structures: [wall(L1)] };
const out = {};
out.E = scenario(
L1,
[
[-13, 0],
[100, 0],
[200, 60],
],
facilities,
facilities,
);
out.F = scenario(
L1,
[
[-7, 0],
[100, 0],
[200, 60],
],
facilities,
facilities,
);
out.G = scenario(
L1,
[
[-7, 0],
[100, 0],
[200, 60],
],
facilities,
{
pipes: [...P1, pipe("p9", "box_culvert", 90, L1, { body_width_m: 2 })],
structures: [wall(L1)],
},
);
// H — 세션에서 관 둘을 엇갈려 옮김(저장 전) · 초안 둔 채 열기(화면 상세 = 옮긴 자리) → 시점 +7
{
const saved = [
pipe("p1", "pipe", 47, L1, { pipe_diameter_mm: "800" }),
pipe("p2", "box_culvert", 90, L1, { body_width_m: 2 }),
];
const moved = [
pipe("p1", "pipe", 110, L1, { pipe_diameter_mm: "800" }),
pipe("p2", "box_culvert", 30, L1, { body_width_m: 2 }),
];
const Snow = SS.buildStations(L1, [], moved, [], { types, intervalM: 20 });
out.H = scenario(
L1,
[
[-7, 0],
[100, 0],
[200, 60],
],
{ pipes: saved, structures: [] },
{ pipes: moved, structures: [] },
{ canon: Snow, canonPipes: moved, keys: Snow.map((s) => s.key) },
);
}
process.stdout.write(JSON.stringify(out));
+195
View File
@@ -0,0 +1,195 @@
/* PLAN 458 노선 바뀜 — 진짜 모듈(따라감 · 측점 · 갈아끼움 · 형상 얹기)을 그 자리에서 트랜스파일해 Node 로 돌림.
* 저장 노선 (0,0)→(100,0)→(200,60) 위 관 · 세월교 · 옹벽 → 시점 당김 / 밀어냄 → 갈아끼움 + 지금 현황으로 다시 얹기.
* 결과 = 표준출력 JSON. 짝 = `test_458_swap_attach.py`.
*/
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..", "..");
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
const cache = new Map();
function resolveTs(base) {
for (const c of [`${base}.ts`, base, path.join(base, "index.ts")])
if (fs.existsSync(c) && fs.statSync(c).isFile()) return c;
throw new Error(`모름: ${base}`);
}
function load(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 need = (spec0) => {
if (/common_util_structure_catalog(\.js)?$/.test(spec0))
return require(path.join(__dirname, "helper_322_catalog_module.cjs"));
const spec = spec0.replace(/\.js$/, "");
if (spec.endsWith(".json"))
return JSON.parse(fs.readFileSync(path.join(path.dirname(full), spec), "utf8"));
if (spec.startsWith("@util/"))
return load(resolveTs(path.join(ROOT, "common_util", spec.slice(6))));
if (spec.startsWith(".")) return load(resolveTs(path.join(path.dirname(full), spec)));
return require(spec);
};
new Function("exports", "module", "require", js)(box.exports, box, need);
return box.exports;
}
const P = (...parts) => path.join(ROOT, ...parts);
const follow = load(P("common_util", "common_util_route_follow.ts"));
const SS = load(P("B05_Profile", "logic", "B05_Profile_Sections_Stations.ts"));
const SW = load(P("B06_Section", "logic", "B06_Section_Station_Swap.ts"));
const AT = load(P("common_util", "common_util_structure_attach.ts"));
const def = JSON.parse(
fs.readFileSync(P("resources", "master_template", "구조물정의.json"), "utf8"),
);
const types = def.types
.filter((t) => t.enabled !== false)
.map((t) => ({
type_id: t.type_id,
group: t.group,
placement: t.placement,
managed_by: t.managed_by,
name: t.name,
options: (t.options || []).map((o) => ({ key: o.key, label: o.label, default: o.default })),
}));
const INTERVAL = 20;
const xy = (L) => L.map(([x, y]) => ({ x, y }));
function at(L, ch) {
let walked = 0;
for (let i = 1; i < L.length; i++) {
const [a, b] = [L[i - 1], L[i]];
const span = Math.hypot(b[0] - a[0], b[1] - a[1]);
if (walked + span >= ch) {
const t = (ch - walked) / span;
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
}
walked += span;
}
return L[L.length - 1];
}
const kindOf = (s) =>
s.kind === "end"
? s.key.endsWith("EP")
? "ep"
: "bp"
: s.kind === "grid"
? "regular"
: s.kind === "curve"
? "curve"
: "irregular";
/** 저장 상세 — 서버처럼 격자 자리 시설 측점에 `grid` · 설계에 열쇠 메모. */
const sectionsFrom = (stations) =>
stations.map((s) => ({
station_id: SW.stationIdOf(s.chainageM),
chainage_m: s.chainageM,
label: "",
kind: kindOf(s),
...(kindOf(s) === "irregular" && s.chainageM % INTERVAL === 0 ? { grid: true } : {}),
center_x: s.x,
center_y: s.y,
center_z: 100,
frame: { left_xy: s.left },
samples: [{ offset_m: 0, elevation_m: 100, valid: true }],
uphill_side: "left",
design: { memo: s.key, design_elevation_m: 101 },
}));
const ground = (s) => ({
...s,
ground: { centerZ: 100, left: [{ offsetM: 5, z: 101 }], right: [{ offsetM: 5, z: 99 }] },
});
const KEYS = ["culvert", "ford", "box", "ford_pavement"];
const view = (sections) =>
sections.map((s) => ({
chainage_m: s.chainage_m,
kind: s.kind,
grid: !!s.grid,
shapes: Object.fromEntries(KEYS.filter((k) => s[k]).map((k) => [k, s[k].chainage_m])),
}));
function run(L1, L2, pipes, structures) {
const S1 = SS.buildStations(L1, [], pipes, structures, { types, intervalM: INTERVAL });
const saved = { longitudinal: { stations: [], length_m: 0 }, cross_sections: sectionsFrom(S1) };
AT.attachShapes(saved.cross_sections, pipes, structures, types);
saved.cross_sections[0].preview = true; // 초안 맞춤의 구조물 없는 사본 표시(갈아끼움이 안 들고 감)
const pipesF = follow.projectPipePoints(pipes, xy(L2));
const structuresF = follow
.followStructures(structures, xy(L2), undefined, INTERVAL)
.map((m) => m.item);
const S2 = SS.buildStations(L2, [], pipesF, structuresF, { types, intervalM: INTERVAL }).map(
ground,
);
const swap = SW.swapCrossSections(
saved,
S2,
S1.map((s) => s.key),
INTERVAL,
);
const now = swap.detail.cross_sections.map((s) => ({ ...s }));
const misses = AT.attachShapes(now, pipesF, structuresF, types);
// 가게 초안이 없는 채(저장분 현황) 얹기 — 상한이 옛 자리 관을 이웃 측점에 안 붙임
const stale = swap.detail.cross_sections.map((s) => ({ ...s }));
const staleMisses = AT.attachShapes(stale, pipes, structures, types);
return {
ids: swap.detail.cross_sections.map((s) => [s.chainage_m, s.station_id]),
preview: swap.detail.cross_sections.filter((s) => s.preview).length,
pipes: pipesF.map((p) => [p.id, p.chainage_m]),
now: view(now),
misses,
staleMisses,
stale: view(stale),
};
}
const L1 = [
[0, 0],
[100, 0],
[200, 60],
];
const pipe = (id, facility, ch, options) => {
const [x, y] = at(L1, ch);
return { id, facility, chainage_m: ch, x, y, options };
};
const pipes = [
pipe("p1", "pipe", 40, { pipe_diameter_mm: "800" }), // 격자 No.2 위
pipe("p2", "ford_bridge", 150, { ford_width_m: 10 }),
];
const [wx, wy] = at(L1, 70);
const wall = {
structure_id: "w1",
type_id: "retaining_wall",
chainage_m: 70,
start_m: 60,
end_m: 80,
x: wx,
y: wy,
options: { height_m: 2, side: "left" },
};
const out = {
// A — 시점 7 m 당김: 관 40 → 47(격자를 떠남) · 세월교 150 → 157
shift: run(
L1,
[
[-7, 0],
[100, 0],
[200, 60],
],
pipes,
[wall],
),
// D — 시점을 50 m 로 밀어냄: 관 40 = 새 시점 10 m 바깥 → 누가거리 0
outside: run(
L1,
[
[50, 0],
[100, 0],
[200, 60],
],
pipes,
[wall],
),
};
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,196 @@
# -*- coding: utf-8 -*-
"""271-1~3 B05 종단 [직선화] 측점 고름 · 끝 R 기본 L · [직선화] · [자유지정] 실시간 띠.
요구 271 · PLAN 461.
계산(`logic/B05_Profile_UI_Profile_Straighten.ts` · `_Tools.ts` · `design_profile_free.ts` ·
`design_profile_range_band.ts` · `_CurveLaw.ts`)은 Node 헬퍼(`helper_271_straighten_pick.cjs`)가
트랜스파일해 돌리고, 파이썬 짝(`build_alignment` · `vertical_curve_shortfalls`)은 같은 base · 편집으로
곡선 자리 · R · L · 미달 경고가 같은지 거울로 본다.
./venv/Scripts/python.exe -m pytest resources/tester/test_271_straighten_pick.py -q
"""
import json
import os
import subprocess
import sys
from pathlib import Path
import numpy as np
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent.parent
sys.path.insert(0, str(ROOT))
from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( # noqa: E402
AlignmentPolicy,
build_alignment,
)
from B05_Profile.B05_Profile_Engine_Grade_CurveLaw import ( # noqa: E402
SHORTFALL_TOLERANCE_M,
vertical_curve_shortfalls,
)
def _read(relative: str) -> str:
return (ROOT / relative).read_text(encoding="utf-8")
@pytest.fixture(scope="module")
def out():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_271_straighten_pick.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def _at(curves, chainage):
return next((c for c in curves if abs(c["at"] - chainage) < 1e-6), None)
def test_직선화_끝_기본_L_사이_곡선_없음(out):
curves = out["straight"]["curves"]
assert not [c for c in curves if 40 < c["at"] < 200]
assert _at(curves, 40)["l"] == pytest.approx(20) and _at(curves, 200)["l"] == pytest.approx(20)
assert out["straight"]["radii"] == []
def test_이미_직선_범위_부분_구간_반대_방향_같음(out):
assert out["sub"] == {"fromM": 80, "toM": 160, "nodes": [80, 100, 120, 140, 160]}
assert out["subReverse"] == out["sub"]
assert out["subBent"] is None and out["subMixed"] is None
assert out["subOneSpan"] is None # 사이 꼭짓점 없음 = 꺾을 곳 없음
def test_부분_구간_꺾기_양_끝_L20_거짓_미달_없음(out):
curves = out["tiltSub"]["curves"]
for at in (80, 120, 160):
assert _at(curves, at)["l"] == pytest.approx(20, abs=1e-3)
assert not [c for c in curves if 80 < c["at"] < 160 and c["at"] != 120]
assert out["tiltSub"]["shortfalls"] == []
def test_법정_판정_오차_짝(out):
assert out["tolerance"] == SHORTFALL_TOLERANCE_M == 1e-3
assert out["nearMiss"] == []
assert len(out["realMiss"]) == 1
near = [{"chainage_m": 120, "l_m": 19.99998, "r_m": 800}]
assert vertical_curve_shortfalls(near, 100, 20) == []
real = [{"chainage_m": 120, "l_m": 19.9, "r_m": 800}]
assert vertical_curve_shortfalls(real, 100, 20) == out["realMiss"]
def test_끝_저장_자동_R_지움_사용자_R_둠(out):
assert out["autoEnds"]["radii"] == []
curves = out["autoEnds"]["curves"]
assert _at(curves, 40)["l"] == pytest.approx(20) and _at(curves, 200)["l"] == pytest.approx(20)
assert out["userEnd"]["radii"] == {"40.000": 600}
assert out["noAuto"] == ["40.000", "200.000"]
def test_BP_EP_곡선_없음(out):
assert out["bpEp"] == []
assert out["tiltBp"][:2] == [40, 80] # 시점 0 엔 곡선 없음 · 가운데 · 끝만
def test_곡선_섞인_범위_안쪽_R_지움_양_끝_L20(out):
assert out["mixed"]["radii"] == []
curves = out["mixed"]["curves"]
assert not [c for c in curves if 100 < c["at"] < 260]
assert _at(curves, 100)["l"] == pytest.approx(20) and _at(curves, 260)["l"] == pytest.approx(20)
def test_자유지정_끝_자동_R_지움_사용자_R_둠(out):
assert out["freeAuto"] == ["120.000"]
assert out["freeUser"] == ["40.000", "120.000"]
def test_자유지정_범위_실시간_띠_Esc_시작만(out):
assert out["liveBand"] == [40, 120] and out["liveEdges"] == [40, 120]
assert out["liveReverse"] == [20, 40]
assert out["outside"] == {"band": None, "edges": [40]} # 그래프 밖 = 시작선만
assert "Esc 시작 취소" in out["hintPending"]
assert out["escTaken"] and out["afterEsc"] == {"phase": "range", "edges": [], "closed": 0}
assert out["fixedBand"] == [40, 160] and out["armedPhase"] == "armed" # 둘째 클릭 뒤 고정
assert out["escArmed"] and out["afterEscArmed"] == {"phase": "range", "closed": 1}
def test_범위_띠_그림(out):
assert out["bandShape"] == ["rect:80+160", "text:40.0–120.0m · 80.0m", "line:80", "line:240"]
assert out["bandStart"] == ["line:80"]
assert out["viewIdle"] == 0 and out["viewOff"] == 0
assert out["viewStart"] == ["line:80"]
assert out["viewHover"] == out["bandShape"]
assert out["viewLeave"] == ["line:80"]
assert out["viewRemount"] == ["rect:80+40", "text:40.0–60.0m · 20.0m", "line:80", "line:120"]
def test_도구_직선화_부분_구간_붉은_강조_아님(out):
assert out["toolsNone"] is False
assert out["pending"] == {
"at": 40,
"hint": "40.0m 선택 — 두 번째 측점을 고르세요 (Esc 시작 취소)",
}
assert out["cancel"] == {"taken": True, "pending": None, "mode": "straighten"}
assert out["cancelAgain"] is False
assert out["subPicked"]["red"] == [] and out["subPicked"]["sub"]["fromM"] == 80
assert out["subPicked"]["hint"] == "이미 직선 — ▲▼ 로 꺾으면 양 끝에 기본 L 곡선"
assert out["tiltCall"] == ["tilt", 80, 160, 0.1]
assert out["newStart"] == {"pending": 60, "sub": None}
assert out["straightenCall"] == [60, 180]
assert out["shiftRed"] == {"red": 1, "sub": None, "cancel": False} # [쉬프트] 붉은 강조 그대로
def test_파이썬_거울_곡선_자리_R_L_미달(out):
base = out["mirror"]["base"]
policy = AlignmentPolicy.from_dict(base["policy"])
for case in out["mirror"]["cases"]:
got = build_alignment(
base_s=np.array([n["chainage_m"] for n in base["basePvi"]]),
base_z=np.array([n["elevation_m"] for n in base["basePvi"]]),
chainage=np.array(base["chainage"]),
ground=np.array(base["ground"]),
stations=base["stations"],
policy=policy,
edits=case["edits"],
)
py = [[c["chainage_m"], c["r_m"], c["l_m"]] for c in got["curves"]]
ts = case["curves"]
assert [c[0] for c in py] == pytest.approx([c[0] for c in ts], abs=1e-6), case["name"]
assert [c[1] for c in py] == pytest.approx([c[1] for c in ts], rel=1e-6), case["name"]
assert [c[2] for c in py] == pytest.approx([c[2] for c in ts], abs=1e-6), case["name"]
assert not [w for w in got["warnings"] if "미달" in w], case["name"]
def test_배선():
panel = _read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts")
assert "tools.handleStationPick(station ?? chainageM)" in panel
assert "straightSubRun(alignment, fromM, toM)" in panel
assert "straightenBetween(base, ctx.edits(), fromM, toM, ctx.savedRadii())" in panel
assert "autoRadii: ctx.savedRadii" in panel
assert 'window.addEventListener("keydown", onEscape, true)' in panel
assert "createRangeBandView(" in panel
view = _read("A00_Common/design/design_profile_free_view.ts")
assert "appendRangeBand(svg, x, heightPx" in view
css = _read("B05_Profile/B05_Profile_UI_Style_Table.css")
rule = css[css.index(".b05-profile-freemark__span {") :]
rule = rule[: rule.index("}")]
assert "--color-primary" in rule and "--color-surface" in rule # 밝음 · 어두움 토큰
def test_줄_수():
for relative in (
"B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts",
"B05_Profile/B05_Profile_UI_Profile_Tools.ts",
"B05_Profile/logic/B05_Profile_UI_Profile_Straighten.ts",
"A00_Common/design/logic/design_profile_free.ts",
"A00_Common/design/design_profile_free_view.ts",
"A00_Common/design/design_profile_range_band.ts",
):
assert len(_read(relative).splitlines()) <= 700
+7 -6
View File
@@ -56,12 +56,13 @@ POINTS = [
]
CHAINAGES = [0.0, 20.0, 40.0, 60.0, 80.0, 85.0, 90.0, 95.0, 100.0, 105.0]
# 같은 키 — 옆 측점 연동(88.5 · 폭 10 · 주인 88)이 90 m 에 더 가깝지만 소유(92.4 · 주인 90)가 먼저
# 같은 키 — 옆 측점 연동(89.8 · 폭 10 · 주인 89.7)이 90 m 에 더 가깝지만 소유(90.4 · 주인 90)가 먼저
# (주인 측점 = 0.51 m 안 · 458)
SAME_KEY_POINTS = [
{"chainage_m": 88.5, "facility": "ford_pavement", "options": {"ford_width_m": 10.0}},
{"chainage_m": 92.4, "facility": "ford_pavement", "options": {"ford_width_m": 0.1}},
{"chainage_m": 89.8, "facility": "ford_pavement", "options": {"ford_width_m": 10.0}},
{"chainage_m": 90.4, "facility": "ford_pavement", "options": {"ford_width_m": 0.1}},
]
SAME_KEY_CHAINAGES = [85.0, 88.0, 90.0, 95.0]
SAME_KEY_CHAINAGES = [85.0, 89.7, 90.0, 95.0]
def _attach(tmp_path: Path, points: list[dict], chainages: list[float]) -> list[dict]:
@@ -103,8 +104,8 @@ def test_흙막이는_자기_관에_밀림_없으면_그대로(sections):
def test_같은_키는_소유_측점_먼저(tmp_path):
sections = _attach(tmp_path, SAME_KEY_POINTS, SAME_KEY_CHAINAGES)
assert _at(sections, 90.0)["ford_pavement"]["chainage_m"] == 92.4
assert _at(sections, 85.0)["ford_pavement"]["chainage_m"] == 88.5
assert _at(sections, 90.0)["ford_pavement"]["chainage_m"] == 90.4
assert _at(sections, 85.0)["ford_pavement"]["chainage_m"] == 89.8
def test_수량은_주인_측점_한_번(sections):
+44 -4
View File
@@ -99,9 +99,35 @@ def _sections(until: float) -> list[dict]:
PIPES, STRUCTURES = _samples()
UNTIL = 15.0 + 30.0 * len(catalog()) + 60
def _moved() -> dict:
"""458 노선 바뀜 — 노선 x 축 0 ~ 100 · 측점 10 m 마다(좌표 있음).
관 30.4(주인 30 · 0.4 m) · 44(가장 가까운 측점 4 m = 측점 없음) · 0(좌표 -3 = 시점 밖) ·
100(좌표 100.3 · 옆 1.5 = 종점 안) · 물넘이포장 70(폭 24 = 60 · 80 연동) · 55(주인 5 m = 연동도 안 얹음).
"""
pipe = _defaults("pipe")
pave = {**_defaults("ford_pavement"), "ford_width_m": 24}
sections = [
{"chainage_m": float(at), "center_x": float(at), "center_y": 0.0}
for at in range(0, 101, 10)
]
pipes = [
{"id": "a", "chainage_m": 30.4, "x": 30.4, "y": 0.0, "facility": "pipe", "options": pipe},
{"id": "b", "chainage_m": 44.0, "x": 44.0, "y": 0.0, "facility": "pipe", "options": pipe},
{"id": "c", "chainage_m": 0.0, "x": -3.0, "y": 0.0, "facility": "pipe", "options": pipe},
{"id": "d", "chainage_m": 100.0, "x": 100.3, "y": 1.5, "facility": "pipe", "options": pipe},
{"id": "e", "chainage_m": 70.0, "facility": "ford_pavement", "options": pave},
{"id": "f", "chainage_m": 55.0, "facility": "ford_pavement", "options": pave},
]
return {"sections": sections, "pipes": pipes, "structures": []}
CASES = {
"all": {"sections": _sections(UNTIL), "pipes": PIPES, "structures": STRUCTURES},
"empty": {"sections": _sections(30), "pipes": [], "structures": []},
"moved": _moved(),
}
@@ -122,22 +148,36 @@ def ts() -> dict:
return json.loads(out.stdout)
def _py(case: dict) -> list[dict]:
def _py(case: dict) -> tuple[list[dict], dict]:
sections = copy.deepcopy(case["sections"])
pipes = parse_pipe_points(case["pipes"])
structures = [StructureInstance.model_validate(s) for s in case["structures"]]
attach_shapes(sections, pipes, structures)
return json.loads(json.dumps(sections))
misses = attach_shapes(sections, pipes, structures)
return json.loads(json.dumps(sections)), misses
def test_mirror_python_equals_ts(ts: dict) -> None:
for name, case in CASES.items():
py, js = _py(case), ts["cases"][name]
(py, misses), js = _py(case), ts["cases"][name]
assert misses == ts["misses"][name], name
assert len(py) == len(js), name
for left, right in zip(py, js):
assert left == right, (name, left["chainage_m"])
def test_owner_cap_and_outside(ts: dict) -> None:
"""458 — 주인 측점 0.51 m 상한 · 시종점 밖 = 안 얹고 경고 · 연동 이웃 = 폭 절반."""
by_at = {s["chainage_m"]: s for s in ts["cases"]["moved"]}
assert ts["misses"]["moved"] == {"outside": [0.0], "far": [44.0, 55.0]}
assert by_at[30.0]["culvert"]["chainage_m"] == 30.4
assert by_at[100.0]["culvert"]["chainage_m"] == 100.0 # 종점 0.3 m 넘음 · 옆 1.5 m = 노선 안
assert "culvert" not in by_at[0.0] and "culvert" not in by_at[40.0]
assert "culvert" not in by_at[50.0]
linked = {at for at, s in by_at.items() if "ford_pavement" in s}
assert linked == {60.0, 70.0, 80.0}
assert all(by_at[at]["ford_pavement"]["chainage_m"] == 70.0 for at in linked)
def test_every_shape_lands(ts: dict) -> None:
keys = {key for section in ts["cases"]["all"] for key in section if key != "chainage_m"}
assert {
+3 -4
View File
@@ -170,10 +170,9 @@ def test_365_5_들어온_화면_무관(r):
g = v["grid20"]
# 설계 칸 = 세션 저장 상세(화면 상세가 굳힌 선택 false 대신 저장값) · 옛 측구 결과도 실음
assert g["ditchChoice"] is None and g["ditchEnabled"] is False
assert v["pipeSpec"] == {
"culvert": {"type": "pipe", "diameter_m": 0.8}
} # 초안 자리 관 = 차례 짝
assert v["specsDiffer"] # 제원이 바뀌면 입력 지문도 바뀜(고리가 다시 셈)
# 초안 자리 관 = 등록 열쇠 짝 · 넘기는 것 = 카드 번호 · 사용자 칸만(그림은 새 자리에서 · 457)
assert v["pipeSpec"] == {} and v["pipeCarried"] # 짝 있음(시험 상세는 카드 번호 없음)
assert v["specsDiffer"] # 짝지은 측점이 바뀌면 입력 지문도 바뀜(고리가 다시 셈)
assert v["firstStored"] == v["savedFirst"] and v["firstShaken"] != v["savedFirst"]
+95
View File
@@ -0,0 +1,95 @@
"""457 노선 바뀜 — 초안 카드 그림 · 이름 · 횡단 맥락 = 지금 현황을 새 측점에 다시 얹음(요구 270).
재현 E · F(시점 당김 +13 · +7 — 배관 · 물넘이포장 · 옹벽) · G(세션에서 놓은 BOX) ·
H(관 둘 엇갈려 옮김 · 초안 둔 채 열기).
① 모든 측점 카드 그림 · 이름 = 새 측점에 따라간 관 · 구조물을 얹은 것(캐시 `withDraftShapes` 한 벌)
② 시설마다 제 측점 하나 · 옛 측점 그림을 옮기지 않음 · 카드 번호만 열쇠로 이어 받음
③ 시설 맥락(포장 · 기울임 · 하강) = 다시 얹은 그림에서 · 짝 = 등록 열쇠(차례 짝 없음 · 엇갈림 0)
④ 가게 쓰기 = 지금 가게 값과 견줌(되돌리기 뒤 같은 조작도 씀)
TS 셈은 `helper_457_draft_card.cjs`.
./venv/Scripts/python.exe -m pytest resources/tester/test_457_draft_card.py -q
"""
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
HELPER = Path(__file__).with_name("helper_457_draft_card.cjs")
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules" / "typescript").is_dir(),
reason="프론트엔드 의존성 미설치",
)
@pytest.fixture(scope="module")
def got() -> dict:
run = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert run.returncode == 0, run.stderr
return json.loads(run.stdout)
def _row(rows: list, key: str) -> dict:
return next(r for r in rows if r["key"] == key)
@pytest.mark.parametrize("name", ["E", "F", "G", "H"])
def test_카드_그림_이름이_새_자리_현황(got, name) -> None:
rows = got[name]["rows"]
bad = [(r["key"], r["card"], r["truth"]) for r in rows if r["card"] != r["truth"]]
assert not bad
names = [(r["key"], r["name"], r["truthName"]) for r in rows if r["name"] != r["truthName"]]
assert not names
@pytest.mark.parametrize("name", ["E", "F", "G", "H"])
def test_카드_번호만_이어_받음(got, name) -> None:
assert got[name]["specFields"] == ["station_id"]
def test_E_물넘이포장은_제_측점에만(got) -> None:
rows = got["E"]["rows"]
fp = _row(rows, "pipe:fp")
assert "ford_pavement@" in fp["card"]
assert fp["ctx"][:2] == [True, True] # 포장 · 기울임 = 새 자리 그림
no6 = _row(rows, "grid:6") # 120 m — 옛 물넘이 범위(119~131) · 새 범위(132~144) 밖
assert "ford_pavement" not in no6["card"] and no6["name"] is None
assert no6["ctx"] == [False, False, None, 0]
pipes = [r for r in rows if "culvert@" in r["card"]]
assert [r["key"] for r in pipes] == ["pipe:p1"] # 배관 그림 = 관 측점 하나(No.3 아님)
def test_G_세션에서_놓은_BOX_카드(got) -> None:
box = _row(got["G"]["rows"], "pipe:p9")
assert "box@" in box["card"] and box["name"]
def test_H_엇갈려_옮긴_관_짝(got) -> None:
h = got["H"]
p1 = _row(h["rows"], "pipe:p1")
p2 = _row(h["rows"], "pipe:p2")
assert "culvert@" in p1["card"] and "box@" not in p1["card"]
assert "box@" in p2["card"] and "culvert@" not in p2["card"]
# 카드 번호 = 같은 관의 화면 상세 번호(등록 열쇠 짝 · 목록이 바뀌어도 처음 본 열쇠)
assert h["sourceKeys"]["pipe:p1"] == h["canonIds"]["pipe:p1"]
assert h["sourceKeys"]["pipe:p2"] == h["canonIds"]["pipe:p2"]
assert p1["id"] == h["canonIds"]["pipe:p1"]
def test_가게_쓰기는_지금_가게_값과_견줌() -> None:
page = (ROOT / "A00_Common" / "design" / "design_pipeline_page.ts").read_text(encoding="utf-8")
body = page[page.index("function writeFacilities") :]
body = body[: body.index("\n }\n")]
assert 'same(store.get("pipes"), next.pipes)' in body
assert 'same(store.get("structures"), next.structures)' in body
assert "written" not in page
+82
View File
@@ -0,0 +1,82 @@
"""458 노선 바뀜 — B06 갈아끼움 + 지금 현황으로 다시 얹기 · 주인 측점 거리 상한 · 시종점 밖.
못박는 것
- 시점 당김(재현 A 꼴): 관 · 세월교 그림 = 제 측점 하나씩(따라간 누가거리) · 격자를 떠난 관 측점 `grid` 지움.
- 가게 초안 없는 채(저장분 현황) 얹어도 옛 자리 세월교가 이웃 일반 측점에 안 붙음(상한 0.51 m · 측점 없음).
- 시점 밖으로 밀린 관(재현 D 꼴) = 시점 측점에 안 얹고 「노선 밖」.
- 카드 번호 안 겹침 — 격자 자리(80 m)를 떠난 옹벽 끝 측점은 새 번호(그 자리 새 일반 측점과 따로) ·
초안 사본 표시(`preview`)는 안 들고 감.
Node 헬퍼 `helper_458_swap_attach.cjs` 가 진짜 모듈(따라감 · 측점 · 갈아끼움 · 형상 얹기)을 부름.
"""
import json
import subprocess
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
)
@pytest.fixture(scope="module")
def out() -> dict:
proc = subprocess.run(
["node", str(HERE / "helper_458_swap_attach.cjs")],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def _shaped(sections: list[dict]) -> dict[float, dict]:
return {s["chainage_m"]: s["shapes"] for s in sections if s["shapes"]}
def test_시설마다_제_측점_하나(out):
shift = out["shift"]
assert shift["pipes"] == [["p1", 47], ["p2", 157]]
assert _shaped(shift["now"]) == {47: {"culvert": 47}, 157: {"ford": 157}}
assert shift["misses"] == {"outside": [], "far": []}
def test_격자_떠난_시설_측점_grid_지움(out):
by_at = {s["chainage_m"]: s for s in out["shift"]["now"]}
assert by_at[47]["kind"] == "irregular" and by_at[47]["grid"] is False
def test_저장분_현황이어도_이웃에_안_붙음(out):
shift = out["shift"]
assert shift["staleMisses"]["far"] == [150]
assert all("ford" not in shapes for shapes in _shaped(shift["stale"]).values())
def test_시점_밖_관은_안_얹고_노선_밖(out):
outside = out["outside"]
assert ["p1", 0] in outside["pipes"]
assert outside["misses"] == {"outside": [0], "far": []}
assert _shaped(outside["now"]) == {100: {"ford": 100}}
assert {s["chainage_m"]: s["grid"] for s in outside["now"]}[20] is True # 격자 자리 옹벽 측점
def test_카드_번호_안_겹침_떠난_끝_측점은_새_번호(out):
ids = dict(out["shift"]["ids"])
assert len(set(ids.values())) == len(ids)
assert ids[87] == "station_000000087000" # 옹벽 끝 80 → 87
assert ids[80] == "station_000000080000" # 그 자리 새 일반 측점
assert ids[47] == "station_000000047000" # 관도 격자 40 을 떠남
assert ids[40] == "station_000000040000"
assert ids[157] == "station_000000150000" # 옛 자리가 빈 세월교 측점 = 옛 번호 그대로
def test_초안_사본_표시_안_들고_감(out):
assert out["shift"]["preview"] == 0 and out["outside"]["preview"] == 0