fix(365-5): 들어온 화면 무관 같은 횡단 값 — 저장 기준 · 설계 칸 · 시설 제원 한 벌

- 화면 검증 원인 셋: ① 초안 첫 계획선 꼭짓점이 보기가 심은 측점(초안 자리 관)으로 흔들린 저장 기준을 씀 → 서버 저장 종단 꼭짓점(storedPvi) ② 묶음 cross 설계 칸이 다시 셈이 고쳐 쓴 화면 상세(옛 측구 결과 → 선택)를 읽음 → 세션 저장 상세 + 옛 측구 결과(ditch_enabled)도 실음 ③ 초안 자리로 미리 뜬 관 측점 제원이 누가거리 짝에서 빠짐 → 남은 저장 시설 측점과 차례 짝(349-0d 규칙)
- 저장 노선 기준이 바뀌면(보기가 늦게 심은 측점 · 초안 · 되돌린 뒤 빼고) 처음부터 · 제원 지문을 cross 입력에 · 보기 첫 마운트 뒤도 고리
- placed → common_util_route_follow placedOnLine(700줄)
- ORCA 검증_sub7: B05 끌기 길 · B06 먼저 새로고침 · B05 먼저 새로고침 = 67 측점 계획고 · 절토 · 성토 같음 · 열 때 1바퀴
- 시험 test_365(설계 칸 = 저장 상세 · 차례 짝 · 제원 지문 · 초안 계획선 = 저장 종단)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
This commit is contained in:
2026-10-17 14:22:51 +09:00
co-authored by Claude Opus 5.5
parent 201ff9eb94
commit 0652812153
8 changed files with 165 additions and 26 deletions
+1 -1
View File
@@ -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: [] };
+49 -9
View File
@@ -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<string, unknown> {
const design = (section.design ?? {}) as unknown as Record<string, unknown>;
function carried(section: CrossSection, saved?: CrossSection["design"]): Record<string, unknown> {
const design = (saved ?? section.design ?? {}) as unknown as Record<string, unknown>;
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<string, unknown> {
...(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<string, SectionSpecs>();
// 설계 칸 = 세션에 남긴 저장 상세(초안 · 다시 셈이 안 고친 정본 — 들어온 화면 · 고친 차례 무관 · 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<string, unknown> = {};
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<string, unknown> = {};
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,
};
}
+11 -13
View File
@@ -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<object>();
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;
@@ -47,6 +47,8 @@ export interface CrossInput {
standard: unknown;
byKey: Record<string, Record<string, unknown>>;
rockBoundaryDefaultM?: number | null;
/** 얹힌 시설 제원 지문(365-5 · 단계는 안 씀 — 바뀌면 고리가 다시 셈). */
specsKey?: string;
}
const mean = (values: number[]): number => values.reduce((sum, v) => sum + v, 0) / values.length;
@@ -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) => ({
+14
View File
@@ -178,3 +178,17 @@ export function followStructures<S extends FollowingStructure>(
};
});
}
/** 묶음 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;
@@ -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,
};
}
@@ -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")