# Conflicts: # B06_Section/B06_Section_Cross_Refresh.ts # B06_Section/B06_Section_Router_Design.py # B06_Section/B06_Section_UI_Page_Persist.ts # B08_Quantity/B08_Quantity_Router_Summary.py
319 lines
12 KiB
JavaScript
319 lines
12 KiB
JavaScript
/* PLAN 137-2 · 137-3 단위검증 헬퍼 — B06 보라 선 고름(조각 · 가림 · 치수 · 구역 · 판정 선 · 한 칸 고름).
|
|
* 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌.
|
|
* 입력 = 손으로 짠 단면 + `fixtures/b07_cross_overlay_sections.json` 배관 측점 85.052. 짝 = `test_137_design_pick.py`.
|
|
*/
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
|
|
|
const ROOT = path.join(__dirname, "..", "..");
|
|
const cache = new Map();
|
|
const STUBS = {
|
|
"@ui/ui_template_toast": { showToast: () => undefined },
|
|
"@ui/ui_template_elements": { showToast: () => undefined },
|
|
};
|
|
|
|
// ── 화면 그릇 흉내 — 오버레이 · 판정 선이 쓰는 만큼만 ─────────────────────────
|
|
class FakeNode {
|
|
constructor(tag) {
|
|
this.tagName = String(tag);
|
|
this.children = [];
|
|
this.attrs = {};
|
|
this.listeners = {};
|
|
this.hidden = false;
|
|
this._text = "";
|
|
this._classes = new Set();
|
|
const self = this;
|
|
this.classList = {
|
|
add: (...names) => names.forEach((n) => self._classes.add(n)),
|
|
remove: (...names) => names.forEach((n) => self._classes.delete(n)),
|
|
toggle: (name, force) => {
|
|
const on = force === undefined ? !self._classes.has(name) : !!force;
|
|
on ? self._classes.add(name) : self._classes.delete(name);
|
|
return on;
|
|
},
|
|
contains: (name) => self._classes.has(name),
|
|
};
|
|
this.style = { setProperty: () => {} };
|
|
}
|
|
set className(value) {
|
|
this._classes = new Set(String(value).split(/\s+/).filter(Boolean));
|
|
}
|
|
get className() {
|
|
return [...this._classes].join(" ");
|
|
}
|
|
set textContent(value) {
|
|
this._text = String(value);
|
|
this.children = [];
|
|
}
|
|
get textContent() {
|
|
return this._text + this.children.map((c) => c.textContent).join("");
|
|
}
|
|
get childElementCount() {
|
|
return this.children.length;
|
|
}
|
|
get nextElementSibling() {
|
|
const kids = this.parent?.children ?? [];
|
|
return kids[kids.indexOf(this) + 1] ?? null;
|
|
}
|
|
_detach(kid) {
|
|
if (kid.parent) kid.parent.children = kid.parent.children.filter((k) => k !== kid);
|
|
kid.parent = this;
|
|
}
|
|
append(...kids) {
|
|
kids.forEach((k) => {
|
|
this._detach(k);
|
|
this.children.push(k);
|
|
});
|
|
}
|
|
appendChild(kid) {
|
|
this.append(kid);
|
|
return kid;
|
|
}
|
|
insertBefore(kid, ref) {
|
|
this._detach(kid);
|
|
const at = ref ? this.children.indexOf(ref) : -1;
|
|
if (at < 0) this.children.push(kid);
|
|
else this.children.splice(at, 0, kid);
|
|
return kid;
|
|
}
|
|
remove() {
|
|
if (this.parent) this.parent.children = this.parent.children.filter((k) => k !== this);
|
|
}
|
|
matches(selector) {
|
|
if (selector === "text") return this.tagName === "text";
|
|
return selector
|
|
.split(",")
|
|
.map((part) => part.trim())
|
|
.some((part) =>
|
|
part === "text" ? this.tagName === "text" : this._classes.has(part.slice(1)),
|
|
);
|
|
}
|
|
setAttribute(name, value) {
|
|
this.attrs[name] = String(value);
|
|
if (name === "class") this.className = value;
|
|
}
|
|
getAttribute(name) {
|
|
return name === "class" ? this.className : (this.attrs[name] ?? null);
|
|
}
|
|
hasAttribute(name) {
|
|
return name in this.attrs;
|
|
}
|
|
addEventListener(type, fn) {
|
|
(this.listeners[type] ??= []).push(fn);
|
|
}
|
|
click() {
|
|
let stopped = false;
|
|
const event = { stopPropagation: () => (stopped = true), preventDefault: () => {} };
|
|
(this.listeners.click ?? []).forEach((fn) => fn(event));
|
|
return stopped;
|
|
}
|
|
}
|
|
globalThis.document = {
|
|
createElement: (tag) => new FakeNode(tag),
|
|
createElementNS: (_ns, tag) => new FakeNode(tag),
|
|
};
|
|
|
|
function loadTs(file) {
|
|
const full = path.resolve(file);
|
|
if (cache.has(full)) return cache.get(full);
|
|
if (full.endsWith(".json")) {
|
|
const data = JSON.parse(fs.readFileSync(full, "utf8"));
|
|
cache.set(full, data);
|
|
return data;
|
|
}
|
|
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
|
compilerOptions: {
|
|
module: ts.ModuleKind.CommonJS,
|
|
target: ts.ScriptTarget.ES2020,
|
|
esModuleInterop: true,
|
|
},
|
|
}).outputText;
|
|
const box = { exports: {} };
|
|
cache.set(full, box.exports);
|
|
const localRequire = (request) => {
|
|
if (STUBS[request]) return STUBS[request];
|
|
if (request.endsWith(".css")) return {}; // 화면 꾸밈 — Node 에선 안 씀(138-4 모달 CSS)
|
|
const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" };
|
|
for (const [prefix, dir] of Object.entries(alias))
|
|
if (request.startsWith(prefix))
|
|
return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length))));
|
|
return loadTs(resolve(path.join(path.dirname(full), request)));
|
|
};
|
|
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
|
cache.set(full, box.exports);
|
|
return box.exports;
|
|
}
|
|
const resolve = (base) =>
|
|
[base, `${base}.ts`, path.join(base, "index.ts")].find(
|
|
(p) => fs.existsSync(p) && fs.statSync(p).isFile(),
|
|
) ?? `${base}.ts`;
|
|
|
|
const B06 = (f) => path.join(ROOT, "B06_Section", `${f}.ts`);
|
|
const pick = loadTs(B06("B06_Section_UI_Cross_Design_Pick"));
|
|
const { pointInPolygon } = loadTs(path.join(ROOT, "A00_Common", "b_polygon_clip.ts"));
|
|
const out = {};
|
|
const r3 = (v) => Math.round(v * 1000) / 1000;
|
|
const pt = (offset, elevation) => ({ offset, elevation });
|
|
|
|
// ① 조각 — 0.5 m 토막 노면(가운데 꺾임) · 측구 · 사면이 직선 조각 다섯으로
|
|
const sampled = (a, b, step = 0.5) => {
|
|
const n = Math.max(1, Math.round(Math.abs(b.offset - a.offset) / step));
|
|
return Array.from({ length: n + 1 }, (_, i) =>
|
|
pt(
|
|
a.offset + ((b.offset - a.offset) * i) / n,
|
|
a.elevation + ((b.elevation - a.elevation) * i) / n,
|
|
),
|
|
);
|
|
};
|
|
const line = [
|
|
...sampled(pt(-6, 98), pt(-2.5, 99.95)), // 우 성토 사면
|
|
...sampled(pt(-2.5, 99.95), pt(0, 100)).slice(1), // 우 노면 2%
|
|
...sampled(pt(0, 100), pt(2.5, 99.95)).slice(1), // 좌 노면 2%
|
|
...sampled(pt(2.5, 99.95), pt(3, 99.65)).slice(1), // 측구 안
|
|
...sampled(pt(3, 99.65), pt(6, 102.65)).slice(1), // 좌 절토 사면
|
|
].map((p) => ({ offset: r3(p.offset), elevation: Math.round(p.elevation * 1e4) / 1e4 }));
|
|
// 토막마다 한 줄(설계선 오버레이 꼴) + 차례를 뒤집은 한 토막(거꾸로 그린 선)
|
|
const runs = [];
|
|
for (let i = 1; i < line.length; i += 1)
|
|
runs.push({ points: [line[i - 1], line[i]], structure: false });
|
|
runs.push({ points: [pt(-6.5, 98 - (0.5 * 1.95) / 3.5), pt(-6, 98)].reverse(), structure: false });
|
|
const merged = pick.mergeStraightPieces(runs);
|
|
out.merge = {
|
|
segments: runs.length,
|
|
pieces: merged.map((p) => [r3(p.from.offset), r3(p.to.offset)].sort((a, b) => a - b)),
|
|
};
|
|
// 구조물 선은 같은 직선이어도 설계선과 안 합침
|
|
out.mergeStructure = pick.mergeStraightPieces([
|
|
{ points: [pt(10, 0), pt(11, 1)], structure: false },
|
|
{ points: [pt(11, 1), pt(12, 2)], structure: true },
|
|
]).length;
|
|
|
|
// ② 가림 — 사면을 가로지르는 벽 → 벽 밖 두 조각
|
|
const slope = [{ from: pt(3, 99.65), to: pt(6, 102.65), structure: false }];
|
|
const wall = [pt(4, 100), pt(4.5, 100), pt(4.5, 103), pt(4, 103)];
|
|
const visible = pick.visiblePieces(slope, [wall]);
|
|
out.occlude = {
|
|
pieces: visible.map((p) => [r3(p.from.offset), r3(p.to.offset)]),
|
|
// 통째로 덮이면 조각 없음
|
|
covered: pick.visiblePieces(slope, [[pt(2, 90), pt(7, 90), pt(7, 110), pt(2, 110)]]).length,
|
|
};
|
|
|
|
// ③ 치수
|
|
const fillPiece = { from: pt(-2.5, 99.95), to: pt(-5.5, 97.95), structure: false };
|
|
const roadPiece = { from: pt(0, 100), to: pt(2.5, 99.95), structure: false };
|
|
out.measure = {
|
|
fill: pick.pieceMeasure(fillPiece, "fill"),
|
|
road: pick.pieceMeasure(roadPiece, "road"),
|
|
flatSlope: pick.pieceMeasure({ from: pt(4, 1), to: pt(5, 1), structure: false }, "cut").grade,
|
|
};
|
|
const design = {
|
|
road_edges: {
|
|
left: { offset_m: 2.5, elevation_m: 99.95 },
|
|
right: { offset_m: -2.5, elevation_m: 99.95 },
|
|
},
|
|
ditch: { type: "standard", top_width_m: 0.5, bottom_width_m: 0.2, depth_m: 0.3 },
|
|
ditch_side: "left",
|
|
ditch_enabled: true,
|
|
section_mode: "left_cut",
|
|
};
|
|
out.label = { fill: pick.pieceLabel(design, fillPiece), road: pick.pieceLabel(design, roadPiece) };
|
|
|
|
// ④ 구역 — 도로 = 두 노견 끝 사이 · 절토 = 측구 + 절토 사면 · 성토 = 성토 사면 + 구조물 선
|
|
const zone = (o, structure = false, d = design) => pick.crossZoneAt(d, o, structure);
|
|
out.zone = {
|
|
road: zone(1),
|
|
roadEdge: zone(-2.5).zone,
|
|
ditch: zone(2.8),
|
|
cutSlope: zone(5),
|
|
fillSlope: zone(-4),
|
|
structure: zone(5, true).zone,
|
|
rightCutDitch: zone(-2.8, false, { ...design, ditch_side: "right", section_mode: "right_cut" })
|
|
.zone,
|
|
bothFill: zone(5, false, { ...design, ditch_enabled: false, section_mode: "both_fill" }).zone,
|
|
pieceZones: merged.map((p) => pick.pieceZone(design, p).zone),
|
|
};
|
|
|
|
// ⑤ 실제 측점 — 설계선 + 배관 오버레이를 그린 뒤 고름을 붙임(카드와 같은 차례)
|
|
const { computeStoredLayouts } = loadTs(B06("B06_Section_Structure_Layouts"));
|
|
const { appendCulvertOverlay } = loadTs(B06("B06_Section_UI_Cross_Culvert"));
|
|
const { appendCrossDesignOverlay } = loadTs(B06("B06_Section_UI_Cross_Design"));
|
|
const { raiseWalls } = loadTs(B06("B06_Section_UI_Cross_Wall"));
|
|
const fixture = JSON.parse(
|
|
fs.readFileSync(path.join(__dirname, "fixtures", "b07_cross_overlay_sections.json"), "utf8"),
|
|
);
|
|
const sections = fixture.cross_sections;
|
|
const pipeSection = sections.find((s) => s.culvert && !s.culvert.hidden_pipe);
|
|
const layout = computeStoredLayouts(pipeSection, sections).culvert;
|
|
const x = (o) => 100 - o * 12;
|
|
const y = (e) => 900 - (e - 850) * 12;
|
|
const layer = new FakeNode("g");
|
|
const from = layer.childElementCount;
|
|
appendCrossDesignOverlay(layer, pipeSection.design, x, y, pipeSection.samples, layout.designTrim);
|
|
layer.children.slice(from).forEach((el) => el.setAttribute(pick.DESIGN_LINE_ATTR, ""));
|
|
appendCulvertOverlay(layer, layout, x, y, () => undefined, false);
|
|
raiseWalls(layer);
|
|
const picks = [];
|
|
const handle = pick.attachDesignLinePick({
|
|
layer,
|
|
design: pipeSection.design,
|
|
x,
|
|
toDisplayY: y,
|
|
onPick: (i) => picks.push(i),
|
|
});
|
|
const kids = layer.children;
|
|
const hits = kids.filter((k) => k.classList.contains("b06-chart__design-hit"));
|
|
const firstStructureHit = kids.findIndex((k) =>
|
|
k.classList.contains("b06-chart__culvert-revet-hit"),
|
|
);
|
|
const lastLineHit = kids.reduce(
|
|
(last, k, i) => (k.classList.contains("b06-chart__design-hit") ? i : last),
|
|
-1,
|
|
);
|
|
const walls = kids
|
|
.filter((k) => k.tagName === "polygon" && k.classList.contains("b06-chart__culvert-revet"))
|
|
.map((k) =>
|
|
k
|
|
.getAttribute("points")
|
|
.split(/\s+/)
|
|
.map((pair) => pair.split(",").map(Number)),
|
|
);
|
|
const insideWall = handle.pieces.filter((p) => {
|
|
const mid = [x((p.from.offset + p.to.offset) / 2), y((p.from.elevation + p.to.elevation) / 2)];
|
|
return walls.some((w) => pointInPolygon(mid, w));
|
|
}).length;
|
|
const designLines = kids.filter((k) => k.classList.contains("b06-chart__design-cross")).length;
|
|
const stopped = hits[0].click();
|
|
handle.setActive(1);
|
|
const activeOn = hits.map((h) => h.classList.contains("is-active"));
|
|
const labelOn = { hidden: handle.label.hidden, text: handle.label.textContent };
|
|
handle.setActive(null);
|
|
out.real = {
|
|
designLines,
|
|
pieces: handle.pieces.length,
|
|
structurePieces: handle.pieces.filter((p) => p.structure).length,
|
|
hits: hits.length,
|
|
wallCount: walls.length,
|
|
insideWall,
|
|
hitsBelowStructure: firstStructureHit < 0 || lastLineHit < firstStructureHit,
|
|
picked: picks,
|
|
stopped,
|
|
activeCount: activeOn.filter(Boolean).length,
|
|
activeIndex: activeOn.indexOf(true),
|
|
labelOn,
|
|
labelOff: { hidden: handle.label.hidden, text: handle.label.textContent },
|
|
zones: [...new Set(handle.pieces.map((p) => pick.pieceZone(pipeSection.design, p).zone))].sort(),
|
|
};
|
|
|
|
// ⑥ 페이지 한 칸 — 다른 측점을 고르면 앞 측점 고름은 사라짐 · 남의 측점 풀기는 안 건드림
|
|
pick.setPickedLine("A", 2);
|
|
const a1 = pick.pickedLineAt("A");
|
|
pick.setPickedLine("B", 1);
|
|
const a2 = pick.pickedLineAt("A");
|
|
pick.setPickedLine("A", null);
|
|
out.slot = { a1, a2, b: pick.pickedLineAt("B") };
|
|
pick.setPickedLine("B", null);
|
|
out.slot.cleared = pick.pickedLineAt("B");
|
|
|
|
process.stdout.write(JSON.stringify(out));
|