diff --git a/A00_Common/design/design_page.ts b/A00_Common/design/design_page.ts index 2a466c75f..dbd6e2dcc 100644 --- a/A00_Common/design/design_page.ts +++ b/A00_Common/design/design_page.ts @@ -246,7 +246,7 @@ export function renderDesignPage(root: HTMLElement): MountedPage { slot.show(); applyView(view); void reconcilePagePipeline(projectId); // 들어올 때 입력(세션 · 가게) 대 묶음 맞춤(365-2) - } else mountView(view, true); + } else void mountView(view, true).ready.then(() => reconcilePagePipeline(projectId)); // 첫 마운트 뒤도(365-5) // 검증용 — 보기 전환에 든 시간(ms)과 횟수를 화면 밖에서 수치로 본다. const probe = window as unknown as { __designPage?: { view: string; switchMs: number[] } }; probe.__designPage ??= { view, switchMs: [] }; diff --git a/A00_Common/design/design_pipeline_cross.ts b/A00_Common/design/design_pipeline_cross.ts index bf08af578..3cd50ce7a 100644 --- a/A00_Common/design/design_pipeline_cross.ts +++ b/A00_Common/design/design_pipeline_cross.ts @@ -12,7 +12,7 @@ * 암 경사 · 소단 깊이) — 노선 초안 동안 B06 고침은 저장 상세에 안 얹혀도 입력에 들어감(365-2 · 365-5). 부를 때마다 모음. * ========================================================================== */ -import { readState } from "../b_page_state"; +import { readState, readStateRaw } from "../b_page_state"; import type { CrossSection, SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch"; import { effectiveStandardCross, @@ -41,12 +41,27 @@ const SPEC_KEYS = [ /** 같은 측점으로 보는 누가거리 차(m) — 측점 열쇠 글 자릿수(0.01 m). */ const SAME_M = 0.01; +/** 세션 저장 상세(노선 초안 동안 안 덮음 · 365-1) — 글이 같으면 앞 해석 그대로. */ +let storedText: { text: string | null; detail: SectionDetailResponse | null } | null = null; +function storedDetail(projectId: string, routeId: number | null): SectionDetailResponse | null { + const text = routeId === null ? null : readStateRaw("section-detail", projectId, routeId); + if (storedText?.text !== text) storedText = { text, detail: text ? JSON.parse(text) : null }; + return storedText.detail; +} + +/** 짧은 지문(FNV-1a) — 제원 묶음이 같은지만 견줌. */ +function digest(text: string): string { + let hash = 0x811c9dc5; + for (let i = 0; i < text.length; i += 1) hash = Math.imul(hash ^ text.charCodeAt(i), 0x01000193); + return `${text.length}:${hash >>> 0}`; +} + const finite = (value: unknown): number | null => typeof value === "number" && Number.isFinite(value) ? value : null; /** 측점 하나의 넘기는 입력(cross 단계 `CrossStationInput` 꼴). */ -function carried(section: CrossSection): Record { - const design = (section.design ?? {}) as unknown as Record; +function carried(section: CrossSection, saved?: CrossSection["design"]): Record { + const design = (saved ?? section.design ?? {}) as unknown as Record; const berm = design.berm as { width_m: number; interval_m: number; slope_deg: number; depth_m?: number } | undefined; const cutSlope = finite(design.cut_slope_ratio_user); @@ -55,6 +70,8 @@ function carried(section: CrossSection): Record { ...(typeof design.ground_type === "string" ? { groundType: design.ground_type } : {}), ...(typeof design.ditch_type === "string" ? { ditchType: design.ditch_type } : {}), ditchChoice: typeof design.ditch_choice === "boolean" ? design.ditch_choice : null, + // 옛 저장분 측구 결과 — 다시 셈 창구와 같은 규칙(자동과 다를 때만 선택 · 들어온 화면 무관 · 365-5) + ditchEnabled: typeof design.ditch_enabled === "boolean" ? design.ditch_enabled : null, twoStageSlope: Boolean(design.two_stage_slope), rockBoundaryOffsetM: finite(design.rock_boundary_offset_m), cutSlopeRatio: cutSlope !== null && cutSlope > 0 ? cutSlope : null, @@ -123,6 +140,18 @@ export function pipelineCrossSource( if (!detail || !standard) return null; const byKey: CrossInput["byKey"] = {}; const specs = new Map(); + // 설계 칸 = 세션에 남긴 저장 상세(초안 · 다시 셈이 안 고친 정본 — 들어온 화면 · 고친 차례 무관 · 365-5) · + // 시설 제원 = 화면 상세(초안 형상을 얹은 것) + const stored = storedDetail(projectId, routeId); + const storedAt = (chainageM: number) => + stored?.cross_sections.find((row) => Math.abs(row.chainage_m - chainageM) < SAME_M)?.design; + const put = (key: string, section: CrossSection, at = section.chainage_m): void => { + byKey[key] = carried(section, storedAt(at)); + const own: Record = {}; + for (const name of SPEC_KEYS) if (section[name] !== undefined) own[name] = section[name]; + specs.set(key, own as SectionSpecs); + }; + const strays: CrossSection[] = []; for (const section of detail.cross_sections) { let best: PipelineStation | null = null; for (const station of savedStations) { @@ -130,18 +159,29 @@ export function pipelineCrossSource( if (gap < SAME_M && (!best || gap < Math.abs(best.chainageM - section.chainage_m))) best = station; } - if (!best || byKey[best.key]) continue; - byKey[best.key] = carried(section); - const own: Record = {}; - for (const name of SPEC_KEYS) if (section[name] !== undefined) own[name] = section[name]; - specs.set(best.key, own as SectionSpecs); + if (best && !byKey[best.key]) put(best.key, section); + else if (!best && section.kind === "irregular") strays.push(section); } + // 초안 자리로 미리 뜬 관 · 구조물 측점(초안 둔 채 열기 — 상세가 초안 누가거리) = 남은 저장 시설 측점과 차례로 짝 · + // 수가 다르면 안 짝지음(349-0d 와 같은 규칙 · 들어온 화면 무관 같은 제원 · 365-5) + const left = savedStations.filter( + (station) => (station.kind === "pipe" || station.kind === "structure") && !byKey[station.key], + ); + left.sort((a, b) => a.chainageM - b.chainageM); + if (strays.length === left.length) + strays.forEach((section, i) => put(left[i].key, section, left[i].chainageM)); if (routeId !== null) for (const [key, entry] of Object.entries(sessionCarried(projectId, routeId))) byKey[key] = { ...byKey[key], ...entry }; return { // 암 경계 기본값 = 서버 config(화면이 문맥에서 기억) — 입력 없는 측점(초안 새 측점)도 서버처럼(349-0p) - input: { standard, byKey, rockBoundaryDefaultM: readRockBoundaryDefault(projectId) }, + // 제원 지문 — 보기가 나중에 얹은 시설 제원도 입력 차이로(고리가 다시 셈 · 들어온 화면 무관 같은 면적 · 365-5) + input: { + standard, + byKey, + rockBoundaryDefaultM: readRockBoundaryDefault(projectId), + specsKey: digest(JSON.stringify([...specs])), + }, specs, }; } diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index 060cc3e40..f7dd07874 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -44,7 +44,7 @@ import type { import { useDesignStore, type DesignStoreValues } from "./design_store"; import { followStructures, - placeOnLine, + placedOnLine as placed, type FollowingStructure, } from "../../common_util/common_util_route_follow"; import { fetchDetailPipePoints } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch"; @@ -52,6 +52,7 @@ import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fe import { createCrossStage } from "../../B06_Section/logic/B06_Section_Cross_Stage"; import { emptyEdits, + toAlignmentBase, type AlignmentBase, type AlignmentEdits, type ProfileAlignment, @@ -303,6 +304,12 @@ function createPagePipeline( emptyEdits() ); }; + const storedPvi = () => { + const stored = canon?.detail && readAlignment(canon.detail.longitudinal); + return stored ? toAlignmentBase(stored).basePvi : null; + }; + const baseMoved = () => + !dropping && !pipe.input("route") && !same(savedSource().base, pipe.output("planline")); /** 횡단 입력 — 부를 때마다 저장 상세 + 세션 고침 · 표준을 모음(상세 객체마다 기억하면 고침이 안 감 · 365-2). */ const crossNow = (): PipelineCrossSource | null => { const routeId = canon?.routeId; @@ -329,16 +336,6 @@ function createPagePipeline( }; }, }); - /** 좌표 없는 관 · 구조물(세션 초안 — 배수유역도 · 구조물 목록이 정본 PUT 꼴로 씀)은 안 옮긴 것 = 정본 좌표 · - * 그 밖 = 앞 노선 위 그 누가거리 자리에서 따라감 — 안 붙이면 측점이 그대로 남음(349-0u). */ - const placed = (list: unknown, line: Vertex[], saved: FollowingStructure[]): unknown => - Array.isArray(list) - ? placeOnLine( - list as FollowingStructure[], - line.map(([x, y]) => ({ x, y })), - saved, - ) - : list; /** 다시 셈 창구가 낸 cross 출력(365-4) — 엔진 재사용에서 뺌. */ const savedOut = new WeakSet(); const pipe = createDesignPipeline({ @@ -362,7 +359,7 @@ function createPagePipeline( }, stations: createStationsStage({ intervalM: options.stationIntervalM, types: () => types }), ground: createGroundStage(groundDeps), - planline: createPlanlineStage({ savedBase: () => savedSource().base, savedLine }), + planline: createPlanlineStage({ savedBase: () => savedSource().base, savedLine, storedPvi }), profile: profileStage, // 단면 유형 = 새 지형 상단측 · 소단 = 지금 구조물(365-5) · 저장 노선 = 다시 셈 창구 한 벌(365-4) · 엔진은 // 창구 출력을 앞 결과로 안 이어 씀(초안 = 새로고침) @@ -677,7 +674,8 @@ function createPagePipeline( }, restart() { if (!started) return start(); - if (canonChanged()) void loadPlan().then(start); // 갈아끼움 · 같은 상세 다시 그림은 안 셈 + if (canonChanged()) return void loadPlan().then(start); // 갈아끼움 · 같은 상세 다시 그림은 안 셈 + if (baseMoved()) start(); // 저장 노선 기준이 바뀜(보기가 늦게 심은 측점 · 되돌린 뒤 · 초안 빼고 · 365-5) }, earthwork(context) { earthwork = context; diff --git a/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts b/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts index e4485d41e..3e33fdf8b 100644 --- a/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts +++ b/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts @@ -47,6 +47,8 @@ export interface CrossInput { standard: unknown; byKey: Record>; rockBoundaryDefaultM?: number | null; + /** 얹힌 시설 제원 지문(365-5 · 단계는 안 씀 — 바뀌면 고리가 다시 셈). */ + specsKey?: string; } const mean = (values: number[]): number => values.reduce((sum, v) => sum + v, 0) / values.length; diff --git a/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts b/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts index 250a66416..d5f712bcd 100644 --- a/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts +++ b/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts @@ -109,6 +109,8 @@ export interface PlanlineDeps { savedBase(): AlignmentBase | null; /** 저장 노선(그린 선) — 바뀐 구간을 가름. */ savedLine(): Vertex[] | null; + /** 노선 초안 첫 계획선 꼭짓점 = 서버 저장 종단 — 보기가 심은 측점(초안 자리 관 등)에 안 흔들림(365-5) · 없으면 `savedBase`. */ + storedPvi?(): AlignmentBase["basePvi"] | null; } export function createPlanlineStage(deps: PlanlineDeps): StageFn<"planline"> { @@ -133,12 +135,11 @@ export function createPlanlineStage(deps: PlanlineDeps): StageFn<"planline"> { }) ?? []; const savedLine = deps.savedLine(); const span = savedLine ? changedSpan(xy(savedLine), xy(route.line)) : null; + const savedPvi = deps.storedPvi?.() ?? saved.basePvi; return { policy: saved.policy, onlyExplicitCurves: saved.onlyExplicitCurves, - basePvi: span - ? carryProfileEdits(saved.basePvi, null, span, groundPvi).basePvi - : saved.basePvi, + basePvi: span ? carryProfileEdits(savedPvi, null, span, groundPvi).basePvi : savedPvi, chainage: profile.chainage, ground: profile.ground, stations: ground.stations.map((station) => ({ diff --git a/common_util/common_util_route_follow.ts b/common_util/common_util_route_follow.ts index 878ac41b4..5441ef643 100644 --- a/common_util/common_util_route_follow.ts +++ b/common_util/common_util_route_follow.ts @@ -178,3 +178,17 @@ export function followStructures( }; }); } + +/** 묶음 follow 입력 꼴 — 목록이면 `placeOnLine`(앞 노선 꼭짓점 `[x, y]`) · 아니면(null 등) 그대로. */ +export const placedOnLine = ( + list: unknown, + line: ReadonlyArray<[number, number]>, + saved: FollowingStructure[], +): unknown => + Array.isArray(list) + ? placeOnLine( + list as FollowingStructure[], + line.map(([x, y]) => ({ x, y })), + saved, + ) + : list; diff --git a/resources/tester/helper_365_page_pipeline.cjs b/resources/tester/helper_365_page_pipeline.cjs index 8e3c124b4..970d5f9eb 100644 --- a/resources/tester/helper_365_page_pipeline.cjs +++ b/resources/tester/helper_365_page_pipeline.cjs @@ -465,8 +465,75 @@ async function run() { depthAfter: bermAfter.find((row) => row[0] === inside[0])[3], runs: runs().length - mark, }; + const draftCtx = { + inputs: { route: bPipe.input("route") }, + out: { ground: bPipe.output("ground"), route: bPipe.output("route") }, + }; pageMod.disposePagePipeline(B); page.clearDrafts(B); + + // ⑫ 들어온 화면 무관(365-5) — 설계 칸 = 세션 저장 상세 · 초안 자리로 미리 뜬 시설 측점 = 차례 짝 · 제원 지문 · + // 옛 측구 결과(ditch_enabled)도 실음 · 초안 첫 계획선 꼭짓점 = 서버 저장 종단 + const crossMod = load("A00_Common", "design", "design_pipeline_cross.ts"); + const C = "proj-365d"; + const sec = (chainage_m, kind, design, extra = {}) => ({ chainage_m, kind, design, ...extra }); + page.writeState( + "section-detail", + { + longitudinal: {}, + cross_sections: [ + sec(0, "bp", { ground_type: "soil" }), + sec(20, "regular", { ground_type: "soil", ditch_choice: null, ditch_enabled: false }), + sec(28, "irregular", { ground_type: "soil" }), + sec(40, "ep", { ground_type: "soil" }), + ], + }, + C, + 7, + ); + const screen = { + longitudinal: {}, + cross_sections: [ + sec(0, "bp", { ground_type: "soil" }), + // 다시 셈이 옛 측구 결과를 선택으로 굳힌 화면 상세 — 설계 칸은 세션 저장 상세가 이김 + sec(20, "regular", { ground_type: "soil", ditch_choice: false, ditch_enabled: true }), + sec(27, "irregular", { ground_type: "soil" }, { culvert: { type: "pipe", diameter_m: 0.8 } }), + sec(40, "ep", { ground_type: "soil" }), + ], + }; + const st = (key, chainageM, kind) => ({ key, chainageM, kind }); + const savedList = [ + st("end:BP", 0, "end"), + st("grid:1", 20, "grid"), + st("pipe:p28", 28, "pipe"), + st("end:EP", 40, "end"), + ]; + const source = crossMod.pipelineCrossSource(C, screen, savedList, 7); + const bare = crossMod.pipelineCrossSource( + C, + { ...screen, cross_sections: screen.cross_sections.filter((x) => x.chainage_m !== 27) }, + savedList, + 7, + ); + // 보기가 심은 측점으로 흔들린 저장 기준(꼭짓점 +5 m) 대 서버 저장 종단 — 초안 첫 계획선은 저장 종단 쪽 + const shaken = { + ...savedBase, + basePvi: savedBase.basePvi.map((node) => ({ ...node, elevation_m: node.elevation_m + 5 })), + }; + const planlineOf = (storedPvi) => + grade.createPlanlineStage({ savedBase: () => shaken, savedLine: () => job.line, storedPvi })( + draftCtx, + ); + const firstPvi = (base) => r3(base.basePvi[0].elevation_m); + const viewFree = { + grid20: source.input.byKey["grid:1"], + pipeSpec: source.specs.get("pipe:p28") ?? null, + specsDiffer: source.input.specsKey !== bare.input.specsKey, + firstStored: firstPvi(planlineOf(() => savedBase.basePvi)), + firstShaken: firstPvi(planlineOf(undefined)), + savedFirst: firstPvi(savedBase), + }; + page.clearDrafts(C); return { kept, b05, @@ -479,6 +546,7 @@ async function run() { shiftRuns, sameAfterReload, berm, + viewFree, }; } diff --git a/resources/tester/test_365_page_pipeline.py b/resources/tester/test_365_page_pipeline.py index ac232a923..7bbc1c325 100644 --- a/resources/tester/test_365_page_pipeline.py +++ b/resources/tester/test_365_page_pipeline.py @@ -14,6 +14,10 @@ - 저장 노선 ▲▼ 한 번 = 횡단 한 벌 — 묶음 cross 가 다시 셈 창구(상세 설계) 한 번 · 종단도 예약 끔. - 중심점 좌우 = 노선 차례는 cross 전에 밀림 → cross 한 번(계획고 맞춤 차례만). - B06 다시 그림 = 겹친 신호(갈아끼움 · 묶음 cross · 초안) 한 번 · profile 차례는 안 그림. +못박는 것(365-5) + - 들어온 화면 무관 — 설계 칸 = 세션 저장 상세 · 초안 자리 시설 = 차례 짝 · 제원 지문 · + 초안 계획선 = 저장 종단. + - 초안 소단 = 지금 구조물 구간 · 쪽 깊이 = 세션 고침. 짝: `helper_365_page_pipeline.cjs`(진짜 TS · 서버 창구만 가짜 · 틀 = 346-2 시험) · `helper_365_swap_entry.ts` + `helper_365_swap.mjs`(진짜 상세 가게 · vite ssr). @@ -161,6 +165,18 @@ def test_365_5_초안_소단_고침(r): assert b["depthBefore"] is None and b["depthAfter"] == 2.5 and b["runs"] == 1 +def test_365_5_들어온_화면_무관(r): + v = r["viewFree"] + 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"] # 제원이 바뀌면 입력 지문도 바뀜(고리가 다시 셈) + assert v["firstStored"] == v["savedFirst"] and v["firstShaken"] != v["savedFirst"] + + def test_365_4_다시_그림_한_번(): swap = (ROOT / "B06_Section/B06_Section_UI_Page_Station_Swap.ts").read_text(encoding="utf-8") view = (ROOT / "A00_Common/design/design_view_cross.ts").read_text(encoding="utf-8")