Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1

This commit is contained in:
2026-10-18 21:47:25 +09:00
13 changed files with 460 additions and 57 deletions
@@ -73,7 +73,6 @@ export interface StructureSpanControl {
/** 이 카드가 구간값을 만질 대상 측점 — 링크면 소유 측점, 아니면 자기 자신. */
ownerOf: (section: CrossSection) => CrossSection | null;
valuesFor: (section: CrossSection, role: SpanRole) => SpanValues | null;
update: (section: CrossSection, role: SpanRole, patch: Partial<SpanValues>) => void;
/** 다단 한 단의 구간값 — 기준벽과 달리 **단마다 따로** 잡는다(2026-08-29 사용자).
* 값은 세션에 담기고 [저장]·[확정] 때 정본으로 간다. 없으면 기본 10m(5/5). */
tierValuesFor: (section: CrossSection, key: RevetKey) => SpanValues;
@@ -15,6 +15,7 @@
* ========================================================================== */
import { createCadHost, type CadHostDrawing } from "@ui/cad_host/cad_host";
import { showToast } from "@ui/ui_template_elements";
import { readState, writeState } from "../A00_Common/b_page_state";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
@@ -45,7 +46,19 @@ export function readStructureNoteCads(
return new Map(Object.entries(raw ?? {}).map(([key, cad]) => [Number(key), cad]));
}
/** 표 위 CAD 요소 한도 — 서버 `CrossNoteCad.entities max_length` 짝(넘으면 횡단 patch 통째 422 · 433-20). */
export const NOTE_CAD_MAX_ENTITIES = 2000;
function writeNoteCad(section: CrossSection, cad: CrossNoteCad, at: NoteCadPlace): void {
// 한도 넘음 = 초안에 안 씀(앞 초안 그대로) · 알림
if (cad.entities.length > NOTE_CAD_MAX_ENTITIES) {
showToast(
`표 위 CAD 요소 ${cad.entities.length}개 — 한도 ${NOTE_CAD_MAX_ENTITIES}개를 넘어 저장하지 않습니다. 요소를 줄이세요.`,
"warning",
8000,
);
return;
}
// 캐시 설계에도 얹음 — B05 [저장](`crossPatchesFromCache`)이 같은 값을 실음
if (section.design) section.design.structure_note_cad = cad;
const all = readState<Record<string, CrossNoteCad>>(
@@ -194,13 +194,9 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe
const role = spanRoleOf(key, ctx.inletIsBasin());
return role && ctx.structureSpan ? ctx.structureSpan.valuesFor(ctx.section, role) : null;
},
// 구간 줄 = 다단 벽만(기준벽 · 집수정은 폼 칸 = 관 초안 · 433-19)
setSpan: (key, patch) => {
if (isTierKey(key)) {
ctx.structureSpan?.updateTier(ctx.section, key, patch);
return;
}
const role = spanRoleOf(key, ctx.inletIsBasin());
if (role) ctx.structureSpan?.update(ctx.section, role, patch);
if (isTierKey(key)) ctx.structureSpan?.updateTier(ctx.section, key, patch);
},
// 벽을 **그리지 않는 카드에는 [연동]을 내지 않는다**(2026-09-06). 링크 카드는 소유
// 측점의 벽을 빌려 그리는데, 빌릴 벽이 없는 카드에도 버튼이 떠 눌러도 아무 일이
@@ -3,8 +3,8 @@
* 구조물 **구간값(길이·전/후)** 제어 — 다단 단별 구간값과 유입·유출·집수정 구간값.
*
* `B06_Section_UI_Page_Station_Controls` 에서 떼어냈다(700줄 제한, 2026-09-02).
* 세션 맵(`extraSpans`)과 캐시(design)·정본 큐(`culvertOptions`)를 함께 만지는
* 자리라 한 덩어리로 옮겼다. 판정 규칙은 종전대로 `_Cross_Culvert_Const` 몫이다.
* 세션 맵(`extraSpans`)과 캐시(design)를 함께 만지는 자리라 한 덩어리로 옮겼다.
* 기준벽 · 집수정 구간값은 폼 칸(관 초안)이 정본 — 여기는 읽기만(433-19 · 444).
* ========================================================================== */
import type { CrossDesign, CrossSection, StoredWallSpan } from "./B06_Section_Api_Fetch";
@@ -25,8 +25,6 @@ export interface SpanControlDeps {
extraSpans: Map<string, SpanValues>;
persistExtraSpans: () => void;
patchCachedDesign: (chainageM: number, patch: Partial<CrossDesign>) => void;
/** 정본 반영 큐(관 옵션). */
culvertOptions: { queue: (chainageM: number, values: Record<string, number>) => void };
}
/** 구간값 제어기와 단별 구간값 조회를 만든다. */
@@ -36,7 +34,7 @@ export function createSpanControl(deps: SpanControlDeps): {
tierSpansOf: (owner: CrossSection) => Record<string, StoredWallSpan>;
spanKeyOf: (chainageM: number, wall: string) => string;
} {
const { extraSpans, persistExtraSpans, patchCachedDesign, ownerOf, culvertOptions } = deps;
const { extraSpans, persistExtraSpans, patchCachedDesign, ownerOf } = deps;
const spanKeyOf = (chainageM: number, wall: string): string => `${chainageM.toFixed(2)}:${wall}`;
const storedTierSpan = (owner: CrossSection, wall: string): SpanValues => {
@@ -91,40 +89,6 @@ export function createSpanControl(deps: SpanControlDeps): {
const owner = ownerOf(section);
return owner ? CulvertConst.spanValuesOf(owner, role) : null;
},
update: (section, role, patch) => {
const owner = ownerOf(section);
if (!owner?.culvert) return;
const current = CulvertConst.spanValuesOf(owner, role);
if (!current) return;
// 길이↔전/후 산식은 다단과 공용(`applySpanPatch`).
const { lengthM, beforeM, afterM } = CulvertConst.applySpanPatch(current, patch);
const spec = role === "outlet" ? owner.culvert.outlet : owner.culvert.inlet;
const keys = CulvertConst.SPAN_OPTION_KEYS[role];
if (role === "basin") {
spec.basin_length_m = lengthM;
spec.basin_before_m = beforeM;
spec.basin_after_m = afterM;
} else {
spec.revet_length_m = lengthM;
spec.revet_before_m = beforeM;
spec.revet_after_m = afterM;
}
culvertOptions.queue(owner.chainage_m, {
[keys.length]: lengthM,
[keys.before]: beforeM,
[keys.after]: afterM,
});
// 연장이 바뀌면 링크되는 옆 측점 목록이 달라진다. 다시 그릴 대상은 **옛 연장과
// 새 연장을 합친 구간**뿐이다 — 측점 23개를 통째로 다시 그리면 조정창이 매번
// 새로 만들어져 연타한 +/-가 중간에 삼켜진다(2026-08-24 화면 실측: 3번 눌러
// 2번만 반영).
const reach = Math.max(current.beforeM, current.afterM, beforeM, afterM);
for (const other of deps.detail()?.cross_sections ?? []) {
if (Math.abs(other.chainage_m - owner.chainage_m) <= reach + 1e-9) {
deps.refreshCard(other.chainage_m);
}
}
},
};
return { control, tierSpansOf, spanKeyOf };
@@ -505,7 +505,6 @@ export function createStationControls(deps: StationControlDeps): StationControls
extraSpans,
persistExtraSpans,
patchCachedDesign,
culvertOptions,
});
const structureSpanControl = spanControl.control;
const spanKeyOf = spanControl.spanKeyOf;
+37 -8
View File
@@ -2,10 +2,12 @@
* B06_Section_Station_Swap.ts
* 측점 수가 바뀔 때 횡단 목록 갈아끼움(PLAN 346-2 4파) — 계산 묶음 측점(`PipelineStation[]`) → 새 상세 객체.
*
* · 같은 측점(열쇠 · 없으면 같은 자리 같은 종류)은 앞 횡단을 이어 받음 — 설계 · 구조물 제원 · 이름 그대로 ·
* 자리(누가거리 · 좌표 · 방위 · 확폭)와 지반 표본(채워 왔으면)만 새것.
* · 같은 측점(열쇠 · MC 가 움직여 바뀐 곡선 열쇠 `curveMoves` · 없으면 같은 자리 같은 종류)은 앞 횡단을 이어
* 받음 — 설계 · 구조물 제원 · 이름 그대로 · 자리(누가거리 · 좌표 · 방위 · 확폭)만 새것(433-4).
* · 지반 표본 · 상단측 · 중심고 = 자리 · 축이 같은 앞 횡단이면 앞 값(서버 샘플링 정본 · 덧 측점만 넣어도 나머지
* 측점 면적이 브라우저 지형으로 안 바뀜 · 433-11) · 새 측점 · 옮긴 측점만 지반 표본(채워 왔으면).
* · 새 측점 = 지반 표본 + 기본 설계(계획고 0 → 낡음 → 브라우저 다시 셈이 곧 채움).
* · 상단측 = 새 지반 표본으로 다시(단면 유형 기본값 재료 · 서버 횡단 파일 `uphill_side` 짝 · 428).
* · 상단측 = 새 지반 표본으로 다시 셈한 측점은 다시(단면 유형 기본값 재료 · 서버 횡단 파일 `uphill_side` 짝 · 428).
* · 0.5 m 넘게 옮긴 관 · 구조물 측점 = 기본 설계 + 사용자 칸(만진 칸 · 지반 등 입력 `ROW_CARRY_KEYS`) — 서버가
* 그 행을 지우고 기본 설계에 [저장] patch 를 얹음(`sync_cross_section_rows` · `compute_default_designs` · 428) ·
* patch 가 그 칸을 모두 실음(`crossPatchesFromCache` · 445 H2).
@@ -20,6 +22,7 @@
import type { PipelineStation } from "../../A00_Common/design/logic/design_pipeline_types";
import type { CrossSection, SectionDetailResponse, SectionStation } from "../B06_Section_Api_Fetch";
import { uphillSide } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail";
import { curveMoves } from "../../common_util/common_util_station_key";
interface Sample {
offset_m: number;
@@ -141,9 +144,21 @@ export interface StationSwap {
moved: boolean;
}
/** 앞 횡단과 자리(누가거리 · 좌표 0.01 m) · 축(왼쪽 단위 벡터)이 같은가 — 같으면 앞 지반 표본을 그대로 씀. */
function samePlace(prior: CrossSection, station: PipelineStation): boolean {
const [lx, ly] = prior.frame?.left_xy ?? [Number.NaN, Number.NaN];
return (
Math.abs(prior.chainage_m - station.chainageM) < 0.01 &&
Math.hypot(prior.center_x - station.x, prior.center_y - station.y) <= 0.01 &&
Math.abs(lx - station.left[0]) < 1e-4 &&
Math.abs(ly - station.left[1]) < 1e-4
);
}
/**
* 새 측점 목록으로 상세를 새로 짬. `oldKeys` = 지금 상세 측점마다 열쇠(같은 차례 · `stationKeysOf`) ·
* 열쇠가 안 맞으면 같은 자리(0.01 m) · 같은 종류의 앞 횡단을 씀.
* 열쇠가 안 맞으면 옮긴 곡선 열쇠(`curveMoves` · 서버 `carry_cross_inputs` 같은 규칙) · 그다음 같은 자리(0.01 m) ·
* 같은 종류의 앞 횡단을 씀.
*/
export function swapCrossSections(
detail: SectionDetailResponse,
@@ -157,8 +172,18 @@ export function swapCrossSections(
const added: string[] = [];
const moves = new Map<string, string>();
const text = (chainageM: number) => chainageM.toFixed(2);
// 새 곡선 열쇠 → 옛 곡선 열쇠(R 바꿔 MC 가 움직임 · 433-4)
const curveFrom = new Map(
[
...curveMoves(
detail.cross_sections.map((section, i) => [oldKeys[i], section.chainage_m] as const),
stations.map((station) => [station.key, station.chainageM] as const),
),
].map(([from, to]) => [to, from]),
);
const priorOf = (station: PipelineStation): CrossSection | undefined => {
const hit = byKey.get(station.key);
const from = curveFrom.get(station.key);
const hit = byKey.get(station.key) ?? (from === undefined ? undefined : byKey.get(from));
if (hit && !used.has(hit)) return hit;
return detail.cross_sections.find(
(section) =>
@@ -182,6 +207,8 @@ export function swapCrossSections(
moved = true;
if (prior && text(prior.chainage_m) !== text(station.chainageM))
moves.set(text(prior.chainage_m), text(station.chainageM));
// 자리 같은 앞 횡단 = 서버 표본 그대로(433-11) · 아니면 새 지반 표본(채워 왔으면)
const fresh = !!station.ground && !(prior?.samples?.length && samePlace(prior, station));
const kind = kindOf(station);
const onGrid =
kind === "irregular" &&
@@ -193,7 +220,9 @@ export function swapCrossSections(
label: formatStation(station.chainageM),
kind,
...(onGrid ? { grid: true } : {}),
center_z: station.ground?.centerZ ?? prior?.center_z ?? null,
center_z: fresh
? (station.ground?.centerZ ?? prior?.center_z ?? null)
: (prior?.center_z ?? null),
azimuth_deg: station.azimuthDeg,
plan_radius_m: station.planRadiusM,
curve_outer_side: station.curveOuterSide,
@@ -203,8 +232,8 @@ export function swapCrossSections(
frame: { left_xy: [station.left[0], station.left[1]] as [number, number] },
};
longStations.push({ ...place, ...(prior?.structure ? { structure: prior.structure } : {}) });
const samples = station.ground ? groundSamples(station) : (prior?.samples ?? []);
const uphill = station.ground ? uphillSide(station) : (prior?.uphill_side ?? null);
const samples = fresh ? groundSamples(station) : (prior?.samples ?? []);
const uphill = fresh ? uphillSide(station) : (prior?.uphill_side ?? null);
if (!prior) return { ...place, uphill_side: uphill, samples, design: freshDesign(uphill) };
const reseed =
(station.kind === "pipe" || station.kind === "structure") &&
@@ -113,6 +113,7 @@ const NEW = (dy) => [
kinds: swap.detail.cross_sections.map((s) => s.kind),
labels: [swap.detail.cross_sections[2].label, swap.detail.cross_sections[7].label],
samples: swap.detail.cross_sections[1].samples.map((s) => s.offset_m),
movedSamples: swap.detail.cross_sections[2].samples.map((s) => s.offset_m),
oldUntouched:
before.cross_sections.length === 7 && before.cross_sections[3].chainage_m === 45.054,
length: swap.detail.longitudinal.length_m,
+215
View File
@@ -0,0 +1,215 @@
// 433-4 · 433-11 — 측점 갈아끼움(`swapCrossSections` · 진짜) · 횡단 엔진(`computeCrossDesign` · 진짜) ·
// 유토(`computeMassHaul` · 진짜). 인자 = 표준단면 json · 토량환산계수 json.
const fs = require("fs");
const path = require("path");
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 STUBS = {
B05_Profile_Pipeline_Grade: { stationIdOf: idOf },
B05_Profile_UI_Profile_Data: { toDesignProfile: () => ({}) },
};
const cache = new Map();
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 = (r) => {
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`));
if (r.startsWith(".")) return load(path.resolve(path.dirname(full), `${r}.ts`));
throw new Error(`stub 없음: ${r}`);
};
new Function("exports", "module", "require", js)(box.exports, box, req);
return box.exports;
}
const swap = load(path.join(ROOT, "B06_Section/logic/B06_Section_Station_Swap.ts"));
const { computeCrossDesign } = load(path.join(ROOT, "common_util/common_util_cross_design.ts"));
const { computeMassHaul } = load(path.join(ROOT, "common_util/common_util_mass_haul.ts"));
const standard = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const conversion = JSON.parse(fs.readFileSync(process.argv[3], "utf8"));
const st = (key, m, kind, ground = null) => ({
key,
chainageM: m,
kind,
x: m,
y: 0,
left: [0, 1],
azimuthDeg: 90,
planRadiusM: null,
curveOuterSide: null,
curveWideningM: 0,
ground,
});
/* ── 433-4: R 바꿔 MC 측점이 움직임(curve:47.35 → curve:49.10) ── */
const mcSec = (m, kind) => ({
station_id: idOf(m),
chainage_m: m,
kind,
center_x: m,
center_y: 0,
frame: { left_xy: [0, 1] },
samples: [],
uphill_side: "left",
design: { memo: `MY-EDIT@${m}` },
});
const mcDetail = {
longitudinal: { stations: [], length_m: 100 },
cross_sections: [
mcSec(0, "bp"),
mcSec(20, "regular"),
mcSec(47.35, "curve"),
mcSec(60, "regular"),
mcSec(100, "ep"),
],
};
const mcOut = swap.swapCrossSections(
mcDetail,
[
st("end:BP", 0, "end"),
st("grid:1", 20, "grid"),
st("curve:49.10", 49.1, "curve"),
st("grid:3", 60, "grid"),
st("end:EP", 101, "end"),
],
["end:BP", "grid:1", "curve:47.35", "grid:3", "end:EP"],
20,
);
// 멀리(> CURVE_NEAR_M) 간 MC = 못 이음(새 측점 · 노선 밖)
const farOut = swap.swapCrossSections(
mcDetail,
[
st("end:BP", 0, "end"),
st("grid:1", 20, "grid"),
st("curve:62.00", 62, "curve"),
st("grid:3", 60, "grid"),
st("end:EP", 100, "end"),
],
["end:BP", "grid:1", "curve:47.35", "grid:3", "end:EP"],
20,
);
/* ── 433-11: 덧 측점 넣기 · 빼기 — 서버 표본(A) 대 브라우저 지형(B) ── */
const OFFSETS = [-12, -9, -6, -4, -2, 0, 2, 4, 6, 9, 12];
const groundA = (m, o) => 100 + 0.02 * m + 0.35 * o; // 서버 샘플링(저장 상세)
const groundB = (m, o) => groundA(m, o) + 0.6 * Math.sin(m / 7 + o / 3); // 브라우저 설계 지형
const planZ = (m) => 100.2 + 0.02 * m;
const samplesOf = (fn, m) =>
OFFSETS.map((o) => ({ offset_m: o, elevation_m: fn(m, o), valid: true }));
const groundOf = (fn, m) => ({
centerZ: fn(m, 0),
left: OFFSETS.filter((o) => o > 0).map((o) => ({ offsetM: o, z: fn(m, o) })),
right: OFFSETS.filter((o) => o < 0).map((o) => ({ offsetM: -o, z: fn(m, o) })),
});
const designOf = (samples, m) => ({
...computeCrossDesign(samples, planZ(m), {
standard,
groundType: "soil",
sectionMode: "left_cut",
}),
design_elevation_m: planZ(m),
});
const baseKeys = [
["end:BP", 0, "end"],
["grid:1", 20, "grid"],
["grid:2", 40, "grid"],
["grid:3", 60, "grid"],
["end:EP", 80, "end"],
];
const kindOf = (kind, key) =>
kind === "end" ? (key.endsWith("EP") ? "ep" : "bp") : kind === "grid" ? "regular" : "irregular";
const savedSec = ([key, m, kind]) => {
const samples = samplesOf(groundA, m);
return {
station_id: idOf(m),
chainage_m: m,
kind: kindOf(kind, key),
center_x: m,
center_y: 0,
center_z: groundA(m, 0),
frame: { left_xy: [0, 1] },
uphill_side: "left",
samples,
design: designOf(samples, m),
};
};
const saved = {
longitudinal: { stations: [], length_m: 80 },
cross_sections: baseKeys.map(savedSec),
};
const savedKeys = baseKeys.map(([key]) => key);
// 브라우저 다시 셈 = 상세 표본으로 면적(Cross_Refresh 꼴)
const recross = (detail) =>
detail.cross_sections.map((s) => ({ ...s, design: designOf(s.samples, s.chainage_m) }));
const cumulative = (sections) =>
computeMassHaul(sections, conversion).points.map((p) => [
p.chainage_m,
Math.round(p.cumulative_volume_m3 * 1000) / 1000,
]);
const plusList = [...baseKeys.slice(0, 2), ["pipe:p30", 30, "pipe"], ...baseKeys.slice(2)];
const plusStations = plusList.map(([key, m, kind]) => st(key, m, kind, groundOf(groundB, m)));
const added = swap.swapCrossSections(saved, plusStations, savedKeys, 20);
const addedSections = recross(added.detail);
// 서버 기준 = 저장 상세(표본 A) + 덧 측점(브라우저 표본 B · 새 측점은 표본이 그것뿐)
const serverPlus = [...saved.cross_sections];
serverPlus.splice(2, 0, addedSections[2]);
const backStations = baseKeys.map(([key, m, kind]) => st(key, m, kind, groundOf(groundB, m)));
const removed = swap.swapCrossSections(
added.detail,
backStations,
plusList.map(([key]) => key),
20,
);
// 노선이 옮겨 간 측점(좌표 다름)은 새 지형 표본
const shifted = swap.swapCrossSections(
saved,
baseKeys.map(([key, m, kind]) => ({ ...st(key, m, kind, groundOf(groundB, m)), y: 1.5 })),
savedKeys,
20,
);
console.log(
JSON.stringify({
mc: {
added: mcOut.added,
removed: mcOut.removed,
moves: [...mcOut.moves],
memo: mcOut.detail.cross_sections[2].design.memo ?? null,
stationId: mcOut.detail.cross_sections[2].station_id,
far: { added: farOut.added, removed: farOut.removed },
},
plus: {
keptSamples: added.detail.cross_sections
.filter((_, i) => i !== 2)
.every(
(s, i) => JSON.stringify(s.samples) === JSON.stringify(saved.cross_sections[i].samples),
),
keptCenterZ: added.detail.cross_sections.filter((_, i) => i !== 2).map((s) => s.center_z),
savedCenterZ: saved.cross_sections.map((s) => s.center_z),
newSamples:
JSON.stringify(added.detail.cross_sections[2].samples) ===
JSON.stringify(samplesOf(groundB, 30)),
browser: cumulative(addedSections),
server: cumulative(serverPlus),
back: cumulative(recross(removed.detail)),
saved: cumulative(saved.cross_sections),
// 고치기 전 꼴(모든 측점 브라우저 표본) — 차가 큼을 보임
allBrowser: cumulative(
plusList.map(([, m], i) => ({
...addedSections[i],
design: designOf(samplesOf(groundB, m), m),
})),
),
shiftedFresh: shifted.detail.cross_sections.every(
(s, i) => JSON.stringify(s.samples) === JSON.stringify(samplesOf(groundB, baseKeys[i][1])),
),
},
}),
);
@@ -5,7 +5,8 @@ const path = require("path");
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)$/;
const REAL =
/(B05_Profile_Pipeline_Detail|B06_Section_Station_Swap|design_pipeline_cross|common_util_station_key)$/;
const STUBS = {
B05_Profile_Pipeline_Grade: { stationIdOf: idOf },
common_util_station: { stationLabel: (m) => String(m) },
+3 -1
View File
@@ -96,7 +96,9 @@ def test_셋_늘고_둘_빠진_목록으로_갈아끼움(out):
"ep",
]
assert pure["labels"] == ["STA.0+047.000", "STA.0+090.000"]
assert pure["samples"] == [-0.5, 0, 0.5]
# 자리 같은 앞 단면 = 앞 표본(서버 정본 · 433-11) · 옮긴 측점 = 새 지반 표본
assert pure["samples"] == [0]
assert pure["movedSamples"] == [-0.5, 0, 0.5]
assert pure["oldUntouched"] is True and pure["length"] == 90
assert pure["same"] is False and pure["sameSelf"] is True
@@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
"""433-11 — 덧 측점 넣기 · 빼기 뒤 브라우저 누가토량 = 서버(0.1 ㎥ 안).
뿌리: 갈아끼움이 안 바뀐 측점까지 지반 표본 · 상단측 · 중심고를 브라우저 지형 값으로 갈음.
못박는 것
- 자리 · 축이 같은 앞 단면 = 서버 표본 · 중심고 그대로 · 새 측점만 브라우저 표본.
- 덧 측점 넣은 뒤 누가토량 = 서버 기준(저장 표본 + 새 측점) · 빼면 저장 값 그대로.
- 노선이 옮겨 간 측점(좌표 다름)은 새 지형 표본.
- 고치기 전 꼴(모든 측점 브라우저 표본)은 1 ㎥ 넘게 어긋남(시험이 차를 잼).
Node 헬퍼 `helper_433_swap.cjs` — 진짜 갈아끼움 · 횡단 엔진 · 유토.
"""
import json
import subprocess
import sys
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from config.config_system import STANDARD_CROSS_SECTION # noqa: E402
from config.config_system_design import EARTHWORK_CONVERSION_FACTORS # noqa: E402
TOL_M3 = 0.1
@pytest.fixture(scope="module")
def plus(tmp_path_factory):
work = tmp_path_factory.mktemp("b433_11")
standard = work / "standard.json"
conversion = work / "conversion.json"
standard.write_text(json.dumps(STANDARD_CROSS_SECTION), encoding="utf-8")
conversion.write_text(json.dumps(EARTHWORK_CONVERSION_FACTORS), encoding="utf-8")
proc = subprocess.run(
["node", str(HERE / "helper_433_swap.cjs"), str(standard), str(conversion)],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)["plus"]
def _max_gap(a, b):
assert [p[0] for p in a] == [p[0] for p in b]
return max(abs(x[1] - y[1]) for x, y in zip(a, b))
def test_안_바뀐_측점은_서버_표본_중심고(plus):
assert plus["keptSamples"]
assert plus["keptCenterZ"] == plus["savedCenterZ"]
assert plus["newSamples"]
def test_덧_측점_넣은_뒤_누가토량_서버와_같음(plus):
assert _max_gap(plus["browser"], plus["server"]) < TOL_M3
def test_덧_측점_뺀_뒤_저장_값_그대로(plus):
assert _max_gap(plus["back"], plus["saved"]) < TOL_M3
def test_고치기_전_꼴은_어긋남(plus):
assert _max_gap(plus["allBrowser"], plus["server"]) > 1.0
def test_노선이_옮긴_측점은_새_지형(plus):
assert plus["shiftedFresh"]
@@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
"""433-19 · 433-20 — 구간값 죽은 길 걷음 · 표 위 CAD 요소 한도(서버 2000 짝).
못박는 것
- 구간값 제어에 기준벽 `update`(culvertopt 큐 쓰기) 없음 · 구간 줄은 다단 벽만(444 관 초안 한 벌).
- 표 위 CAD 한도 = 서버 `CrossNoteCad.entities max_length` · 넘으면 초안에 안 쓰고 알림.
"""
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
B06 = ROOT / "B06_Section"
def _read(name: str) -> str:
return (B06 / name).read_text(encoding="utf-8")
def test_구간값_제어에_culvertopt_큐_쓰기_없음():
span = _read("B06_Section_UI_Page_Span_Control.ts")
assert "culvertOptions" not in span
assert "update: (section, role" not in span
wire = _read("B06_Section_UI_Cross_Culvert_Wire.ts")
assert not re.search(r"^\s*update: \(section: CrossSection, role", wire, re.M)
view = _read("B06_Section_UI_Cross_View_Structure.ts")
assert "structureSpan?.update(" not in view
def test_CAD_한도_서버와_같음():
schema = _read("B06_Section_Schema.py")
server = re.search(
r"class CrossNoteCad\(BaseModel\):.*?entities:.*?max_length=(\d+)", schema, re.S
)
cad = _read("B06_Section_UI_Cross_Detail_Cad.ts")
screen = re.search(r"NOTE_CAD_MAX_ENTITIES = (\d+)", cad)
assert server and screen and server.group(1) == screen.group(1)
def test_CAD_한도_넘으면_초안에_안_씀():
cad = _read("B06_Section_UI_Cross_Detail_Cad.ts")
body = cad[cad.index("function writeNoteCad(") :]
guard = body.index("NOTE_CAD_MAX_ENTITIES")
assert guard < body.index("structure_note_cad = cad")
assert "showToast(" in body[guard : body.index("structure_note_cad = cad")]
+64
View File
@@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
"""433-4 — 곡선 R 바꿔 MC 측점이 움직임(curve:47.35 → curve:49.10).
상세 갈아끼움이 옛 MC 횡단을 이어 받음.
못박는 것
- MC 설계(사용자 칸) 남음 · 측점 id 그대로 · added · removed 빔.
- moves 에 MC 짝(누가거리 열쇠 칸이 따라감).
- CURVE_NEAR_M 밖으로 간 MC = 못 이음(새 측점 · 옛 열쇠 removed).
Node 헬퍼 `helper_433_swap.cjs` 가 진짜 모듈(`B06_Section_Station_Swap.ts`)을 부름.
"""
import json
import subprocess
import sys
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from config.config_system import STANDARD_CROSS_SECTION # noqa: E402
from config.config_system_design import EARTHWORK_CONVERSION_FACTORS # noqa: E402
@pytest.fixture(scope="module")
def out(tmp_path_factory):
work = tmp_path_factory.mktemp("b433")
standard = work / "standard.json"
conversion = work / "conversion.json"
standard.write_text(json.dumps(STANDARD_CROSS_SECTION), encoding="utf-8")
conversion.write_text(json.dumps(EARTHWORK_CONVERSION_FACTORS), encoding="utf-8")
proc = subprocess.run(
["node", str(HERE / "helper_433_swap.cjs"), str(standard), str(conversion)],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_MC_설계가_새_곡선_열쇠로_남음(out):
mc = out["mc"]
assert mc["memo"] == "MY-EDIT@47.35"
assert mc["stationId"] == "station_000000047350"
assert mc["added"] == []
assert mc["removed"] == []
def test_moves_에_MC_짝(out):
assert ["47.35", "49.10"] in out["mc"]["moves"]
def test_멀리_간_MC_는_못_이음(out):
far = out["mc"]["far"]
assert far["added"] == ["curve:62.00"]
assert far["removed"] == ["curve:47.35"]