diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index d2d6d8187..60ee6b9dd 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -89,7 +89,7 @@ export const STATE_REGISTRY = { "masshaul-visible": { bucket: "pref", scope: "global", - version: 5, + version: 6, legacy: () => "b06:masshaul-visible-v4", }, "table-open": { bucket: "pref", scope: "global", legacy: () => "b05:profile:table:open" }, diff --git a/A00_Common/design/design_page.ts b/A00_Common/design/design_page.ts index 63fc84c16..2a466c75f 100644 --- a/A00_Common/design/design_page.ts +++ b/A00_Common/design/design_page.ts @@ -32,6 +32,7 @@ import { saveSharedDesign, type DesignPageShared } from "./design_shared"; import { guardDesignLeave } from "./design_leave"; import { usePipeIds } from "./design_pipe_ids"; import { setRouteShiftOpener } from "../../B06_Section/B06_Section_UI_Cross_Center"; +import { reconcilePagePipeline } from "./design_pipeline_page"; import "./design_page.css"; export type { DesignPageShared } from "./design_shared"; @@ -244,6 +245,7 @@ export function renderDesignPage(root: HTMLElement): MountedPage { if (slot) { slot.show(); applyView(view); + void reconcilePagePipeline(projectId); // 들어올 때 입력(세션 · 가게) 대 묶음 맞춤(365-2) } else mountView(view, true); // 검증용 — 보기 전환에 든 시간(ms)과 횟수를 화면 밖에서 수치로 본다. const probe = window as unknown as { __designPage?: { view: string; switchMs: number[] } }; diff --git a/A00_Common/design/design_pipeline_cross.ts b/A00_Common/design/design_pipeline_cross.ts index 4ef809138..866b66758 100644 --- a/A00_Common/design/design_pipeline_cross.ts +++ b/A00_Common/design/design_pipeline_cross.ts @@ -8,9 +8,11 @@ * 넘기는 칸 = 서버 노선 저장(`CARRY_INPUT_KEYS`)과 같은 일곱(지반 유형 · 측구 종류 · 측구 선택 · 2단 경사 · * 암 경계 · 암 경사 · 소단) + 얹힌 시설 맥락(포장 · 물넘이 기울임 · 세월교 노면 하강). 단면 유형 · 측구 * 방향은 새 지형이 정함(`logic/B05_Profile_Pipeline_Detail.ts` `crossWithUphill`) · 노견 확장 · 소단 - * 깊이는 안 넘김. 값 = 상세에 얹힌 설계(세션 고침은 종단도 · 횡단 보기가 다시 셈하며 이미 얹음). + * 깊이는 안 넘김. 값 = 상세에 얹힌 설계 위에 세션 고침(측점 열쇠 칸 · 지반 유형 · 측구 · 2단 경사 · 암 경계 · + * 암 경사) — 노선 초안 동안 B06 고침은 저장 상세에 안 얹혀도 입력에 들어감(365-2). 부를 때마다 모음. * ========================================================================== */ +import { readState } from "../b_page_state"; import type { CrossSection, SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch"; import { effectiveStandardCross, @@ -66,16 +68,50 @@ function carried(section: CrossSection): Record { : null, paved: Boolean(section.pavement?.length) || Boolean(section.ford_pavement), tilted: Boolean(section.ford_pavement || section.underdrain), - tiltPct: section.ford_pavement?.slope_pct ?? section.underdrain?.slope_pct ?? null, + tiltPct: section.ford_pavement?.slope_pct ?? null, // 맹암거 = 표준 경사(371-3) surfaceDropM: drop > 0 ? drop : 0, }; } -/** 저장 상세 + 저장 노선 측점(열쇠) → cross 입력 · 시설 제원 — 표준단면 · 상세가 없으면 null. */ +/** 세션 칸 하나를 측점 열쇠마다 — 옛 열쇠(누가거리 · `at:`)는 읽는 쪽이 옮긴 뒤에 들어옴(빼고 봄). */ +function sessionCells( + name: "crossdesign" | "rockb" | "cutslope", + projectId: string, + routeId: number, +): Array<[string, unknown]> { + const raw = readState>(name, projectId, routeId); + if (!raw || typeof raw !== "object") return []; + return Object.entries(raw).filter(([key]) => key.includes(":") && !key.startsWith("at:")); +} + +/** 세션 횡단 고침 → 측점 열쇠마다 넘기는 입력 조각(`carried` 와 같은 칸 이름). */ +function sessionCarried(projectId: string, routeId: number): CrossInput["byKey"] { + const out: CrossInput["byKey"] = {}; + for (const [key, value] of sessionCells("crossdesign", projectId, routeId)) { + const choice = (value ?? {}) as Record; + const entry = (out[key] ??= {}); + if (typeof choice.ground_type === "string") entry.groundType = choice.ground_type; + if (typeof choice.ditch_type === "string") entry.ditchType = choice.ditch_type; + if ("ditch_choice" in choice) + entry.ditchChoice = typeof choice.ditch_choice === "boolean" ? choice.ditch_choice : null; + if ("two_stage_slope" in choice) entry.twoStageSlope = Boolean(choice.two_stage_slope); + } + for (const [key, value] of sessionCells("rockb", projectId, routeId)) + if (finite(value) !== null) (out[key] ??= {}).rockBoundaryOffsetM = value; + for (const [key, value] of sessionCells("cutslope", projectId, routeId)) { + const ratio = finite(value); + if (ratio !== null) (out[key] ??= {}).cutSlopeRatio = ratio > 0 ? ratio : null; + } + return out; +} + +/** 저장 상세 + 저장 노선 측점(열쇠) + 세션 고침(`routeId` 칸) → cross 입력 · 시설 제원 — 표준단면 · + * 상세가 없으면 null. */ export function pipelineCrossSource( projectId: string, detail: SectionDetailResponse | null, savedStations: ReadonlyArray, + routeId: number | null = null, ): PipelineCrossSource | null { const standard = detail && effectiveStandardCross(projectId); if (!detail || !standard) return null; @@ -94,6 +130,9 @@ export function pipelineCrossSource( for (const name of SPEC_KEYS) if (section[name] !== undefined) own[name] = section[name]; specs.set(best.key, own as SectionSpecs); } + 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) }, diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index 3ec3d51f0..895ecb64e 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -12,7 +12,10 @@ * * 입력 — 노선 = 편집기 다리(`RouteDraftBridge` · 캐시 칸 route-draft) · 계획고 = 종단도 편집 * (`pipelineGrade` · 노선 초안 동안은 alignment-draft 노선 `DRAFT_ROUTE_ID`) · 횡단 = 저장 상세의 측점 - * 입력(`design_pipeline_cross.ts`) · 관 = 가게 초안 → 저장 관 목록(좌표 · id) · 구조물 = 가게 초안 → 저장분. + * 입력 + 세션 고침(`design_pipeline_cross.ts` · 부를 때마다 모음 · 횡단 고침 뒤 `pipelineCross` · 365-2) · + * 관 = 가게 초안 → 저장 관 목록(좌표 · id) · 구조물 = 가게 초안 → 저장분. + * 고리(365-2) — 열 때 · 페이지 들어올 때(`reconcilePagePipeline`) 입력(세션 · 가게)을 묶음이 쥔 입력과 견줌 → + * 다르면 한 바퀴 → 끝난 뒤 다시 견줌 · 최대 `RECONCILE_ROUNDS` · 넘으면 `onUnstable`(「계산 불안정」 한 줄). * 출력 — 가게 stations · profile · footprint · generation · 노선 초안이 있으면 종단도가 새 첫 계획선 · * 옮긴 계획고 · 새 노선 상세(`pipelineDetail` — 지반 · 측점 · 횡단 면적 → 유토곡선)를 그림. * 첫 계산 = 열 때 한 번(저장 노선 · 발자국 · 저장 안 한 노선 초안이 있으면 그 노선). @@ -22,15 +25,19 @@ * ========================================================================== */ import { createDesignPipeline, publishToStore, type DesignPipeline } from "./logic/design_pipeline"; +import { SOURCE_INPUT } from "./logic/design_pipeline_types"; export { pipelineWork } from "./logic/design_pipeline"; import { pipelineCrossSource, type PipelineCrossSource } from "./design_pipeline_cross"; -import { createStationsStage } from "./logic/design_pipeline_stations"; +import { createStationsStage, stationDiff } from "./logic/design_pipeline_stations"; import type { + ChangeSource, + CrossOutput, FollowPatch, PipelineInputs, PipelineOutputs, PipelineRunRecord, PipelineStation, + RangePart, RouteDraft, StageFn, Vertex, @@ -102,6 +109,8 @@ import type { /** 노선 초안 동안의 계획고 초안 칸 — alignment-draft 의 노선 0(노선 번호는 [저장] 때 남). */ export const DRAFT_ROUTE_ID = 0; +/** 입력 ↔ 묶음 입력 맞춤 상한(바퀴) — 넘으면 「계산 불안정」(365-2). */ +export const RECONCILE_ROUNDS = 3; /** 종단도(`design_profile`)가 묶음에 내주는 것. */ export interface ProfileLink { @@ -137,6 +146,15 @@ export interface PagePipelineOptions { routeId: number, saved: SectionDetailResponse | null, ) => (stations: PipelineStation[]) => Promise; + /** 고리가 `RECONCILE_ROUNDS` 바퀴 안에 안 맞음 — 「계산 불안정」 한 줄(365-2). */ + onUnstable?: () => void; + /** 저장 노선 cross = 다시 셈 창구 한 벌(365-4) — 상세 설계를 고치고 그 값을 냄 · 측점이 상세와 다르면 null + * (묶음 엔진). 주면 종단도가 계획고 편집 뒤 따로 다시 셈 안 함(`pipelineOwnsCross`). */ + savedCross?: ( + detail: SectionDetailResponse, + routeId: number, + stations: ReadonlyArray, + ) => Promise; } interface PagePipeline { @@ -145,6 +163,9 @@ interface PagePipeline { grade(edits: AlignmentEdits): void; restart(): void; earthwork(context: MassHaulStageContext | null): void; + cross(): void; + reconcile(): Promise; + ownsCross: boolean; detail(): SectionDetailResponse | null; dispose(): void; } @@ -155,22 +176,6 @@ type Facilities = Pick; const pages = new Map(); const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b); -/** 상세 측점 대 묶음 측점(1 mm) — 같으면 null · 다르면 첫 다른 자리 · 개수(묶음 기록 `restoreDiff`). */ -function stationDiff( - detail: SectionDetailResponse, - stations: ReadonlyArray, -): NonNullable | null { - const saved = detail.cross_sections.map((section) => section.chainage_m); - const n = Math.max(saved.length, stations.length); - for (let i = 0; i < n; i += 1) { - const pipelineM = stations[i]?.chainageM ?? null; - if (saved[i] !== undefined && pipelineM !== null && Math.abs(saved[i] - pipelineM) < 1e-3) - continue; - return { savedM: saved[i] ?? null, pipelineM, saved: saved.length, pipeline: stations.length }; - } - return null; -} - /** 편집기에 넘길 다리 — 그 프로젝트 묶음을 처음 부를 때 세움. */ export function routeDraftBridge( projectId: string, @@ -193,6 +198,17 @@ export const restartPagePipeline = (projectId: string): void => pages.get(projec export const pipelineEarthwork = (projectId: string, context: MassHaulStageContext | null): void => pages.get(projectId)?.earthwork(context); +/** 횡단 고침(지반 유형 · 측구 · 암 · 소단 · 표준 — B06 · 365-2) — 세션 횡단 입력을 다시 모아 cross 부터. */ +export const pipelineCross = (projectId: string): void => pages.get(projectId)?.cross(); + +/** 저장 노선 횡단 다시 셈을 묶음 cross 가 맡는가(365-4) — 종단도는 계획고 편집 뒤 따로 안 셈. */ +export const pipelineOwnsCross = (projectId: string): boolean => + pages.get(projectId)?.ownsCross ?? false; + +/** 입력(세션 · 가게) 대 묶음 입력을 견줘 맞춤(365-2) — 돈 바퀴 수(0 = 같음 · -1 = 넘음 · 묶음 없음 = 0). */ +export const reconcilePagePipeline = (projectId: string): Promise => + pages.get(projectId)?.reconcile() ?? Promise.resolve(0); + /** 노선 초안 동안 종단도가 그릴 새 노선 상세 — 초안이 없으면 null(저장 상세). */ export const pipelineDetail = (projectId: string): SectionDetailResponse | null => pages.get(projectId)?.detail() ?? null; @@ -219,10 +235,9 @@ function createPagePipeline( let savedStructures: PlanStructure[] = []; let savedPipes: PlanPipe[] = []; let earthwork: MassHaulStageContext | null = null; - /** 저장 노선 측점(열쇠) · 그것으로 모은 횡단 입력 — 저장 상세가 바뀌면 다시. */ + /** 저장 노선 측점(열쇠) · 마지막으로 모은 횡단 입력(시설 제원 — 새 노선 상세가 씀). */ let savedStations: PipelineStation[] = []; - let crossSource: { detail: SectionDetailResponse; source: PipelineCrossSource | null } | null = - null; + let crossSource: PipelineCrossSource | null = null; /** 저장 정본(서버가 준 상세 · 노선 번호) — 측점 갈아끼움(swap)이 만든 새 상세는 지반 표본 배열을 * 그대로 둠 → 정본 아님(묶음 출력 → 상세 → 다시 묶음 고리를 끊음). */ let canon: { routeId: unknown; samples: unknown; detail: SectionDetailResponse | null } | null = @@ -286,12 +301,17 @@ function createPagePipeline( emptyEdits() ); }; + /** 횡단 입력 — 부를 때마다 저장 상세 + 세션 고침 · 표준을 모음(상세 객체마다 기억하면 고침이 안 감 · 365-2). */ const crossNow = (): PipelineCrossSource | null => { - const detail = canon?.detail ?? null; - if (!detail) return null; - if (crossSource?.detail !== detail || !crossSource.source) - crossSource = { detail, source: pipelineCrossSource(projectId, detail, savedStations) }; - return crossSource.source; + const routeId = canon?.routeId; + const source = pipelineCrossSource( + projectId, + canon?.detail ?? null, + savedStations, + typeof routeId === "number" ? routeId : null, + ); + if (source) crossSource = source; + return source; }; const groundDeps = groundStageDeps(projectId); const crossStage = createCrossStage({ grid: groundDeps.grid }); // 미교차 넓힘 = 같은 지형 격자(349-0j) @@ -340,18 +360,27 @@ function createPagePipeline( ground: createGroundStage(groundDeps), planline: createPlanlineStage({ savedBase: () => savedSource().base, savedLine }), profile: profileStage, - // 단면 유형 기본값 = 새 지형 상단측(서버 노선 저장 규칙) — 단계 본문은 그대로 부름 - cross: (ctx) => - crossStage.stage({ - ...ctx, - inputs: { - ...ctx.inputs, - cross: crossWithUphill( - ctx.inputs.cross as CrossInput | null, - ctx.out.ground?.stations ?? [], - ), - }, - }), + // 단면 유형 기본값 = 새 지형 상단측(서버 노선 저장 규칙) — 단계 본문은 그대로 부름 · 저장 노선 = 다시 셈 + // 창구 한 벌(카드 · 유토곡선이 보는 상세 설계 · 365-4) + cross: (ctx) => { + const engine = () => + crossStage.stage({ + ...ctx, + inputs: { + ...ctx.inputs, + cross: crossWithUphill( + ctx.inputs.cross as CrossInput | null, + ctx.out.ground?.stations ?? [], + ), + }, + }); + const { detail = null, routeId } = savedSource(); + const saved = + !ctx.inputs.route && detail && typeof routeId === "number" + ? options.savedCross?.(detail, routeId, ctx.out.ground?.stations ?? []) + : null; + return saved ? saved.then((out) => out ?? engine()) : engine(); + }, footprint: footprintStage, masshaul: createMassHaulStage(() => earthwork), haul: (ctx) => (ctx.inputs.route ? haulStage(ctx) : (ctx.prev ?? null)), @@ -460,7 +489,7 @@ function createPagePipeline( ground, profile, pipe.output("cross"), - crossSource?.source?.specs ?? new Map(), + crossSource?.specs ?? new Map(), options.stationIntervalM ?? profile.policy.station_interval_m, ) : null; @@ -513,12 +542,52 @@ function createPagePipeline( if (disposed || !ready || !savedSource().base) return; canonChanged(); started = true; - held = null; + const inputs = inputsNow(); + // 초안 둔 채 열기(새로고침 · B06 바로 열기 · 365-1) — 초안 전 = 지금 저장 상세(세션 그대로) · 측점은 가게에 냄 + held = inputs.route ? (held ?? holdNow()) : null; dropping = null; - void pipe.start(inputsNow()); - openGeneration = quietGeneration = pipe.generation; + Object.assign(given, inputs); + const run = (lastRun = pipe.start(inputs)); + openGeneration = pipe.generation; + quietGeneration = inputs.route ? -1 : pipe.generation; + void run.then(() => reconcile()); // 열 때 셈하는 사이 바뀐 입력(세션 · 가게)도 맞춤 }; + /** 묶음에 넘긴 입력(follow 가 고치기 전) · 마지막으로 띄운 차례 — 고리가 견줌 · 기다림. */ + const given: Partial = {}; + let lastRun: Promise = Promise.resolve(); + const feed = (source: ChangeSource, value: unknown, part: RangePart | null = null) => { + given[SOURCE_INPUT[source]] = value as never; + const run = pipe.update(source, value, part); + if (run) lastRun = run; + return run; + }; + let reconciling: Promise | null = null; + /** 입력 지문 대 묶음 입력 — 넘긴 값 · follow 뒤 값 둘 다와 다르면 한 바퀴(노선 = 다리 길) · 끝난 뒤 다시 견줌. */ + function reconcile(): Promise { + if (!started || disposed) return Promise.resolve(0); + return (reconciling ??= (async () => { + for (let round = 0; ; round += 1) { + let seen: Promise | null = null; + while (seen !== lastRun) await (seen = lastRun); // 기다리는 사이 뜬 차례까지 + await pipe.settled(); + if (disposed || canon?.routeId !== savedSource().routeId) return round; // 정본 바뀜 = restart 몫 + const now = inputsNow(); + const stale = (Object.entries(SOURCE_INPUT) as Array<[ChangeSource, keyof PipelineInputs]>) + .filter(([, key]) => !same(now[key], pipe.input(key)) && !same(now[key], given[key])) + .map(([source, key]): [ChangeSource, unknown] => [source, now[key]]); + if (!stale.length) return round; + if (round >= RECONCILE_ROUNDS) { + options.onUnstable?.(); + return -1; + } + for (const [source, value] of stale) + if (source === "route") bridge.edited(value as RouteDraft | null); + else feed(source, value); + } + })().finally(() => (reconciling = null))); + } + /** 저장 노선(그린 선 · 곡선표 · 기본 반경) · 그 측점 열쇠 — [저장] 뒤 다시 받음(`restart`). */ const loadPlan = (): Promise => fetchRoutePlan(projectId) @@ -552,13 +621,10 @@ function createPagePipeline( .catch(() => undefined); const unsubscribe = [ - store.subscribe( - "pipes", - (pipes) => started && !echo && void pipe.update("pipe", pipes ?? savedPipes), - ), + store.subscribe("pipes", (pipes) => started && !echo && void feed("pipe", pipes ?? savedPipes)), store.subscribe( "structures", - (list) => started && !echo && void pipe.update("structure", list ?? savedStructures), + (list) => started && !echo && void feed("structure", list ?? savedStructures), ), ]; @@ -579,7 +645,7 @@ function createPagePipeline( if (!started) return start(); const before = pipe.output("route")?.line ?? null; const after = draft ? draftLine(draft, plan?.min_radius_m ?? 0).line : savedLine(); - const run = pipe.update("route", draft, routeRange(before, after)); + const run = feed("route", draft, routeRange(before, after)); if (run && !draft && wasDraft) { quietGeneration = pipe.generation; dropping = run.then(finishDrop); @@ -595,7 +661,7 @@ function createPagePipeline( if (same(edits, pipe.input("alignment"))) return; const nodes = pipe.output("planline")?.basePvi.map((node) => node.chainage_m) ?? []; const before = pipe.input("alignment") as AlignmentEdits | null; - void pipe.update("grade", edits, gradeRange(before, edits, nodes)); + void feed("grade", edits, gradeRange(before, edits, nodes)); }, restart() { if (!started) return start(); @@ -604,8 +670,14 @@ function createPagePipeline( earthwork(context) { earthwork = context; const cross = crossNow()?.input ?? null; - if (started && cross) void pipe.update("cross", cross); + if (started && cross) void feed("cross", cross); }, + cross() { + const cross = started ? (crossNow()?.input ?? null) : null; + if (cross) void feed("cross", cross); + }, + reconcile, + ownsCross: Boolean(options.savedCross), detail: () => shown, dispose() { disposed = true; diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index faa2750cb..6481c3a03 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -324,9 +324,9 @@ export function createDesignProfile(ctx: DesignProfileContext) { refresh: () => renderBalance(), }); - /** 편집을 적용한다. 법정 위반 정책이 block이면 새 위반이 생기는 편집을 막는다. */ - function applyEdits(next: AlignmentEdits): void { - if (!base || !alignment) return; + /** 편집을 적용한다. 법정 위반 정책이 block이면 새 위반이 생기는 편집을 막는다. false = 막힘. */ + function applyEdits(next: AlignmentEdits): boolean { + if (!base || !alignment) return false; const candidate = buildAlignment(base, next); if ( base.policy.grade_violation_policy === "block" && @@ -336,12 +336,13 @@ export function createDesignProfile(ctx: DesignProfileContext) { `종단기울기 상한 ${base.policy.max_grade_pct.toFixed(1)}%를 넘어 편집을 적용하지 않았습니다.`, "error", ); - return; + return false; } // 최소고 가드 — 편집 경로가 전부 여기로 모인다(2026-09-02 직선화·쉬프트·틸팅·방향키 누락 수정). - if (blocksMinCover(base, alignment, candidate, enforceMinCover, minCoverTargets)) return; + if (blocksMinCover(base, alignment, candidate, enforceMinCover, minCoverTargets)) return false; store.replace(next); history.record(); + return true; } /** 계획선 측점 줄 = 측점 한 벌 — 셈은 `design_profile_base` 몫(계산 묶음 planline 도 같은 기준). */ @@ -353,8 +354,8 @@ export function createDesignProfile(ctx: DesignProfileContext) { pagePipeline.pipelineGrade(projectId, store.edits()); // 계획고가 바뀌면 측점별 횡단 단면적도 함께 바뀐다 — 전 측점을 다시 계산해 공유 // 캐시에 얹는다. 그래야 **횡단 기준** 유토곡선과 횡단 카드가 따라 움직인다 - // (2026-08-03 사용자 보고: B05에서 계획선을 끌어도 횡단 곡선이 그대로였음). - scheduleCrossPreview(); + // (2026-08-03 사용자 보고: B05에서 계획선을 끌어도 횡단 곡선이 그대로였음). 묶음이 맡으면 그 cross 한 벌(365-4). + if (!pagePipeline.pipelineOwnsCross(projectId)) scheduleCrossPreview(); drawSoon(); } /** 다음 틀에 한 번 그림 — 같은 차례 알림 · 갈아끼움 · 설정이 여러 번 불러도 한 번(349-0s). */ diff --git a/A00_Common/design/design_profile_overlays.ts b/A00_Common/design/design_profile_overlays.ts index 5926afe74..f5735dcb2 100644 --- a/A00_Common/design/design_profile_overlays.ts +++ b/A00_Common/design/design_profile_overlays.ts @@ -59,8 +59,6 @@ export function createProfileOverlays(deps: ProfileOverlaysDeps) { const column = document.createElement("div"); column.className = "b05-route-profile__column"; column.append(bodyWrap, massHaul.overlay); - // 판의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지. - massHaul.attachScrollSync(body); // 테이블은 종단 칸 바닥 겹침 판(2026-08-05 사용자 지시). const tableOverlay = createProfileTableOverlay(onChange); bodyWrap.append(tableOverlay.overlay, tableOverlay.handle); diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index 803c7c158..3d87fd8c8 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -29,6 +29,19 @@ import { saveDesign, type DesignSaveStep } from "./design_save"; import { markDesignClean } from "./design_leave"; import { resetDesign } from "./design_reset"; import type { DesignView } from "./design_dock"; +import { routeDraftBridge, type PagePipelineOptions } from "./design_pipeline_page"; +import { SECTION_STATION_INTERVAL_M } from "../../B05_Profile/logic/B05_Profile_Sections_Stations"; +import { + applyStructureAreaRows, + structureAreaRows, +} from "../../B06_Section/B06_Section_Structure_Layouts"; +import { + holdSectionDetail, + restoreSectionDetail, + swapSectionStations, +} from "../../B06_Section/B06_Section_Section_Store"; +import { refreshCrossDesigns } from "../../B06_Section/B06_Section_Cross_Refresh"; +import { detailCross } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -62,6 +75,8 @@ export interface DesignViewBinding { onReset?: () => void; /** 다른 보기가 고친 관 옵션을 이 보기 캐시(횡단 스펙)에 얹는다. */ onPipeOptions?: (chainageM: number, patch: Record) => void; + /** 계산 묶음 알림 — 묶음은 페이지가 세움(365-1) · 보기는 알림만 받음. */ + pipeline?: Pick; } /** 한 벌 자리 — 페이지(`design_page`)가 만들어 두 보기에 같은 객체로 넘긴다. */ @@ -182,9 +197,48 @@ export function sharedProfile(shared: DesignPageShared): DesignProfile { attachProfilePopout(profile, shared.profileSlot, popoutButton); attachSubPanelPopouts(profile); shared.profile = profile; + if (shared.projectId) startPagePipeline(shared, profile); return profile; } +/** 계산 묶음 한 벌(365-1) — 종단도와 함께 페이지가 세움 · B05 · B06 어느 쪽으로 들어와도(새로고침도) 노선 + * 초안을 셈. 편집기는 `routeDraftBridge` 로 이 다리를 받아 씀 · 보기 몫 알림은 `pipeline` 바인딩으로. */ +function startPagePipeline(shared: DesignPageShared, profile: DesignProfile): void { + const projectId = shared.projectId; + const each = (fn: (hooks: NonNullable) => void): void => + Object.values(shared.bindings).forEach((binding) => binding?.pipeline && fn(binding.pipeline)); + routeDraftBridge(projectId, profile, { + stationIntervalM: SECTION_STATION_INTERVAL_M, + onCorridorStale: () => each((hooks) => hooks.onCorridorStale?.()), + onFacilitiesFollowed: (far) => each((hooks) => hooks.onFacilitiesFollowed?.(far)), + onUnstable: () => showToast(L("Design_Pipeline_Unstable"), "warning"), + // 저장 노선 횡단 = 다시 셈 창구 한 벌(365-4) — 카드 · 유토곡선 · [저장] 패치가 보는 상세 설계를 고치고 그 값을 + // 묶음 출력으로(발자국 · 평면 횡단 판) · 측점이 상세와 다르면(갈아끼움 전) 묶음 엔진 + savedCross: async (detail, routeId, stations) => { + if (!detailCross(detail, stations)) return null; + const edits = profile.pipelineSource().edits; + const updated = await refreshCrossDesigns({ projectId, routeId, detail, edits }); + if (updated.length) toAll(shared, "onCrossDesignsUpdated")(updated); + profile.redraw(); + return detailCross(detail, stations); + }, + structureAreas: ({ cross_sections: rows }) => + applyStructureAreaRows(rows, structureAreaRows(rows)), + // 초안 시작 — 초안 전 상태를 떠 둠 · 버리면 그대로(측점이 달라도 · 349-0o) · 상세가 없으면 묶음 측점으로 갈아끼움 + holdDraft: (routeId, saved) => { + const hold = saved && holdSectionDetail(projectId, routeId, saved); + return async (stations) => { + if (!hold) { + await swapSectionStations(projectId, routeId, stations); + return "no-saved"; + } + const back = await restoreSectionDetail(projectId, routeId, hold, stations); + return !back ? "no-cache" : back.same ? "saved" : "saved-diff"; + }; + }, + }); +} + /** 「구조물 배치」 폼 한 벌 — 알림은 지금 보기로, 상세 옵션은 횡단 보기에서만 그린다. */ export function sharedStructures(shared: DesignPageShared): StructuresSection { if (shared.structures) return shared.structures; diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 85da53112..8d9063801 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -225,11 +225,11 @@ export async function mountCrossView( const seq = ++structuresRefreshSeq; // 잇단 조작 — 최신 요청만 그림(54-4) if (savingDesign(projectId)) return; // 저장 도중 = 낡은 캐시 · 빈 초안 — 저장 뒤 한 번(323-9) try { + await new Promise((next) => setTimeout(next, 0)); // 겹친 신호(갈아끼움 · 묶음 · 초안) = 한 번(365-4) const fresh = await loadSectionDetail(projectId, currentRouteId); if (seq !== structuresRefreshSeq) return; sectionDetail = fresh; - // 면적을 **먼저** 다시 얹는다 — 유토곡선은 카드보다 앞서 계산되므로, 카드 렌더가 - // 고치는 것만으로는 곡선이 옛 면적으로 남는다(2026-09-06 실측). + // 면적을 **먼저** 다시 얹는다 — 유토곡선은 카드보다 앞서 셈(카드만 고치면 곡선이 옛 면적 · 2026-09-06 실측). if (sectionDetail) { applyStructureAreaRows( sectionDetail.cross_sections, diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 2293d6b97..7334d380c 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -48,16 +48,7 @@ import { rememberStandardDefaults, } from "../../B06_Section/B06_Section_UI_Standard_Panel"; import { fetchSectionContext } from "../../B06_Section/B06_Section_Api_Fetch"; -import { - applyStructureAreaRows, - structureAreaRows, -} from "../../B06_Section/B06_Section_Structure_Layouts"; -import { - holdSectionDetail, - invalidateSectionDetail, - restoreSectionDetail, - swapSectionStations, -} from "../../B06_Section/B06_Section_Section_Store"; +import { invalidateSectionDetail } from "../../B06_Section/B06_Section_Section_Store"; import { onStaleStructureTypes, structureAnchorM, @@ -419,6 +410,11 @@ export async function mountProfileView( bindDesignView(shared, "profile", { save: profileSaveParts(actionContext), onReset: () => uphillOverrides.clear(), + // 계산 묶음(페이지 몫 · 365-1) 알림 중 종단 보기 몫 — 3D 「낡음」 · 관 · 구조물 따라감 + pipeline: { + onCorridorStale: () => sections.setCorridorPending(true), + onFacilitiesFollowed: followFacilities, + }, }); // 탭이 다시 보일 때 정의 판이 바뀌었으면 종류 · 패널 다시 읽음(322-0) @@ -539,27 +535,8 @@ export async function mountProfileView( smooth: latestResponse.surface_params.smooth, tools: [view3d.modal.button], recommend: panel.recommend, - // 계산 묶음(346-1) — 노선을 고칠 때마다 종단이 따라옴(미리보기 · [저장]이 정본). - draft: routeDraftBridge(activeProjectId, profilePanel, { - stationIntervalM: panel.values().stationInterval ?? undefined, - onCorridorStale: () => sections.setCorridorPending(true), - onFacilitiesFollowed: followFacilities, - structureAreas: ({ cross_sections: rows }) => - applyStructureAreaRows(rows, structureAreaRows(rows)), - // 초안 시작 — 초안 전 상태를 떠 둠 · 버리면 그대로(측점이 달라도 · 349-0o) · 상세가 없으면(초안 둔 채 - // 새로고침) 묶음 측점으로 갈아끼움 - holdDraft: (routeId, saved) => { - const hold = saved && holdSectionDetail(activeProjectId, routeId, saved); - return async (stations) => { - if (!hold) { - await swapSectionStations(activeProjectId, routeId, stations); - return "no-saved"; - } - const back = await restoreSectionDetail(activeProjectId, routeId, hold, stations); - return !back ? "no-cache" : back.same ? "saved" : "saved-diff"; - }; - }, - }), + // 계산 묶음(346-1) — 페이지가 세운 다리(365-1) · 노선을 고칠 때마다 종단이 따라옴(미리보기 · [저장]이 정본). + draft: routeDraftBridge(activeProjectId, profilePanel), }); // ③ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다. diff --git a/A00_Common/design/logic/design_pipeline_stations.ts b/A00_Common/design/logic/design_pipeline_stations.ts index 9dc61c492..fcd29e639 100644 --- a/A00_Common/design/logic/design_pipeline_stations.ts +++ b/A00_Common/design/logic/design_pipeline_stations.ts @@ -14,7 +14,13 @@ import type { PlanStructure, PlanType, } from "../../../common_util/common_util_structure_station"; -import type { PipelineStation, StageContext, StageFn } from "./design_pipeline_types"; +import type { + PipelineRunRecord, + PipelineStation, + StageContext, + StageFn, +} from "./design_pipeline_types"; +import type { SectionDetailResponse } from "../../../B06_Section/B06_Section_Api_Fetch"; /** 범위 밖 측점은 앞 값(같은 열쇠 · 같은 자리)을 그대로 — 뒤 단계가 같은 객체로 「안 바뀜」을 앎. */ export function reuseOutside( @@ -54,3 +60,19 @@ export function createStationsStage( return reuseOutside(next, ctx.prev, ctx.range); }; } + +/** 상세 측점 대 묶음 측점(1 mm) — 같으면 null · 다르면 첫 다른 자리 · 개수(묶음 기록 `restoreDiff`). */ +export function stationDiff( + detail: SectionDetailResponse, + stations: ReadonlyArray, +): NonNullable | null { + const saved = detail.cross_sections.map((section) => section.chainage_m); + const n = Math.max(saved.length, stations.length); + for (let i = 0; i < n; i += 1) { + const pipelineM = stations[i]?.chainageM ?? null; + if (saved[i] !== undefined && pipelineM !== null && Math.abs(saved[i] - pipelineM) < 1e-3) + continue; + return { savedM: saved[i] ?? null, pipelineM, saved: saved.length, pipeline: stations.length }; + } + return null; +} diff --git a/A00_Common/design/logic/design_profile_free.ts b/A00_Common/design/logic/design_profile_free.ts index 4bb03c965..c658fca63 100644 --- a/A00_Common/design/logic/design_profile_free.ts +++ b/A00_Common/design/logic/design_profile_free.ts @@ -4,7 +4,8 @@ * * 흐름: 세로 측점선 둘로 범위를 고름 → 시작(또는 끝) 세로선 근처 클릭 = 그 측점 계획고에 이은 * 채 시작 → 클릭마다 점 하나(가로 = 범위 안 가장 가까운 측점선 · 높이 = 클릭한 높이) → 반대쪽 - * 세로선 근처 클릭 또는 Enter = 그 측점 계획고에 이어 끝. Esc = 취소 · Backspace = 마지막 점 빼기. + * 세로선 근처 클릭 또는 Enter = 그 측점 계획고에 이어 끝. Esc = 취소 · ↶ ↷(Backspace = ↶) = + * 자유지정 안 한 걸음씩(범위 · 시작 · 점 · 367-3) — 큰 이력과 따로 · 끝나면 큰 이력 한 걸음. * * 적용은 직선화와 같은 길 — 편집 델타(`station_offsets` · `curve_radii`)만 만들어 캐시에 쓰고 * [저장] 때 서버가 다시 셈한다. 꺾이는 점 종단곡선 = 직선화 틸팅과 같은 기본 길이 L @@ -181,8 +182,8 @@ export interface FreeDrawDeps { base: () => AlignmentBase | null; alignment: () => ProfileAlignment | null; edits: () => AlignmentEdits; - /** 편집 적용 — 직선화 결과와 같은 길(캐시 · 되돌리기 · 막기 규칙). */ - apply: (next: AlignmentEdits) => void; + /** 편집 적용 — 직선화 결과와 같은 길(캐시 · 되돌리기 · 막기 규칙). false = 막힘(열린 채). */ + apply: (next: AlignmentEdits) => boolean | void; /** 모드를 끈다(적용 뒤 · Esc). */ close: () => void; /** 단계 · 안내가 바뀜. */ @@ -198,6 +199,11 @@ export interface FreeDraw { hover: (pointer: { chainageM: number; elevationM: number; stations: number[] } | null) => void; /** Enter · Escape · Backspace — 먹었으면 true. */ key: (key: string) => boolean; + /** 자유지정 안 한 걸음 되돌림 · 다시함(367-3) — 큰 이력과 따로. */ + undo: () => void; + redo: () => void; + canUndo: () => boolean; + canRedo: () => boolean; marks: () => FreeMarks; hint: () => string; } @@ -209,6 +215,25 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw { let anchor: number | null = null; let points: FreeVertex[] = []; let target: FreeVertex | null = null; + /** 자유지정 안 이력(367-3) — 상태 바뀐 클릭만 쌓음 · 새 클릭이 future 비움. */ + interface Snap { + phase: FreePhase; + pending: number | null; + range: [number, number] | null; + anchor: number | null; + points: FreeVertex[]; + } + let past: Snap[] = []; + let future: Snap[] = []; + const snap = (): Snap => ({ phase, pending, range, anchor, points: [...points] }); + function step(from: Snap[], to: Snap[]): void { + const state = from.pop(); + if (!state) return; + to.push(snap()); + ({ phase, pending, range, anchor, points } = state); + target = null; + deps.changed(); + } function reset(): void { phase = "range"; @@ -217,6 +242,8 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw { anchor = null; points = []; target = null; + past = []; + future = []; } /** 그 측점의 지금 계획고(변화점이면 라운드 중심 z — 직선화와 같은 값). */ @@ -263,8 +290,9 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw { if (!base || end === null || z === null) return; const vertices = [...path(), { chainage_m: end, elevation_m: z }]; const next = buildFreeEdits(base, deps.edits(), vertices); + // 막히면(기울기 · 흙덮기) 자유지정 열린 채 — 그린 점 그대로(367-3) + if (deps.apply(next) === false) return; reset(); - deps.apply(next); deps.close(); } @@ -272,6 +300,7 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw { phase: () => phase, reset, click(chainageM, elevationM, stations) { + const before = snap(); if (phase === "range") { const station = snapStation(stations, chainageM); if (station === null) return true; @@ -297,6 +326,10 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw { } points.push(next); } + if (JSON.stringify(snap()) !== JSON.stringify(before)) { + past.push(before); + future = []; + } target = null; deps.changed(); return true; @@ -316,18 +349,16 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw { return true; } if (key === "Backspace") { - if (phase !== "drawing") return false; - if (points.length) points.pop(); - else { - anchor = null; - phase = "armed"; - } - target = null; - deps.changed(); + if (!past.length) return false; + step(past, future); return true; } return false; }, + undo: () => step(past, future), + redo: () => step(future, past), + canUndo: () => past.length > 0, + canRedo: () => future.length > 0, marks() { const maxPct = deps.base()?.policy.max_grade_pct ?? Number.POSITIVE_INFINITY; const dots = path(); @@ -357,7 +388,7 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw { : `${pending.toFixed(1)}m 선택 — 범위 끝 세로 측점선을 고르세요`; } if (phase === "armed") return "시작(또는 끝) 세로선 근처를 눌러 시작하세요 (Esc 취소)"; - return "클릭마다 점 · 반대쪽 세로선 또는 Enter 로 끝 (Backspace 점 빼기 · Esc 취소)"; + return "클릭마다 점 · 반대쪽 세로선 또는 Enter 로 끝 (↶ · Backspace 되돌리기 · Esc 취소)"; }, }; } diff --git a/B03_FileInput/B03_FileInput_Email.py b/B03_FileInput/B03_FileInput_Email.py index 4da466963..2c273837a 100644 --- a/B03_FileInput/B03_FileInput_Email.py +++ b/B03_FileInput/B03_FileInput_Email.py @@ -218,14 +218,14 @@ async def send_initial_analysis_complete_email( {design_rows}
- 기본값으로 계산한 결과입니다. 종단설계 화면에서 검토·수정한 뒤 횡단설계에서 + 기본값으로 계산한 결과입니다. 노선설계 화면에서 검토·수정한 뒤 횡단설계에서 [확정]을 누르면 다음 단계(수량 산출)로 넘어갑니다.
""" else: design_block = """
- 초기 노선·종횡단 자동 계산은 완료되지 않았습니다. 종단설계 화면에서 직접 + 초기 노선·종횡단 자동 계산은 완료되지 않았습니다. 노선설계 화면에서 직접 경로를 계산해 주세요.
""" @@ -239,7 +239,7 @@ async def send_initial_analysis_complete_email( {design_block}

- 종단설계 화면 열기 + 노선설계 화면 열기

로그인이 필요한 경우 인증 후 같은 화면으로 이동합니다. diff --git a/B03_FileInput/B03_FileInput_Service_Chain.py b/B03_FileInput/B03_FileInput_Service_Chain.py index c4902a77c..afdcc4f3f 100644 --- a/B03_FileInput/B03_FileInput_Service_Chain.py +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -105,7 +105,7 @@ async def _prepare_drainage_pipes_and_reprofile( marks.append(("배수유역 분석", time.perf_counter())) - # 2) 기본 관(도로 × 상류 세류선) + 최대 간격 자동 보충을 정본으로 저장한다. + # 2) 기본 관(도로 × 상류 세류선 교차)만 정본으로 저장한다 — 간격 보충은 [자동 제안](369). pipes = await put_pipe_points(project_id, None) if isinstance(pipes, JSONResponse): logger.error( diff --git a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts index b406cc303..89744f458 100644 --- a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts +++ b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts @@ -620,14 +620,16 @@ export async function fetchDetailPipePoints(projectId: string): Promise { return requestJson(`/projects/${projectId}/drainage/detail-basins`, { method: "POST", - body: JSON.stringify({ points }), + body: JSON.stringify(auto ? { points, auto } : { points }), }); } diff --git a/B04_PreProcess/B04_PreProcess_Engine_Watershed_Analyze.py b/B04_PreProcess/B04_PreProcess_Engine_Watershed_Analyze.py index 4d8981143..eb44d6739 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_Watershed_Analyze.py +++ b/B04_PreProcess/B04_PreProcess_Engine_Watershed_Analyze.py @@ -342,7 +342,7 @@ def build_flow_arrows(analysis: Any, flow: Any) -> list[tuple[float, float, floa def _base_pipes( vertices: list[RouteVertex], stream_features: list[dict[str, Any]] ) -> list[StructureCandidate]: - """도로 × 세류선 교차점을 기본 관 위치로 삼는다. 300m 보충 배치는 다음 단계다.""" + """도로 × 세류선 교차점을 기본 관 위치로 삼는다. 간격 보충은 [자동 제안] 때만(369).""" pipes: list[StructureCandidate] = [] for candidate in find_stream_crossings(vertices, stream_features): if pipes and candidate.chainage_m - pipes[-1].chainage_m < DRAINAGE_PIPE_MIN_SPACING_M: diff --git a/B04_PreProcess/B04_PreProcess_Router_Basins.py b/B04_PreProcess/B04_PreProcess_Router_Basins.py index 2b27942d4..d4a6f9726 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Basins.py +++ b/B04_PreProcess/B04_PreProcess_Router_Basins.py @@ -2,7 +2,7 @@ 격자 해석은 하지 않는다. 배수유역 분석이 저장해 둔 `03_road_routing` 산출물을 읽어 - · 기본 관(도로 × 상류 세류선 교차점) + 최대 간격 자동 보충으로 관 목록을 만들고 + · 기본 관(도로 × 상류 세류선 교차점)으로 관 목록을 만들고([자동 제안] = 최대 간격 보충 · 369) · 계획노선 종단 Z로 노면 물이 어느 관으로 가는지 정해 세부유역을 나눈다 계산 알고리즘은 B05 사용자 화면과 공용이다(`common_util_drainage_detail`) — 같은 이름의 @@ -67,6 +67,9 @@ router = APIRouter(prefix="/api/projects", tags=["B04 Surface Drainage Basins"]) _NO_ANALYSIS = "배수유역 분석 결과가 없습니다. [유역 분석]을 먼저 실행하세요." # 노선 지문이 분석 때와 다름(옛 저장분은 지문 없음) — 옛 격자로 나누면 틀린 유역이 나온다(342-3). +# 머리 「배수유역 낡음」 = 화면 `STALE_PREFIX`(B05 Drainage_Live · 369) 짝. +# [자동 제안] 방식(369) — None = 지금 목록 그대로(없으면 세류선 교차만) · all · add +AUTO_MODES = (None, "all", "add") STALE_MESSAGE = ( "배수유역 낡음 — 노선이 분석 때와 다릅니다. 유역을 다시 분석해야 세부 유역을 나눕니다." ) @@ -78,20 +81,22 @@ def _build( points: list[PipePoint] | None, routing: Any = None, upstream_lonlat: list | None = None, + auto: str | None = None, ) -> tuple[DrainageDetail | None, list[PipePoint]]: """세부유역을 계산하고, 그 결과에 쓰인 관 목록을 함께 돌려준다. - `points`가 None 이면 B04 기본 관에 최대 간격 규칙으로 자동 보충한 목록이 만들어진다. - 있으면 그 자리를 그대로 쓰되, 관이 왜 거기 있는지(기본/자동/수동)는 넘겨받은 목록의 - 표시를 유지한다 — 계산기는 확정 좌표만 보므로 그대로 두면 전부 "수동"이 된다. + `points`가 None 이면 B04 기본 관(세류선 교차)만. 있으면 그 자리를 그대로 쓰되, 관이 왜 거기 + 있는지(기본/자동/수동)는 넘겨받은 목록의 표시를 유지한다 — 계산기는 확정 좌표만 보므로 그대로 + 두면 전부 "수동"이 된다. `auto`([자동 제안] · 369) = "all" 자리 새로 · "add" 빈 구간 보충 — + 같은 자리(0.05 m) 관만 속성 · id 를 이어받고 나머지는 새 관(등록부 기본값). """ directory = drainage_dir(stored_path) chainages = [point.chainage_m for point in points] if points is not None else None - detail = build_detail(directory, context.vertices, chainages, routing, upstream_lonlat) + detail = build_detail(directory, context.vertices, chainages, routing, upstream_lonlat, auto) if detail is None: return None, [] if points is not None: - _retag(detail.pipes, points) + _retag(detail.pipes, points, keep_new=auto is not None) rebuilt = _rebuilt(detail) # 원본에 없던 자리 = 이번에 새로 놓인 관(자동 배치 · 간격 보충) — 등록부 기본값을 받을 관 fresh = [ @@ -132,11 +137,14 @@ _DETAIL_CACHE_SIZE = 4 DETAIL_CACHE_FILENAME = "04_detail_cache.pkl" -def _detail_key(context: DrainageContext, points: list[PipePoint] | None) -> str: +def _detail_key( + context: DrainageContext, points: list[PipePoint] | None, auto: str | None = None +) -> str: directory = drainage_dir(context.stored_path) digest = hashlib.sha1(usedforsecurity=False) listed = None if points is None else [point.as_dict() for point in points] - digest.update(json.dumps(listed, sort_keys=True, ensure_ascii=False).encode()) + # auto 를 열쇠에 — 같은 관 목록이라도 [자동 제안] 결과와 섞이지 않게(369 · 옛 저장본도 비껴감) + digest.update(json.dumps([listed, auto], sort_keys=True, ensure_ascii=False).encode()) for vertex in context.vertices: digest.update(f"{vertex.x:.3f},{vertex.y:.3f},{vertex.z:.3f};".encode()) routing = STAGES["road_routing"] @@ -167,10 +175,10 @@ def _remember(key: str, value: tuple[DrainageDetail, list[PipePoint]]) -> None: def _build_cached( - context: DrainageContext, points: list[PipePoint] | None + context: DrainageContext, points: list[PipePoint] | None, auto: str | None = None ) -> tuple[DrainageDetail | None, list[PipePoint]]: """`_build` + 저장본 — 관 목록은 사본으로 돌려준다(저장이 좌표를 채워도 저장본은 그대로).""" - key = _detail_key(context, points) + key = _detail_key(context, points, auto) hit = _DETAIL_CACHE.get(key) if hit is None: try: @@ -180,7 +188,7 @@ def _build_cached( except (OSError, pickle.PickleError, EOFError, AttributeError, KeyError, TypeError): hit = None if hit is None: - detail, carried = _build(context.stored_path, context, points) + detail, carried = _build(context.stored_path, context, points, auto=auto) if detail is None: return None, [] hit = (detail, carried) @@ -276,8 +284,20 @@ def ford_height_m(flow_m3s: float | None, width_m: float) -> float | None: return math.ceil(depth * 100 - 1e-9) / 100 -def _retag(pipes: list[StructureCandidate], points: list[PipePoint]) -> None: - """확정 좌표로 되돌아온 관에 원래의 생성 사유를 다시 붙인다.""" +def _retag( + pipes: list[StructureCandidate], points: list[PipePoint], keep_new: bool = False +) -> None: + """확정 좌표로 되돌아온 관에 원래의 생성 사유를 다시 붙인다. + + `keep_new`([자동 제안]) = 같은 자리(0.05 m) 관만 · 새로 놓인 관은 계산기 사유 + (stream · spacing) 그대로. + """ + if keep_new: + picks = carry_indices([pipe.chainage_m for pipe in pipes], [p.chainage_m for p in points]) + for pipe, pick in zip(pipes, picks): + if pick is not None: + pipe.reason = points[pick].source + return by_chainage = {round(point.chainage_m, 2): point.source for point in points} for pipe in pipes: source = by_chainage.get(round(pipe.chainage_m, 2)) @@ -452,11 +472,16 @@ async def _resolve( 돌려준다 — 관 목록도 없으면(자동 생성 자리) 409(342-3). 마지막 값 = 낡음. 빈 목록(`[]`)은 「관 0개」로 그대로 쓴다 — 자동 생성은 요청에 칸이 없고 저장분도 없을(None) 때만(312). + 요청 `auto`([자동 제안] · 369) = "all" · "add" — `_build` 참고 · 없으면 None. """ context, reason = await load_drainage_context(project_id) if context is None: return JSONResponse(status_code=404, content={"status": "error", "message": reason}) + auto = (payload or {}).get("auto") + if auto not in AUTO_MODES: + message = f"auto 는 {', '.join(m for m in AUTO_MODES if m)} 중 하나입니다." + return JSONResponse(status_code=400, content={"status": "error", "message": message}) try: raw = (payload or {}).get("points") requested = None if raw is None else parse_pipe_points(raw) @@ -470,7 +495,7 @@ async def _resolve( saved = requested is None and stored is not None if analysis_route_signature(context.stored_path) != signature: - if points is None: + if points is None or auto is not None: # 자동 자리 = 지금 노선 분석이 있어야(369) return JSONResponse( status_code=409, content={"status": "stale", "message": STALE_MESSAGE} ) @@ -479,9 +504,11 @@ async def _resolve( # [저장]은 정본으로 다시 셈 — 저장본은 GET · POST 만 if fresh: - result = await asyncio.to_thread(_build, context.stored_path, context, points) + result = await asyncio.to_thread( + lambda: _build(context.stored_path, context, points, auto=auto) + ) else: - result = await asyncio.to_thread(_build_cached, context, points) + result = await asyncio.to_thread(_build_cached, context, points, auto) detail, resolved = result if detail is None: return JSONResponse(status_code=404, content={"status": "error", "message": _NO_ANALYSIS}) @@ -511,8 +538,9 @@ async def post_detail_basins( ) -> dict[str, Any] | JSONResponse: """화면에서 편집 중인 관 목록으로 세부유역을 다시 나눈다(저장하지 않는다). - `points` 칸 없이 부르면 저장분을 무시하고 기본 관 + 자동 보충으로 되돌린다 — - 노선이 바뀌어 전체 분석을 다시 돌린 직후의 경로다. 빈 목록은 관 0개(312). + `points` 칸 없이 부르면 저장분을 무시하고 기본 관(세류선 교차)만으로 되돌린다 — + [배관 배치 초기화] · 노선이 바뀌어 전체 분석을 다시 돌린 직후의 경로다. 빈 목록은 관 0개(312). + `auto` = [자동 제안](369) — "all" 전체 자동 · "add" 지금 관 + 빈 구간 보충. """ resolved = await _resolve(project_id, payload, use_stored=False) if isinstance(resolved, JSONResponse): @@ -523,7 +551,7 @@ async def post_detail_basins( @router.delete("/{project_id}/drainage/pipe-points", response_model=None) async def delete_pipe_points(project_id: UUID) -> dict[str, Any] | JSONResponse: - """저장된 관 지점을 버리고 기본 관 + 자동 보충 배치로 되돌린다("초기화"). + """저장된 관 지점을 버리고 기본 관(세류선 교차) 배치로 되돌린다("초기화"). 화면만 되돌리면 다시 들어왔을 때 옛 관이 살아난다 — 저장분과 파생 산출물까지 지운다 (2026-08-02 사용자 보고). diff --git a/B04_PreProcess/B04_PreProcess_Router_Watershed_Live.py b/B04_PreProcess/B04_PreProcess_Router_Watershed_Live.py index 471da8f43..1ec330489 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Watershed_Live.py +++ b/B04_PreProcess/B04_PreProcess_Router_Watershed_Live.py @@ -22,7 +22,7 @@ from fastapi.responses import JSONResponse from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import preview_stages from B04_PreProcess.B04_PreProcess_Engine_Watershed_Expand import AnalysisStopped from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import drainage_dir -from B04_PreProcess.B04_PreProcess_Router_Basins import _build, _payload +from B04_PreProcess.B04_PreProcess_Router_Basins import AUTO_MODES, _build, _payload from B04_PreProcess.B04_PreProcess_Router_Watershed import _prepare, router from common_util.common_util_drainage_context import _open_sampler, load_drainage_context from common_util.common_util_drainage_detail import RoadRouting @@ -78,7 +78,8 @@ def live_routing(preview: Any, route_xy: list[tuple[float, float]], to_lonlat: A async def post_drainage_live( project_id: UUID, payload: dict[str, Any] | None = None ) -> dict[str, Any] | JSONResponse: - """몸통 = {generation, route: RouteDraft | null(저장 노선), points: 관 목록 | null(자동)}.""" + """몸통 = {generation, route: RouteDraft | null(저장 노선), + points: 관 목록 | null(세류선 교차만), auto: [자동 제안] 방식(all · add · 369) | 없음}.""" body = payload or {} generation = int(body.get("generation") or 0) key = str(project_id) @@ -87,6 +88,9 @@ async def post_drainage_live( def superseded() -> bool: return _LATEST_GENERATION.get(key) != generation + auto = body.get("auto") + if auto not in AUTO_MODES: + return JSONResponse(status_code=400, content={"status": "error", "message": "auto 틀림"}) try: raw = body.get("points") points = None if raw is None else parse_pipe_points(raw) @@ -139,7 +143,7 @@ async def post_drainage_live( [list(to_lonlat(x, y)) for x, y in line.coords] for line in preview.region.split.upstream ] detail, carried = await asyncio.to_thread( - _build, context.stored_path, live, points, routing, upstream + lambda: _build(context.stored_path, live, points, routing, upstream, auto=auto) ) if detail is None: return JSONResponse( diff --git a/B05_Profile/B05_Profile_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts index d8e10a59b..9379b468f 100644 --- a/B05_Profile/B05_Profile_Api_Replan.ts +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -129,8 +129,8 @@ export interface RouteReplanResponse { route_id: number | null; total_length_m: number | null; vertex_count?: number; - /** 노선 저장 결과(346-3 · 서버 `B05_Profile_Route_Save` 머리) — 관 지킴 · 새 교차 · 오목 보충. */ - pipes?: { kept: number; crossing: number; concave: number }; + /** 노선 저장 결과(346-3 · 서버 `B05_Profile_Route_Save` 머리) — 관 지킴 · 새 교차. */ + pipes?: { kept: number; crossing: number }; structures?: { moved: number; far: number }; /** 새 노선에 못 넘긴 횡단 고침(측점이 사라짐) 수. */ cross_dropped?: number; @@ -229,7 +229,7 @@ export async function fetchRouteElevations( return payload.z; } -/** 횡단 미리보기 한 장 — 고치던 노선 그대로 그 측점만 서버가 셈해 준다(계획서 0-9 ⑧). */ +/** 횡단 미리보기 한 장(평면 오른쪽 판) — 계산 묶음 그 측점을 이 꼴로 그림(365-3 · 서버 길 없음). */ export interface CrossPreviewResponse { status: string; chainage_m: number; @@ -241,7 +241,7 @@ export interface CrossPreviewResponse { cross_half_width_m: number; /** 원지반 횡단 샘플. */ samples: Array<{ offset_m?: number; elevation_m?: number | null; valid: boolean }>; - /** 기본 계획 횡단 — B06 `compute_cross_design` 이 낸 것. 계획고를 못 세우면 null. */ + /** 계획 횡단 — 묶음 cross 단계 설계(B06 카드와 같음). 계획고를 못 세우면 null. */ design: { design_line: Array<{ offset_m: number; elevation_m: number }>; cut_area_m2: number; @@ -250,32 +250,6 @@ export interface CrossPreviewResponse { } | null; } -export interface CrossPreviewRequest { - vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>; - chainage_m: number; - min_radius_m: number; - station_interval_m: number; - /** 횡단 반폭(m) — 없으면 서버 기본값. */ - cross_half_width_m?: number; - /** [측점 스냅] 켬 — 호 안 측점 하나를 곡선 중점으로(요구 224-2). */ - station_snap?: boolean; -} - -/** 한 측점 횡단을 묻는다. 종·횡단을 한 번 돌리므로 **한두 초** 걸린다(사용자 확정: 괜찮음). */ -export async function fetchCrossPreview( - projectId: string, - request: CrossPreviewRequest, -): Promise { - return requestJson( - `/projects/${projectId}/route/cross-preview`, - { - method: "POST", - body: JSON.stringify({ ...request, terrain: analysisTerrain(projectId) }), - }, - 120000, - ); -} - /** 계획노선을 예상노선으로 되돌리고 같은 재계산을 돈다(노선 초기화). */ export async function resetRoutePlan(projectId: string): Promise { return requestJson( diff --git a/B05_Profile/B05_Profile_Route_Save.py b/B05_Profile/B05_Profile_Route_Save.py index 9829b0680..6afb130af 100644 --- a/B05_Profile/B05_Profile_Route_Save.py +++ b/B05_Profile/B05_Profile_Route_Save.py @@ -9,14 +9,14 @@ API — `POST /api/projects/{id}/route/replan`(`B05_Profile_Router_Replan.replan stationSnap, terrain}` · 오면 `vertices` · `station_snap` · `terrain` 대신 이것. `as_initial` — 추천 노선 [적용](초기값 다시 뜸) · 손으로 고친 노선 저장은 false(초기값 그대로). 응답 200 `{status: "success", project_id, route_id, total_length_m, vertex_count, current_initial, - initial_refreshed, pipes: {kept, crossing, concave}, structures: {moved, far}, cross_dropped, + initial_refreshed, pipes: {kept, crossing}, structures: {moved, far}, cross_dropped, drainage_stale: true, seconds}`. 오류 400(노드 둘 미만 · 초안 칸 길이 어긋남) · 404(프로젝트 없음) · 409 `{status: "error", message}`(체인 실패 — 노선 · 관 · 구조물 · 종횡단 파일 · 새 노선 행을 옛것으로 되살린 뒤). 규칙: - - 관 정본을 지우지 않음 — 좌표를 따라감 · 새 세류선 교차점 기본 관 · 오목 지점 보충만 더함 + - 관 정본을 지우지 않음 — 좌표를 따라감 · 새 세류선 교차점 기본 관만 더함 (`B05_Profile_Route_Save_Parts`) · 배수유역 저장분은 노선 지문이 달라 「낡음」(B04 `_resolve`). - 계획고 = 바뀐 구간만 새 지반 첫 계획선 · 그 밖 사용자 계획고 지킴 (`B05_Profile_Engine_Grade_Carry` · 바뀐 구간 = 옛 · 새 **계획 폴리라인** — 화면 planline 과 @@ -37,15 +37,12 @@ from pathlib import Path from typing import Any, Literal from uuid import UUID -import numpy as np from fastapi.responses import JSONResponse from pydantic import BaseModel, Field from B05_Profile.B05_Profile_Route_Save_Parts import ( add_crossing_pipes, carry_cross_inputs, - concave_chainages, - fill_concave_pipes, follow_pipes, station_keys, vertices_of, @@ -161,40 +158,14 @@ def _xy(project_root: Path, route_data_path: str | None) -> list[list[float]]: return [] -def _ground_along(project_root: Path, selection: dict[str, Any], vertices: list) -> tuple: - """새 노선 지반 종단(2 m) — 오목 지점 찾기. 지표면을 못 열면 빈 배열(보충 건너뜀).""" - from shapely.geometry import LineString - - from B05_Profile.B05_Profile_Route_Save_Parts import CONCAVE_STEP_M - from common_util.common_util_surface_sampler import build_surface_sampler - - try: - sampler = build_surface_sampler( - project_root / "B04_PreProcess" / "models", - str(selection.get("source_filter")), - str(selection.get("method") or "dtm"), - bool(selection.get("smooth")), - ) - except (FileNotFoundError, KeyError, OSError, ValueError): - logger.warning("노선 저장: 지표면을 못 열어 오목 지점 보충을 건너뜀") - return np.zeros(0), np.zeros(0) - line = LineString([(vertex.x, vertex.y) for vertex in vertices]) - chainage = np.arange(0.0, line.length + 1e-9, CONCAVE_STEP_M) - xy = np.array([line.interpolate(float(s)).coords[0] for s in chainage], dtype=np.float64) - z, valid = sampler.sample_xy(xy) - return chainage[valid], np.asarray(z, dtype=np.float64)[valid] - - async def _place_pipes( project_id: UUID, - project_root: Path, stored_path: str, - selection: dict[str, Any], old_pipes: list, old_xy: list[list[float]], points: list[dict[str, float]], ) -> tuple[list, dict[str, int]]: - """관 따라감 → 새 교차점 기본 관 → 오목 지점 보충 → 정본 저장(새 노선 지문).""" + """관 따라감 → 새 교차점 기본 관 → 정본 저장(새 노선 지문) · 간격 보충 없음(369).""" from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import _base_pipes from B04_PreProcess.B04_PreProcess_Router_Watershed import _prepare from common_util.common_util_drainage_pipes import route_signature, save_pipe_points @@ -207,8 +178,6 @@ async def _place_pipes( pairs = follow_pipes(old_pipes, vertices) total = float(vertices[-1].chainage_m) crossing = add_crossing_pipes(pairs, _base_pipes(vertices, streams), old_xy, total) - chainage, ground = await asyncio.to_thread(_ground_along, project_root, selection, vertices) - concave = fill_concave_pipes(pairs, concave_chainages(chainage, ground), vertices, old_xy) await asyncio.to_thread( save_pipe_points, stored_path, @@ -216,7 +185,7 @@ async def _place_pipes( [pipe for _, pipe in pairs], vertices, ) - return pairs, {"kept": len(old_pipes), "crossing": crossing, "concave": concave} + return pairs, {"kept": len(old_pipes), "crossing": crossing} async def _carry_profile( @@ -431,10 +400,8 @@ async def run_route_save_chain( new_xy = _xy(project_root, solved.route_data_path) marks.append(("노선 풀기 · 구조물 따라감", time.perf_counter())) - pairs, pipes = await _place_pipes( - project_id, project_root, stored_path, selection, old_pipes, old_xy, points - ) - marks.append(("관 따라감 · 교차점 · 오목 보충", time.perf_counter())) + pairs, pipes = await _place_pipes(project_id, stored_path, old_pipes, old_xy, points) + marks.append(("관 따라감 · 교차점", time.perf_counter())) seed = asdict(_section_options(request, old_section_options)) seed.pop("extra_stations", None) diff --git a/B05_Profile/B05_Profile_Route_Save_Parts.py b/B05_Profile/B05_Profile_Route_Save_Parts.py index d31a7277a..c4f37273d 100644 --- a/B05_Profile/B05_Profile_Route_Save_Parts.py +++ b/B05_Profile/B05_Profile_Route_Save_Parts.py @@ -1,4 +1,4 @@ -"""노선 저장 길(346-3) 순수 부품 — 관 따라감 · 새 교차점 기본 관 · 오목 지점 보충 · 측점 열쇠 · +"""노선 저장 길(346-3) 순수 부품 — 관 따라감 · 새 교차점 기본 관 · 측점 열쇠 · 횡단 이월. DB · 파일 없이 값만 받아 값을 돌려준다(시험이 그대로 부름). 차례 · 저장 · 되살림은 @@ -7,7 +7,7 @@ DB · 파일 없이 값만 받아 값을 돌려준다(시험이 그대로 부름 - 관 = 좌표가 정본 — 새 노선에 투영해 누가거리만 다시(`project_pipe_points` · 정수 m). 지우지 않음 (사용자 관 편집 지킴). - 새 세류선 교차점(옛 노선 위에 없던 자리)에 관이 없으면 기본 관 하나(`_base_pipes` 결과를 받음). - - 관 최대 간격 보충 = 옛 노선에서 벗어난 자리의 **오목 지점**(지반 종단 낮은 곳)에만 — 격자 없음. + - 관 최대 간격 보충 없음(369) — 간격 보충은 배수유역 [자동 제안]만. - 측점 열쇠 = `common_util_station_key.station_key` 한 벌(sub2 짝) — 일반 `grid:k` · 관 `pipe:` · 구조물 `structure:` · 시종점 `end:BP` · `end:EP` · id 없으면 `at:<누가거리 0.01 m>`. @@ -22,11 +22,9 @@ import copy import math from typing import Any, Sequence -import numpy as np from shapely.geometry import LineString, Point from common_util.common_util_drainage_pipes import ( - PIPE_SOURCE_SPACING, PIPE_SOURCE_STREAM, PipePoint, project_pipe_points, @@ -35,14 +33,10 @@ from common_util.common_util_route_geometry import RouteVertex from common_util.common_util_station_key import chainage_key as station_chainage_key from common_util.common_util_station_key import station_key from common_util.common_util_structure_station import plan_stations, snap_structure_station -from config.config_system import DRAINAGE_PIPE_MAX_SPACING_M, DRAINAGE_PIPE_MIN_SPACING_M +from config.config_system import DRAINAGE_PIPE_MIN_SPACING_M # 옛 노선에서 이만큼(m) 넘게 떨어진 자리 = 노선이 바뀐 자리(교차점 · 보충 대상). MOVED_AWAY_M = 0.5 -# 오목 지점을 찾을 지반 표본 간격(m) · 고름 창(표본 수) — DTM 잔물결을 오목으로 안 읽게. -# ponytail: 이동 평균 + 국소 최저 · 계곡이 얕아 놓치면 창을 줄이거나 흐름 강도와 섞을 것. -CONCAVE_STEP_M = 2.0 -CONCAVE_WINDOW = 5 _SAME_M = 0.01 @@ -96,57 +90,6 @@ def add_crossing_pipes( return added -def concave_chainages(chainage: np.ndarray, ground: np.ndarray) -> list[tuple[float, float]]: - """지반 종단의 오목 지점(이동 평균 국소 최저) → [(누가거리, 고른 지반고)].""" - if ground.size < 3: - return [] - kernel = np.ones(CONCAVE_WINDOW) / CONCAVE_WINDOW - pad = CONCAVE_WINDOW // 2 - smooth = np.convolve(np.pad(ground, pad, mode="edge"), kernel, mode="valid") - return [ - (float(chainage[i]), float(smooth[i])) - for i in range(1, smooth.size - 1) - if smooth[i] < smooth[i - 1] and smooth[i] <= smooth[i + 1] - ] - - -def fill_concave_pipes( - pairs: list[tuple[int, PipePoint]], - concave: list[tuple[float, float]], - vertices: list[RouteVertex], - old_xy: Sequence[Sequence[float]] | None, -) -> int: - """관 사이(시종점 포함)가 최대 간격을 넘으면 옛 노선 밖 오목 지점 중 가장 낮은 곳에 관.""" - line = LineString([(vertex.x, vertex.y) for vertex in vertices]) - old_line = LineString(old_xy) if old_xy and len(old_xy) >= 2 else None - total = float(line.length) - added = 0 - while True: - edges = [0.0, *(pipe.chainage_m for _, pipe in pairs), total] - best: tuple[float, float] | None = None - for start, end in zip(edges, edges[1:]): - if end - start <= DRAINAGE_PIPE_MAX_SPACING_M: - continue - for at, z in concave: - if not ( - start + DRAINAGE_PIPE_MIN_SPACING_M < at < end - DRAINAGE_PIPE_MIN_SPACING_M - ): - continue - point = line.interpolate(at) - if _moved_away(old_line, point.x, point.y) and (best is None or z < best[1]): - best = (at, z) - if best is None: - return added - at = snap_structure_station(best[0], [total]) - point = line.interpolate(at) - pairs.append( - (-1, PipePoint(chainage_m=at, source=PIPE_SOURCE_SPACING, x=point.x, y=point.y)) - ) - pairs.sort(key=lambda pair: pair[1].chainage_m) - concave = [item for item in concave if abs(item[0] - best[0]) > _SAME_M] - added += 1 - - def _facility_owners( pipes: Sequence[PipePoint], structures: Sequence[Any] ) -> dict[float, tuple[str, str | None]]: diff --git a/B05_Profile/B05_Profile_Router_Terrain.py b/B05_Profile/B05_Profile_Router_Terrain.py index 379907567..bbf439ac5 100644 --- a/B05_Profile/B05_Profile_Router_Terrain.py +++ b/B05_Profile/B05_Profile_Router_Terrain.py @@ -1,15 +1,14 @@ """계획노선 편집 중 **지반고만** 묻는 가벼운 통로. 편집 모달은 [확인] 전까지 아무 계산도 내보내지 않는다(계획서 0-2 확정 7). 다만 두 점을 찍어 -**구간 길이와 종단기울기**를 볼 때(0-9 ⑤)와 한 측점의 **횡단도 미리보기**(⑧)는 지반고가 -있어야 한다. 새 계산이 아니라 **이미 확정된 지표면을 읽기만** 하는 통로라 그 규칙과 부딪히지 +**구간 길이와 종단기울기**를 볼 때(0-9 ⑤)는 지반고가 있어야 한다(측점 횡단 미리보기 = 계산 +묶음 · 365-3). 새 계산이 아니라 **이미 확정된 지표면을 읽기만** 하는 통로라 그 규칙과 부딪히지 않는다 — 노선을 갈아 끼우지도, 정본을 건드리지도 않는다. 표고 조회는 종·횡단 생성기가 쓰는 것과 **같은 sampler**(`build_surface_sampler`)를 연다. 두 화면이 다른 표고를 보면 같은 자리의 기울기가 갈린다. POST /api/projects/{id}/route/elevations → 점 묶음의 지반고 - POST /api/projects/{id}/route/cross-preview → 고치던 노선의 한 측점 횡단 미리보기 GET /api/projects/{id}/route/terrain-sources → 고를 수 있는 분석 지형(라이다 · 수치지형도) GET /api/projects/{id}/surface/models/{mid}/grid → DTM 격자 원시 이진(브라우저 표본기 · ETag) @@ -30,13 +29,7 @@ from fastapi.responses import JSONResponse, Response from pydantic import BaseModel, Field from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path -from B05_Profile.B05_Profile_Engine_Sections_Core import ( - SectionGenerationOptions, - generate_sections, -) -from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args from common_util.common_util_http_cache import cached_file_response -from common_util.common_util_route_polyline import build_planned_polyline from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_surface_confirmation import ( AnalysisTerrain, @@ -121,143 +114,6 @@ async def read_route_elevations(project_id: UUID, request: ElevationRequest) -> } -class PreviewVertex(BaseModel): - """편집 중인 꺾임점 하나 — `RouteVertexInput` 과 같은 꼴.""" - - x: float - y: float - curve: bool = True - radius_m: float | None = None - - -class CrossPreviewRequest(BaseModel): - """고치던 노선 그대로 한 측점의 횡단을 미리 본다.""" - - vertices: list[PreviewVertex] = Field(..., min_length=2) - chainage_m: float = Field(..., ge=0) - #: 법정 최소곡선반지름(m) — 화면이 `/route/plan` 에서 받은 값을 그대로 돌려준다. - min_radius_m: float = Field(12.0, gt=0) - station_interval_m: float | None = None - #: 횡단 반폭(m) — 화면이 적용해 둔 값. 비면 서버 기본값. - cross_half_width_m: float | None = Field(None, gt=0) - terrain: AnalysisTerrain | None = None - #: [측점 스냅] 켬(요구 224-2) — 편집 창 단추 값 그대로. - station_snap: bool = False - - -def _cross_preview( - project_root: Path, - params: dict, - request: CrossPreviewRequest, -) -> dict | None: - """고치던 노선으로 종·횡단을 한 번 돌려 그 측점 한 장을 뽑는다. - - **B05·B06 의 정본 로직을 그대로 재사용한다**(2026-09-12 사용자 확정 「기본 로직은 B06에 - 존재함. 재사용」) — `generate_sections` 가 측점·접선·지반 샘플을, `compute_cross_design` - 이 설계선을 만든다. 여기서 기하를 새로 짜지 않는다. - - ⚠ **계획고는 아직 없다.** 계획고는 [확인] 뒤 전 체인이 낳는 값이라 편집 중에는 존재하지 - 않는다. 그래서 그 측점의 **지반고를 그대로 계획고로 놓는다**(지반 추종) — 절·성토가 사면 - 기울기만으로 서는 「기본 계획 횡단」이며, 사용자가 보기로 한 것도 그것이다. - """ - try: - sampler = build_surface_sampler( - project_root / _MODELS_SUBDIR, - str(params["source_filter"]), - str(params["method"]), - bool(params["smooth"]), - ) - except (FileNotFoundError, KeyError, OSError, ValueError) as exc: - logger.warning("횡단 미리보기: 지표면을 열지 못했습니다 — %s", exc) - return None - - built = build_planned_polyline( - [(vertex.x, vertex.y) for vertex in request.vertices], - min_radius_m=request.min_radius_m, - # 화면이 준 노드는 이미 꺾임점이다 — 다시 뽑으면 선이 깎인다(`_write_planned_polyline`). - simplify=False, - curve_flags=[vertex.curve for vertex in request.vertices], - radii=[vertex.radius_m for vertex in request.vertices], - ) - interval = request.station_interval_m - defaults = SectionGenerationOptions() - options = SectionGenerationOptions( - station_interval_m=float(interval) - if interval and interval > 0 - else defaults.station_interval_m, - cross_half_width_m=request.cross_half_width_m or defaults.cross_half_width_m, - ) - # 곡선표 = 방금 지은 노선의 곡선 — 스냅 자리 · 확폭이 [확인] 뒤 서버 정본과 같은 규칙. - result = generate_sections( - built.vertices, - sampler, - options, - design_curves=[curve.as_dict() for curve in built.curves], - station_snap=request.station_snap, - ) - sections = result["cross_sections"] - if not sections: - return None - section = min(sections, key=lambda row: abs(float(row["chainage_m"]) - request.chainage_m)) - - design = None - center_z = section.get("center_z") - if center_z is not None: - # 단면유형 기본값은 B06 화면과 같다 — 등고가 높은 쪽을 절토로 본다. - section_mode = "right_cut" if section.get("uphill_side") == "right" else "left_cut" - design = compute_cross_design( - section["samples"], - float(center_z), - ground_type="soil", - section_mode=section_mode, - **curve_widening_args(section), - ) - return { - "chainage_m": round(float(section["chainage_m"]), 3), - "label": section.get("label"), - "uphill_side": section.get("uphill_side"), - "plan_radius_m": section.get("plan_radius_m"), - "curve_widening_m": section.get("curve_widening_m"), - # 샘플이 실제로 덮은 반폭 — 평면도 반폭 선이 이 값을 그대로 쓴다. - "cross_half_width_m": options.cross_half_width_m, - "samples": section["samples"], - "design": design, - "total_length_m": round(float(result["longitudinal"]["total_length_m"]), 3) - if result.get("longitudinal", {}).get("total_length_m") is not None - else None, - } - - -@router.post("/{project_id}/route/cross-preview", response_model=None) -async def read_cross_preview(project_id: UUID, request: CrossPreviewRequest) -> dict | JSONResponse: - """고치던 계획노선의 **한 측점 횡단**을 돌려준다(계획서 0-9 ⑧). - - 정본을 건드리지 않는다 — 파일도 DB 도 쓰지 않고 그 자리에서 셈해 돌려주기만 한다. - """ - pool = get_db_pool() - async with pool.acquire() as connection: - stored = await get_project_storage_relative_path(connection, project_id) - if not stored: - return JSONResponse( - status_code=404, - content={"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."}, - ) - params = await get_surface_confirmation_params(connection, str(project_id)) - params = analysis_terrain_params(params, request.terrain) - - project_root = Path(resolve_stored_project_path(stored)) - preview = await asyncio.to_thread(_cross_preview, project_root, params, request) - if preview is None: - return JSONResponse( - status_code=409, - content={ - "status": "error", - "message": "확정된 지표면이 없어 횡단을 미리 볼 수 없습니다.", - }, - ) - return {"status": "success", "project_id": str(project_id), **preview} - - @router.get("/{project_id}/route/terrain-sources", response_model=None) async def read_terrain_sources(project_id: UUID) -> dict | JSONResponse: """편집 모달이 고를 수 있는 분석 지형 — 둘이면 고르기를 띄움 · 하나면 숨김.""" diff --git a/B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts b/B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts index 8a22ff43b..cac55c2ca 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts @@ -308,8 +308,8 @@ export function culvertSilhouettes(section: CrossSection): StructureSilhouette[] const side: "left" | "right" = wall.outward > 0 ? "left" : "right"; const slope = trimSlopeOf(side); if (slope) pushTopSlope(side, slope.points); - // 구조물 전면 실루엣 — 이음선 상단에서 전면 발끝까지 내려온다. - push(side, [wall.topJoint, wall.bottomFront]); + // 구조물 전면 실루엣 — 성토 위 이음에서 전면 발끝까지 내려온다(371-1). + push(side, [wall.fillTop, wall.bottomFront]); spans[side] = wallSpanOf(section, wall.role === "inlet" ? "inlet" : "outlet"); } const outletSegments = layout.outletFill.segments.filter((segment) => segment.kind !== "cut"); @@ -321,13 +321,13 @@ export function culvertSilhouettes(section: CrossSection): StructureSilhouette[] addRun(outletSide, segment.points); } const wall = layout.extraWalls[step]; - if (wall) push(outletSide, [wall.topJoint, wall.bottomFront]); + if (wall) push(outletSide, [wall.fillTop, wall.bottomFront]); } layout.basinFill.segments.forEach((segment) => { push(inletSide, segment.points); addRun(inletSide, segment.points); }); - layout.basinExtras.forEach((wall) => push(inletSide, [wall.topJoint, wall.bottomFront])); + layout.basinExtras.forEach((wall) => push(inletSide, [wall.fillTop, wall.bottomFront])); let basinCut = false; if (layout.basin) { diff --git a/B05_Profile/B05_Profile_UI_Drainage_Chrome.ts b/B05_Profile/B05_Profile_UI_Drainage_Chrome.ts index 1978588af..7a82c1545 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Chrome.ts @@ -7,6 +7,7 @@ * 배선(토글·포인터·그리기)은 전부 본체가 맡는다. * ========================================================================== */ +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; import { createProgressCircle } from "@ui/ui_template_progress"; @@ -20,6 +21,8 @@ export interface DrainageChrome { analyzeButton: HTMLButtonElement; deleteButton: HTMLButtonElement; autoButton: HTMLButtonElement; + /** [자동 제안](369) — 고름 창 · 배선은 `B05_Profile_UI_Drainage_Live`. */ + proposeButton: HTMLButtonElement; viewport: HTMLElement; backgroundImage: HTMLImageElement; canvas: HTMLCanvasElement; @@ -88,13 +91,18 @@ export function createDrainageChrome( deleteButton.textContent = labels.deleteSelected; deleteButton.title = labels.deleteSelectedTip; deleteButton.disabled = true; - // 자동 제안으로 되돌리기 — 편집한 배관 배치를 버리고 백엔드 자동 제안으로 재산정. + // 배관 배치 초기화 — 편집한 배관 배치를 버리고 세류선 교차 자리만(초안 · 369). const autoButton = document.createElement("button"); autoButton.type = "button"; autoButton.className = "b05-drainage__analyze b05-drainage__tool"; autoButton.textContent = labels.auto; autoButton.title = labels.autoTip; - header.append(analyzeButton, deleteButton, autoButton); + const proposeButton = document.createElement("button"); + proposeButton.type = "button"; + proposeButton.className = "b05-drainage__analyze b05-drainage__tool b05-drainage__propose"; + proposeButton.textContent = ui_locales.B05_Drainage_Btn_Propose[currentLanguageIndex]; + proposeButton.title = ui_locales.B05_Drainage_Btn_Propose_Tip[currentLanguageIndex]; + header.append(analyzeButton, deleteButton, autoButton, proposeButton); const viewport = document.createElement("div"); viewport.className = "b05-drainage__viewport"; @@ -155,6 +163,7 @@ export function createDrainageChrome( analyzeButton, deleteButton, autoButton, + proposeButton, viewport, backgroundImage, canvas, diff --git a/B05_Profile/B05_Profile_UI_Drainage_Live.ts b/B05_Profile/B05_Profile_UI_Drainage_Live.ts index 9bbff9a3e..e83a28ef8 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Live.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Live.ts @@ -14,9 +14,14 @@ * - 서버가 「낡음」으로 답하면(노선 [저장] 뒤 등) 같은 흐림 + [업데이트] · 자동이면 3 s 뒤. * - 답은 패널 요청 감싸기(`DetailRunner`, quiet)로 올림 — 옛 차례 · 서버가 그만둔 답은 버림. * - 덮개 · 잠금 없음 — 다른 칸 조작은 그대로. + * - [자동 제안](369) — 고름 창 ① 전체 자동(all) ② 지정 포함 추가(add) · [배관 배치 초기화] = 세류선 교차 + * 관만 · 셋 다 초안(세션) · [저장] 때 정본 · 답이 오면 사진첩 한 걸음(늦은 답도 앞 걸음에 안 묻힘) · + * 낡은 동안 [자동 제안] 끔 · 노선 초안 중이거나 저장 분석이 낡았으면(옛 노선) 「지금 노선으로 셈」 길에 + * 방식을 실어 보냄(저장 분석으로 셈 = 옛 노선 자리 · 409). * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createProgressCircle } from "@ui/ui_template_progress"; import { openModal } from "@ui/ui_template_modal"; import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; @@ -37,6 +42,7 @@ import { } from "./logic/B05_Profile_Drainage_Live"; import type { DetailRunner } from "./B05_Profile_UI_Drainage_Run"; import { createDrainageSuggest, type DrainageSuggest } from "./B05_Profile_UI_Drainage_Suggest"; +import { pageHistory } from "./B05_Profile_UI_Profile_History"; const MODE_KEY = "drainage-mode"; @@ -47,7 +53,7 @@ export interface DrainageLiveDeps { basinList: HTMLElement; pipeRequest: () => DetailPipeInput[]; runner: DetailRunner; - /** [적용] 답이 화면에 오른 뒤 — 세션 초안에 담음. */ + /** [적용] · [자동 제안] 답이 화면에 오른 뒤 — 세션 초안에 담음. */ rememberPipes: () => void; /** 저장 배수유역 다시 받기(열 때와 같은 길 · 조용히) — 답을 화면에 올렸으면 true. */ loadSaved: () => Promise; @@ -62,6 +68,8 @@ export interface DrainageLive { markStale(): void; /** [확정]이 서버에서 다시 셈 — 낡음 지움(떠 있는 요청 · 기다림도 끊음). */ markFresh(): void; + /** [배관 배치 초기화] — 세류선 교차 관만으로 초안(369 · 저장분은 [저장] 때). */ + resetToStreams(): void; readonly stale: boolean; suggest: DrainageSuggest; } @@ -69,7 +77,7 @@ export interface DrainageLive { /** 「지금 노선으로 셈」 — 서버 `post_drainage_live` · 작업본에 안 씀. */ async function fetchLive( projectId: string, - body: ReturnType, + body: ReturnType | (Proposal & { generation: number }), signal: AbortSignal, ): Promise { const response = await fetch(`${API_BASE_URL}/projects/${projectId}/drainage/live`, { @@ -88,16 +96,33 @@ export function readDrainageMode(): DrainageMode { return parseDrainageMode(readStateRaw(MODE_KEY)); } +const L = (key: keyof typeof ui_locales): string => ui_locales[key][currentLanguageIndex]; + +/** 서버 「낡음」 글 머리 — 짝 `B04_PreProcess_Router_Basins.STALE_MESSAGE`. */ +const STALE_PREFIX = "배수유역 낡음"; + +/** [자동 제안] · [배관 배치 초기화] 요청 — 관 목록 없음(null) = 세류선 교차만. */ +type Proposal = { points: DetailPipeInput[] | null; auto?: "all" | "add" }; + export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive { const viewport = deps.root.querySelector(".b05-drainage__viewport") as HTMLElement; const header = deps.root.querySelector(".b05-drainage__header") as HTMLElement; - const suggest = createDrainageSuggest(deps.pipeRequest, (points) => { + const propose = header.querySelector(".b05-drainage__propose"); + /** 관 초안 요청 — 답이 화면에 오르면 세션 초안 + 사진첩 한 걸음(400 ms 넘은 답도 제 걸음). */ + const draft = (request: (projectId: string) => Promise): void => { const projectId = deps.runner.projectId(); if (!projectId) return; void deps.runner - .run(() => computeDetailBasins(projectId, points)) - .then((ok) => ok && deps.rememberPipes()); - }); + .run(() => request(projectId)) + .then((ok) => { + if (!ok) return; + deps.rememberPipes(); + pageHistory()?.record(); + }); + }; + const suggest = createDrainageSuggest(deps.pipeRequest, (points) => + draft((projectId) => computeDetailBasins(projectId, points)), + ); deps.basinList.before(suggest.root); let mode = readDrainageMode(); let route: unknown = null; @@ -106,6 +131,8 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive { let drafted = false; /** 저장분 다시 받기 답이 「낡음」이었나(`markStale` 이 세움). */ let savedStale = false; + /** 저장 분석이 옛 노선(서버 「낡음」 답 · [확정] 전까지) — [자동 제안]은 지금 노선으로 셈. */ + let analysisStale = false; let generation = 0; /** 마지막 단계 입력 지문 — 같으면 묶음이 다시 돈 것뿐. */ let key = ""; @@ -136,6 +163,10 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive { const paint = (): void => { deps.root.classList.toggle("is-stale", stale && mode === "manual"); suggest.setStale(stale); + if (propose) { + propose.disabled = stale; + propose.title = L(stale ? "B05_Drainage_Btn_Propose_Stale" : "B05_Drainage_Btn_Propose_Tip"); + } veil.hidden = !stale || mode === "auto" || inflight !== null; busy.root.hidden = inflight === null; }; @@ -147,7 +178,8 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive { inflight = null; } - function refresh(): void { + /** 지금 노선으로 셈 — `proposal` 이면 그 관 목록 · 방식으로(답 = 관 초안 · 사진첩 한 걸음). */ + function refresh(proposal?: Proposal): void { const projectId = deps.runner.projectId(); if (!projectId) return; cancel(); @@ -155,7 +187,7 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive { const controller = new AbortController(); inflight = controller; paint(); - const body = liveRequestBody(sent.generation, route, deps.pipeRequest()); + const body = { ...liveRequestBody(sent.generation, route, deps.pipeRequest()), ...proposal }; void deps.runner .run(async () => { const response = await fetchLive(projectId, body, controller.signal); @@ -168,10 +200,30 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive { if (inflight !== controller) return; inflight = null; if (applied) stale = false; + if (applied && proposal) { + deps.rememberPipes(); + pageHistory()?.record(); + } paint(); }); } + /** [자동 제안] · [배관 배치 초기화] — 저장 분석이 지금 노선과 같으면 빠른 길(detail-basins) · + * 그 길이 「낡음」(409 · 화면이 아직 몰랐던 낡음)이면 곧바로 지금 노선으로 셈 길로 다시. */ + function proposeDraft(proposal: Proposal): void { + if (analysisStale || draftMakesStale(route)) return refresh(proposal); + draft(async (projectId) => { + try { + return await computeDetailBasins(projectId, proposal.points, proposal.auto); + } catch (error) { + if (!(error instanceof Error) || !error.message.startsWith(STALE_PREFIX)) throw error; + analysisStale = true; + queueMicrotask(() => refresh(proposal)); // 이 요청은 조용히 버림(뒤 차례가 받음) + throw new DOMException("stale", "AbortError"); + } + }); + } + /** 평면 · 관 자리 조작 — 처음으로(요청 끊기 · 다시 흐림 또는 다시 3 s). */ function invalidate(): void { cancel(); @@ -223,6 +275,36 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive { }); } + /** [자동 제안] 고름 창 — ⚙ 창과 같은 틀 · 고르면 닫고 그 방식으로 초안. */ + function openPropose(): void { + if (stale) return; + openModal({ + title: L("B05_Drainage_Propose_Title"), + closeLabel: "닫기", + mount(body, close) { + const choices: Array<["all" | "add", string, string]> = [ + ["all", L("B05_Drainage_Propose_All"), L("B05_Drainage_Propose_All_Hint")], + ["add", L("B05_Drainage_Propose_Add"), L("B05_Drainage_Propose_Add_Hint")], + ]; + choices.forEach(([value, label, hint], order) => { + const row = document.createElement("label"); + row.className = "b05-drainage__mode"; + const input = document.createElement("input"); + input.type = "radio"; + input.name = "b05-drainage-propose"; + input.addEventListener("change", () => { + close(); + proposeDraft({ points: deps.pipeRequest(), auto: value }); + }); + const text = document.createElement("span"); + text.textContent = `${order === 0 ? "①" : "②"} ${label} — ${hint}`; + row.append(input, text); + body.append(row); + }); + }, + }); + } + function setMode(next: DrainageMode): void { mode = next; writeStateRaw(MODE_KEY, next); @@ -232,8 +314,9 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive { paint(); } - update.addEventListener("click", refresh); + update.addEventListener("click", () => refresh()); gear.addEventListener("click", openSettings); + propose?.addEventListener("click", openPropose); return { stage(ctx) { @@ -247,10 +330,11 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive { else if (wasDraft) backToSaved(); return null; }, - refresh, + refresh: () => refresh(), suggest, markStale() { savedStale = true; + analysisStale = true; if (stale) return; stale = true; if (mode === "auto" && !inflight) timer = window.setTimeout(refresh, QUIET_MS); @@ -259,8 +343,10 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive { markFresh() { cancel(); stale = false; + analysisStale = false; paint(); }, + resetToStreams: () => proposeDraft({ points: null }), get stale() { return stale; }, diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 1dbc5ccf5..ab255052d 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -5,7 +5,6 @@ import { computeDetailBasins, fetchDetailPipePoints, getVWorldMapUrl, - resetDetailPipePoints, saveDetailPipePoints, type DetailBasin, type DetailBasinResponse, @@ -35,6 +34,7 @@ import { snapStructureStation } from "../common_util/common_util_structure_stati import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility"; import { mergeFacilityOptions } from "./logic/B05_Profile_UI_Drainage_Facility_Merge"; import { readPendingPipes, writePendingPipes } from "./B05_Profile_Api_Pipes_Draft"; +import { pageReplaying } from "./B05_Profile_UI_Profile_History"; import { buildPipeReports } from "./B05_Profile_UI_Drainage_Report"; import { fillFordHeights, fordWaitsOf, waitsFordHeight } from "./B05_Profile_UI_Drainage_Ford_Fill"; import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome"; @@ -59,6 +59,7 @@ import { summaryText, basinIndexOfPipe, pipeMarkerColor, + adoptDraftPipes, reconcilePipes, WIDTH_KEY, type DrainageLayer, @@ -416,10 +417,6 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra : fetchDetailPipePoints(projectId as string); }; - /** 저장분까지 버리고 자동 배치로 되돌린다("초기화"). 화면만 되돌리면 다시 들어왔을 때 - * 옛 관이 살아난다(2026-08-02 사용자 보고). */ - const resetPipes = (): Promise => run(() => resetDetailPipePoints(projectId as string)); - /** 세부유역을 다시 나눈다(격자 해석 없음) — 같은 틱의 여러 부름은 한 번(342-3 · 옛 소수 관 옮김). * 시설 종류·구간·옵션은 계산기에서 사라지므로 요청에 되붙인다. */ const analyze = batched(() => run(() => computeDetailBasins(projectId as string, pipeRequest()))); @@ -435,7 +432,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra }); analyzeButton.addEventListener("click", () => void analyze()); deleteButton.addEventListener("click", () => void pipeEditor.deleteSelected()); - autoButton.addEventListener("click", () => void resetPipes()); + autoButton.addEventListener("click", live.resetToStreams); // 세류선만 · 초안(369) /** 노선 전체가 보이도록 배율·중심을 맞춘다(초기 보기 = 도로 기준 줌인). * @@ -596,11 +593,13 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra const before = pipeEditor.pipes().map((pipe) => pipe.chainage_m); const next = reconcilePipes(pipeEditor.pipes(), chainages); if (!next) return; // 같은 목록 — 되돌아온 것이므로 여기서 끊는다 - // 되돌리기 · 구조물 폼으로 옮겨진 관도 속성을 데려감 - facilityStore.follow( - before, - next.map((pipe) => pipe.chainage_m), - ); + // 되돌리기 = 그 걸음 초안의 속성 · id · 초안 없는 걸음은 저장분(369) · 구조물 폼은 속성 데려감 + const pending = pageReplaying() ? readPendingPipes(projectId as string) : null; + if (pageReplaying() && !pending) return void run(loadSaved, true); + const draft = adoptDraftPipes(pending, next); + const after = next.map((pipe) => pipe.chainage_m); + if (draft) facilityStore.replaceFromResponse(draft); + else facilityStore.follow(before, after); pipeEditor.setPipes(next); void analyze(); }, diff --git a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts index fc92c1ec2..babcebadf 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts @@ -343,6 +343,22 @@ export function mountDrainageToggles( /** 밖에서 바뀐 관 목록을 현재 목록과 맞춘다. 같으면 null — 되먹임 고리를 끊는 지점이다. * 다르면 새 목록을 돌려주되, 원래 생성 사유는 자리로 맞춰 이어 붙인다(기본/자동/수동 표시 보존). */ +/** 되돌리기(369) — 되살린 걸음의 관 초안(세션)이 `next` 와 같은 자리면 그 초안(속성 정본) · id 도 + * `next` 에 옮김. 자리가 다르면(구조물 폼이 옮김 등) null — 부르는 쪽이 속성을 따라 옮김. */ +export function adoptDraftPipes( + draft: ReadonlyArray | null, + next: PipePoint[], +): ReadonlyArray | null { + const same = + draft?.length === next.length && + draft.every((item, index) => Math.abs(item.chainage_m - next[index].chainage_m) < 0.01); + if (!draft || !same) return null; + next.forEach((pipe, index) => { + if (draft[index].id) pipe.id = draft[index].id; + }); + return draft; +} + export function reconcilePipes( before: ReadonlyArray, chainages: ReadonlyArray, diff --git a/B05_Profile/B05_Profile_UI_Drainage_Suggest.ts b/B05_Profile/B05_Profile_UI_Drainage_Suggest.ts index 71bd39b57..379702fa1 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Suggest.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Suggest.ts @@ -1,6 +1,7 @@ /* ============================================================================= * B05_Profile_UI_Drainage_Suggest.ts - * 배수유역 칸 제안 목록(347-3) — 관경 · 시설 종류 · 관 자리 후보를 보여 주고 [적용] 한 번에 관 초안으로. + * 배수유역 칸 제안 목록(347-3) — 관경 · 시설 종류 · 관 자리 후보를 보여 주고 [적용] 한 번에 관경 · 시설만 + * 관 초안으로(관 자리 = [자동 제안] · 369). * 모으기 · 얹기 규칙은 `logic/B05_Profile_Drainage_Suggest.ts` · 자동 바꿈 없음. * 관 자리 후보는 배수유역도에도 점선 원으로 그림(`candidates()` → 그리기 판). * ========================================================================== */ @@ -26,7 +27,8 @@ const facilityName = (id: string): string => FACILITY_NAME[id] ?? id; /** 제안 한 줄 글. */ export function suggestionText(item: Suggestion): string { const at = `측점 ${Math.round(item.chainageM)} m`; - if (item.kind === "position") return `${at} — 관 자리 후보(흐름이 모이는 오목 자리)`; + if (item.kind === "position") + return `${at} — 관 자리 후보(흐름이 모이는 오목 자리 · [자동 제안])`; if (item.kind === "facility") return `${at} — ${facilityName(item.from)} → ${facilityName(item.to)} 검토`; return `${at} — 관경 ${item.from === null ? "미정" : `Ø${item.from}`} → Ø${item.to}`; @@ -56,13 +58,19 @@ export function createDrainageSuggest( apply.className = "b05-drainage__analyze"; apply.textContent = "적용"; const applyTitle = - "제안을 관 초안에 한 번에 얹습니다(관 더함 · 관경 · 시설 바꿈) — [저장] 때 남습니다."; + "관경 · 시설 제안을 관 초안에 한 번에 얹습니다 — [저장] 때 남습니다(관 자리는 [자동 제안])."; apply.title = applyTitle; head.append(title, apply); const list = document.createElement("ul"); list.className = "b05-drainage__suggest-list"; root.append(head, list); let suggestions: Suggestion[] = []; + let stale = false; + /** [적용] 켜짐 — 낡지 않고 관경 · 시설 제안이 있을 때(관 자리 후보만이면 할 일 없음). */ + const paintApply = (): void => { + apply.disabled = stale || !suggestions.some((item) => item.kind !== "position"); + apply.title = stale ? "노선이 바뀌어 제안이 낡음 — 새 계산 뒤 적용" : applyTitle; + }; apply.addEventListener("click", () => { if (suggestions.length && !apply.disabled) @@ -77,6 +85,7 @@ export function createDrainageSuggest( : collectSuggestions(response.pipe_points, response.basins, response.pipe_candidates ?? []); root.hidden = suggestions.length === 0; title.textContent = `제안 ${suggestions.length}건`; + paintApply(); list.replaceChildren( ...suggestions.map((item) => { const row = document.createElement("li"); @@ -85,9 +94,9 @@ export function createDrainageSuggest( }), ); }, - setStale(stale) { - apply.disabled = stale; - apply.title = stale ? "노선이 바뀌어 제안이 낡음 — 새 계산 뒤 적용" : applyTitle; + setStale(next) { + stale = next; + paintApply(); }, candidates: () => suggestions.filter((item) => item.kind === "position").map((item) => item.chainageM), diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index 201cfa586..6a4b5b778 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -17,8 +17,8 @@ * ── 배치 (354-1 · 요구 235-7 「유토곡선도 분리」) ───────────────────── * 종단 열(그래프 · 표) 아래 칸에 따로 선다 — 종단도 · 표 위를 덮지 않고 높이 나눔 · 표 쌓기에도 * 안 낀다. 높이는 판 위 경계 리사이저(`createPanelResizer`)로 조절하고 세션 동안 유지한다. - * 머리줄([창 분할] · 접기 손잡이)은 접어도 남는다. 곡선 SVG는 판 안 자체 가로 스크롤러에 - * 들어가며, 종단 스크롤러와 scrollLeft를 양방향 동기화해 측점 세로선이 어긋나지 않게 한다. + * 접으면 손잡이만 남는다(367-2). 곡선은 늘 노선 전체를 한 화면에 맞춰 그린다 — 종단 측점과 + * 맞물리는 보기 · 스크롤 동기는 없앰(367-1). * ========================================================================== */ import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state"; @@ -46,16 +46,13 @@ import { resetBalloonOffsets } from "@util/common_util_mass_haul_balance_view"; import { createMassHaulChart, createMassHaulLegend, - createMassHaulWindowState, MASS_HAUL_MIN_HEIGHT, - scheduleMassHaulSettle, } from "@util/common_util_mass_haul_view"; import { Y_WINDOW_BAKED_ATTR } from "../B06_Section/B06_Section_UI_Longitudinal"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; import "@util/common_util_mass_haul.css"; import "./B05_Profile_UI_Style_MassHaul.css"; -import { attachWheelHorizontalScroll, linkScrollLeft } from "./B05_Profile_UI_Profile_Wheel"; import { frameOf } from "../common_util/common_util_view_window"; /** 2차 패널 펼침 여부 — 세션 동안만 유지(패널 높이·접힘과 같은 수명). */ @@ -76,10 +73,8 @@ const HEIGHT_VAR = "--b05-masshaul-height"; * 표시 상태가 갈리면 "같은 곡선인데 왜 다르게 보이나"가 된다(2026-08-03 사용자 확정). * 정의처는 B06 `_UI_Section_View`와 이 파일 두 곳뿐이며 값이 반드시 같아야 한다. */ -/** 처음 열 때는 **곡선만** 보인다 — 토량 분배(평형선·운반 블록)는 범례에서 켠다 - * (2026-09-06 사용자 지시). 판을 v4 → v5 로 올려 이미 켜 둔 브라우저도 새 기본값으로 - * 시작하게 한다. */ -const DEFAULT_VISIBLE: string[] = [...MASS_HAUL_DEFAULT_VISIBLE]; +/** 기본 = 횡단 기준 + 토량 분배 켬(367-1) — 판 v6. [토량 분배] 는 분배 그림 보임/숨김. */ +const DEFAULT_VISIBLE: string[] = [...MASS_HAUL_DEFAULT_VISIBLE, MASS_HAUL_BALANCE_KEY]; /** 유토곡선 계산에 필요한 프로젝트 설정 — B05 Page가 `fetchSectionContext()`에서 받아 넘긴다. */ export interface RouteMassHaulContext { @@ -150,12 +145,8 @@ export interface RouteMassHaulPanel { overlay: HTMLElement; isOpen(): boolean; setContext(next: RouteMassHaulContext | null): void; - /** 종단 가로 스크롤러와 scrollLeft를 양방향 동기화한다(설치 1회). */ - attachScrollSync(main: HTMLElement): void; - /** 판 내용을 다시 그린다. 접혀 있으면 머리줄만 남기고 끝낸다. */ + /** 판 내용을 다시 그린다. 접혀 있으면 손잡이만 남기고 끝낸다. */ draw(params: RouteMassHaulDrawParams): void; - /** 가로 스크롤로 보이는 구간만 바뀜 — 창을 따를 때만 · 한 틀 한 번 · 다시 셈 없음(354-3). */ - drawWindow(params: RouteMassHaulDrawParams): void; } function readVisible(): Set { @@ -199,7 +190,7 @@ export function createRouteMassHaulPanel( handle.className = "b05-profile__masshaul-handle"; handle.append(handleControl.root); handleControl.root.setAttribute("aria-label", "유토곡선 패널"); - // 머리줄 — 접어도 남아 [창 분할]([attachSubPanelPopouts] 가 맨 앞에 붙임) · 손잡이가 닿는다(354-1). + // 머리줄 — [창 분할]([attachSubPanelPopouts] 가 맨 앞에 붙임 · 펼친 때만 보임) · 손잡이(접으면 판 위 경계에 매달림 · 367-2). const head = document.createElement("div"); head.className = "b05-profile__masshaul-head"; head.append(handle); @@ -248,10 +239,6 @@ export function createRouteMassHaulPanel( pendingChip.hidden = true; overlay.append(resizer.root, head, bar, scroll, legendLayer, pendingChip); - // 종단 그래프 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(2026-08-04 사용자 지시). - // scrollLeft 동기화 덕에 위 종단 그래프도 함께 움직인다. Shift+휠은 브라우저 기본 그대로. - attachWheelHorizontalScroll(scroll); - /** 곡선을 그리는가 — 펼침이거나 새 창으로 뗌(뗀 창엔 접기가 없음 · 354-1). */ const shown = (): boolean => open || overlay.classList.contains("is-popout"); @@ -262,7 +249,7 @@ export function createRouteMassHaulPanel( // 툴팁은 setOpen이 일반 문구로 덮으므로 매번 유토곡선용으로 다시 밝힌다. handleControl.root.title = next ? "유토곡선 접기" : "유토곡선 펼치기"; handle.classList.toggle("is-open", next); - // 접으면 머리줄만 남는다(배수유역도처럼 손잡이는 늘 닿음) + // 접으면 손잡이만 남는다(배수유역도처럼 손잡이는 늘 닿음) overlay.classList.toggle("is-collapsed", !next); onChanged(); } @@ -279,29 +266,14 @@ export function createRouteMassHaulPanel( onChanged(); } - // 종단 스크롤러와의 양방향 동기화 — 메아리는 공용 도우미가 걸러 냄(354-3). - // 토량 분배(전체 맞춤) 중에는 끊는다 — 유토곡선이 0 으로 붙으며 낸 scroll 이 종단 그래프 · - // 표까지 0 으로 되돌렸다(PLAN 79-1). 유토곡선만 스크롤이 없는 상태. - let fitted = false; - let syncedMain: HTMLElement | null = null; - let putScroll: ((to: HTMLElement, left: number) => void) | null = null; - /** 종단과 맞물려 그린 곡선이 놓인 문서 — 처음 · 분배 맞춤 뒤 · 창 옮김 뒤면 종단 자리로 맞춘다. */ - let syncedIn: Document | null = null; - function attachScrollSync(main: HTMLElement): void { - if (syncedMain === main) return; - syncedMain = main; - putScroll = linkScrollLeft(main, scroll, () => fitted); - } - /** 곡선을 한 번이라도 그렸는지 — 재계산 대기 중 화면을 비우지 않기 위한 표시. */ let drawnOnce = false; - /** 마지막으로 낸 계열 — 가로 스크롤(보이는 구간만 바뀜)은 다시 셈 없이 이걸로 그린다(354-3). */ + /** 마지막으로 낸 계열 — 같은 계열이면 총괄값을 다시 내지 않는다. */ let lastSeries: MassHaulSeries[] | null = null; function note(message: string): void { drawnOnce = false; lastSeries = null; - syncedIn = null; pendingChip.hidden = true; bar.replaceChildren(); scroll.replaceChildren(); @@ -312,11 +284,6 @@ export function createRouteMassHaulPanel( bar.append(empty); } - // 세로창 버티기·부드러운 이동 상태 — 이 패널이 사는 동안 하나만 둔다(2026-09-04). - const windowState = createMassHaulWindowState(); - /** 마지막으로 그린 입력 — 이동이 아직 안 끝났으면 다음 프레임에 이걸로 한 번 더 그린다. */ - let lastParams: RouteMassHaulDrawParams | null = null; - /** 이미 낸 계열에서 총괄값만 꺼낸다 — 계산은 하지 않는다. */ function emitSummary(series: MassHaulSeries[]): void { if (!onSummary) return; @@ -348,33 +315,16 @@ export function createRouteMassHaulPanel( ); } - /** 그리기 몫 기록(349-0s) — 묶음 마지막 차례 `work.유토곡선`. `reuse` = 마지막 계열로(다시 셈 없음). */ - function draw(params: RouteMassHaulDrawParams, reuse = false): void { - pipelineWork("유토곡선", () => drawNow(params, reuse)); + /** 그리기 몫 기록(349-0s) — 묶음 마지막 차례 `work.유토곡선`. */ + function draw(params: RouteMassHaulDrawParams): void { + pipelineWork("유토곡선", () => drawNow(params)); } - /** 가로 스크롤로 보이는 구간만 바뀜(354-3) — 곡선이 창을 따를 때만(열림 · 분배 맞춤 아님 · 붙음) · - * 한 틀 한 번 · 다시 셈 없이 세로 창만 다시 그림. 접힘 · 분배 · 새 창은 스크롤마다 할 일 없음. */ - let windowDirty = false; - function drawWindow(params: RouteMassHaulDrawParams): void { - if (!open || fitted || overlay.classList.contains("is-popout") || !lastSeries) return; - lastParams = params; - // 미끄러짐 틀이 이미 잡혀 있으면 그 틀이 이 값으로 그림 - if (windowDirty || windowState.frame) return; - windowDirty = true; - frameOf(overlay, () => { - if (windowDirty && lastParams) draw(lastParams, true); - }); - } - - function drawNow(params: RouteMassHaulDrawParams, reuse: boolean): void { - lastParams = params; - windowDirty = false; + function drawNow(params: RouteMassHaulDrawParams): void { overlay.classList.toggle("is-collapsed", !open); // 접혀 있어도 상단줄에는 값이 서야 한다 — 이때는 **횡단 기준만** 적분한다. // 펼친 경우에는 아래에서 낸 계열로 총괄값을 낸다(계산 한 번). if (!shown()) { - syncedIn = null; reportSummary(params); return; } @@ -407,17 +357,13 @@ export function createRouteMassHaulPanel( // (354-2 · 계산 코드는 공용에 남김). // 결과를 아껴 두지 **않는다** — 횡단 설계가 제자리에서 갈리므로 객체 동일성으로는 // 바뀐 것을 못 잰다(2026-09-04 캐시를 넣었다가 곡선이 안 따라와 되돌림). - // 스크롤로 창만 바뀐 그리기(`reuse`)는 직전 계열 그대로 — 같은 입력의 두 번째 적분이었다(354-3). - const series: MassHaulSeries[] = - reuse && lastSeries - ? lastSeries - : computeMassHaulSeries( - params.longitudinal, - params.crossSections, - context.conversion, - context.naturalSpoilMinSlope, - ["cross"], - ); + const series: MassHaulSeries[] = computeMassHaulSeries( + params.longitudinal, + params.crossSections, + context.conversion, + context.naturalSpoilMinSlope, + ["cross"], + ); // 총괄값은 방금 낸 계열에서 꺼낸다 — 접힘 경로처럼 따로 적분하지 않는다. if (series !== lastSeries) emitSummary(series); lastSeries = series; @@ -446,29 +392,16 @@ export function createRouteMassHaulPanel( (overlay.clientHeight || OVERLAY_DEFAULT_HEIGHT) - bar.offsetHeight - 12; const chartHeight = Math.max(scrollHeight, MASS_HAUL_MIN_HEIGHT); - // 토량 분배를 켜면 **곡선 전체가 한 화면에** 들어와야 한다(2026-09-06 사용자 지시). - // 평형선·운반 블록은 노선 전체를 놓고 읽는 그림이라, 종단 그래프와의 X 맞춤(가로 - // 스크롤)과 보이는 구간만 보는 Y 창을 둘 다 버리고 스크롤러 안쪽 폭·전 구간 Y로 그린다. - // 종단과 맞물릴 필요가 없으므로 반 칸 들여쓰기(originOffset)도 쓰지 않는다 — 그만큼 - // 좌우가 비어 곡선이 가운데로 쪼그라들었다(2026-09-06 사용자 보고: 「우측 여유 과다」). - const fitAll = haulPlan !== null; - fitted = fitAll; - const widthPx = fitAll ? scroll.clientWidth || params.widthPx : params.widthPx; - const chartAxis = fitAll - ? { - ...params.axis, - padLeft: params.axis.axisX ?? params.axis.padLeft, - padRight: MASS_FIT_PAD_RIGHT, - viewRange: undefined, - window: undefined, - hideStations: true, - } - : { - ...params.axis, - // 새 창으로 뗀 판은 넓어 메인 보이는 구간 세로면 잘림 — 노선 전체 세로(325-2). - viewRange: overlay.classList.contains("is-popout") ? undefined : params.axis.viewRange, - window: windowState, - }; + // 늘 **곡선 전체가 한 화면에**(367-1 · 측점 연계 보기 없앰) — 스크롤러 안쪽 폭 · 전 구간 Y · + // 측점선 없음. 반 칸 들여쓰기(originOffset)도 안 씀(2026-09-06 「우측 여유 과다」). + const widthPx = scroll.clientWidth || params.widthPx; + const chartAxis = { + ...params.axis, + padLeft: params.axis.axisX ?? params.axis.padLeft, + padRight: MASS_FIT_PAD_RIGHT, + viewRange: undefined, + hideStations: true, + }; let massAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null; const chart = createMassHaulChart( @@ -495,33 +428,17 @@ export function createRouteMassHaulPanel( wrap.append(chart); // 앵커는 첫 자식 — SVG 뒤면 흐름 위치가 밀려 스크롤 시 축이 안 보인다(B06과 같은 규칙). if (massAxis) wrap.prepend(buildStickyYAxis(massAxis, chartHeight)); - // 이미 종단과 맞물려 그린 곡선이면 제 자리 그대로 — 종단 값으로 되쓰면 그 사이 유토 쪽 휠이 - // 더 간 만큼 뒤로 튐(354-3). 처음 · 분배 맞춤 뒤 · 창 옮김 뒤만 종단 자리에서 시작한다. - const keepLeft = !fitAll && syncedIn === scroll.ownerDocument ? scroll.scrollLeft : null; scroll.replaceChildren(wrap); - // 전체 맞춤일 때는 스크롤할 것이 없으므로 왼쪽 끝에 둔다. - if (fitAll) scroll.scrollLeft = 0; - else if (keepLeft !== null) scroll.scrollLeft = keepLeft; - else if (syncedMain) putScroll?.(scroll, syncedMain.scrollLeft); - syncedIn = fitAll ? null : scroll.ownerDocument; + // 스크롤할 것이 없으므로 왼쪽 끝에 둔다. + scroll.scrollLeft = 0; - // 범례는 계열 · 켬이 바뀔 때만 — 스크롤 그리기는 그대로 둔다. - if (!reuse || !legendLayer.firstChild) - legendLayer.replaceChildren( - createMassHaulLegend(series, visible, toggleSeries, () => { - resetBalloonOffsets(); - onChanged(); - }), - ); - drawnOnce = true; - // 세로창이 아직 목표까지 안 갔으면 다음 프레임에 한 걸음 더 — 확 튀지 않고 미끄러진다. - scheduleMassHaulSettle( - windowState, - () => { - if (lastParams) draw(lastParams, true); - }, - overlay, + legendLayer.replaceChildren( + createMassHaulLegend(series, visible, toggleSeries, () => { + resetBalloonOffsets(); + onChanged(); + }), ); + drawnOnce = true; } return { @@ -531,8 +448,6 @@ export function createRouteMassHaulPanel( setContext(next) { context = next; }, - attachScrollSync, - draw: (params) => draw(params), - drawWindow, + draw, }; } diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index 12bc11376..6372ff738 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -49,7 +49,8 @@ export interface PanelToolsContext { base: () => AlignmentBase | null; alignment: () => ProfileAlignment | null; edits: () => AlignmentEdits; - applyEdits: (next: AlignmentEdits) => void; + /** false = 막힘(기울기 · 흙덮기). */ + applyEdits: (next: AlignmentEdits) => boolean; /** 고른 측점 id(없으면 null). */ selectedStationId: () => string | null; /** 비정규(구조물) 측점 목록 — 좌우 이동 대상 판정에 쓴다. */ @@ -157,10 +158,11 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { history.record(); }, canResetToSaved: ctx.canRestoreSaved, - onUndo: () => inner.undo(), - onRedo: () => inner.redo(), - canUndo: () => inner.canUndo(), - canRedo: () => inner.canRedo(), + // 자유지정 동안 ↶ ↷ = 자유지정 안 한 걸음(367-3) — 큰 이력은 안 건드림. + onUndo: () => (tools.mode() === "free" ? (free.undo(), freeView.redraw()) : inner.undo()), + onRedo: () => (tools.mode() === "free" ? (free.redo(), freeView.redraw()) : inner.redo()), + canUndo: () => (tools.mode() === "free" ? free.canUndo() : inner.canUndo()), + canRedo: () => (tools.mode() === "free" ? free.canRedo() : inner.canRedo()), hasStraightRuns: () => { const alignment = ctx.alignment(); return alignment ? hasStraightRuns(alignment) : false; @@ -327,6 +329,13 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { // 한 줄 되돌리기(346-4) — 평면 · 종단 · 횡단 · 관 · 구조물을 만진 차례 거꾸로. if (isCtrlKey(event, "z") || isCtrlKey(event, "y")) { event.preventDefault(); + // 자유지정 동안은 자유지정 안만 — 되돌릴 것 없어도 큰 이력으로 안 감(367-3) + if (tools.mode() === "free") { + if (event.shiftKey || isCtrlKey(event, "y")) free.redo(); + else free.undo(); + freeView.redraw(); + return; + } flushHold(); if (event.shiftKey || isCtrlKey(event, "y")) history.redo(); else history.undo(); diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index ad3b09227..36095813d 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -71,7 +71,6 @@ export interface ProfileRenderContext { callbacks: RouteProfilePanelCallbacks | undefined; massHaul: { draw: (params: RouteMassHaulDrawParams) => void; - drawWindow: (params: RouteMassHaulDrawParams) => void; }; tableOverlay: { isOpen: () => boolean; @@ -362,8 +361,6 @@ export function renderProfile(ctx: ProfileRenderContext): void { }); // 세로 창 갱신기 — 가로로 스크롤할 때마다 패널이 이것을 부른다. 도형은 그대로 두고 // 겹 하나의 변환만 갈아 끼우므로 매 프레임 불러도 된다(2026-09-04 사용자 확정). - // 유토곡선 갱신기 — 아래에서 유토곡선을 그린 뒤 채워진다(그리기 순서상 여기서는 아직 없다). - let massHaulUpdater: ((fromM: number, toM: number) => void) | null = null; ctx.setElevationWindowUpdater(() => { const from = Math.max(0, toChainage(body.scrollLeft)); const to = Math.min(maxChainageM, toChainage(body.scrollLeft + body.clientWidth)); @@ -374,7 +371,6 @@ export function renderProfile(ctx: ProfileRenderContext): void { to, ) ?? null, ); - massHaulUpdater?.(from, to); return applyElevationWindow(chartWrap, next); }); @@ -477,15 +473,5 @@ export function renderProfile(ctx: ProfileRenderContext): void { onSelectStation: ctx.selectStation, }; massHaul.draw(massHaulParams); - // 유토곡선도 **같은 창**을 본다(2026-09-04 사용자 확정) — 스크롤할 때마다 곡선만 다시 - // 그린다. 종단처럼 변환으로 옮기지 않는 이유: 곡선 위에 앉는 말풍선·EP 표·측점 점이 - // 세로로 늘어나면 안 되는 것들이라, 그것만 따로 옮기는 값이 곡선 자체를 다시 그리는 - // 값보다 크다(요소 수가 종단의 1/3). 열림 · 분배 맞춤 아닐 때만 · 한 틀 한 번 · 다시 셈 없음(354-3). - massHaulUpdater = (fromM, toM) => { - massHaul.drawWindow({ - ...massHaulParams, - axis: { ...massHaulParams.axis, viewRange: { fromM, toM } }, - }); - }; } } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 3d05ffc66..540c6e57a 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -56,7 +56,6 @@ import { captureView, createViewMemory, fitCanvas, - fitView, openingView, restoreSavedView, screenToMetric, @@ -92,6 +91,7 @@ import { import type { DesignPageHost } from "../A00_Common/design/design_page"; import * as draftLink from "./B05_Profile_UI_RouteEdit_Draft"; import { bindRouteShift } from "./B05_Profile_UI_RouteEdit_Shift"; +import * as fit from "./logic/B05_Profile_RouteEdit_StationFit"; import "./B05_Profile_UI_Style_RouteEdit.css"; /** 등고선 눈금을 고를 때 쓸 라벨 예산 — B04 지도 기본값(350)보다 **넉넉히** 둔다 @@ -160,7 +160,7 @@ export async function mountRouteEditor( let apexLock: boolean[] = []; /** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */ let picked = -1; - /** [측점 스냅](요구 224-2) — 보기 모드라 되돌리기 밖 · 열 때 서버 값 · [확인] 때 저장. */ + /** [측점 스냅](224-2 · 366-3) — 켬 = 곡선 MC 를 일반측점에 · 켬끔 자체는 되돌리기 밖 · [저장] 때 저장. */ let stationSnap = false; let savedStationSnap = false; /** 편집 창 측점 한 곳 — 끔이면 null(간격마다) · 켬이면 호 안 하나를 곡선 중점으로. @@ -195,7 +195,7 @@ export async function mountRouteEditor( /** 등고선 자물쇠(353-2) — 잠긴 동안 빈 곳 · 다른 가닥을 눌러도 고른 것이 안 바뀜. */ const contourLock = createContourLock( canvas, - () => contours && { layer: contours.layer, view, stepM: contourStepM(), rotation }, + () => contours && { ...contours, view, rotation, stepM: contourStepM(), picked: pickedContour }, (index) => ((pickedContour = index), draw()), ); /** 측점 횡단 미리보기 창 — 측점 눈금을 누르면 뜬다(계획서 0-9 ⑧). */ @@ -266,7 +266,7 @@ export async function mountRouteEditor( const showMinorContours = bindMinorContourToggle(minorContourButton, () => draw()); stationSnapButton.addEventListener("click", () => { setStationSnap(!stationSnap); - void crossPreview.refresh(); // 떠 있는 횡단도 옮긴 측점 규칙으로. + if (stationSnap && history) applyEdit("곡선 가운데(MC)를 일반측점에 맞췄습니다."); // 전체 · 한 걸음 draw(); }); /** 보던 화면 기억(계획서 18 ③) — 자료를 읽고 화면을 되살린 **뒤부터** 적는다. */ @@ -318,13 +318,6 @@ export async function mountRouteEditor( rotation.set(opening.rotationRad); viewReady = true; } - // [맞춤] — 처음 그림과 같은 등고 영역 맞춤(회전 0 · 349-0). 셈은 `_View` 몫. - overlay.querySelector('[data-act="fit"]')!.addEventListener("click", () => { - if (!meta || view.width <= 0 || !planned.length) return; - view = fitView(meta, view, planned, contours?.layer ?? null); - rotation.set(0); - draw(); - }); const toScreen = (vertex: Vertex): [number, number] => meta ? metricToScreen(meta, view, vertex[0], vertex[1]) : [0, 0]; @@ -423,6 +416,7 @@ export async function mountRouteEditor( violationCount: nodeInfo.filter((node) => node.violations.length).length, minRadiusM, fresh: nodeInfo.length === 0, + offStation: stationSnap ? fit.offStationCount(plannedLine, curveInfo, stationIntervalM) : 0, }); // ── 곡선 라벨 — 고른 꺾임점 옆에 뜨는 조작 패널. 배선은 `_CurveBar` 몫 ── @@ -453,8 +447,12 @@ export async function mountRouteEditor( const curveLabelBox = curveBar.label; const syncCurveBar = curveBar.sync; - /** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다. */ - function applyEdit(message: string, record = true): void { + /** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다. + * 측점 스냅 켬이면 고친 자리 앞 곡선부터 MC 를 일반측점에(366-3 · 되살림 `fitting=false` 은 안 맞춤). */ + function applyEdit(message: string, record = true, fitting = true): void { + const limits = { minRadiusM, limitRadiusM, limitArcM, intervalM: stationIntervalM }; + const from = fit.fitStart(history?.current?.(), snapshotNow()); + if (stationSnap && fitting) planned = fit.fitCurveStations(snapshotNow(), limits, from); markEdited(); writeRouteLocks(projectId, planned, apexLock); // 고정은 [취소] · 다시 열기에도 남는다(22-5). syncCurveBar(); @@ -484,7 +482,7 @@ export async function mountRouteEditor( const locks = record ? routeLocksOf(planned, apexLock) : null; ({ planned, curveOn, curveRadius, curveLock, curveArc, apexLock, picked } = snapshot); if (locks) apexLock = lockColumn(planned, locks); - applyEdit(message, record); + applyEdit(message, record, false); // 되돌리기 · 추천 [적용] · 초안 되살림은 안 맞춤(366-3) } const historyControls = bindHistoryControls({ @@ -540,6 +538,7 @@ export async function mountRouteEditor( curveHint, contourHint, commit: () => { + if (stationSnap) return applyEdit("곡선 가운데(MC)를 일반측점에 맞췄습니다."); // 놓을 때(366-3) history?.commit(snapshotNow()); historyControls.sync(); }, @@ -684,7 +683,6 @@ export async function mountRouteEditor( useBackground(background); measure.clear(); slope.refresh(); - void crossPreview.refresh(); showStatus(); draw(); }); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts index 8e19191ef..796b9022a 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts @@ -128,7 +128,6 @@ export function routeSaveSummary(saved: RouteReplanResponse, outside = 0): strin `구조물 옮김 ${saved.structures?.moved ?? 0}`, `멀어짐 ${saved.structures?.far ?? 0}`, ]; - if (saved.pipes?.concave) parts.splice(2, 0, `오목 보충 관 ${saved.pipes.concave}`); const dropped = (saved.cross_dropped ?? 0) + outside; if (dropped) parts.push(`노선 밖 고침 ${dropped} 건`); if (saved.drainage_stale) parts.push("배수유역 낡음"); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index 211b0658a..13021c9e8 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -11,6 +11,7 @@ import { bindFootprintToggle } from "./B05_Profile_UI_RouteEdit_Footprint"; * **배치**(352-1) — 지도가 칸 전체 · 머리 줄 · 테두리 없음. 도구는 3D 와 같은 떠 있는 유리 단추 * (지도 왼쪽 위 · ☰ 비킴), [?] 조작 설명은 도구줄 아래 2열, 상태·범례는 왼쪽 아래, 잰 값은 오른쪽 * 아래. 횡단 두 판은 지도 오른쪽을 덮는 판(아래 종단 판이 그 위). + * 도구줄 차례(366-2) — [?] | ↶ ↷ | 3D | ⟲ ⟳ · 1m 등고선 · 절/성토선 · 거리 재기 | 측점 스냅 · 분석 지형. * * 자리(345-3) — 모달이 아니라 종단 보기 가운데 위 본문(옛 3D 자리)에 붙는다. 덮개 · 전체 화면 · * 닫기 단추는 걷었다 · [취소] · [확인] · [노선 반영] · [저장 전으로]는 없앰(346-3 · 352-2 — 노선은 페이지 @@ -60,8 +61,31 @@ export function createRouteEditChrome(

+ + + + + + + + + + + + - - - - - - - -
-
노드 끌기 = 옮기기노드 클릭 = R 라벨 선 두 번 클릭 = 노드 추가노드 오른쪽 클릭 = 삭제 @@ -97,12 +102,6 @@ export function createRouteEditChrome( 가운데(휠) 버튼 끌기 = 지도 이동휠 = 확대
-
- - -