diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts index c00cf4b20..8d21faa62 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts @@ -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) => void; /** 다단 한 단의 구간값 — 기준벽과 달리 **단마다 따로** 잡는다(2026-08-29 사용자). * 값은 세션에 담기고 [저장]·[확정] 때 정본으로 간다. 없으면 기본 10m(5/5). */ tierValuesFor: (section: CrossSection, key: RevetKey) => SpanValues; diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts b/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts index b9682361d..c62aecddc 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts @@ -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>( diff --git a/B06_Section/B06_Section_UI_Cross_View_Structure.ts b/B06_Section/B06_Section_UI_Cross_View_Structure.ts index ff68ab31c..7eab881c9 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Structure.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Structure.ts @@ -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). 링크 카드는 소유 // 측점의 벽을 빌려 그리는데, 빌릴 벽이 없는 카드에도 버튼이 떠 눌러도 아무 일이 diff --git a/B06_Section/B06_Section_UI_Page_Span_Control.ts b/B06_Section/B06_Section_UI_Page_Span_Control.ts index 83f35a97c..757789a8f 100644 --- a/B06_Section/B06_Section_UI_Page_Span_Control.ts +++ b/B06_Section/B06_Section_UI_Page_Span_Control.ts @@ -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; persistExtraSpans: () => void; patchCachedDesign: (chainageM: number, patch: Partial) => void; - /** 정본 반영 큐(관 옵션). */ - culvertOptions: { queue: (chainageM: number, values: Record) => void }; } /** 구간값 제어기와 단별 구간값 조회를 만든다. */ @@ -36,7 +34,7 @@ export function createSpanControl(deps: SpanControlDeps): { tierSpansOf: (owner: CrossSection) => Record; 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 }; diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index 845147f36..86de72a45 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -505,7 +505,6 @@ export function createStationControls(deps: StationControlDeps): StationControls extraSpans, persistExtraSpans, patchCachedDesign, - culvertOptions, }); const structureSpanControl = spanControl.control; const spanKeyOf = spanControl.spanKeyOf; diff --git a/B06_Section/logic/B06_Section_Station_Swap.ts b/B06_Section/logic/B06_Section_Station_Swap.ts index 8dd6b34e8..aa7278010 100644 --- a/B06_Section/logic/B06_Section_Station_Swap.ts +++ b/B06_Section/logic/B06_Section_Station_Swap.ts @@ -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(); 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") && diff --git a/resources/tester/helper_346_2_station_swap.mjs b/resources/tester/helper_346_2_station_swap.mjs index 08fc7ef39..52373e024 100644 --- a/resources/tester/helper_346_2_station_swap.mjs +++ b/resources/tester/helper_346_2_station_swap.mjs @@ -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, diff --git a/resources/tester/helper_433_swap.cjs b/resources/tester/helper_433_swap.cjs new file mode 100644 index 000000000..fd6cccf3c --- /dev/null +++ b/resources/tester/helper_433_swap.cjs @@ -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])), + ), + }, + }), +); diff --git a/resources/tester/helper_440_draft_card_ids.cjs b/resources/tester/helper_440_draft_card_ids.cjs index 30a3c9fc6..1dfe816e7 100644 --- a/resources/tester/helper_440_draft_card_ids.cjs +++ b/resources/tester/helper_440_draft_card_ids.cjs @@ -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) }, diff --git a/resources/tester/test_346_2_station_swap.py b/resources/tester/test_346_2_station_swap.py index d03def1d7..6c74a76d4 100644 --- a/resources/tester/test_346_2_station_swap.py +++ b/resources/tester/test_346_2_station_swap.py @@ -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 diff --git a/resources/tester/test_433_11_plus_station_masshaul.py b/resources/tester/test_433_11_plus_station_masshaul.py new file mode 100644 index 000000000..988b6b040 --- /dev/null +++ b/resources/tester/test_433_11_plus_station_masshaul.py @@ -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"] diff --git a/resources/tester/test_433_19_20_span_cad.py b/resources/tester/test_433_19_20_span_cad.py new file mode 100644 index 000000000..a03c49530 --- /dev/null +++ b/resources/tester/test_433_19_20_span_cad.py @@ -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")] diff --git a/resources/tester/test_433_4_mc_swap.py b/resources/tester/test_433_4_mc_swap.py new file mode 100644 index 000000000..49d8bead7 --- /dev/null +++ b/resources/tester/test_433_4_mc_swap.py @@ -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"]