From 99babfacaf4d1c3ccebd05ed12108bd82a8ab45d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 26 Jul 2026 20:41:48 +0900 Subject: [PATCH 01/61] auto: 2026-07-26 20:41 (ESD_LAPTOP) --- .../screenCanvas.drawController.ts | 23 ++++ .../openwebcad/src/helpers/draw.ts | 21 ++- .../openwebcad/src/helpers/scene-cache.ts | 121 ++++++++++++++++++ .../openwebcad/src/helpers/scene-version.ts | 13 ++ B07_wf4_DesignDetail/openwebcad/src/main.tsx | 28 +++- B07_wf4_DesignDetail/openwebcad/src/state.ts | 7 + 6 files changed, 206 insertions(+), 7 deletions(-) create mode 100644 B07_wf4_DesignDetail/openwebcad/src/helpers/scene-cache.ts create mode 100644 B07_wf4_DesignDetail/openwebcad/src/helpers/scene-version.ts diff --git a/B07_wf4_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts b/B07_wf4_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts index 2b5fd28c..2eec4a8b 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts +++ b/B07_wf4_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts @@ -238,6 +238,29 @@ export class ScreenCanvasDrawController implements DrawController { this.context.fillStyle = fillColor; } + /** + * Temporarily redirect all draw calls to another 2d context (eg an + * offscreen canvas used as static scene cache), reusing the current + * offset/scale/canvasSize without touching state or react triggers. + */ + public withContext(temporaryContext: CanvasRenderingContext2D, renderFunction: () => void) { + const originalContext = this.context; + this.context = temporaryContext; + try { + renderFunction(); + } finally { + this.context = originalContext; + } + } + + /** + * Blit a pre-rendered bitmap (static scene cache) onto the canvas at a + * pixel offset. Used while panning to avoid re-stroking every entity. + */ + public blitImage(source: CanvasImageSource, dx: number, dy: number) { + this.context.drawImage(source, dx, dy); + } + public clear() { if (this.canvasSize === null) return; diff --git a/B07_wf4_DesignDetail/openwebcad/src/helpers/draw.ts b/B07_wf4_DesignDetail/openwebcad/src/helpers/draw.ts index d86bcc8a..337be55c 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/helpers/draw.ts +++ b/B07_wf4_DesignDetail/openwebcad/src/helpers/draw.ts @@ -14,6 +14,7 @@ import { getDebugEntities, getEntities, getGhostHelperEntities, + getHighlightedEntityIds, getHoveredSnapPoints, getInputController, getShouldDrawCursor, @@ -21,13 +22,31 @@ import { getSnapPointOnAngleGuide, } from '../state'; import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController'; +import { drawScene } from './scene-cache'; + +/** + * Hover highlight is excluded from the static scene cache (it changes every + * mouse move) — re-draw the few highlighted entities on top of the blit. + */ +function drawHighlightedEntities(drawController: ScreenCanvasDrawController) { + const highlightedIds = getHighlightedEntityIds(); + if (!highlightedIds.length) return; + const idSet = new Set(highlightedIds); + drawEntities( + drawController, + getEntities().filter(entity => idSet.has(entity.id)), + ); +} export function draw(drawController: ScreenCanvasDrawController) { drawController.clear(); + // Static scene (all entities): cached bitmap blit, rebuilt only when needed. + drawScene(drawController, performance.now()); + drawHighlightedEntities(drawController); + drawHelpers(drawController, getAngleGuideEntities()); drawEntities(drawController, getGhostHelperEntities()); - drawEntities(drawController, getEntities()); drawDebugEntities(drawController, getDebugEntities()); const { snapPoint: closestSnapPoint } = getClosestSnapPoint( diff --git a/B07_wf4_DesignDetail/openwebcad/src/helpers/scene-cache.ts b/B07_wf4_DesignDetail/openwebcad/src/helpers/scene-cache.ts new file mode 100644 index 00000000..f96d2d9e --- /dev/null +++ b/B07_wf4_DesignDetail/openwebcad/src/helpers/scene-cache.ts @@ -0,0 +1,121 @@ +import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController'; +import { + getEntities, + getGridEnabled, + getHighlightedEntityIds, + setHighlightedEntityIds, +} from '../state'; +import { drawEntities } from './draw-functions'; +import { getSceneVersion } from './scene-version'; + +/** + * Static scene cache: all entities are rendered once into an offscreen canvas. + * While panning, the cached bitmap is blitted at a pixel offset instead of + * re-stroking every entity each frame. The cache is rebuilt when the scene + * version bumps (entities/layers/selection changed), when zoom or canvas size + * changes, or after the pan offset has settled for PAN_SETTLE_MS. + * + * Highlight is intentionally NOT baked into the cache (it changes on every + * mouse move) — draw.ts re-draws highlighted entities on top each frame. + */ +const PAN_SETTLE_MS = 120; + +interface RenderedParams { + version: number; + scale: number; + offsetX: number; + offsetY: number; + sizeX: number; + sizeY: number; +} + +let offscreenCanvas: HTMLCanvasElement | null = null; +let rendered: RenderedParams | null = null; +let prevOffsetX = Number.NaN; +let prevOffsetY = Number.NaN; +let lastOffsetChangeAt = 0; + +export const scenePerf = { + sceneRebuilds: 0, + lastRebuildMs: 0, + avgFrameMs: 0, +}; +(window as unknown as Record).__aisloCadPerf = scenePerf; + +export function invalidateSceneCache(): void { + rendered = null; +} + +function rebuildScene(drawController: ScreenCanvasDrawController): void { + const size = drawController.getCanvasSize(); + if (!offscreenCanvas) { + offscreenCanvas = document.createElement('canvas'); + } + if (offscreenCanvas.width !== size.x || offscreenCanvas.height !== size.y) { + offscreenCanvas.width = Math.max(1, size.x); + offscreenCanvas.height = Math.max(1, size.y); + } + const offscreenContext = offscreenCanvas.getContext('2d'); + if (!offscreenContext) return; + + const startedAt = performance.now(); + // Exclude the (rapidly changing) hover highlight from the baked bitmap. + const savedHighlight = getHighlightedEntityIds(); + if (savedHighlight.length) setHighlightedEntityIds([]); + drawController.withContext(offscreenContext, () => { + drawController.clear(); + drawEntities(drawController, getEntities()); + }); + if (savedHighlight.length) setHighlightedEntityIds(savedHighlight); + + scenePerf.lastRebuildMs = performance.now() - startedAt; + scenePerf.sceneRebuilds++; + + const offset = drawController.getScreenOffset(); + rendered = { + version: getSceneVersion(), + scale: drawController.getScreenScale(), + offsetX: offset.x, + offsetY: offset.y, + sizeX: size.x, + sizeY: size.y, + }; +} + +/** + * Draw the static scene: rebuild the cache when needed, otherwise blit the + * cached bitmap (shifted by the pan delta). Called once per frame by draw(). + */ +export function drawScene(drawController: ScreenCanvasDrawController, now: number): void { + const size = drawController.getCanvasSize(); + const scale = drawController.getScreenScale(); + const offset = drawController.getScreenOffset(); + + if (offset.x !== prevOffsetX || offset.y !== prevOffsetY) { + lastOffsetChangeAt = now; + prevOffsetX = offset.x; + prevOffsetY = offset.y; + } + + const paramsChanged = + !rendered || + rendered.version !== getSceneVersion() || + rendered.scale !== scale || + rendered.sizeX !== size.x || + rendered.sizeY !== size.y; + const offsetChanged = + !!rendered && (rendered.offsetX !== offset.x || rendered.offsetY !== offset.y); + + // Grid lines are screen-fixed (drawn in clear()), so blitting a shifted + // bitmap would drag the grid along — always re-render while grid is on. + if (paramsChanged || getGridEnabled() || (offsetChanged && now - lastOffsetChangeAt >= PAN_SETTLE_MS)) { + rebuildScene(drawController); + } + + if (!offscreenCanvas || !rendered) return; + // screenX = (worldX - offsetX) * scale, canvasY is y-flipped afterwards: + // content shifts left when offset.x grows, down when offset.y grows. + const dx = (rendered.offsetX - offset.x) * scale; + const dy = (offset.y - rendered.offsetY) * scale; + drawController.blitImage(offscreenCanvas, dx, dy); +} diff --git a/B07_wf4_DesignDetail/openwebcad/src/helpers/scene-version.ts b/B07_wf4_DesignDetail/openwebcad/src/helpers/scene-version.ts new file mode 100644 index 00000000..5d64ca06 --- /dev/null +++ b/B07_wf4_DesignDetail/openwebcad/src/helpers/scene-version.ts @@ -0,0 +1,13 @@ +/** + * Scene version counter — incremented whenever content that is baked into the + * cached static scene bitmap changes (entities, layers, selection, grid). + * Kept dependency-free so both state.ts and scene-cache.ts can import it + * without a cycle. + */ +let sceneVersion = 0; + +export const bumpSceneVersion = (): void => { + sceneVersion++; +}; + +export const getSceneVersion = (): number => sceneVersion; diff --git a/B07_wf4_DesignDetail/openwebcad/src/main.tsx b/B07_wf4_DesignDetail/openwebcad/src/main.tsx index a1884e83..f30c6057 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/main.tsx +++ b/B07_wf4_DesignDetail/openwebcad/src/main.tsx @@ -7,6 +7,7 @@ import App from './App.tsx'; import { ScreenCanvasDrawController } from './drawControllers/screenCanvas.drawController'; import { draw } from './helpers/draw'; import { findClosestEntity } from './helpers/find-closest-entity'; +import { scenePerf } from './helpers/scene-cache'; import { getNewLayer } from './helpers/get-new-layer.ts'; import { trackHoveredSnapPoint } from './helpers/track-hovered-snap-points'; import { InputController } from './inputController/input-controller.ts'; @@ -40,6 +41,13 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLDivElement).render( ); +// Hover highlight throttle: the closest-entity scan is O(entities) so it runs +// at most every HOVER_THROTTLE_MS and only when the mouse actually moved. +const HOVER_THROTTLE_MS = 30; +let lastHoverCheckAt = 0; +let lastHoverMouseX = Number.NaN; +let lastHoverMouseY = Number.NaN; + function startDrawLoop( screenCanvasDrawController: ScreenCanvasDrawController, timestamp: DOMHighResTimeStamp @@ -48,6 +56,7 @@ function startDrawLoop( const elapsedTime = timestamp - lastDrawTimestamp; setLastDrawTimestamp(timestamp); + scenePerf.avgFrameMs = scenePerf.avgFrameMs * 0.9 + elapsedTime * 0.1; // biome-ignore lint/suspicious/noExplicitAny: const activeToolSnapshot: MachineSnapshot | undefined = @@ -70,13 +79,20 @@ function startDrawLoop( if (!screenCanvasDrawController) { throw new Error('getScreenCanvasDrawController() returned null'); } - const { distance, entity: closestEntity } = findClosestEntity( - screenCanvasDrawController.getWorldMouseLocation(), - getEntities() - ); + const mouseLocation = screenCanvasDrawController.getScreenMouseLocation(); + const mouseMoved = mouseLocation.x !== lastHoverMouseX || mouseLocation.y !== lastHoverMouseY; + if (mouseMoved && timestamp - lastHoverCheckAt >= HOVER_THROTTLE_MS) { + lastHoverCheckAt = timestamp; + lastHoverMouseX = mouseLocation.x; + lastHoverMouseY = mouseLocation.y; + const { distance, entity: closestEntity } = findClosestEntity( + screenCanvasDrawController.getWorldMouseLocation(), + getEntities() + ); - if (distance < HIGHLIGHT_ENTITY_DISTANCE) { - setHighlightedEntityIds([closestEntity.id]); + if (distance < HIGHLIGHT_ENTITY_DISTANCE) { + setHighlightedEntityIds([closestEntity.id]); + } } } diff --git a/B07_wf4_DesignDetail/openwebcad/src/state.ts b/B07_wf4_DesignDetail/openwebcad/src/state.ts index 28563c07..8aee5c52 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/state.ts +++ b/B07_wf4_DesignDetail/openwebcad/src/state.ts @@ -12,6 +12,7 @@ import { } from './App.types'; import type { ScreenCanvasDrawController } from './drawControllers/screenCanvas.drawController'; import type { Entity } from './entities/Entity'; +import { bumpSceneVersion } from './helpers/scene-version'; import { createStack, StateVariable, type UndoState } from './helpers/undo-stack'; import type { InputController } from './inputController/input-controller.ts'; // state variables @@ -214,6 +215,7 @@ export const getNotSelectedEntities = (): Entity[] => { }; export const isEntitySelected = (entity: Entity) => selectedEntityIds.includes(entity.id); export const isEntityHighlighted = (entity: Entity) => highlightedEntityIds.includes(entity.id); +export const getHighlightedEntityIds = () => highlightedEntityIds; export const getLayers = () => { return layers; }; @@ -279,6 +281,7 @@ export const setEntities = (newEntities: Entity[], trackInUndoStack = false) => trackUndoState(StateVariable.entities, newEntities); } entities = newEntities; + bumpSceneVersion(); if (trackInUndoStack) { window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED)); } @@ -288,6 +291,7 @@ export const setHighlightedEntityIds = (newEntityIds: string[]) => { }; export const setSelectedEntityIds = (newEntityIds: string[]) => { selectedEntityIds = newEntityIds; + bumpSceneVersion(); // selection style (dashed) is baked into the scene cache window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE)); }; export const setShouldDrawCursor = (newValue: boolean) => { @@ -374,6 +378,7 @@ export const setActiveTextStyle = ( }; export const setLayers = (newLayers: Layer[], triggerReact = true) => { layers = newLayers; + bumpSceneVersion(); // layer visibility/lock affects what the scene cache shows if (triggerReact) { triggerReactUpdate(StateVariable.layers); @@ -398,6 +403,7 @@ export const setSnapEnabled = (enabled: boolean) => { }; export const setGridEnabled = (enabled: boolean) => { gridEnabled = enabled; + bumpSceneVersion(); window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE)); }; export const setDesignMeta = (newMeta: DesignMeta | null) => { @@ -462,6 +468,7 @@ function updateStates(undoState: UndoState) { switch (variable) { case StateVariable.entities: entities = value; + bumpSceneVersion(); break; } } From eb5bdfebad4e74628d0af6333c5ef85dec784793 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 26 Jul 2026 20:54:20 +0900 Subject: [PATCH 02/61] auto: 2026-07-26 20:54 (ESD_LAPTOP) --- .../screenCanvas.drawController.ts | 114 ++++++++++++++ .../calculate-angle-guides-and-snap-points.ts | 14 +- .../openwebcad/src/helpers/draw-functions.ts | 4 +- .../openwebcad/src/helpers/scene-cache.ts | 26 +++- .../openwebcad/src/helpers/spatial-index.ts | 140 ++++++++++++++++++ B07_wf4_DesignDetail/openwebcad/src/main.tsx | 12 +- B07_wf4_DesignDetail/openwebcad/src/state.ts | 20 ++- 7 files changed, 316 insertions(+), 14 deletions(-) create mode 100644 B07_wf4_DesignDetail/openwebcad/src/helpers/spatial-index.ts diff --git a/B07_wf4_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts b/B07_wf4_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts index 2eec4a8b..a10e9010 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts +++ b/B07_wf4_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts @@ -27,12 +27,26 @@ import { DEFAULT_TEXT_OPTIONS, type DrawController } from './DrawController'; * * To convert between the 2 coordinate systems, you need the screenOffset and screenScale */ +// Batch-mode stroke decimation: skip chain segments shorter than this (screen px) +const BATCH_LOD_PX = 0.5; +// Batch-mode text smaller than this (screen px) is unreadable — skip drawing it +const BATCH_MIN_TEXT_PX = 2; + export class ScreenCanvasDrawController implements DrawController { private screenOffset: Point = new Point(0, 0); private screenScale = 1; private screenMouseLocation: Point; private canvasSize: Point = new Point(100, 100); + // Style-run batching (static scene rendering): consecutive stroke calls + // with the same style are collected into one Path2D and stroked once. + private batching = false; + private batchPath: Path2D | null = null; + private batchKey: string | null = null; + private batchStyle: { color: string; lineWidth: number; dash: number[] } | null = null; + private batchLastX = Number.NaN; + private batchLastY = Number.NaN; + constructor(private context: CanvasRenderingContext2D) { this.screenMouseLocation = new Point(this.canvasSize.x / 2, this.canvasSize.y / 2); this.setScreenOffset(new Point(0, 0)); // User expects mathematical coordinates, where y axis goes up, but canvas y axis goes down @@ -221,6 +235,18 @@ export class ScreenCanvasDrawController implements DrawController { lineWidth: number, dash: number[] = [] ) { + if (this.batching) { + const effectiveWidth = isHighlighted ? lineWidth + 1 : lineWidth; + const effectiveDash = isSelected ? [5, 5] : dash; + const key = `${color}|${effectiveWidth}|${effectiveDash.join(',')}`; + if (key !== this.batchKey) { + this.flushBatch(); + this.batchKey = key; + this.batchStyle = { color, lineWidth: effectiveWidth, dash: effectiveDash }; + } + return; + } + this.context.strokeStyle = color; this.context.lineWidth = lineWidth; this.context.setLineDash(dash); @@ -234,6 +260,43 @@ export class ScreenCanvasDrawController implements DrawController { } } + /** + * Start style-run batching: consecutive stroke calls sharing a style are + * accumulated into a single Path2D and stroked once (with sub-pixel + * segment decimation). Used while rendering the static scene cache. + */ + public beginBatch() { + this.flushBatch(); + this.batching = true; + this.batchKey = null; + this.batchStyle = null; + } + + public endBatch() { + this.flushBatch(); + this.batching = false; + this.batchKey = null; + this.batchStyle = null; + } + + private flushBatch() { + if (this.batchPath && this.batchStyle) { + this.context.strokeStyle = this.batchStyle.color; + this.context.lineWidth = this.batchStyle.lineWidth; + this.context.setLineDash(this.batchStyle.dash); + // Round caps/joins replace the per-segment endpoint dots drawn in + // the unbatched path (see _drawRoundedEndpoint) + this.context.lineCap = 'round'; + this.context.lineJoin = 'round'; + this.context.stroke(this.batchPath); + this.context.lineCap = 'butt'; + this.context.lineJoin = 'miter'; + } + this.batchPath = null; + this.batchLastX = Number.NaN; + this.batchLastY = Number.NaN; + } + public setFillStyles(fillColor: string) { this.context.fillStyle = fillColor; } @@ -265,6 +328,7 @@ export class ScreenCanvasDrawController implements DrawController { if (this.canvasSize === null) return; if (!this.context) return; + if (this.batching) this.flushBatch(); this.context.fillStyle = CANVAS_BACKGROUND_COLOR; this.context.fillRect(0, 0, this.canvasSize?.x, this.canvasSize?.y); @@ -305,6 +369,33 @@ export class ScreenCanvasDrawController implements DrawController { * @param screenEndPoint */ public drawLineScreen(screenStartPoint: Point, screenEndPoint: Point): void { + if (this.batching) { + const startX = screenStartPoint.x; + const startY = this.canvasSize.y - screenStartPoint.y; + const endX = screenEndPoint.x; + const endY = this.canvasSize.y - screenEndPoint.y; + if (!this.batchPath) this.batchPath = new Path2D(); + // Chain break: start is not where the previous segment ended + const chainBroken = + Math.abs(startX - this.batchLastX) > BATCH_LOD_PX || + Math.abs(startY - this.batchLastY) > BATCH_LOD_PX; + if (chainBroken || Number.isNaN(this.batchLastX)) { + this.batchPath.moveTo(startX, startY); + this.batchLastX = startX; + this.batchLastY = startY; + } + const isTinyStep = + Math.abs(endX - this.batchLastX) < BATCH_LOD_PX && + Math.abs(endY - this.batchLastY) < BATCH_LOD_PX; + // Decimate sub-pixel steps inside a chain; isolated segments always draw + if (!isTinyStep || chainBroken) { + this.batchPath.lineTo(endX, endY); + this.batchLastX = endX; + this.batchLastY = endY; + } + return; + } + this.context.beginPath(); this.context.moveTo(screenStartPoint.x, this.canvasSize.y - screenStartPoint.y); this.context.lineTo(screenEndPoint.x, this.canvasSize.y - screenEndPoint.y); @@ -357,6 +448,22 @@ export class ScreenCanvasDrawController implements DrawController { endAngle: number, counterClockWise: boolean ) { + if (this.batching) { + if (screenRadius < BATCH_LOD_PX) return; // invisible at this zoom + if (!this.batchPath) this.batchPath = new Path2D(); + const centerX = screenCenterPoint.x; + const centerY = this.canvasSize.y - screenCenterPoint.y; + this.batchPath.moveTo( + centerX + screenRadius * Math.cos(startAngle), + centerY + screenRadius * Math.sin(startAngle) + ); + this.batchPath.arc(centerX, centerY, screenRadius, startAngle, endAngle, counterClockWise); + // Arc end becomes the new chain tail + this.batchLastX = centerX + screenRadius * Math.cos(endAngle); + this.batchLastY = centerY + screenRadius * Math.sin(endAngle); + return; + } + this.context.beginPath(); this.context.arc( screenCenterPoint.x, @@ -434,6 +541,10 @@ export class ScreenCanvasDrawController implements DrawController { ...DEFAULT_TEXT_OPTIONS, ...options, }; + if (this.batching) { + if (opts.fontSize < BATCH_MIN_TEXT_PX) return; // unreadable at this zoom + this.flushBatch(); // keep draw order: strokes so far go under this text + } this.context.save(); this.context.translate(basePoint.x, this.canvasSize.y - basePoint.y); const angle = getAngleWithXAxis( @@ -466,6 +577,7 @@ export class ScreenCanvasDrawController implements DrawController { height: number, angle: number ): void { + if (this.batching) this.flushBatch(); const [screenBasePoint, screenDimensions] = this.worldsToTargets([ new Point(xMin, yMin), new Point(width, height), @@ -516,6 +628,7 @@ export class ScreenCanvasDrawController implements DrawController { * @param color */ public fillRectScreen(xMin: number, yMin: number, width: number, height: number, color: string) { + if (this.batching) this.flushBatch(); // TODO see if we need to replace this with a call to fillPolygon this.context.fillStyle = color; this.context.fillRect(xMin, this.canvasSize.y - yMin, width, height); @@ -526,6 +639,7 @@ export class ScreenCanvasDrawController implements DrawController { * @param points */ public fillPolygon(...points: Point[]) { + if (this.batching) this.flushBatch(); const screenPoints = points.map(this.worldToTarget.bind(this)); this.context.beginPath(); screenPoints.forEach((screenPoint, index) => { diff --git a/B07_wf4_DesignDetail/openwebcad/src/helpers/calculate-angle-guides-and-snap-points.ts b/B07_wf4_DesignDetail/openwebcad/src/helpers/calculate-angle-guides-and-snap-points.ts index d4a79d2a..61118822 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/helpers/calculate-angle-guides-and-snap-points.ts +++ b/B07_wf4_DesignDetail/openwebcad/src/helpers/calculate-angle-guides-and-snap-points.ts @@ -1,8 +1,8 @@ import { getAngleGuideOriginPoint, getAngleStep, - getEntities, getHoveredSnapPoints, + getLayerById, getScreenCanvasDrawController, getShouldDrawHelpers, setAngleGuideEntities, @@ -11,6 +11,7 @@ import { } from '../state.ts'; import { HOVERED_SNAP_POINT_TIME, SNAP_POINT_DISTANCE } from '../App.consts.ts'; import { getDrawHelpers } from './get-draw-guides.ts'; +import { queryEntitiesNearPoint } from './spatial-index.ts'; import { compact } from 'es-toolkit'; /** @@ -19,9 +20,16 @@ import { compact } from 'es-toolkit'; export function calculateAngleGuidesAndSnapPoints() { const angleStep = getAngleStep(); const screenCanvasDrawController = getScreenCanvasDrawController(); - const entities = getEntities(); const screenScale = screenCanvasDrawController.getScreenScale(); const worldMouseLocation = screenCanvasDrawController.getWorldMouseLocation(); + // 스냅 후보: 공간 인덱스로 마우스 주변만 조회 (전 엔티티 O(n²) 교차 계산 제거), + // 잠금 레이어(b07-frame 등 참조용)는 스냅 대상에서 제외한다. + const maxSnapDistance = SNAP_POINT_DISTANCE / screenScale; + const entities = queryEntitiesNearPoint( + worldMouseLocation.x, + worldMouseLocation.y, + maxSnapDistance * 2, + ).filter(entity => !getLayerById(entity.layerId)?.isLocked); const hoveredSnapPoints = getHoveredSnapPoints(); const eligibleHoveredSnapPoints = hoveredSnapPoints.filter( @@ -39,7 +47,7 @@ export function calculateAngleGuidesAndSnapPoints() { compact([getAngleGuideOriginPoint(), ...eligibleHoveredPoints]), worldMouseLocation, angleStep, - SNAP_POINT_DISTANCE / screenScale, + maxSnapDistance, ); setAngleGuideEntities(angleGuides); setSnapPoint(entitySnapPoint); diff --git a/B07_wf4_DesignDetail/openwebcad/src/helpers/draw-functions.ts b/B07_wf4_DesignDetail/openwebcad/src/helpers/draw-functions.ts index 7912eea9..71ff2abe 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/helpers/draw-functions.ts +++ b/B07_wf4_DesignDetail/openwebcad/src/helpers/draw-functions.ts @@ -4,12 +4,12 @@ import {type SnapPoint, SnapPointType} from '../App.types'; import type {DrawController} from '../drawControllers/DrawController'; import type {ScreenCanvasDrawController} from '../drawControllers/screenCanvas.drawController'; import type {Entity} from '../entities/Entity'; -import {getLayers, isEntityHighlighted, isEntitySelected} from '../state'; +import {getLayerById, isEntityHighlighted, isEntitySelected} from '../state'; import {toast} from 'react-toastify'; export function drawEntities(drawController: DrawController, entities: Entity[]) { for (const entity of entities) { - const layer = getLayers().find((layer) => layer.id === entity.layerId); + const layer = getLayerById(entity.layerId); if (!layer) { toast.error(`Failed to find layer for entity: ${entity?.id}`); console.error('Failed to find layer for entity: ', entity); diff --git a/B07_wf4_DesignDetail/openwebcad/src/helpers/scene-cache.ts b/B07_wf4_DesignDetail/openwebcad/src/helpers/scene-cache.ts index f96d2d9e..b2eb832b 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/helpers/scene-cache.ts +++ b/B07_wf4_DesignDetail/openwebcad/src/helpers/scene-cache.ts @@ -1,12 +1,12 @@ import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController'; import { - getEntities, getGridEnabled, getHighlightedEntityIds, setHighlightedEntityIds, } from '../state'; import { drawEntities } from './draw-functions'; import { getSceneVersion } from './scene-version'; +import { queryEntitiesInBox } from './spatial-index'; /** * Static scene cache: all entities are rendered once into an offscreen canvas. @@ -46,6 +46,25 @@ export function invalidateSceneCache(): void { rendered = null; } +/** + * Viewport culling: only entities whose bbox intersects the current view + * (expanded by one viewport on each side, so short pans stay covered by the + * blit before the settle-rebuild) are rendered into the scene cache. + */ +function sceneEntitiesForViewport(drawController: ScreenCanvasDrawController) { + const size = drawController.getCanvasSize(); + const scale = drawController.getScreenScale(); + const offset = drawController.getScreenOffset(); + const viewWidth = size.x / scale; + const viewHeight = size.y / scale; + return queryEntitiesInBox( + offset.x - viewWidth, + offset.y - viewHeight, + offset.x + 2 * viewWidth, + offset.y + 2 * viewHeight + ); +} + function rebuildScene(drawController: ScreenCanvasDrawController): void { const size = drawController.getCanvasSize(); if (!offscreenCanvas) { @@ -64,7 +83,10 @@ function rebuildScene(drawController: ScreenCanvasDrawController): void { if (savedHighlight.length) setHighlightedEntityIds([]); drawController.withContext(offscreenContext, () => { drawController.clear(); - drawEntities(drawController, getEntities()); + // Style-run batching + sub-pixel decimation: one stroke per style run + drawController.beginBatch(); + drawEntities(drawController, sceneEntitiesForViewport(drawController)); + drawController.endBatch(); }); if (savedHighlight.length) setHighlightedEntityIds(savedHighlight); diff --git a/B07_wf4_DesignDetail/openwebcad/src/helpers/spatial-index.ts b/B07_wf4_DesignDetail/openwebcad/src/helpers/spatial-index.ts new file mode 100644 index 00000000..2c9c8453 --- /dev/null +++ b/B07_wf4_DesignDetail/openwebcad/src/helpers/spatial-index.ts @@ -0,0 +1,140 @@ +import type { Entity } from '../entities/Entity'; +import { getEntities } from '../state'; +import { getSceneVersion } from './scene-version'; + +/** + * Uniform-grid spatial index over top-level entity bounding boxes. + * Rebuilt lazily whenever the scene version changes (entity edits bump it). + * Queries return entities in original array order so z-order is preserved. + */ +const GRID_CELLS_PER_AXIS = 64; + +interface IndexedEntity { + entity: Entity; + minX: number; + minY: number; + maxX: number; + maxY: number; +} + +let indexVersion = -1; +let indexedEntities: IndexedEntity[] = []; +let unindexedEntities: Entity[] = []; // bbox unavailable — always included in results +let cells: Map = new Map(); +let cellSize = 1; +let gridMinX = 0; +let gridMinY = 0; +let gridCols = 1; + +function cellRange(min: number, max: number, gridMin: number): [number, number] { + return [Math.floor((min - gridMin) / cellSize), Math.floor((max - gridMin) / cellSize)]; +} + +function ensureIndex(): void { + const version = getSceneVersion(); + if (version === indexVersion) return; + + indexedEntities = []; + unindexedEntities = []; + cells = new Map(); + + const entities = getEntities(); + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + + for (const entity of entities) { + let box: { xmin: number; ymin: number; xmax: number; ymax: number } | null = null; + try { + box = entity.getBoundingBox(); + } catch { + box = null; + } + if ( + !box || + !Number.isFinite(box.xmin) || + !Number.isFinite(box.ymin) || + !Number.isFinite(box.xmax) || + !Number.isFinite(box.ymax) + ) { + unindexedEntities.push(entity); + continue; + } + indexedEntities.push({ + entity, + minX: box.xmin, + minY: box.ymin, + maxX: box.xmax, + maxY: box.ymax, + }); + if (box.xmin < minX) minX = box.xmin; + if (box.ymin < minY) minY = box.ymin; + if (box.xmax > maxX) maxX = box.xmax; + if (box.ymax > maxY) maxY = box.ymax; + } + + if (indexedEntities.length) { + const extent = Math.max(maxX - minX, maxY - minY, 1e-9); + cellSize = extent / GRID_CELLS_PER_AXIS; + gridMinX = minX; + gridMinY = minY; + gridCols = GRID_CELLS_PER_AXIS + 2; + + indexedEntities.forEach((item, index) => { + const [cx0, cx1] = cellRange(item.minX, item.maxX, gridMinX); + const [cy0, cy1] = cellRange(item.minY, item.maxY, gridMinY); + for (let cy = cy0; cy <= cy1; cy++) { + for (let cx = cx0; cx <= cx1; cx++) { + const key = cy * gridCols + cx; + const bucket = cells.get(key); + if (bucket) bucket.push(index); + else cells.set(key, [index]); + } + } + }); + } + + indexVersion = version; +} + +/** 뷰포트/사각 영역과 bbox가 겹치는 엔티티 (원본 배열 순서 유지). */ +export function queryEntitiesInBox( + minX: number, + minY: number, + maxX: number, + maxY: number +): Entity[] { + ensureIndex(); + if (!indexedEntities.length) return [...unindexedEntities]; + + const seen = new Set(); + const [cx0, cx1] = cellRange(minX, maxX, gridMinX); + const [cy0, cy1] = cellRange(minY, maxY, gridMinY); + for (let cy = cy0; cy <= cy1; cy++) { + for (let cx = cx0; cx <= cx1; cx++) { + const bucket = cells.get(cy * gridCols + cx); + if (!bucket) continue; + for (const index of bucket) seen.add(index); + } + } + + const result: Entity[] = []; + let unindexedCursor = 0; + for (let index = 0; index < indexedEntities.length; index++) { + if (!seen.has(index)) continue; + const item = indexedEntities[index]; + if (item.maxX < minX || item.minX > maxX || item.maxY < minY || item.minY > maxY) continue; + result.push(item.entity); + } + // bbox 불명 엔티티는 항상 포함 (뒤에 붙여도 소수라 시각 영향 없음) + for (; unindexedCursor < unindexedEntities.length; unindexedCursor++) { + result.push(unindexedEntities[unindexedCursor]); + } + return result; +} + +/** 점 주변 반경 후보 엔티티 (스냅·호버용). */ +export function queryEntitiesNearPoint(x: number, y: number, radius: number): Entity[] { + return queryEntitiesInBox(x - radius, y - radius, x + radius, y + radius); +} diff --git a/B07_wf4_DesignDetail/openwebcad/src/main.tsx b/B07_wf4_DesignDetail/openwebcad/src/main.tsx index f30c6057..6ffec00a 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/main.tsx +++ b/B07_wf4_DesignDetail/openwebcad/src/main.tsx @@ -8,6 +8,7 @@ import { ScreenCanvasDrawController } from './drawControllers/screenCanvas.drawC import { draw } from './helpers/draw'; import { findClosestEntity } from './helpers/find-closest-entity'; import { scenePerf } from './helpers/scene-cache'; +import { queryEntitiesNearPoint } from './helpers/spatial-index'; import { getNewLayer } from './helpers/get-new-layer.ts'; import { trackHoveredSnapPoint } from './helpers/track-hovered-snap-points'; import { InputController } from './inputController/input-controller.ts'; @@ -15,7 +16,6 @@ import { registerAisloDrawingBridge } from './integration/aislo-drawing-bridge.t import { getActiveToolActor, getCanvas, - getEntities, getHoveredSnapPoints, getLastDrawTimestamp, getScreenCanvasDrawController, @@ -85,9 +85,15 @@ function startDrawLoop( lastHoverCheckAt = timestamp; lastHoverMouseX = mouseLocation.x; lastHoverMouseY = mouseLocation.y; + const worldMouseLocation = screenCanvasDrawController.getWorldMouseLocation(); const { distance, entity: closestEntity } = findClosestEntity( - screenCanvasDrawController.getWorldMouseLocation(), - getEntities() + worldMouseLocation, + // 공간 인덱스로 후보를 좁혀 O(전체) 스캔 제거 + queryEntitiesNearPoint( + worldMouseLocation.x, + worldMouseLocation.y, + HIGHLIGHT_ENTITY_DISTANCE + ) ); if (distance < HIGHLIGHT_ENTITY_DISTANCE) { diff --git a/B07_wf4_DesignDetail/openwebcad/src/state.ts b/B07_wf4_DesignDetail/openwebcad/src/state.ts index 8aee5c52..3c3049d0 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/state.ts +++ b/B07_wf4_DesignDetail/openwebcad/src/state.ts @@ -42,11 +42,13 @@ let entities: Entity[] = []; * Entities that are highlighted: when the mouse is close to an entity */ let highlightedEntityIds: string[] = []; +let highlightedEntityIdSet: Set = new Set(); /** * Entities that are selected by the user by clicking on them with the select tool or by selecting them with a selection rectangle */ let selectedEntityIds: string[] = []; +let selectedEntityIdSet: Set = new Set(); /** * Whether to draw the cursor or not @@ -163,6 +165,12 @@ let layers: Layer[] = [ */ let activeLayerId: string = layers[0].id; +/** + * layerId → Layer lookup, kept in sync with `layers` (drawEntities runs this + * lookup once per entity per frame — a linear find() was a hot spot) + */ +let layersById: Map = new Map(layers.map((layer) => [layer.id, layer])); + let snapEnabled = true; let gridEnabled = false; @@ -208,17 +216,18 @@ export const getInputController = (): InputController => { }; export const getSelectedEntities = (): Entity[] => { - return entities.filter((e) => selectedEntityIds.includes(e.id)); + return entities.filter((e) => selectedEntityIdSet.has(e.id)); }; export const getNotSelectedEntities = (): Entity[] => { - return entities.filter((e) => !selectedEntityIds.includes(e.id)); + return entities.filter((e) => !selectedEntityIdSet.has(e.id)); }; -export const isEntitySelected = (entity: Entity) => selectedEntityIds.includes(entity.id); -export const isEntityHighlighted = (entity: Entity) => highlightedEntityIds.includes(entity.id); +export const isEntitySelected = (entity: Entity) => selectedEntityIdSet.has(entity.id); +export const isEntityHighlighted = (entity: Entity) => highlightedEntityIdSet.has(entity.id); export const getHighlightedEntityIds = () => highlightedEntityIds; export const getLayers = () => { return layers; }; +export const getLayerById = (layerId: string): Layer | undefined => layersById.get(layerId); export const getActiveLayerId = (): string => { return activeLayerId; }; @@ -288,9 +297,11 @@ export const setEntities = (newEntities: Entity[], trackInUndoStack = false) => }; export const setHighlightedEntityIds = (newEntityIds: string[]) => { highlightedEntityIds = newEntityIds; + highlightedEntityIdSet = new Set(newEntityIds); }; export const setSelectedEntityIds = (newEntityIds: string[]) => { selectedEntityIds = newEntityIds; + selectedEntityIdSet = new Set(newEntityIds); bumpSceneVersion(); // selection style (dashed) is baked into the scene cache window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE)); }; @@ -378,6 +389,7 @@ export const setActiveTextStyle = ( }; export const setLayers = (newLayers: Layer[], triggerReact = true) => { layers = newLayers; + layersById = new Map(newLayers.map((layer) => [layer.id, layer])); bumpSceneVersion(); // layer visibility/lock affects what the scene cache shows if (triggerReact) { From 14724f415484be6534ffbd87160e58ecfc76f91b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 28 Jul 2026 18:07:38 +0900 Subject: [PATCH 03/61] auto: 2026-07-28 18:07 (EOMSANGDON-HOME) --- .../B04_wf1_Surface_UI_MapRender.ts | 359 ++++++++++++++++++ .../B04_wf1_Surface_UI_MapViewer.ts | 291 ++++---------- 2 files changed, 443 insertions(+), 207 deletions(-) create mode 100644 B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts new file mode 100644 index 00000000..f54ad0c3 --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts @@ -0,0 +1,359 @@ +import type { VWorldMeta } from "./B04_wf1_Surface_Api_Fetch"; + +// 2D 지도 벡터 레이어 렌더 엔진. +// GeoJSON 좌표를 로드 시 1회만 정규화 맵 좌표(0~1)로 사전 투영해 두고, +// 매 프레임에는 뷰포트·scale·offset을 합친 어파인 변환만 적용한다. +// 정규화 좌표라 뷰포트 리사이즈 시에도 재투영이 필요 없다. 원본 GeoJSON은 변형하지 않는다. + +export type GeoJsonGeometry = { + type: string; + coordinates: unknown; +}; + +export type GeoJsonFeature = { + geometry?: GeoJsonGeometry | null; + properties?: Record | null; +}; + +export type GeoJsonCollection = { + features?: GeoJsonFeature[]; +}; + +export type MarkerKind = "dot" | "x"; + +/** 사전 투영된 하나의 파트(선/링/점 묶음). 좌표는 정규화 맵 좌표(0~1) x,y 교차 배열. */ +type PreparedPart = { + coords: Float64Array; + closed: boolean; +}; + +/** 사전 투영된 피처 1개. bbox는 정규화 좌표 기준이며 컬링에 사용한다. */ +type PreparedFeature = { + kind: "line" | "point"; + parts: PreparedPart[]; + minX: number; + minY: number; + maxX: number; + maxY: number; + /** 등고 라벨 앵커(정규화 좌표). 라벨 대상이 아니면 labelText가 null. */ + labelAnchorX: number; + labelAnchorY: number; + labelText: string | null; +}; + +export type PreparedLayer = { + features: PreparedFeature[]; +}; + +/** lon/lat → 정규화 맵 좌표 변환 계수. meta에만 의존한다. */ +export type Normalizer = { + lonMin: number; + latMin: number; + lonRange: number; + latRange: number; +}; + +/** 뷰포트 안에서 지도 이미지가 차지하는 사각형(기존 getMapRect와 동일 계산). */ +export type MapRect = { + x: number; + y: number; + width: number; + height: number; +}; + +/** 프레임 단위 뷰 상태. */ +export type ViewState = { + width: number; + height: number; + scale: number; + offsetX: number; + offsetY: number; + mapRect: MapRect; +}; + +export function createNormalizer(meta: VWorldMeta): Normalizer { + return { + lonMin: meta.lon_min, + latMin: meta.lat_min, + lonRange: meta.lon_max - meta.lon_min || 1, + latRange: meta.lat_max - meta.lat_min || 1, + }; +} + +export function computeMapRect(meta: VWorldMeta | null, width: number, height: number): MapRect { + if (!meta) return { x: 0, y: 0, width, height }; + const mapRatio = meta.width_meters / Math.max(meta.height_meters, 1); + const viewportRatio = width / Math.max(height, 1); + const mapWidth = mapRatio > viewportRatio ? width : height * mapRatio; + const mapHeight = mapRatio > viewportRatio ? width / mapRatio : height; + return { + x: (width - mapWidth) / 2, + y: (height - mapHeight) / 2, + width: mapWidth, + height: mapHeight, + }; +} + +function isPoint(value: unknown): value is [number, number] { + return Array.isArray(value) && typeof value[0] === "number" && typeof value[1] === "number"; +} + +/** lon/lat 배열 → 정규화 좌표 Float64Array. 유효 정점이 없으면 null. */ +function projectRing(ring: unknown, normalizer: Normalizer): Float64Array | null { + if (!Array.isArray(ring) || ring.length === 0) return null; + const coords = new Float64Array(ring.length * 2); + let count = 0; + for (const point of ring) { + if (!isPoint(point)) continue; + coords[count * 2] = (point[0] - normalizer.lonMin) / normalizer.lonRange; + coords[count * 2 + 1] = 1 - (point[1] - normalizer.latMin) / normalizer.latRange; + count += 1; + } + if (count === 0) return null; + return count * 2 === coords.length ? coords : coords.slice(0, count * 2); +} + +function collectParts( + geometry: GeoJsonGeometry, + normalizer: Normalizer, + parts: PreparedPart[], +): "line" | "point" { + const coordinates = geometry.coordinates; + if (!Array.isArray(coordinates)) return "line"; + switch (geometry.type) { + case "Point": { + const projected = projectRing([coordinates], normalizer); + if (projected) parts.push({ coords: projected, closed: false }); + return "point"; + } + case "MultiPoint": { + const projected = projectRing(coordinates, normalizer); + if (projected) parts.push({ coords: projected, closed: false }); + return "point"; + } + case "LineString": { + const projected = projectRing(coordinates, normalizer); + if (projected) parts.push({ coords: projected, closed: false }); + return "line"; + } + case "MultiLineString": { + for (const line of coordinates) { + const projected = projectRing(line, normalizer); + if (projected) parts.push({ coords: projected, closed: false }); + } + return "line"; + } + case "Polygon": { + for (const ring of coordinates) { + const projected = projectRing(ring, normalizer); + if (projected) parts.push({ coords: projected, closed: true }); + } + return "line"; + } + case "MultiPolygon": { + for (const polygon of coordinates) { + if (!Array.isArray(polygon)) continue; + for (const ring of polygon) { + const projected = projectRing(ring, normalizer); + if (projected) parts.push({ coords: projected, closed: true }); + } + } + return "line"; + } + default: + return "line"; + } +} + +/** 등고 라벨 앵커: LineString/MultiLineString 첫 파트의 중앙 정점 (기존 동작 유지). */ +function labelAnchorOf(geometry: GeoJsonGeometry, normalizer: Normalizer): [number, number] | null { + const coords = geometry.coordinates; + if (!Array.isArray(coords)) return null; + const line = + geometry.type === "LineString" + ? coords + : geometry.type === "MultiLineString" + ? coords[0] + : null; + if (!Array.isArray(line) || line.length === 0) return null; + const mid = line[Math.floor(line.length / 2)]; + if (!isPoint(mid)) return null; + return [ + (mid[0] - normalizer.lonMin) / normalizer.lonRange, + 1 - (mid[1] - normalizer.latMin) / normalizer.latRange, + ]; +} + +/** + * GeoJSON 컬렉션 1개를 사전 투영한다. + * labelKeys가 주어지면 계곡선(25m 배수) 피처에만 라벨 텍스트·앵커를 계산해 둔다. + */ +export function prepareLayer( + collection: GeoJsonCollection | undefined, + normalizer: Normalizer, + labelKeys?: string[], +): PreparedLayer { + const features: PreparedFeature[] = []; + for (const feature of collection?.features ?? []) { + if (!feature.geometry) continue; + const parts: PreparedPart[] = []; + const kind = collectParts(feature.geometry, normalizer, parts); + if (parts.length === 0) continue; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const part of parts) { + const coords = part.coords; + for (let i = 0; i < coords.length; i += 2) { + const x = coords[i]; + const y = coords[i + 1]; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + } + let labelText: string | null = null; + let labelAnchorX = 0; + let labelAnchorY = 0; + if (labelKeys && labelKeys.length > 0) { + const raw = labelKeys.map((key) => feature.properties?.[key]).find((value) => value != null); + const elevation = typeof raw === "number" ? raw : Number(raw); + // 계곡선(25m 배수)만 라벨 — 전체 표기 시 화면이 숫자로 뒤덮이는 것 방지 + if (Number.isFinite(elevation) && elevation % 25 === 0) { + const anchor = labelAnchorOf(feature.geometry, normalizer); + if (anchor) { + labelText = String(elevation); + labelAnchorX = anchor[0]; + labelAnchorY = anchor[1]; + } + } + } + features.push({ kind, parts, minX, minY, maxX, maxY, labelAnchorX, labelAnchorY, labelText }); + } + return { features }; +} + +/** + * 프레임당 1회 계산하는 어파인 계수. + * base = mapRect.x + norm * mapRect.width + * screen = center + (base - center) * scale + offset + * = norm * (mapRect.width * scale) + (mapRect.x * scale + center * (1 - scale) + offset) + */ +type Affine = { ax: number; bx: number; ay: number; by: number }; + +function affineOf(view: ViewState): Affine { + const centerX = view.width / 2; + const centerY = view.height / 2; + return { + ax: view.mapRect.width * view.scale, + bx: view.mapRect.x * view.scale + centerX * (1 - view.scale) + view.offsetX, + ay: view.mapRect.height * view.scale, + by: view.mapRect.y * view.scale + centerY * (1 - view.scale) + view.offsetY, + }; +} + +function drawLineParts( + context: CanvasRenderingContext2D, + feature: PreparedFeature, + affine: Affine, +): void { + for (const part of feature.parts) { + const coords = part.coords; + if (coords.length < 2) continue; + context.beginPath(); + context.moveTo(coords[0] * affine.ax + affine.bx, coords[1] * affine.ay + affine.by); + for (let i = 2; i < coords.length; i += 2) { + context.lineTo(coords[i] * affine.ax + affine.bx, coords[i + 1] * affine.ay + affine.by); + } + if (part.closed) context.closePath(); + context.stroke(); + } +} + +function drawPointParts( + context: CanvasRenderingContext2D, + feature: PreparedFeature, + affine: Affine, + marker: MarkerKind, +): void { + for (const part of feature.parts) { + const coords = part.coords; + for (let i = 0; i < coords.length; i += 2) { + const x = coords[i] * affine.ax + affine.bx; + const y = coords[i + 1] * affine.ay + affine.by; + if (marker === "x") { + // 표고점: 조금 굵고 큰 X 마커 + const arm = 4; + const prevWidth = context.lineWidth; + context.lineWidth = 2; + context.beginPath(); + context.moveTo(x - arm, y - arm); + context.lineTo(x + arm, y + arm); + context.moveTo(x - arm, y + arm); + context.lineTo(x + arm, y - arm); + context.stroke(); + context.lineWidth = prevWidth; + continue; + } + context.beginPath(); + context.arc(x, y, 2, 0, Math.PI * 2); + context.fillStyle = context.strokeStyle; + context.fill(); + } + } +} + +/** 컬링 여백: 선 굵기·X 마커 팔 길이·라벨 폭을 감안한 화면 밖 판정 마진(px). */ +const CULL_MARGIN = 32; + +function isVisible(feature: PreparedFeature, affine: Affine, view: ViewState): boolean { + const minX = feature.minX * affine.ax + affine.bx; + const maxX = feature.maxX * affine.ax + affine.bx; + const minY = feature.minY * affine.ay + affine.by; + const maxY = feature.maxY * affine.ay + affine.by; + return !( + maxX < -CULL_MARGIN || + minX > view.width + CULL_MARGIN || + maxY < -CULL_MARGIN || + minY > view.height + CULL_MARGIN + ); +} + +/** 레이어 1개를 그린다. context의 lineWidth/strokeStyle은 호출부에서 설정한다. */ +export function drawPreparedLayer( + context: CanvasRenderingContext2D, + layer: PreparedLayer, + view: ViewState, + marker: MarkerKind, +): void { + const affine = affineOf(view); + for (const feature of layer.features) { + if (!isVisible(feature, affine, view)) continue; + if (feature.kind === "point") drawPointParts(context, feature, affine, marker); + else drawLineParts(context, feature, affine); + } +} + +/** 사전 계산된 계곡선 라벨을 그린다. 폰트·정렬은 호출부에서 설정한다. */ +export function drawPreparedLabels( + context: CanvasRenderingContext2D, + layer: PreparedLayer, + view: ViewState, + color: string, +): void { + const affine = affineOf(view); + for (const feature of layer.features) { + if (feature.labelText === null) continue; + const x = feature.labelAnchorX * affine.ax + affine.bx; + const y = feature.labelAnchorY * affine.ay + affine.by; + if (x < -CULL_MARGIN || x > view.width + CULL_MARGIN) continue; + if (y < -CULL_MARGIN || y > view.height + CULL_MARGIN) continue; + context.lineWidth = 3; + context.strokeStyle = "rgba(255, 255, 255, 0.9)"; + context.strokeText(feature.labelText, x, y); + context.fillStyle = color; + context.fillText(feature.labelText, x, y); + } +} diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index a5dac98e..5d48cf02 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -7,6 +7,17 @@ import { type VWorldMeta, } from "./B04_wf1_Surface_Api_Fetch"; import { niceScaleDistance } from "./B04_wf1_Surface_UI_Camera"; +import { + computeMapRect, + createNormalizer, + drawPreparedLabels, + drawPreparedLayer, + prepareLayer, + type GeoJsonCollection, + type MapRect, + type PreparedLayer, + type ViewState, +} from "./B04_wf1_Surface_UI_MapRender"; export interface SurfaceMapViewer { root: HTMLElement; @@ -14,20 +25,6 @@ export interface SurfaceMapViewer { dispose: () => void; } -type GeoJsonGeometry = { - type: string; - coordinates: unknown; -}; - -type GeoJsonFeature = { - geometry?: GeoJsonGeometry | null; - properties?: Record | null; -}; - -type GeoJsonCollection = { - features?: GeoJsonFeature[]; -}; - const BACKGROUND_LAYERS = ["white", "satellite", "hybrid"] as const; const GIS_LAYERS = [ "지적도", @@ -133,7 +130,8 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { let currentProjectId: string | null = null; let referenceBounds: SurfaceBounds | null = null; let meta: VWorldMeta | null = null; - const geoJsonLayers = new Map(); + // 사전 투영된 렌더용 레이어. 원본 GeoJSON은 변형하지 않으며 투영 후에는 참조를 잡아두지 않는다. + const preparedLayers = new Map(); const activeBackgrounds = new Set(BACKGROUND_LAYERS); // gpkg 등고선은 기본 꺼짐(도엽 등고선이 기본 표기), 등고 라벨은 기본 켜짐 (2026-07-26 사용자 지시) const activeGisLayers = new Set(GIS_LAYERS.filter((layer) => layer !== "등고선")); @@ -143,6 +141,12 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { let offsetY = 0; let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null; let loadSequence = 0; + // rAF 스로틀: 팬/줌 이벤트는 상태만 갱신하고 프레임당 1회만 드로잉한다. + let frameHandle = 0; + // 캔버스 버퍼는 크기가 실제로 변할 때만 재할당한다(재할당 시 내용이 지워지므로 매 프레임 금지). + let canvasWidth = 0; + let canvasHeight = 0; + let canvasDpr = 0; function makeLayerButton( label: string, @@ -208,7 +212,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { showContourLabels = !showContourLabels; contourLabelButton.classList.toggle("is-active", showContourLabels); contourLabelButton.setAttribute("aria-pressed", String(showContourLabels)); - drawVectorLayer(); + scheduleDraw(); }); gisButtons.append(contourLabelButton); @@ -223,7 +227,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { image.hidden = !activeBackgrounds.has(layer); }); empty.hidden = activeBackgrounds.size > 0 || activeGisLayers.size > 0; - drawVectorLayer(); + scheduleDraw(); } function fitReferenceBounds(): void { @@ -231,7 +235,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { const rect = viewport.getBoundingClientRect(); const width = Math.max(rect.width, 1); const height = Math.max(rect.height, 1); - const mapRect = getMapRect(width, height); + const mapRect = computeMapRect(meta, width, height); const referenceWidth = Math.max(referenceBounds.x_max - referenceBounds.x_min, 1); const referenceHeight = Math.max(referenceBounds.y_max - referenceBounds.y_min, 1); scale = @@ -250,176 +254,15 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { offsetY = 0; fitReferenceBounds(); updateImageTransform(); - drawVectorLayer(); + scheduleDraw(); } - function getMapRect(width: number, height: number): DOMRect { - if (!meta) return new DOMRect(0, 0, width, height); - const mapRatio = meta.width_meters / Math.max(meta.height_meters, 1); - const viewportRatio = width / Math.max(height, 1); - const mapWidth = mapRatio > viewportRatio ? width : height * mapRatio; - const mapHeight = mapRatio > viewportRatio ? width / mapRatio : height; - return new DOMRect((width - mapWidth) / 2, (height - mapHeight) / 2, mapWidth, mapHeight); - } - - function toCanvasPoint( - lon: number, - lat: number, - width: number, - height: number, - ): [number, number] { - if (!meta) return [0, 0]; - const mapRect = getMapRect(width, height); - const lonRange = meta.lon_max - meta.lon_min || 1; - const latRange = meta.lat_max - meta.lat_min || 1; - const baseX = mapRect.x + ((lon - meta.lon_min) / lonRange) * mapRect.width; - const baseY = mapRect.y + mapRect.height * (1 - (lat - meta.lat_min) / latRange); - const centerX = width / 2; - const centerY = height / 2; - return [ - centerX + (baseX - centerX) * scale + offsetX, - centerY + (baseY - centerY) * scale + offsetY, - ]; - } - - function drawRing( - context: CanvasRenderingContext2D, - ring: unknown, - width: number, - height: number, - closed: boolean, - ): void { - if (!Array.isArray(ring) || ring.length === 0) return; - const points = ring.filter( - (point): point is [number, number] => - Array.isArray(point) && typeof point[0] === "number" && typeof point[1] === "number", - ); - if (points.length === 0) return; - context.beginPath(); - points.forEach(([lon, lat], index) => { - const [x, y] = toCanvasPoint(lon, lat, width, height); - if (index === 0) context.moveTo(x, y); - else context.lineTo(x, y); - }); - if (closed) context.closePath(); - context.stroke(); - } - - function drawPoint( - context: CanvasRenderingContext2D, - coordinates: unknown, - width: number, - height: number, - marker: "dot" | "x" = "dot", - ): void { - if ( - !Array.isArray(coordinates) || - typeof coordinates[0] !== "number" || - typeof coordinates[1] !== "number" - ) { - return; - } - const [x, y] = toCanvasPoint(coordinates[0], coordinates[1], width, height); - if (marker === "x") { - // 표고점: 조금 굵고 큰 X 마커 - const arm = 4; - const prevWidth = context.lineWidth; - context.lineWidth = 2; - context.beginPath(); - context.moveTo(x - arm, y - arm); - context.lineTo(x + arm, y + arm); - context.moveTo(x - arm, y + arm); - context.lineTo(x + arm, y - arm); - context.stroke(); - context.lineWidth = prevWidth; - return; - } - context.beginPath(); - context.arc(x, y, 2, 0, Math.PI * 2); - context.fillStyle = context.strokeStyle; - context.fill(); - } - - function contourLabelAnchor(geometry: GeoJsonGeometry): [number, number] | null { - const coords = geometry.coordinates; - if (!Array.isArray(coords)) return null; - const line = - geometry.type === "LineString" - ? coords - : geometry.type === "MultiLineString" - ? coords[0] - : null; - if (!Array.isArray(line) || line.length === 0) return null; - const mid = line[Math.floor(line.length / 2)]; - if (!Array.isArray(mid) || typeof mid[0] !== "number" || typeof mid[1] !== "number") { - return null; - } - return [mid[0], mid[1]]; - } - - function drawContourLabels( - context: CanvasRenderingContext2D, - width: number, - height: number, - ): void { - context.font = "600 13px sans-serif"; - context.textAlign = "center"; - context.textBaseline = "middle"; - (Object.keys(CONTOUR_LABEL_KEYS) as GisLayer[]).forEach((layer) => { - if (!activeGisLayers.has(layer)) return; - const keys = CONTOUR_LABEL_KEYS[layer] ?? []; - geoJsonLayers.get(layer)?.features?.forEach((feature) => { - if (!feature.geometry) return; - const raw = keys.map((key) => feature.properties?.[key]).find((value) => value != null); - const elevation = typeof raw === "number" ? raw : Number(raw); - // 계곡선(25m 배수)만 라벨 — 전체 표기 시 화면이 숫자로 뒤덮이는 것 방지 - if (!Number.isFinite(elevation) || elevation % 25 !== 0) return; - const anchor = contourLabelAnchor(feature.geometry); - if (!anchor) return; - const [x, y] = toCanvasPoint(anchor[0], anchor[1], width, height); - context.lineWidth = 3; - context.strokeStyle = "rgba(255, 255, 255, 0.9)"; - context.strokeText(String(elevation), x, y); - context.fillStyle = GIS_LAYER_COLORS[layer]; - context.fillText(String(elevation), x, y); - }); - }); - } - - function drawGeometry( - context: CanvasRenderingContext2D, - geometry: GeoJsonGeometry, - width: number, - height: number, - marker: "dot" | "x" = "dot", - ): void { - const coordinates = geometry.coordinates; - if (!Array.isArray(coordinates)) return; - if (geometry.type === "Point") { - drawPoint(context, coordinates, width, height, marker); - } else if (geometry.type === "MultiPoint") { - coordinates.forEach((point) => drawPoint(context, point, width, height, marker)); - } else if (geometry.type === "LineString") { - drawRing(context, coordinates, width, height, false); - } else if (geometry.type === "MultiLineString") { - coordinates.forEach((line) => drawRing(context, line, width, height, false)); - } else if (geometry.type === "Polygon") { - coordinates.forEach((ring) => drawRing(context, ring, width, height, true)); - } else if (geometry.type === "MultiPolygon") { - coordinates.forEach((polygon) => { - if (Array.isArray(polygon)) { - polygon.forEach((ring) => drawRing(context, ring, width, height, true)); - } - }); - } - } - - function drawScaleBar(width: number, height: number): void { - if (!meta || width <= 0) { + function drawScaleBar(mapRect: MapRect): void { + if (!meta || mapRect.width <= 0) { scaleBar.hidden = true; return; } - const metersPerPixel = meta.width_meters / getMapRect(width, height).width / scale; + const metersPerPixel = meta.width_meters / mapRect.width / scale; const meters = niceScaleDistance(100 * metersPerPixel); const pixels = meters / metersPerPixel; scaleBar.hidden = false; @@ -427,34 +270,63 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { scaleText.textContent = meters >= 1000 ? `${meters / 1000} km` : `${meters} m`; } + // 등고선(전국 gpkg·도엽)은 선 수가 많아 가장 아래에 얇게 깔아 다른 레이어 판독을 방해하지 않게 한다. + const isContourLayer = (layer: GisLayer): boolean => + layer === "등고선" || layer === "도엽_등고선"; + const DRAW_ORDER = [...GIS_LAYERS].sort((a, b) => + isContourLayer(a) ? -1 : isContourLayer(b) ? 1 : 0, + ); + function drawVectorLayer(): void { const rect = viewport.getBoundingClientRect(); const width = Math.max(1, Math.floor(rect.width)); const height = Math.max(1, Math.floor(rect.height)); const dpr = window.devicePixelRatio || 1; - canvas.width = Math.floor(width * dpr); - canvas.height = Math.floor(height * dpr); - canvas.style.width = `${width}px`; - canvas.style.height = `${height}px`; + // 버퍼 재할당은 캔버스 내용을 지우므로 크기가 실제로 변할 때만 수행한다. + if (width !== canvasWidth || height !== canvasHeight || dpr !== canvasDpr) { + canvasWidth = width; + canvasHeight = height; + canvasDpr = dpr; + canvas.width = Math.floor(width * dpr); + canvas.height = Math.floor(height * dpr); + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + } const context = canvas.getContext("2d"); if (!context) return; context.setTransform(dpr, 0, 0, dpr, 0, 0); context.clearRect(0, 0, width, height); - // 등고선(전국 gpkg·도엽)은 선 수가 많아 가장 아래에 얇게 깔아 다른 레이어 판독을 방해하지 않게 한다. - const isContour = (layer: GisLayer): boolean => layer === "등고선" || layer === "도엽_등고선"; - const drawOrder = [...GIS_LAYERS].sort((a, b) => (isContour(a) ? -1 : isContour(b) ? 1 : 0)); - drawOrder.forEach((layer) => { + const mapRect = computeMapRect(meta, width, height); + const view: ViewState = { width, height, scale, offsetX, offsetY, mapRect }; + DRAW_ORDER.forEach((layer) => { if (!activeGisLayers.has(layer)) return; - context.lineWidth = isContour(layer) ? 0.7 : 1.5; + const prepared = preparedLayers.get(layer); + if (!prepared) return; + context.lineWidth = isContourLayer(layer) ? 0.7 : 1.5; context.strokeStyle = GIS_LAYER_COLORS[layer]; - const marker = layer === "도엽_표고점" ? "x" : "dot"; - geoJsonLayers.get(layer)?.features?.forEach((feature) => { - if (feature.geometry) drawGeometry(context, feature.geometry, width, height, marker); - }); + drawPreparedLayer(context, prepared, view, layer === "도엽_표고점" ? "x" : "dot"); }); - if (showContourLabels) drawContourLabels(context, width, height); + if (showContourLabels) { + context.font = "600 13px sans-serif"; + context.textAlign = "center"; + context.textBaseline = "middle"; + (Object.keys(CONTOUR_LABEL_KEYS) as GisLayer[]).forEach((layer) => { + if (!activeGisLayers.has(layer)) return; + const prepared = preparedLayers.get(layer); + if (prepared) drawPreparedLabels(context, prepared, view, GIS_LAYER_COLORS[layer]); + }); + } updateImageTransform(); - drawScaleBar(width, height); + drawScaleBar(mapRect); + } + + /** 팬/줌 등 연속 이벤트에서는 프레임당 1회만 실제 드로잉이 일어나게 한다. */ + function scheduleDraw(): void { + if (frameHandle) return; + frameHandle = window.requestAnimationFrame(() => { + frameHandle = 0; + drawVectorLayer(); + }); } async function loadLayers(): Promise { @@ -463,7 +335,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { const sequence = ++loadSequence; backgroundImages.forEach((image) => image.removeAttribute("src")); meta = null; - geoJsonLayers.clear(); + preparedLayers.clear(); resetView(); status.textContent = L("B04_Surface_Map_Loading"); try { @@ -480,16 +352,17 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { ); if (sequence !== loadSequence) return; meta = nextMeta; + // 좌표 변환은 여기서 1회만 수행하고, 이후 프레임은 사전 투영 결과만 사용한다. + const normalizer = createNormalizer(nextMeta); + let featureCount = 0; loadedLayers.forEach(([layer, data]) => { - if (data) geoJsonLayers.set(layer, data); + if (!data) return; + featureCount += data.features?.length ?? 0; + preparedLayers.set(layer, prepareLayer(data, normalizer, CONTOUR_LABEL_KEYS[layer])); }); BACKGROUND_LAYERS.forEach((layer) => { backgroundImages.get(layer)!.src = `${getVWorldMapUrl(projectId, layer)}&_t=${Date.now()}`; }); - const featureCount = [...geoJsonLayers.values()].reduce( - (sum, collection) => sum + (collection.features?.length ?? 0), - 0, - ); status.textContent = L("B04_Surface_Map_Features").replace( "{count}", featureCount.toLocaleString(), @@ -508,7 +381,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { (event) => { event.preventDefault(); scale = Math.min(8, Math.max(0.5, scale * (event.deltaY < 0 ? 1.15 : 0.87))); - drawVectorLayer(); + scheduleDraw(); }, { passive: false }, ); @@ -520,7 +393,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { if (!dragStart) return; offsetX = dragStart.offsetX + event.clientX - dragStart.x; offsetY = dragStart.offsetY + event.clientY - dragStart.y; - drawVectorLayer(); + scheduleDraw(); }); const stopDragging = (): void => { dragStart = null; @@ -528,7 +401,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { viewport.addEventListener("pointerup", stopDragging); viewport.addEventListener("pointercancel", stopDragging); - const resizeObserver = new ResizeObserver(drawVectorLayer); + const resizeObserver = new ResizeObserver(scheduleDraw); resizeObserver.observe(viewport); return { @@ -540,6 +413,10 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { }, dispose() { loadSequence += 1; + if (frameHandle) { + window.cancelAnimationFrame(frameHandle); + frameHandle = 0; + } resizeObserver.disconnect(); }, }; From b55d10ddd6c8254e008fc5dfca27d1065df1bffe Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 28 Jul 2026 18:13:07 +0900 Subject: [PATCH 04/61] auto: 2026-07-28 18:13 (EOMSANGDON-HOME) --- .../B04_wf1_Surface_UI_MapRender.ts | 17 +-- .../B04_wf1_Surface_UI_MapViewer.ts | 101 +++++++++++++++--- 2 files changed, 95 insertions(+), 23 deletions(-) diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts index f54ad0c3..7ca62ffe 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts @@ -61,7 +61,7 @@ export type MapRect = { height: number; }; -/** 프레임 단위 뷰 상태. */ +/** 프레임 단위 뷰 상태. overscan은 뷰포트 밖까지 미리 그려두는 여백(px) — 컬링 범위를 그만큼 넓힌다. */ export type ViewState = { width: number; height: number; @@ -69,6 +69,7 @@ export type ViewState = { offsetX: number; offsetY: number; mapRect: MapRect; + overscan: number; }; export function createNormalizer(meta: VWorldMeta): Normalizer { @@ -309,15 +310,16 @@ function drawPointParts( const CULL_MARGIN = 32; function isVisible(feature: PreparedFeature, affine: Affine, view: ViewState): boolean { + const margin = CULL_MARGIN + view.overscan; const minX = feature.minX * affine.ax + affine.bx; const maxX = feature.maxX * affine.ax + affine.bx; const minY = feature.minY * affine.ay + affine.by; const maxY = feature.maxY * affine.ay + affine.by; return !( - maxX < -CULL_MARGIN || - minX > view.width + CULL_MARGIN || - maxY < -CULL_MARGIN || - minY > view.height + CULL_MARGIN + maxX < -margin || + minX > view.width + margin || + maxY < -margin || + minY > view.height + margin ); } @@ -344,12 +346,13 @@ export function drawPreparedLabels( color: string, ): void { const affine = affineOf(view); + const margin = CULL_MARGIN + view.overscan; for (const feature of layer.features) { if (feature.labelText === null) continue; const x = feature.labelAnchorX * affine.ax + affine.bx; const y = feature.labelAnchorY * affine.ay + affine.by; - if (x < -CULL_MARGIN || x > view.width + CULL_MARGIN) continue; - if (y < -CULL_MARGIN || y > view.height + CULL_MARGIN) continue; + if (x < -margin || x > view.width + margin) continue; + if (y < -margin || y > view.height + margin) continue; context.lineWidth = 3; context.strokeStyle = "rgba(255, 255, 255, 0.9)"; context.strokeText(feature.labelText, x, y); diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index 5d48cf02..8efdf267 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -141,12 +141,23 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { let offsetY = 0; let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null; let loadSequence = 0; - // rAF 스로틀: 팬/줌 이벤트는 상태만 갱신하고 프레임당 1회만 드로잉한다. + // rAF 스로틀: 팬/줌 이벤트는 상태만 갱신하고 프레임당 1회만 처리한다. let frameHandle = 0; + let framePendingFull = false; // 캔버스 버퍼는 크기가 실제로 변할 때만 재할당한다(재할당 시 내용이 지워지므로 매 프레임 금지). let canvasWidth = 0; let canvasHeight = 0; let canvasDpr = 0; + // 비트맵 캐시: 캔버스에 마지막으로 풀 렌더한 시점의 뷰 상태. + // 팬/줌 제스처 중에는 재드로잉 대신 이 상태 대비 CSS transform만 적용하고, + // 제스처가 끝나면(settle) 현재 뷰로 다시 풀 렌더해 선명도를 복원한다. + let cachedScale = 0; + let cachedOffsetX = 0; + let cachedOffsetY = 0; + let hasCachedFrame = false; + let settleTimer = 0; + // 팬 여유분: 뷰포트 밖까지 미리 그려 두는 폭(px). 이 범위 안의 팬은 가장자리 공백 없이 즉시 표시된다. + const OVERSCAN = 256; function makeLayerButton( label: string, @@ -283,21 +294,25 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { const height = Math.max(1, Math.floor(rect.height)); const dpr = window.devicePixelRatio || 1; // 버퍼 재할당은 캔버스 내용을 지우므로 크기가 실제로 변할 때만 수행한다. + // 버퍼는 뷰포트보다 OVERSCAN만큼 사방으로 크게 잡아, 제스처 중 팬 시 가장자리 공백을 막는다. if (width !== canvasWidth || height !== canvasHeight || dpr !== canvasDpr) { canvasWidth = width; canvasHeight = height; canvasDpr = dpr; - canvas.width = Math.floor(width * dpr); - canvas.height = Math.floor(height * dpr); - canvas.style.width = `${width}px`; - canvas.style.height = `${height}px`; + canvas.width = Math.floor((width + OVERSCAN * 2) * dpr); + canvas.height = Math.floor((height + OVERSCAN * 2) * dpr); + canvas.style.width = `${width + OVERSCAN * 2}px`; + canvas.style.height = `${height + OVERSCAN * 2}px`; + canvas.style.left = `${-OVERSCAN}px`; + canvas.style.top = `${-OVERSCAN}px`; } const context = canvas.getContext("2d"); if (!context) return; - context.setTransform(dpr, 0, 0, dpr, 0, 0); - context.clearRect(0, 0, width, height); + // 뷰포트 좌표계로 그리되 버퍼 원점을 OVERSCAN만큼 밀어 여유분까지 채운다. + context.setTransform(dpr, 0, 0, dpr, OVERSCAN * dpr, OVERSCAN * dpr); + context.clearRect(-OVERSCAN, -OVERSCAN, width + OVERSCAN * 2, height + OVERSCAN * 2); const mapRect = computeMapRect(meta, width, height); - const view: ViewState = { width, height, scale, offsetX, offsetY, mapRect }; + const view: ViewState = { width, height, scale, offsetX, offsetY, mapRect, overscan: OVERSCAN }; DRAW_ORDER.forEach((layer) => { if (!activeGisLayers.has(layer)) return; const prepared = preparedLayers.get(layer); @@ -316,17 +331,64 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { if (prepared) drawPreparedLabels(context, prepared, view, GIS_LAYER_COLORS[layer]); }); } + // 풀 렌더 완료 — 이 시점의 뷰 상태를 캐시 기준으로 삼고 제스처용 transform을 초기화한다. + canvas.style.transform = ""; + cachedScale = scale; + cachedOffsetX = offsetX; + cachedOffsetY = offsetY; + hasCachedFrame = true; updateImageTransform(); drawScaleBar(mapRect); } - /** 팬/줌 등 연속 이벤트에서는 프레임당 1회만 실제 드로잉이 일어나게 한다. */ - function scheduleDraw(): void { - if (frameHandle) return; - frameHandle = window.requestAnimationFrame(() => { - frameHandle = 0; + /** + * 제스처 중 프레임: 재드로잉 없이 캐시된 비트맵에 CSS transform만 적용한다. + * 캐시 시점 뷰(k0, o0)와 현재 뷰(k1, o1)의 관계는 뷰포트 중심 기준 + * scale(r = k1/k0) + translate(o1 - o0·r)와 정확히 일치한다. + */ + function applyInteractiveTransform(): void { + if (!hasCachedFrame || !cachedScale) { drawVectorLayer(); - }); + return; + } + const ratio = scale / cachedScale; + const tx = offsetX - cachedOffsetX * ratio; + const ty = offsetY - cachedOffsetY * ratio; + canvas.style.transform = `translate(${tx}px, ${ty}px) scale(${ratio})`; + updateImageTransform(); + const rect = viewport.getBoundingClientRect(); + drawScaleBar(computeMapRect(meta, Math.max(1, rect.width), Math.max(1, rect.height))); + } + + /** 다음 프레임에 풀 렌더 1회 수행 (토글·리셋·로드·제스처 종료 등). */ + function scheduleDraw(): void { + if (settleTimer) { + window.clearTimeout(settleTimer); + settleTimer = 0; + } + framePendingFull = true; + if (frameHandle) return; + frameHandle = window.requestAnimationFrame(runFrame); + } + + /** 제스처 중: 다음 프레임에 transform만 갱신하고, 잠잠해지면 풀 렌더로 선명도를 복원한다. */ + function scheduleInteractive(): void { + if (!frameHandle) frameHandle = window.requestAnimationFrame(runFrame); + if (settleTimer) window.clearTimeout(settleTimer); + settleTimer = window.setTimeout(() => { + settleTimer = 0; + scheduleDraw(); + }, 150); + } + + function runFrame(): void { + frameHandle = 0; + if (framePendingFull) { + framePendingFull = false; + drawVectorLayer(); + } else { + applyInteractiveTransform(); + } } async function loadLayers(): Promise { @@ -381,7 +443,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { (event) => { event.preventDefault(); scale = Math.min(8, Math.max(0.5, scale * (event.deltaY < 0 ? 1.15 : 0.87))); - scheduleDraw(); + scheduleInteractive(); }, { passive: false }, ); @@ -393,10 +455,13 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { if (!dragStart) return; offsetX = dragStart.offsetX + event.clientX - dragStart.x; offsetY = dragStart.offsetY + event.clientY - dragStart.y; - scheduleDraw(); + scheduleInteractive(); }); const stopDragging = (): void => { + if (!dragStart) return; dragStart = null; + // 팬 종료 즉시 현재 뷰로 풀 렌더 — 가장자리 여유분을 채우고 선명도를 복원한다. + scheduleDraw(); }; viewport.addEventListener("pointerup", stopDragging); viewport.addEventListener("pointercancel", stopDragging); @@ -417,6 +482,10 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { window.cancelAnimationFrame(frameHandle); frameHandle = 0; } + if (settleTimer) { + window.clearTimeout(settleTimer); + settleTimer = 0; + } resizeObserver.disconnect(); }, }; From 5e545d2b1474c20f64086b0a3a9200bb8daf47bb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 28 Jul 2026 18:19:57 +0900 Subject: [PATCH 05/61] auto: 2026-07-28 18:19 (EOMSANGDON-HOME) --- .../B04_wf1_Surface_UI_MapRender.ts | 167 ++++++++++++++---- .../B04_wf1_Surface_UI_MapViewer.ts | 21 ++- 2 files changed, 151 insertions(+), 37 deletions(-) diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts index 7ca62ffe..3255d59b 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts @@ -21,10 +21,18 @@ export type GeoJsonCollection = { export type MarkerKind = "dot" | "x"; -/** 사전 투영된 하나의 파트(선/링/점 묶음). 좌표는 정규화 맵 좌표(0~1) x,y 교차 배열. */ +/** + * 사전 투영된 하나의 파트(선/링/점 묶음). 좌표는 정규화 맵 좌표(0~1) x,y 교차 배열. + * weights: Douglas-Peucker 가중치(정점 제거 시 발생하는 최대 오차, 종횡비 보정 좌표계). + * 렌더 시 "화면 오차 < LOD_PX가 되는 정점"만 제외해 어느 줌에서도 시각적 무손실 LOD를 얻는다. + * avgSeg: 평균 정점 간격(종횡비 보정 좌표계) — 줌인 시 곡선 스무딩 발동 판단용. + * 둘 다 line 파트에만 존재하며 원본 GeoJSON은 변형하지 않는다. + */ type PreparedPart = { coords: Float64Array; closed: boolean; + weights: Float64Array | null; + avgSeg: number; }; /** 사전 투영된 피처 1개. bbox는 정규화 좌표 기준이며 컬링에 사용한다. */ @@ -45,12 +53,13 @@ export type PreparedLayer = { features: PreparedFeature[]; }; -/** lon/lat → 정규화 맵 좌표 변환 계수. meta에만 의존한다. */ +/** lon/lat → 정규화 맵 좌표 변환 계수. meta에만 의존한다. aspect는 지도 종횡비(w/h). */ export type Normalizer = { lonMin: number; latMin: number; lonRange: number; latRange: number; + aspect: number; }; /** 뷰포트 안에서 지도 이미지가 차지하는 사각형(기존 getMapRect와 동일 계산). */ @@ -78,6 +87,7 @@ export function createNormalizer(meta: VWorldMeta): Normalizer { latMin: meta.lat_min, lonRange: meta.lon_max - meta.lon_min || 1, latRange: meta.lat_max - meta.lat_min || 1, + aspect: meta.width_meters / Math.max(meta.height_meters, 1), }; } @@ -121,51 +131,95 @@ function collectParts( ): "line" | "point" { const coordinates = geometry.coordinates; if (!Array.isArray(coordinates)) return "line"; + const push = (ring: unknown, closed: boolean): void => { + const projected = projectRing(ring, normalizer); + if (projected) parts.push({ coords: projected, closed, weights: null, avgSeg: 0 }); + }; switch (geometry.type) { - case "Point": { - const projected = projectRing([coordinates], normalizer); - if (projected) parts.push({ coords: projected, closed: false }); + case "Point": + push([coordinates], false); return "point"; - } - case "MultiPoint": { - const projected = projectRing(coordinates, normalizer); - if (projected) parts.push({ coords: projected, closed: false }); + case "MultiPoint": + push(coordinates, false); return "point"; - } - case "LineString": { - const projected = projectRing(coordinates, normalizer); - if (projected) parts.push({ coords: projected, closed: false }); + case "LineString": + push(coordinates, false); return "line"; - } - case "MultiLineString": { - for (const line of coordinates) { - const projected = projectRing(line, normalizer); - if (projected) parts.push({ coords: projected, closed: false }); - } + case "MultiLineString": + for (const line of coordinates) push(line, false); return "line"; - } - case "Polygon": { - for (const ring of coordinates) { - const projected = projectRing(ring, normalizer); - if (projected) parts.push({ coords: projected, closed: true }); - } + case "Polygon": + for (const ring of coordinates) push(ring, true); return "line"; - } - case "MultiPolygon": { + case "MultiPolygon": for (const polygon of coordinates) { if (!Array.isArray(polygon)) continue; - for (const ring of polygon) { - const projected = projectRing(ring, normalizer); - if (projected) parts.push({ coords: projected, closed: true }); - } + for (const ring of polygon) push(ring, true); } return "line"; - } default: return "line"; } } +/** + * Douglas-Peucker 가중치 계산 (반복형, 스택 오버플로 방지). + * weights[i] = "허용 오차가 이 값보다 크면 정점 i를 버려도 되는" 임계값. + * 부모 구간의 오차로 상한을 걸어(cap) 어떤 허용 오차에서도 일관된 부분집합이 나오게 한다. + * y축은 1/aspect로 보정해 화면 픽셀 거리와 비례하는 좌표계에서 계산한다. + */ +function computeDpWeights(coords: Float64Array, aspect: number): Float64Array { + const n = coords.length / 2; + const weights = new Float64Array(n); + weights[0] = Infinity; + weights[n - 1] = Infinity; + if (n <= 2) return weights; + const stack: number[] = [0, n - 1]; + const caps: number[] = [Infinity]; + while (stack.length) { + const last = stack.pop()!; + const first = stack.pop()!; + const cap = caps.pop()!; + if (last - first < 2) continue; + const ax = coords[first * 2]; + const ay = coords[first * 2 + 1] / aspect; + const bx = coords[last * 2]; + const by = coords[last * 2 + 1] / aspect; + const dx = bx - ax; + const dy = by - ay; + const len = Math.sqrt(dx * dx + dy * dy); + let maxDist = -1; + let maxIndex = -1; + for (let i = first + 1; i < last; i += 1) { + const px = coords[i * 2] - ax; + const py = coords[i * 2 + 1] / aspect - ay; + const dist = len === 0 ? Math.sqrt(px * px + py * py) : Math.abs(px * dy - py * dx) / len; + if (dist > maxDist) { + maxDist = dist; + maxIndex = i; + } + } + const weight = Math.min(maxDist, cap); + weights[maxIndex] = weight; + stack.push(first, maxIndex, maxIndex, last); + caps.push(weight, weight); + } + return weights; +} + +/** 평균 정점 간격(종횡비 보정 좌표계). 스무딩 발동 판단용. */ +function averageSegment(coords: Float64Array, aspect: number): number { + const n = coords.length / 2; + if (n < 2) return 0; + let total = 0; + for (let i = 1; i < n; i += 1) { + const dx = coords[i * 2] - coords[(i - 1) * 2]; + const dy = (coords[i * 2 + 1] - coords[(i - 1) * 2 + 1]) / aspect; + total += Math.sqrt(dx * dx + dy * dy); + } + return total / (n - 1); +} + /** 등고 라벨 앵커: LineString/MultiLineString 첫 파트의 중앙 정점 (기존 동작 유지). */ function labelAnchorOf(geometry: GeoJsonGeometry, normalizer: Normalizer): [number, number] | null { const coords = geometry.coordinates; @@ -200,6 +254,13 @@ export function prepareLayer( const parts: PreparedPart[] = []; const kind = collectParts(feature.geometry, normalizer, parts); if (parts.length === 0) continue; + if (kind === "line") { + for (const part of parts) { + if (part.coords.length < 6) continue; + part.weights = computeDpWeights(part.coords, normalizer.aspect); + part.avgSeg = averageSegment(part.coords, normalizer.aspect); + } + } let minX = Infinity; let minY = Infinity; let maxX = -Infinity; @@ -255,20 +316,54 @@ function affineOf(view: ViewState): Affine { }; } +/** 시각적 무손실 LOD 허용 오차(화면 px). 이보다 작은 오차의 정점만 생략된다. */ +const LOD_PX = 0.75; +/** 평균 정점 간격이 이 화면 px를 넘으면(고배율 줌인) 곡선 스무딩을 켠다. */ +const SMOOTH_SEG_PX = 10; + +/** LOD 필터를 통과한 화면 좌표를 담는 재사용 버퍼 (settle 렌더에서만 쓰여 할당 부담 없음). */ +let filtered = new Float64Array(4096); + function drawLineParts( context: CanvasRenderingContext2D, feature: PreparedFeature, affine: Affine, ): void { + // affine.ax = 정규화 1.0당 화면 px — 종횡비 보정 좌표계의 거리를 px로 바꾸는 계수. + const tolerance = LOD_PX / affine.ax; for (const part of feature.parts) { const coords = part.coords; if (coords.length < 2) continue; + const weights = part.weights; + if (filtered.length < coords.length) filtered = new Float64Array(coords.length); + // 1) 현재 줌에서 화면 오차 LOD_PX 미만인 정점 생략 (끝점은 weight=∞라 항상 유지) + let count = 0; + for (let i = 0; i < coords.length; i += 2) { + if (weights && weights[i / 2] < tolerance) continue; + filtered[count] = coords[i] * affine.ax + affine.bx; + filtered[count + 1] = coords[i + 1] * affine.ay + affine.by; + count += 2; + } + if (count < 4) continue; context.beginPath(); - context.moveTo(coords[0] * affine.ax + affine.bx, coords[1] * affine.ay + affine.by); - for (let i = 2; i < coords.length; i += 2) { - context.lineTo(coords[i] * affine.ax + affine.bx, coords[i + 1] * affine.ay + affine.by); + // 2) 줌인으로 정점 간격이 벌어져 각이 보이는 상태면 중점 quadratic 스플라인으로 곡선 표현 + const smooth = + !part.closed && count >= 6 && part.avgSeg > 0 && part.avgSeg * affine.ax > SMOOTH_SEG_PX; + if (smooth) { + context.moveTo(filtered[0], filtered[1]); + for (let i = 2; i < count - 2; i += 2) { + const midX = (filtered[i] + filtered[i + 2]) / 2; + const midY = (filtered[i + 1] + filtered[i + 3]) / 2; + context.quadraticCurveTo(filtered[i], filtered[i + 1], midX, midY); + } + context.lineTo(filtered[count - 2], filtered[count - 1]); + } else { + context.moveTo(filtered[0], filtered[1]); + for (let i = 2; i < count; i += 2) { + context.lineTo(filtered[i], filtered[i + 1]); + } + if (part.closed) context.closePath(); } - if (part.closed) context.closePath(); context.stroke(); } } diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index 8efdf267..624daaee 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -460,7 +460,26 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { const stopDragging = (): void => { if (!dragStart) return; dragStart = null; - // 팬 종료 즉시 현재 뷰로 풀 렌더 — 가장자리 여유분을 채우고 선명도를 복원한다. + // 순수 팬(줌 비율 1)이고 이동량이 여유분 안이며 정수 디바이스 픽셀 이동이면 + // 캐시 transform 유지가 풀 렌더와 픽셀 단위 동일하므로 재렌더를 생략한다. + const dpr = window.devicePixelRatio || 1; + const tx = offsetX - cachedOffsetX; + const ty = offsetY - cachedOffsetY; + if ( + hasCachedFrame && + scale === cachedScale && + Number.isInteger(tx * dpr) && + Number.isInteger(ty * dpr) && + Math.abs(tx) < OVERSCAN / 2 && + Math.abs(ty) < OVERSCAN / 2 + ) { + if (settleTimer) { + window.clearTimeout(settleTimer); + settleTimer = 0; + } + return; + } + // 그 외에는 풀 렌더로 여유분을 다시 채우고 선명도를 복원한다. scheduleDraw(); }; viewport.addEventListener("pointerup", stopDragging); From 5ee3b7e2cb798fa4ea1ca71d88b7c39e739f4ab0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 28 Jul 2026 18:25:58 +0900 Subject: [PATCH 06/61] auto: 2026-07-28 18:25 (EOMSANGDON-HOME) --- .../B04_wf1_Surface_UI_MapRender.ts | 71 +++-------- .../B04_wf1_Surface_UI_MapViewer.ts | 117 +++--------------- 2 files changed, 33 insertions(+), 155 deletions(-) diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts index 3255d59b..4fbdf35e 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts @@ -25,14 +25,12 @@ export type MarkerKind = "dot" | "x"; * 사전 투영된 하나의 파트(선/링/점 묶음). 좌표는 정규화 맵 좌표(0~1) x,y 교차 배열. * weights: Douglas-Peucker 가중치(정점 제거 시 발생하는 최대 오차, 종횡비 보정 좌표계). * 렌더 시 "화면 오차 < LOD_PX가 되는 정점"만 제외해 어느 줌에서도 시각적 무손실 LOD를 얻는다. - * avgSeg: 평균 정점 간격(종횡비 보정 좌표계) — 줌인 시 곡선 스무딩 발동 판단용. - * 둘 다 line 파트에만 존재하며 원본 GeoJSON은 변형하지 않는다. + * line 파트에만 존재하며 원본 GeoJSON은 변형하지 않는다. */ type PreparedPart = { coords: Float64Array; closed: boolean; weights: Float64Array | null; - avgSeg: number; }; /** 사전 투영된 피처 1개. bbox는 정규화 좌표 기준이며 컬링에 사용한다. */ @@ -70,7 +68,7 @@ export type MapRect = { height: number; }; -/** 프레임 단위 뷰 상태. overscan은 뷰포트 밖까지 미리 그려두는 여백(px) — 컬링 범위를 그만큼 넓힌다. */ +/** 프레임 단위 뷰 상태. */ export type ViewState = { width: number; height: number; @@ -78,7 +76,6 @@ export type ViewState = { offsetX: number; offsetY: number; mapRect: MapRect; - overscan: number; }; export function createNormalizer(meta: VWorldMeta): Normalizer { @@ -133,7 +130,7 @@ function collectParts( if (!Array.isArray(coordinates)) return "line"; const push = (ring: unknown, closed: boolean): void => { const projected = projectRing(ring, normalizer); - if (projected) parts.push({ coords: projected, closed, weights: null, avgSeg: 0 }); + if (projected) parts.push({ coords: projected, closed, weights: null }); }; switch (geometry.type) { case "Point": @@ -207,19 +204,6 @@ function computeDpWeights(coords: Float64Array, aspect: number): Float64Array { return weights; } -/** 평균 정점 간격(종횡비 보정 좌표계). 스무딩 발동 판단용. */ -function averageSegment(coords: Float64Array, aspect: number): number { - const n = coords.length / 2; - if (n < 2) return 0; - let total = 0; - for (let i = 1; i < n; i += 1) { - const dx = coords[i * 2] - coords[(i - 1) * 2]; - const dy = (coords[i * 2 + 1] - coords[(i - 1) * 2 + 1]) / aspect; - total += Math.sqrt(dx * dx + dy * dy); - } - return total / (n - 1); -} - /** 등고 라벨 앵커: LineString/MultiLineString 첫 파트의 중앙 정점 (기존 동작 유지). */ function labelAnchorOf(geometry: GeoJsonGeometry, normalizer: Normalizer): [number, number] | null { const coords = geometry.coordinates; @@ -258,7 +242,6 @@ export function prepareLayer( for (const part of parts) { if (part.coords.length < 6) continue; part.weights = computeDpWeights(part.coords, normalizer.aspect); - part.avgSeg = averageSegment(part.coords, normalizer.aspect); } } let minX = Infinity; @@ -318,11 +301,6 @@ function affineOf(view: ViewState): Affine { /** 시각적 무손실 LOD 허용 오차(화면 px). 이보다 작은 오차의 정점만 생략된다. */ const LOD_PX = 0.75; -/** 평균 정점 간격이 이 화면 px를 넘으면(고배율 줌인) 곡선 스무딩을 켠다. */ -const SMOOTH_SEG_PX = 10; - -/** LOD 필터를 통과한 화면 좌표를 담는 재사용 버퍼 (settle 렌더에서만 쓰여 할당 부담 없음). */ -let filtered = new Float64Array(4096); function drawLineParts( context: CanvasRenderingContext2D, @@ -333,37 +311,24 @@ function drawLineParts( const tolerance = LOD_PX / affine.ax; for (const part of feature.parts) { const coords = part.coords; - if (coords.length < 2) continue; + if (coords.length < 4) continue; const weights = part.weights; - if (filtered.length < coords.length) filtered = new Float64Array(coords.length); - // 1) 현재 줌에서 화면 오차 LOD_PX 미만인 정점 생략 (끝점은 weight=∞라 항상 유지) - let count = 0; + // 현재 줌에서 화면 오차 LOD_PX 미만인 정점만 생략 (끝점은 weight=∞라 항상 유지). + // 정점 사이 보간은 하지 않는다 — 원본 데이터의 형상 그대로 표시 (2026-07-28 사용자 지시). + context.beginPath(); + let started = false; for (let i = 0; i < coords.length; i += 2) { if (weights && weights[i / 2] < tolerance) continue; - filtered[count] = coords[i] * affine.ax + affine.bx; - filtered[count + 1] = coords[i + 1] * affine.ay + affine.by; - count += 2; - } - if (count < 4) continue; - context.beginPath(); - // 2) 줌인으로 정점 간격이 벌어져 각이 보이는 상태면 중점 quadratic 스플라인으로 곡선 표현 - const smooth = - !part.closed && count >= 6 && part.avgSeg > 0 && part.avgSeg * affine.ax > SMOOTH_SEG_PX; - if (smooth) { - context.moveTo(filtered[0], filtered[1]); - for (let i = 2; i < count - 2; i += 2) { - const midX = (filtered[i] + filtered[i + 2]) / 2; - const midY = (filtered[i + 1] + filtered[i + 3]) / 2; - context.quadraticCurveTo(filtered[i], filtered[i + 1], midX, midY); + const x = coords[i] * affine.ax + affine.bx; + const y = coords[i + 1] * affine.ay + affine.by; + if (started) context.lineTo(x, y); + else { + context.moveTo(x, y); + started = true; } - context.lineTo(filtered[count - 2], filtered[count - 1]); - } else { - context.moveTo(filtered[0], filtered[1]); - for (let i = 2; i < count; i += 2) { - context.lineTo(filtered[i], filtered[i + 1]); - } - if (part.closed) context.closePath(); } + if (!started) continue; + if (part.closed) context.closePath(); context.stroke(); } } @@ -405,7 +370,7 @@ function drawPointParts( const CULL_MARGIN = 32; function isVisible(feature: PreparedFeature, affine: Affine, view: ViewState): boolean { - const margin = CULL_MARGIN + view.overscan; + const margin = CULL_MARGIN; const minX = feature.minX * affine.ax + affine.bx; const maxX = feature.maxX * affine.ax + affine.bx; const minY = feature.minY * affine.ay + affine.by; @@ -441,7 +406,7 @@ export function drawPreparedLabels( color: string, ): void { const affine = affineOf(view); - const margin = CULL_MARGIN + view.overscan; + const margin = CULL_MARGIN; for (const feature of layer.features) { if (feature.labelText === null) continue; const x = feature.labelAnchorX * affine.ax + affine.bx; diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index 624daaee..89843297 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -141,23 +141,13 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { let offsetY = 0; let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null; let loadSequence = 0; - // rAF 스로틀: 팬/줌 이벤트는 상태만 갱신하고 프레임당 1회만 처리한다. + // rAF 스로틀: 팬/줌 이벤트는 상태만 갱신하고 프레임당 1회만 드로잉한다. + // LOD(MapRender) 덕에 프레임 렌더 비용이 낮아 매 프레임 직접 렌더가 항상 완전한 화면을 보장한다. let frameHandle = 0; - let framePendingFull = false; // 캔버스 버퍼는 크기가 실제로 변할 때만 재할당한다(재할당 시 내용이 지워지므로 매 프레임 금지). let canvasWidth = 0; let canvasHeight = 0; let canvasDpr = 0; - // 비트맵 캐시: 캔버스에 마지막으로 풀 렌더한 시점의 뷰 상태. - // 팬/줌 제스처 중에는 재드로잉 대신 이 상태 대비 CSS transform만 적용하고, - // 제스처가 끝나면(settle) 현재 뷰로 다시 풀 렌더해 선명도를 복원한다. - let cachedScale = 0; - let cachedOffsetX = 0; - let cachedOffsetY = 0; - let hasCachedFrame = false; - let settleTimer = 0; - // 팬 여유분: 뷰포트 밖까지 미리 그려 두는 폭(px). 이 범위 안의 팬은 가장자리 공백 없이 즉시 표시된다. - const OVERSCAN = 256; function makeLayerButton( label: string, @@ -294,25 +284,21 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { const height = Math.max(1, Math.floor(rect.height)); const dpr = window.devicePixelRatio || 1; // 버퍼 재할당은 캔버스 내용을 지우므로 크기가 실제로 변할 때만 수행한다. - // 버퍼는 뷰포트보다 OVERSCAN만큼 사방으로 크게 잡아, 제스처 중 팬 시 가장자리 공백을 막는다. if (width !== canvasWidth || height !== canvasHeight || dpr !== canvasDpr) { canvasWidth = width; canvasHeight = height; canvasDpr = dpr; - canvas.width = Math.floor((width + OVERSCAN * 2) * dpr); - canvas.height = Math.floor((height + OVERSCAN * 2) * dpr); - canvas.style.width = `${width + OVERSCAN * 2}px`; - canvas.style.height = `${height + OVERSCAN * 2}px`; - canvas.style.left = `${-OVERSCAN}px`; - canvas.style.top = `${-OVERSCAN}px`; + canvas.width = Math.floor(width * dpr); + canvas.height = Math.floor(height * dpr); + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; } const context = canvas.getContext("2d"); if (!context) return; - // 뷰포트 좌표계로 그리되 버퍼 원점을 OVERSCAN만큼 밀어 여유분까지 채운다. - context.setTransform(dpr, 0, 0, dpr, OVERSCAN * dpr, OVERSCAN * dpr); - context.clearRect(-OVERSCAN, -OVERSCAN, width + OVERSCAN * 2, height + OVERSCAN * 2); + context.setTransform(dpr, 0, 0, dpr, 0, 0); + context.clearRect(0, 0, width, height); const mapRect = computeMapRect(meta, width, height); - const view: ViewState = { width, height, scale, offsetX, offsetY, mapRect, overscan: OVERSCAN }; + const view: ViewState = { width, height, scale, offsetX, offsetY, mapRect }; DRAW_ORDER.forEach((layer) => { if (!activeGisLayers.has(layer)) return; const prepared = preparedLayers.get(layer); @@ -331,64 +317,17 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { if (prepared) drawPreparedLabels(context, prepared, view, GIS_LAYER_COLORS[layer]); }); } - // 풀 렌더 완료 — 이 시점의 뷰 상태를 캐시 기준으로 삼고 제스처용 transform을 초기화한다. - canvas.style.transform = ""; - cachedScale = scale; - cachedOffsetX = offsetX; - cachedOffsetY = offsetY; - hasCachedFrame = true; updateImageTransform(); drawScaleBar(mapRect); } - /** - * 제스처 중 프레임: 재드로잉 없이 캐시된 비트맵에 CSS transform만 적용한다. - * 캐시 시점 뷰(k0, o0)와 현재 뷰(k1, o1)의 관계는 뷰포트 중심 기준 - * scale(r = k1/k0) + translate(o1 - o0·r)와 정확히 일치한다. - */ - function applyInteractiveTransform(): void { - if (!hasCachedFrame || !cachedScale) { - drawVectorLayer(); - return; - } - const ratio = scale / cachedScale; - const tx = offsetX - cachedOffsetX * ratio; - const ty = offsetY - cachedOffsetY * ratio; - canvas.style.transform = `translate(${tx}px, ${ty}px) scale(${ratio})`; - updateImageTransform(); - const rect = viewport.getBoundingClientRect(); - drawScaleBar(computeMapRect(meta, Math.max(1, rect.width), Math.max(1, rect.height))); - } - - /** 다음 프레임에 풀 렌더 1회 수행 (토글·리셋·로드·제스처 종료 등). */ + /** 팬/줌 등 연속 이벤트에서는 프레임당 1회만 실제 드로잉이 일어나게 한다. */ function scheduleDraw(): void { - if (settleTimer) { - window.clearTimeout(settleTimer); - settleTimer = 0; - } - framePendingFull = true; if (frameHandle) return; - frameHandle = window.requestAnimationFrame(runFrame); - } - - /** 제스처 중: 다음 프레임에 transform만 갱신하고, 잠잠해지면 풀 렌더로 선명도를 복원한다. */ - function scheduleInteractive(): void { - if (!frameHandle) frameHandle = window.requestAnimationFrame(runFrame); - if (settleTimer) window.clearTimeout(settleTimer); - settleTimer = window.setTimeout(() => { - settleTimer = 0; - scheduleDraw(); - }, 150); - } - - function runFrame(): void { - frameHandle = 0; - if (framePendingFull) { - framePendingFull = false; + frameHandle = window.requestAnimationFrame(() => { + frameHandle = 0; drawVectorLayer(); - } else { - applyInteractiveTransform(); - } + }); } async function loadLayers(): Promise { @@ -443,7 +382,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { (event) => { event.preventDefault(); scale = Math.min(8, Math.max(0.5, scale * (event.deltaY < 0 ? 1.15 : 0.87))); - scheduleInteractive(); + scheduleDraw(); }, { passive: false }, ); @@ -455,32 +394,10 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { if (!dragStart) return; offsetX = dragStart.offsetX + event.clientX - dragStart.x; offsetY = dragStart.offsetY + event.clientY - dragStart.y; - scheduleInteractive(); + scheduleDraw(); }); const stopDragging = (): void => { - if (!dragStart) return; dragStart = null; - // 순수 팬(줌 비율 1)이고 이동량이 여유분 안이며 정수 디바이스 픽셀 이동이면 - // 캐시 transform 유지가 풀 렌더와 픽셀 단위 동일하므로 재렌더를 생략한다. - const dpr = window.devicePixelRatio || 1; - const tx = offsetX - cachedOffsetX; - const ty = offsetY - cachedOffsetY; - if ( - hasCachedFrame && - scale === cachedScale && - Number.isInteger(tx * dpr) && - Number.isInteger(ty * dpr) && - Math.abs(tx) < OVERSCAN / 2 && - Math.abs(ty) < OVERSCAN / 2 - ) { - if (settleTimer) { - window.clearTimeout(settleTimer); - settleTimer = 0; - } - return; - } - // 그 외에는 풀 렌더로 여유분을 다시 채우고 선명도를 복원한다. - scheduleDraw(); }; viewport.addEventListener("pointerup", stopDragging); viewport.addEventListener("pointercancel", stopDragging); @@ -501,10 +418,6 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { window.cancelAnimationFrame(frameHandle); frameHandle = 0; } - if (settleTimer) { - window.clearTimeout(settleTimer); - settleTimer = 0; - } resizeObserver.disconnect(); }, }; From 0c51aef49e689641342ed77b29f443acf096390e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 28 Jul 2026 18:28:02 +0900 Subject: [PATCH 07/61] auto: 2026-07-28 18:28 (EOMSANGDON-HOME) --- B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index 89843297..7635d957 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -381,7 +381,17 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { "wheel", (event) => { event.preventDefault(); + const prevScale = scale; scale = Math.min(8, Math.max(0.5, scale * (event.deltaY < 0 ? 1.15 : 0.87))); + // 마우스 커서 아래 지점이 줌 전후로 같은 화면 위치에 머물도록 offset 보정. + // screen = center + (base - center)·scale + offset 이므로, + // 커서 고정 조건을 풀면 offset' = (cursor - center)·(1 - r) + offset·r (r = scale'/scale). + const ratio = scale / prevScale; + const rect = viewport.getBoundingClientRect(); + const cursorX = event.clientX - rect.left - rect.width / 2; + const cursorY = event.clientY - rect.top - rect.height / 2; + offsetX = cursorX * (1 - ratio) + offsetX * ratio; + offsetY = cursorY * (1 - ratio) + offsetY * ratio; scheduleDraw(); }, { passive: false }, From 86d27add437c46d50d557641d7dc2175895f17c0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 28 Jul 2026 18:33:47 +0900 Subject: [PATCH 08/61] auto: 2026-07-28 18:33 (EOMSANGDON-HOME) --- B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index 7635d957..42f3a300 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -397,6 +397,9 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { { passive: false }, ); viewport.addEventListener("pointerdown", (event) => { + // 중간 버튼은 브라우저 기본 동작(페이지 자동 스크롤)이 지도 팬과 겹쳐 페이지 전체를 + // 흔들므로 기본 동작을 차단하고 지도 팬으로만 사용한다. + if (event.button === 1) event.preventDefault(); dragStart = { x: event.clientX, y: event.clientY, offsetX, offsetY }; viewport.setPointerCapture(event.pointerId); }); From 294151f8628e392383b1f8e709adb2f9e56a3030 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 28 Jul 2026 19:19:31 +0900 Subject: [PATCH 09/61] auto: 2026-07-28 19:19 (EOMSANGDON-HOME) --- .../B05_wf2_Route_UI_Profile_Panel.ts | 3 ++ .../B05_wf2_Route_UI_Profile_Table.ts | 38 ++++++++++++++++++- B05_wf2_Route/B05_wf2_Route_UI_Style.css | 33 +++++++++++++--- 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts index 2202d33d..d4fd64fb 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -50,6 +50,9 @@ import { } from "./B05_wf2_Route_UI_IrregularStations"; import type { SectionStation } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch"; import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css"; +// SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때 +// 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인. +import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css"; const COLLAPSED_KEY = "b05-route-profile-collapsed"; /** 정보 라인을 뺀 본문 세로를 그래프 40% : 테이블 60%로 나눈다(4:6, 6이 테이블). */ diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts index 8da2bf6d..adbdfad8 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts @@ -374,6 +374,7 @@ function buildSelectedColumn( interval: number, display: { station: number; cumulative: number }, onAdjustStation?: (chainageM: number, deltaM: number) => void, + isIrregular = false, ): HTMLElement { const plan = interpolateSample(alignment.samples, chainage, "elevation_m"); const ground = interpolateSample(alignment.samples, chainage, "ground_elevation_m"); @@ -419,7 +420,10 @@ function buildSelectedColumn( { value: { text: curve ? curve.l_m.toFixed(2) : "" }, modifier: "" }, // 곡선 L { value: { text: curve ? curve.r_m.toFixed(1) : "" }, modifier: "" }, // 곡선 R ]; - const column = element("div", "b05-profile-table__irregular-col"); + const column = element( + "div", + `b05-profile-table__irregular-col${isIrregular ? " is-floating" : ""}`, + ); column.style.left = `${centerX}px`; column.style.width = `${cellWidth}px`; rows.forEach((row) => { @@ -501,15 +505,20 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement { ? alignment.stations.findIndex((row) => row.station_id === options.selectedStationId) : -1; if (selectedIrregular) { + const centerX = x(selectedIrregular.chainage_m); + // 비정규 측점은 측점 격자와 무관한 위치라 오버레이가 좌우 이웃 셀을 반씩 덮어 값이 잘려 + // 보인다. 겹치는 규칙 측점 셀을 숨겨 하이라이트 창이 깨끗한 자리에 뜨게 한다. + hideCoveredCells(table, centers, centerX, cellWidth); table.append( buildSelectedColumn( selectedIrregular.chainage_m, alignment, - x(selectedIrregular.chainage_m), + centerX, cellWidth, stationInterval, display, options.onAdjustStation, + true, ), ); } else if (selectedRegularIndex >= 0) { @@ -522,8 +531,33 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement { stationInterval, display, options.onAdjustStation, + false, ), ); } return table; } + +/** + * 하이라이트 열이 덮는 자리의 규칙 측점 값 셀을 숨긴다. + * 셀과 하이라이트 열 모두 폭 `cellWidth`를 중심 정렬로 쓰므로, 중심 간 거리가 `cellWidth` + * 미만이면 두 사각형이 겹친다. 겹치는 측점은 어차피 값이 잘려 읽을 수 없으므로 통째로 숨긴다. + */ +function hideCoveredCells( + table: HTMLElement, + centers: readonly number[], + columnCenter: number, + cellWidth: number, +): void { + const covered = new Set(); + centers.forEach((center, index) => { + if (Math.abs(center - columnCenter) < cellWidth) covered.add(index); + }); + if (covered.size === 0) return; + table.querySelectorAll(".b05-profile-table__row").forEach((row) => { + const cells = row.querySelectorAll( + ".b05-profile-table__cell, .b05-profile-table__curve", + ); + covered.forEach((index) => cells[index]?.classList.add("is-covered")); + }); +} diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index b9cd8f16..f475fa62 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -16,6 +16,7 @@ } .b05-route__main { + position: relative; display: flex; flex-direction: column; width: 100%; @@ -35,22 +36,28 @@ } /* 하단 종단 패널: 그래프 + 12행 도면 테이블을 담기 위해 화면 높이의 60%를 차지한다. + 3D 뷰포트를 밀어내지 않고 그 위를 덮는 오버레이로 띄운다 — 패널을 여닫아도 3D 뷰 + 크기가 변하지 않아 카메라 시점과 렌더 비용이 그대로 유지된다. 접기 핸들로 언제든 내릴 수 있어 3D 뷰를 전체 영역으로 볼 수 있다. */ .b05-route-profile { - position: relative; + position: absolute; + z-index: 3; + right: 0; + bottom: 0; + left: 0; display: flex; - flex: 0 0 60vh; - flex: 0 0 60dvh; + height: 60vh; + height: 60dvh; flex-direction: column; min-height: 0; overflow: visible; border-top: 1px solid var(--color-border); background: var(--color-surface-raised); - transition: flex-basis var(--transition-fast); + transition: height var(--transition-fast); } .b05-route-profile.is-collapsed { - flex-basis: 0; + height: 0; min-height: 0; } @@ -663,6 +670,22 @@ pointer-events: none; } +/* 비정규(구조물) 측점은 측점 격자와 무관한 위치에 떠서, 이웃 셀을 가린 자리에 뜬다. + 떠 있는 창임이 드러나게 그림자·굵은 테두리로 구분한다(규칙 측점은 격자와 일치해 불필요). */ +.b05-profile-table__irregular-col.is-floating { + border-inline-width: 2px; + box-shadow: + 0 0 0 1px var(--color-surface-raised), + 0 4px 12px rgb(0 0 0 / 28%); +} + +/* 하이라이트 창이 덮은 자리의 규칙 측점 값 셀 — 값이 잘려 읽을 수 없으므로 숨긴다. + 자리(레이아웃)는 유지해야 세로 구분선 격자가 끊기지 않으므로 visibility로 감춘다. */ +.b05-profile-table__cell.is-covered, +.b05-profile-table__curve.is-covered { + visibility: hidden; +} + .b05-profile-table__irregular-col-cell { display: flex; flex: 1 1 0; From 7a7cd217b6214034103d6316f11074f2a7903a06 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 28 Jul 2026 19:23:57 +0900 Subject: [PATCH 10/61] auto: 2026-07-28 19:23 (EOMSANGDON-HOME) --- .../B04_wf1_Surface_UI_MapRender.ts | 44 +++ .../B05_wf2_Route_UI_Drainage_Panel.ts | 310 ++++++++++++++++++ B05_wf2_Route/B05_wf2_Route_UI_Page.ts | 1 + .../B05_wf2_Route_UI_Profile_Panel.ts | 14 +- B05_wf2_Route/B05_wf2_Route_UI_Style.css | 123 +++++++ 5 files changed, 491 insertions(+), 1 deletion(-) create mode 100644 B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts index 4fbdf35e..061e3f85 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts @@ -280,6 +280,50 @@ export function prepareLayer( return { features }; } +/** + * 사업지 좌표계(m) 폴리라인을 한 개 피처짜리 레이어로 사전 투영한다. + * meta의 x/y 범위와 lon/lat 범위는 같은 사각형을 가리키므로, 미터 좌표도 GeoJSON과 동일한 + * 정규화 공간으로 들어간다 — 노선 선형을 도엽 레이어 위에 그대로 겹칠 수 있다. + */ +export function prepareMetricPolyline( + points: ReadonlyArray<{ x: number; y: number }>, + meta: VWorldMeta, +): PreparedLayer { + if (points.length < 2) return { features: [] }; + const widthMeters = meta.width_meters || 1; + const heightMeters = meta.height_meters || 1; + const coords = new Float64Array(points.length * 2); + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + points.forEach((point, index) => { + const nx = (point.x - meta.x_min) / widthMeters; + const ny = 1 - (point.y - meta.y_min) / heightMeters; + coords[index * 2] = nx; + coords[index * 2 + 1] = ny; + if (nx < minX) minX = nx; + if (nx > maxX) maxX = nx; + if (ny < minY) minY = ny; + if (ny > maxY) maxY = ny; + }); + return { + features: [ + { + kind: "line", + parts: [{ coords, closed: false, weights: null }], + minX, + minY, + maxX, + maxY, + labelAnchorX: 0, + labelAnchorY: 0, + labelText: null, + }, + ], + }; +} + /** * 프레임당 1회 계산하는 어파인 계수. * base = mapRect.x + norm * mapRect.width diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts new file mode 100644 index 00000000..5f3ad08d --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -0,0 +1,310 @@ +import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; +import { + fetchGisGeoJson, + fetchVWorldMeta, + getVWorldMapUrl, + type VWorldMeta, +} from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch"; +import { + computeMapRect, + createNormalizer, + drawPreparedLayer, + prepareLayer, + prepareMetricPolyline, + type GeoJsonCollection, + type MapRect, + type PreparedLayer, + type ViewState, +} from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender"; +import type { RoutePoint } from "./B05_wf2_Route_Api_Fetch"; + +// 배수유역도 패널 — 하단 종단 패널 안쪽 우측에 도킹되는 2단 사이드 패널. +// 하단 패널이 닫히면 이 패널도 함께 화면에서 사라진다(부모 안에 들어 있으므로 자동). +// 지도는 B04에서 분리한 렌더 엔진(B04_wf1_Surface_UI_MapRender)을 그대로 재사용해 +// 사전 투영·LOD·뷰포트 컬링·커서 중심 줌 동작을 동일하게 얻는다. + +/** 배수유역 산정의 근거가 되는 도엽 레이어. 3D는 쓰지 않는다(사용자 지시). */ +const DRAINAGE_LAYERS = ["도엽_등고선", "도엽_하천중심선", "도엽_표고점"] as const; +type DrainageLayer = (typeof DRAINAGE_LAYERS)[number]; + +const LAYER_COLORS: Record = { + 도엽_등고선: "#a5b4fc", + 도엽_하천중심선: "#2563eb", + 도엽_표고점: "#f9a8d4", +}; + +const LAYER_LABELS: Record = { + 도엽_등고선: "등고선", + 도엽_하천중심선: "세류", + 도엽_표고점: "표고점", +}; + +const ROUTE_COLOR = "#f97316"; +const COLLAPSED_KEY = "b05-route-drainage-collapsed"; + +export interface DrainagePanel { + root: HTMLElement; + /** 프로젝트가 정해지면 배경지도·도엽 레이어를 불러온다. */ + load: (projectId: string) => void; + /** 확정된 노선 평면 선형(사업지 좌표계 m)을 지도 위에 겹친다. */ + setRoute: (points: ReadonlyArray) => void; + dispose: () => void; +} + +export function createDrainagePanel(): DrainagePanel { + const root = document.createElement("aside"); + root.className = "b05-drainage"; + const panelHandle = createWorkflowPanelHandle("side"); + + const header = document.createElement("div"); + header.className = "b05-drainage__header"; + const title = document.createElement("h3"); + title.textContent = "배수유역도"; + const layerButtons = document.createElement("div"); + layerButtons.className = "b05-drainage__layers"; + header.append(title, layerButtons); + + const viewport = document.createElement("div"); + viewport.className = "b05-drainage__viewport"; + const backgroundImage = document.createElement("img"); + backgroundImage.className = "b05-drainage__image"; + backgroundImage.alt = "배경 위성지도"; + backgroundImage.draggable = false; + const canvas = document.createElement("canvas"); + canvas.className = "b05-drainage__canvas"; + const status = document.createElement("span"); + status.className = "b05-drainage__status"; + status.textContent = "노선을 확정하면 배수유역 배경도가 표시됩니다."; + viewport.append(backgroundImage, canvas, status); + root.append(panelHandle.root, header, viewport); + + let projectId: string | null = null; + let meta: VWorldMeta | null = null; + const preparedLayers = new Map(); + const activeLayers = new Set(DRAINAGE_LAYERS); + let routeLayer: PreparedLayer | null = null; + let routePoints: ReadonlyArray = []; + let scale = 1; + let offsetX = 0; + let offsetY = 0; + let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null; + let frameHandle = 0; + let loadSequence = 0; + let canvasWidth = 0; + let canvasHeight = 0; + let canvasDpr = 0; + + DRAINAGE_LAYERS.forEach((layer) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "b05-drainage__layer-button is-active"; + button.textContent = LAYER_LABELS[layer]; + button.style.setProperty("--b05-layer-color", LAYER_COLORS[layer]); + button.setAttribute("aria-pressed", "true"); + button.addEventListener("click", () => { + if (activeLayers.has(layer)) activeLayers.delete(layer); + else activeLayers.add(layer); + const isActive = activeLayers.has(layer); + button.classList.toggle("is-active", isActive); + button.setAttribute("aria-pressed", String(isActive)); + scheduleDraw(); + }); + layerButtons.append(button); + }); + + function updateImageTransform(): void { + backgroundImage.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`; + } + + function draw(): void { + const rect = viewport.getBoundingClientRect(); + const width = Math.max(1, Math.floor(rect.width)); + const height = Math.max(1, Math.floor(rect.height)); + const dpr = window.devicePixelRatio || 1; + if (width !== canvasWidth || height !== canvasHeight || dpr !== canvasDpr) { + canvasWidth = width; + canvasHeight = height; + canvasDpr = dpr; + canvas.width = Math.floor(width * dpr); + canvas.height = Math.floor(height * dpr); + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + } + const context = canvas.getContext("2d"); + if (!context) return; + context.setTransform(dpr, 0, 0, dpr, 0, 0); + context.clearRect(0, 0, width, height); + const mapRect: MapRect = computeMapRect(meta, width, height); + const view: ViewState = { width, height, scale, offsetX, offsetY, mapRect }; + // 등고선을 가장 아래에 얇게 깔고 세류·표고점을 그 위에, 노선을 맨 위에 둔다. + DRAINAGE_LAYERS.forEach((layer) => { + if (!activeLayers.has(layer)) return; + const prepared = preparedLayers.get(layer); + if (!prepared) return; + context.lineWidth = layer === "도엽_등고선" ? 0.7 : 1.5; + context.strokeStyle = LAYER_COLORS[layer]; + drawPreparedLayer(context, prepared, view, layer === "도엽_표고점" ? "x" : "dot"); + }); + if (routeLayer) { + context.lineWidth = 2.4; + context.strokeStyle = ROUTE_COLOR; + drawPreparedLayer(context, routeLayer, view, "dot"); + } + updateImageTransform(); + } + + function scheduleDraw(): void { + if (frameHandle) return; + frameHandle = window.requestAnimationFrame(() => { + frameHandle = 0; + draw(); + }); + } + + /** 노선 전체가 보이도록 배율·중심을 맞춘다. 노선이 없으면 도엽 전체를 그대로 보여준다. */ + function fitToRoute(): void { + scale = 1; + offsetX = 0; + offsetY = 0; + if (!meta || routePoints.length < 2) return; + const rect = viewport.getBoundingClientRect(); + const width = Math.max(rect.width, 1); + const height = Math.max(rect.height, 1); + const mapRect = computeMapRect(meta, width, height); + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + routePoints.forEach((point) => { + if (point.x < minX) minX = point.x; + if (point.x > maxX) maxX = point.x; + if (point.y < minY) minY = point.y; + if (point.y > maxY) maxY = point.y; + }); + const routeWidth = Math.max(maxX - minX, 1); + const routeHeight = Math.max(maxY - minY, 1); + scale = Math.min(meta.width_meters / routeWidth, meta.height_meters / routeHeight) * 0.85; + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + const baseX = mapRect.x + ((centerX - meta.x_min) / meta.width_meters) * mapRect.width; + const baseY = mapRect.y + (1 - (centerY - meta.y_min) / meta.height_meters) * mapRect.height; + offsetX = -(baseX - width / 2) * scale; + offsetY = -(baseY - height / 2) * scale; + } + + async function loadLayers(): Promise { + if (!projectId) return; + const activeProjectId = projectId; + const sequence = ++loadSequence; + meta = null; + preparedLayers.clear(); + routeLayer = null; + backgroundImage.removeAttribute("src"); + status.hidden = false; + status.textContent = "배경도를 불러오는 중…"; + try { + const nextMeta = await fetchVWorldMeta(activeProjectId, "satellite"); + const loaded = await Promise.all( + DRAINAGE_LAYERS.map(async (layer) => { + try { + const data = (await fetchGisGeoJson(activeProjectId, layer)) as GeoJsonCollection; + return [layer, data] as const; + } catch { + return [layer, null] as const; + } + }), + ); + if (sequence !== loadSequence) return; + meta = nextMeta; + const normalizer = createNormalizer(nextMeta); + let featureCount = 0; + loaded.forEach(([layer, data]) => { + if (!data) return; + featureCount += data.features?.length ?? 0; + preparedLayers.set(layer, prepareLayer(data, normalizer)); + }); + backgroundImage.src = `${getVWorldMapUrl(activeProjectId, "satellite")}&_t=${Date.now()}`; + if (routePoints.length > 1) routeLayer = prepareMetricPolyline(routePoints, nextMeta); + status.hidden = featureCount > 0; + if (featureCount === 0) status.textContent = "도엽 레이어가 없습니다. B04에서 임포트하세요."; + fitToRoute(); + scheduleDraw(); + } catch (error) { + if (sequence !== loadSequence) return; + status.hidden = false; + status.textContent = error instanceof Error ? error.message : "배경도를 불러오지 못했습니다."; + } + } + + viewport.addEventListener( + "wheel", + (event) => { + event.preventDefault(); + const prevScale = scale; + scale = Math.min(16, Math.max(0.5, scale * (event.deltaY < 0 ? 1.15 : 0.87))); + // 커서 아래 지점을 고정한 채 확대/축소 (B04 지도와 동일 동작). + const ratio = scale / prevScale; + const rect = viewport.getBoundingClientRect(); + const cursorX = event.clientX - rect.left - rect.width / 2; + const cursorY = event.clientY - rect.top - rect.height / 2; + offsetX = cursorX * (1 - ratio) + offsetX * ratio; + offsetY = cursorY * (1 - ratio) + offsetY * ratio; + scheduleDraw(); + }, + { passive: false }, + ); + viewport.addEventListener("pointerdown", (event) => { + // 중간 버튼 기본 동작(페이지 자동 스크롤)이 지도 팬과 겹치지 않게 막는다. + if (event.button === 1) event.preventDefault(); + dragStart = { x: event.clientX, y: event.clientY, offsetX, offsetY }; + viewport.setPointerCapture(event.pointerId); + }); + viewport.addEventListener("pointermove", (event) => { + if (!dragStart) return; + offsetX = dragStart.offsetX + event.clientX - dragStart.x; + offsetY = dragStart.offsetY + event.clientY - dragStart.y; + scheduleDraw(); + }); + const stopDragging = (): void => { + dragStart = null; + }; + viewport.addEventListener("pointerup", stopDragging); + viewport.addEventListener("pointercancel", stopDragging); + + const resizeObserver = new ResizeObserver(scheduleDraw); + resizeObserver.observe(viewport); + + function setCollapsed(collapsed: boolean): void { + root.classList.toggle("is-collapsed", collapsed); + panelHandle.setOpen(!collapsed); + sessionStorage.setItem(COLLAPSED_KEY, String(collapsed)); + if (!collapsed) scheduleDraw(); + } + panelHandle.root.addEventListener("click", () => + setCollapsed(!root.classList.contains("is-collapsed")), + ); + setCollapsed(sessionStorage.getItem(COLLAPSED_KEY) !== "false"); + + return { + root, + load(nextProjectId: string) { + if (projectId === nextProjectId && meta) return; + projectId = nextProjectId; + void loadLayers(); + }, + setRoute(points) { + routePoints = points; + routeLayer = meta && points.length > 1 ? prepareMetricPolyline(points, meta) : null; + if (routeLayer) fitToRoute(); + scheduleDraw(); + }, + dispose() { + loadSequence += 1; + if (frameHandle) { + window.cancelAnimationFrame(frameHandle); + frameHandle = 0; + } + resizeObserver.disconnect(); + }, + }; +} diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index 61b2d44c..3f1ed77b 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -447,6 +447,7 @@ export async function renderB05Route(root: HTMLElement): Promise { routeReady = Boolean(next.route && next.route_points.length > 1); stale = false; panel.setStale(false); + profilePanel.setRoutePolyline(next.route_points ?? []); if (next.route) { const stored = next.route.algorithm_params ?? {}; viewer.markers.renderRoute( diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts index d4fd64fb..ac71c7b2 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -21,6 +21,7 @@ import { } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal"; import { LONG_PAD } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; +import { createDrainagePanel } from "./B05_wf2_Route_UI_Drainage_Panel"; import { showToast } from "@ui/ui_template_elements"; import { saveProfileAlignment } from "./B05_wf2_Route_Api_Fetch"; import type { @@ -269,7 +270,14 @@ export function createRouteProfilePanel( empty.className = "b05-route-profile__empty"; empty.textContent = "최적 경로를 계산하면 종단면도가 표시됩니다."; body.append(empty); - root.append(panelHandle.root, balanceBar, body); + // 종단면 본문 + 우측 배수유역 패널을 나란히 놓는 2단 구성. + // 배수유역 패널이 이 안에 있으므로 하단 패널을 접으면 함께 사라진다(사용자 지시). + const content = document.createElement("div"); + content.className = "b05-route-profile__content"; + const drainagePanel = createDrainagePanel(); + content.append(body, drainagePanel.root); + root.append(panelHandle.root, balanceBar, content); + drainagePanel.load(projectId); let detail: SectionDetailResponse | null = null; let selectedStationId: string | null = null; @@ -583,6 +591,10 @@ export function createRouteProfilePanel( selectedStationId = stationId; draw(); }, + /** 확정된 노선 평면 선형을 우측 배수유역 지도에 겹친다(사업지 좌표계 m). */ + setRoutePolyline(points: ReadonlyArray<{ x: number; y: number }>) { + drainagePanel.setRoute(points); + }, /** 비정규 측점 목록을 반영해 그래프(세로선+라벨)·테이블(주석)을 다시 그린다. */ setIrregularStations(stations: IrregularStation[]) { irregularStations = stations; diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index f475fa62..d9d16d5b 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -61,9 +61,18 @@ min-height: 0; } +/* 종단면 본문 + 우측 배수유역 패널의 2단 가로 배치. */ +.b05-route-profile__content { + display: flex; + flex: 1 1 auto; + min-height: 0; + min-width: 0; +} + .b05-route-profile__body { box-sizing: border-box; flex: 1 1 auto; + min-width: 0; min-height: 0; overflow-x: scroll; overflow-y: hidden; @@ -807,3 +816,117 @@ color: color-mix(in srgb, var(--color-royal-amethyst, rgb(139 92 246)) 85%, var(--color-text)); opacity: 0.95; } + +/* ─── 배수유역도 패널 (하단 종단 패널 안쪽 우측 2단 사이드 패널) ──────────── */ +.b05-drainage { + position: relative; + display: flex; + width: 38%; + min-width: 320px; + max-width: 640px; + flex: 0 0 auto; + flex-direction: column; + min-height: 0; + border-left: 1px solid var(--color-border); + background: var(--color-surface-raised); + transition: width var(--transition-fast); +} + +/* 접으면 폭만 0으로 줄고, 좌측 가장자리 핸들은 남아 다시 펼 수 있다. */ +.b05-drainage.is-collapsed { + width: 0; + min-width: 0; +} + +.b05-drainage.is-collapsed .b05-drainage__header, +.b05-drainage.is-collapsed .b05-drainage__viewport { + display: none; +} + +/* 좌측 가장자리 세로 중앙 핸들 — 공용 side 핸들을 패널 왼쪽 밖으로 내보낸다. */ +.b05-drainage .ui-workflow-overlay__handle--side { + z-index: 2; + right: auto; + left: calc(-1 * var(--spacing-24)); + border-right: 0; + border-radius: var(--radius-buttons) 0 0 var(--radius-buttons); +} + +.b05-drainage__header { + display: flex; + flex: 0 0 auto; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); + padding: var(--spacing-8) calc(var(--spacing-8) + var(--spacing-4)); + border-bottom: 1px solid var(--color-border); +} + +.b05-drainage__header h3 { + margin: 0; + color: var(--color-text); + font-size: var(--text-body-sm); +} + +.b05-drainage__layers { + display: flex; + gap: var(--spacing-4); +} + +.b05-drainage__layer-button { + padding: 2px var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + background: var(--color-surface); + color: var(--color-text-muted); + font-size: var(--text-caption); + cursor: pointer; +} + +/* 켜진 레이어는 그 레이어의 선 색을 그대로 띠 색으로 써서 지도와 바로 대조된다. */ +.b05-drainage__layer-button.is-active { + border-color: var(--b05-layer-color, var(--color-border)); + box-shadow: inset 3px 0 0 var(--b05-layer-color, transparent); + color: var(--color-text-body); +} + +.b05-drainage__viewport { + position: relative; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; + background: var(--color-surface); + cursor: grab; + touch-action: none; +} + +.b05-drainage__viewport:active { + cursor: grabbing; +} + +.b05-drainage__image, +.b05-drainage__canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.b05-drainage__image { + object-fit: contain; + transform-origin: center; + user-select: none; + pointer-events: none; +} + +.b05-drainage__canvas { + pointer-events: none; +} + +.b05-drainage__status { + position: absolute; + inset: var(--spacing-8) var(--spacing-8) auto var(--spacing-8); + color: var(--color-text-secondary); + font-size: var(--text-caption); + pointer-events: none; +} From 7f5c0ab069c9eb0d78844ac27ee994cefbd86265 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 28 Jul 2026 19:32:17 +0900 Subject: [PATCH 11/61] auto: 2026-07-28 19:32 (EOMSANGDON-HOME) --- .../B04_wf1_Surface_UI_MapRender.ts | 57 ++++ B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts | 57 ++++ .../B05_wf2_Route_Engine_Drainage.py | 279 ++++++++++++++++++ .../B05_wf2_Route_Engine_Drainage_Basin.py | 257 ++++++++++++++++ .../B05_wf2_Route_Router_Drainage.py | 224 ++++++++++++++ .../B05_wf2_Route_UI_Drainage_Panel.ts | 116 +++++++- B05_wf2_Route/B05_wf2_Route_UI_Style.css | 71 +++++ main.py | 2 + 8 files changed, 1058 insertions(+), 5 deletions(-) create mode 100644 B05_wf2_Route/B05_wf2_Route_Engine_Drainage.py create mode 100644 B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py create mode 100644 B05_wf2_Route/B05_wf2_Route_Router_Drainage.py diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts index 061e3f85..3892c42e 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts @@ -464,3 +464,60 @@ export function drawPreparedLabels( context.fillText(feature.labelText, x, y); } } + +/** 채움 폴리곤 오버레이(배수유역 등). 좌표는 lon/lat 링 1개. */ +export type FilledRing = { + ring: ReadonlyArray; + /** 면적 중심에 얹을 번호. 없으면 라벨을 그리지 않는다. */ + label?: string; +}; + +/** + * lon/lat 폴리곤 링을 파스텔 채움 + 테두리 + 중심 번호로 그린다. + * 사전 투영 캐시를 쓰지 않는 소량(유역 수 개) 오버레이 전용이라 매 프레임 변환해도 부담이 없다. + */ +export function drawFilledRing( + context: CanvasRenderingContext2D, + entry: FilledRing, + normalizer: Normalizer, + view: ViewState, + color: string, +): void { + if (entry.ring.length < 3) return; + const affine = affineOf(view); + let sumX = 0; + let sumY = 0; + context.beginPath(); + entry.ring.forEach(([lon, lat], index) => { + const nx = (lon - normalizer.lonMin) / normalizer.lonRange; + const ny = 1 - (lat - normalizer.latMin) / normalizer.latRange; + const x = nx * affine.ax + affine.bx; + const y = ny * affine.ay + affine.by; + sumX += x; + sumY += y; + if (index === 0) context.moveTo(x, y); + else context.lineTo(x, y); + }); + context.closePath(); + context.fillStyle = color; + context.fill(); + context.strokeStyle = color; + context.lineWidth = 1.6; + context.stroke(); + if (!entry.label) return; + // 면적 중심(정점 평균)에 번호를 원형 배지로 얹는다. + const centerX = sumX / entry.ring.length; + const centerY = sumY / entry.ring.length; + context.beginPath(); + context.arc(centerX, centerY, 11, 0, Math.PI * 2); + context.fillStyle = color; + context.fill(); + context.strokeStyle = "rgba(255, 255, 255, 0.9)"; + context.lineWidth = 1.5; + context.stroke(); + context.font = "600 12px sans-serif"; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillStyle = "#1f2937"; + context.fillText(entry.label, centerX, centerY); +} diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index 60637049..e6c23a42 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -247,3 +247,60 @@ export async function fetchLatestRoute(projectId: string): Promise; + area_m2: number; + relief_m: number; + flow_length_m: number; + pipe_diameter_mm: number | null; +} + +export interface DrainageBasinResponse { + status: string; + project_id: string; + route_id: number; + basins: DrainageBasin[]; +} + +export async function fetchDrainageCandidates( + projectId: string, +): Promise { + return requestJson(`/projects/${projectId}/drainage/candidates`, { + method: "GET", + }); +} + +/** chainages를 주면 그 위치로 확정 산정하고, 비우면 자동 제안분으로 산정한다. */ +export async function fetchDrainageBasins( + projectId: string, + chainages?: number[], +): Promise { + return requestJson(`/projects/${projectId}/drainage/basins`, { + method: "POST", + body: JSON.stringify({ chainages: chainages ?? [] }), + }); +} diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage.py new file mode 100644 index 00000000..99be8d85 --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage.py @@ -0,0 +1,279 @@ +"""배수유역 산정 엔진. + +관 매설 구조물 측점 후보를 제안하고, 각 측점이 받는 배수유역 경계를 산정한다. +지형 판단은 **도엽 등고선·세류선(하천중심선)·표고점**만 사용한다 — 3D 포인트클라우드나 +지형 메시는 쓰지 않는다(2026-07-28 사용자 지시). + +유역을 나누는 최종 목적은 각 지점의 파이프 관경 결정이다. 유역 경사면에 100년 강우빈도를 +적용해 모이는 물의 양을 산정하고 그 유량으로 관경을 정한다. 관경 수식은 아직 미확정이라 +`estimate_pipe_diameter_mm()`은 골격만 두고 비워 둔다. +""" + +from __future__ import annotations + +import logging +import math +from dataclasses import dataclass, field +from typing import Any + +from shapely.geometry import LineString, Point, shape + +logger = logging.getLogger(__name__) + +# 구조물 측점 사이 최대 허용 간격(m). 세류 교차가 없어도 이 간격을 넘으면 절토부에 추가 배치한다. +MAX_STRUCTURE_SPACING_M = 300.0 +# 같은 세류 교차로 볼 최소 이격(m). 이보다 가까운 교차점은 하나로 묶는다. +MIN_STRUCTURE_SPACING_M = 20.0 +# 유역 경계 탐색 반경(m). 측점에서 이 거리를 넘는 지형은 해당 유역으로 보지 않는다. +MAX_BASIN_RADIUS_M = 800.0 + + +@dataclass +class RouteVertex: + """노선 폴리라인의 한 점. chainage는 시점 기준 누가거리(m).""" + + x: float + y: float + z: float + chainage_m: float + + +@dataclass +class StructureCandidate: + """관 매설 구조물 측점 후보.""" + + chainage_m: float + x: float + y: float + # "stream"=세류 교차, "spacing"=300m 규칙에 따른 보충 배치 + reason: str + stream_name: str | None = None + + +@dataclass +class DrainageBasin: + """한 구조물 측점이 받는 배수유역.""" + + index: int + chainage_m: float + outlet_x: float + outlet_y: float + polygon_lonlat: list[list[float]] = field(default_factory=list) + area_m2: float = 0.0 + # 유역 최고 표고 − 측점 표고(m). 경사면 낙차. + relief_m: float = 0.0 + # 유하거리: 측점에서 유역 최상단까지 물길 길이(m). + flow_length_m: float = 0.0 + pipe_diameter_mm: float | None = None + + +def build_route_vertices(points: list[dict[str, Any]]) -> list[RouteVertex]: + """DB route_points 행을 누가거리가 채워진 정점 목록으로 바꾼다.""" + vertices: list[RouteVertex] = [] + cumulative = 0.0 + previous: tuple[float, float] | None = None + for row in points: + x = float(row["x"]) + y = float(row["y"]) + z = float(row.get("z") or 0.0) + if previous is not None: + cumulative += math.dist(previous, (x, y)) + chainage = row.get("chainage_m") + vertices.append( + RouteVertex( + x=x, + y=y, + z=z, + chainage_m=float(chainage) if chainage is not None else cumulative, + ) + ) + previous = (x, y) + return vertices + + +def _interpolate_vertex( + vertices: list[RouteVertex], chainage_m: float +) -> tuple[float, float, float]: + """누가거리 위치의 (x, y, z)를 선형 보간한다.""" + if not vertices: + return (0.0, 0.0, 0.0) + if chainage_m <= vertices[0].chainage_m: + return (vertices[0].x, vertices[0].y, vertices[0].z) + for previous, current in zip(vertices, vertices[1:]): + if chainage_m <= current.chainage_m: + span = current.chainage_m - previous.chainage_m + ratio = 0.0 if span <= 0 else (chainage_m - previous.chainage_m) / span + return ( + previous.x + (current.x - previous.x) * ratio, + previous.y + (current.y - previous.y) * ratio, + previous.z + (current.z - previous.z) * ratio, + ) + last = vertices[-1] + return (last.x, last.y, last.z) + + +def is_uphill_at(vertices: list[RouteVertex], chainage_m: float, window_m: float = 20.0) -> bool: + """해당 위치가 오르막(절토부)인지 판정한다. + + 내리막(성토부)은 물이 노선 바깥으로 흘러나가므로 배수유역을 만들지 않는다 + (2026-07-28 사용자 지시). 판정은 종단 계획선의 국소 기울기 부호로 한다. + """ + _, _, back_z = _interpolate_vertex(vertices, max(0.0, chainage_m - window_m)) + _, _, forward_z = _interpolate_vertex(vertices, chainage_m + window_m) + return forward_z >= back_z + + +def find_stream_crossings( + vertices: list[RouteVertex], + stream_features: list[dict[str, Any]], +) -> list[StructureCandidate]: + """노선 평면 선형과 세류선의 교차 지점을 찾는다.""" + if len(vertices) < 2: + return [] + route_line = LineString([(vertex.x, vertex.y) for vertex in vertices]) + candidates: list[StructureCandidate] = [] + for feature in stream_features: + geometry = feature.get("geometry") + if not geometry: + continue + try: + stream = shape(geometry) + except Exception: # noqa: BLE001 - 손상된 피처는 건너뛴다 + continue + if stream.is_empty: + continue + intersection = route_line.intersection(stream) + if intersection.is_empty: + continue + name = _stream_name(feature) + for point in _collect_points(intersection): + candidates.append( + StructureCandidate( + chainage_m=route_line.project(point), + x=point.x, + y=point.y, + reason="stream", + stream_name=name, + ) + ) + candidates.sort(key=lambda item: item.chainage_m) + return candidates + + +def _stream_name(feature: dict[str, Any]) -> str | None: + properties = feature.get("properties") or {} + for key in ("명칭", "하천명", "NAME", "name"): + value = properties.get(key) + if value: + return str(value) + return None + + +def _collect_points(geometry: Any) -> list[Point]: + """교차 결과(Point/MultiPoint/LineString 등)에서 대표 점들을 뽑는다.""" + if geometry.geom_type == "Point": + return [geometry] + if geometry.geom_type in {"MultiPoint", "GeometryCollection"}: + points: list[Point] = [] + for part in geometry.geoms: + points.extend(_collect_points(part)) + return points + # 선분끼리 겹쳐 선으로 나온 경우는 중점을 대표로 쓴다. + if geometry.geom_type in {"LineString", "MultiLineString"}: + return [geometry.interpolate(0.5, normalized=True)] + return [] + + +def propose_structure_stations( + vertices: list[RouteVertex], + stream_features: list[dict[str, Any]], +) -> list[StructureCandidate]: + """구조물 측점 후보를 제안한다. + + ① 세류 교차 지점 ② 내리막(성토부) 제외 ③ 직전 측점에서 300m 초과 시 절토부에 보충 배치. + """ + if len(vertices) < 2: + return [] + total_length = vertices[-1].chainage_m + crossings = [ + candidate + for candidate in find_stream_crossings(vertices, stream_features) + if is_uphill_at(vertices, candidate.chainage_m) + ] + + # 너무 가까운 교차는 하나로 본다(같은 계곡을 여러 선분이 지나는 경우). + merged: list[StructureCandidate] = [] + for candidate in crossings: + if merged and candidate.chainage_m - merged[-1].chainage_m < MIN_STRUCTURE_SPACING_M: + continue + merged.append(candidate) + + # 300m 규칙: 빈 구간에 절토부 지점을 찾아 보충한다. + filled: list[StructureCandidate] = [] + previous_chainage = 0.0 + for candidate in [*merged, None]: + boundary = candidate.chainage_m if candidate else total_length + filled.extend(_fill_spacing(vertices, previous_chainage, boundary)) + if candidate: + filled.append(candidate) + previous_chainage = candidate.chainage_m + else: + previous_chainage = boundary + filled.sort(key=lambda item: item.chainage_m) + return filled + + +def _fill_spacing( + vertices: list[RouteVertex], + start_m: float, + end_m: float, +) -> list[StructureCandidate]: + """[start, end] 구간이 300m를 넘으면 절토부 지점에 보충 측점을 만든다.""" + added: list[StructureCandidate] = [] + cursor = start_m + while end_m - cursor > MAX_STRUCTURE_SPACING_M: + target = cursor + MAX_STRUCTURE_SPACING_M + placed = _nearest_uphill(vertices, target, end_m) + if placed is None: + break + x, y, _ = _interpolate_vertex(vertices, placed) + added.append(StructureCandidate(chainage_m=placed, x=x, y=y, reason="spacing")) + cursor = placed + return added + + +def _nearest_uphill( + vertices: list[RouteVertex], + target_m: float, + limit_m: float, + step_m: float = 10.0, +) -> float | None: + """목표 위치에서 가장 가까운 절토부(오르막) 지점을 찾는다. 없으면 None.""" + if is_uphill_at(vertices, target_m): + return target_m + offset = step_m + while offset <= MAX_STRUCTURE_SPACING_M / 2: + for probe in (target_m - offset, target_m + offset): + if probe <= 0 or probe >= limit_m: + continue + if is_uphill_at(vertices, probe): + return probe + offset += step_m + return None + + +def estimate_pipe_diameter_mm( + area_m2: float, + relief_m: float, + flow_length_m: float, + rainfall_mm_per_hour: float | None = None, +) -> float | None: + """유역 제원으로 배수 파이프 관경(mm)을 산정한다. + + 100년 강우빈도와 유역 경사면을 곱해 유출량을 구하고, 그 유량으로 관경을 정하는 것이 + 목적이다. **수식은 아직 확정되지 않았다** — 사용자가 로직을 제공하면 여기를 채운다. + 그때까지는 None을 돌려 호출부가 "미정"으로 표기하게 한다. + """ + # TODO(사용자 로직 대기): 100년 강우강도 × 유역면적 × 유출계수 → 유량 Q → 관경 D 산정. + _ = (area_m2, relief_m, flow_length_m, rainfall_mm_per_hour) + return None diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py new file mode 100644 index 00000000..854dd34f --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py @@ -0,0 +1,257 @@ +"""배수유역 경계 산정. + +구조물 측점(관 매설 지점)에서 산정상부까지 역추적해 밀폐된 유역 경계를 만든다. +지형 판단 근거는 도엽 등고선·세류선·표고점뿐이다(3D 미사용, 2026-07-28 사용자 지시). + +정상부 판정 규칙(사용자 지시): + 표고점 데이터는 산 정상부가 아닌 경우가 많다. 따라서 **등고선의 동심 폐합 패턴** + (안쪽으로 갈수록 표고가 높아지는 폐합 등고선의 최내곽)으로 정상부를 먼저 판단하고, + 표고점은 그 판정을 보조·검증하는 용도로만 쓴다. +""" + +from __future__ import annotations + +import logging +import math +from typing import Any + +from shapely.geometry import LineString, Point, Polygon, shape +from shapely.ops import unary_union + +from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import ( + MAX_BASIN_RADIUS_M, + DrainageBasin, + RouteVertex, + StructureCandidate, + estimate_pipe_diameter_mm, +) + +logger = logging.getLogger(__name__) + +# 등고선을 폐합으로 볼 때 허용하는 시종점 이격(m). 도엽 경계에서 잘린 선을 걸러낸다. +CLOSED_TOLERANCE_M = 1.0 +# 폐합 등고선이 정상부 후보가 되는 최대 둘레(m). 이보다 크면 산체 전체라 정상부로 보지 않는다. +MAX_SUMMIT_PERIMETER_M = 1200.0 + + +class ContourField: + """도엽 등고선 피처 모음을 표고 조회·정상부 판정에 쓸 수 있게 감싼 것.""" + + def __init__(self, features: list[dict[str, Any]], elevation_keys: tuple[str, ...]): + self.lines: list[tuple[LineString, float]] = [] + self.closed: list[tuple[Polygon, float]] = [] + for feature in features: + elevation = _read_elevation(feature.get("properties") or {}, elevation_keys) + if elevation is None: + continue + geometry = feature.get("geometry") + if not geometry: + continue + try: + geom = shape(geometry) + except Exception: # noqa: BLE001 - 손상된 피처는 건너뛴다 + continue + for line in _iter_lines(geom): + self.lines.append((line, elevation)) + polygon = _as_closed_polygon(line) + if polygon is not None: + self.closed.append((polygon, elevation)) + + def elevation_at(self, x: float, y: float, radius_m: float = 200.0) -> float | None: + """가장 가까운 등고선의 표고를 그 지점의 표고로 본다.""" + point = Point(x, y) + best: tuple[float, float] | None = None + for line, elevation in self.lines: + distance = line.distance(point) + if distance > radius_m: + continue + if best is None or distance < best[0]: + best = (distance, elevation) + return None if best is None else best[1] + + def find_summits(self, near: Point, radius_m: float) -> list[tuple[Polygon, float]]: + """주변의 정상부 후보를 찾는다. + + 동심 폐합 등고선 중 **자기보다 높은 폐합 등고선을 안에 품지 않은 것**이 최내곽, + 즉 정상부다. 도엽 경계에서 잘린 선과 산체 전체를 감싸는 큰 폐합은 제외한다. + """ + nearby = [ + (polygon, elevation) + for polygon, elevation in self.closed + if polygon.length <= MAX_SUMMIT_PERIMETER_M and polygon.distance(near) <= radius_m + ] + summits: list[tuple[Polygon, float]] = [] + for polygon, elevation in nearby: + has_higher_inside = any( + other_elevation > elevation and polygon.contains(other.representative_point()) + for other, other_elevation in nearby + if other is not polygon + ) + if not has_higher_inside: + summits.append((polygon, elevation)) + return summits + + +def _read_elevation(properties: dict[str, Any], keys: tuple[str, ...]) -> float | None: + for key in keys: + value = properties.get(key) + if value is None: + continue + try: + return float(value) + except (TypeError, ValueError): + continue + return None + + +def _iter_lines(geometry: Any) -> list[LineString]: + if geometry.geom_type == "LineString": + return [geometry] + if geometry.geom_type == "MultiLineString": + return list(geometry.geoms) + return [] + + +def _as_closed_polygon(line: LineString) -> Polygon | None: + coords = list(line.coords) + if len(coords) < 4: + return None + if math.dist(coords[0], coords[-1]) > CLOSED_TOLERANCE_M: + return None + try: + polygon = Polygon(coords) + except Exception: # noqa: BLE001 + return None + return polygon if polygon.is_valid and polygon.area > 0 else None + + +def build_basins( + vertices: list[RouteVertex], + candidates: list[StructureCandidate], + contours: ContourField, + stream_features: list[dict[str, Any]], + to_lonlat: Any, +) -> list[DrainageBasin]: + """확정된 구조물 측점별 배수유역을 만든다. + + 측점에 물을 보내는 세류 가지를 따라 위로 올라가 정상부 폐합 등고선까지 닿는 범위를 + 유역으로 본다. 정상부는 ContourField.find_summits가 등고선 폐합 패턴으로 판정한다. + 번호는 노선 시점에 가까운 순서(측점 누가거리 오름차순)로 1부터 매긴다. + """ + route_line = ( + LineString([(vertex.x, vertex.y) for vertex in vertices]) if len(vertices) > 1 else None + ) + streams = _stream_lines(stream_features) + basins: list[DrainageBasin] = [] + for index, candidate in enumerate( + sorted(candidates, key=lambda item: item.chainage_m), start=1 + ): + outlet = Point(candidate.x, candidate.y) + uphill = _uphill_streams(outlet, streams, contours) + summits = contours.find_summits(outlet, MAX_BASIN_RADIUS_M) + boundary = _basin_polygon(outlet, uphill, summits, route_line) + if boundary is None or boundary.is_empty: + continue + outlet_elevation = contours.elevation_at(candidate.x, candidate.y) or 0.0 + top_elevation = max((elevation for _, elevation in summits), default=outlet_elevation) + basin = DrainageBasin( + index=index, + chainage_m=candidate.chainage_m, + outlet_x=candidate.x, + outlet_y=candidate.y, + polygon_lonlat=[list(to_lonlat(x, y)) for x, y in boundary.exterior.coords], + area_m2=float(boundary.area), + relief_m=float(max(0.0, top_elevation - outlet_elevation)), + flow_length_m=_flow_length(outlet, uphill, boundary), + ) + basin.pipe_diameter_mm = estimate_pipe_diameter_mm( + basin.area_m2, basin.relief_m, basin.flow_length_m + ) + basins.append(basin) + return basins + + +def _stream_lines(features: list[dict[str, Any]]) -> list[LineString]: + lines: list[LineString] = [] + for feature in features: + geometry = feature.get("geometry") + if not geometry: + continue + try: + lines.extend(_iter_lines(shape(geometry))) + except Exception: # noqa: BLE001 + continue + return lines + + +def _uphill_streams( + outlet: Point, + streams: list[LineString], + contours: ContourField, + tolerance_m: float = 30.0, +) -> list[LineString]: + """측점에 연결된 세류 가지 중 위쪽(표고가 높아지는 방향)으로 뻗은 것만 모은다.""" + connected = [line for line in streams if line.distance(outlet) <= tolerance_m] + uphill: list[LineString] = [] + outlet_elevation = contours.elevation_at(outlet.x, outlet.y) + for line in connected: + far = _far_end(line, outlet) + far_elevation = contours.elevation_at(far.x, far.y) + if outlet_elevation is None or far_elevation is None or far_elevation >= outlet_elevation: + uphill.append(line) + return uphill + + +def _far_end(line: LineString, outlet: Point) -> Point: + start = Point(line.coords[0]) + end = Point(line.coords[-1]) + return end if start.distance(outlet) <= end.distance(outlet) else start + + +def _basin_polygon( + outlet: Point, + uphill: list[LineString], + summits: list[tuple[Polygon, float]], + route_line: LineString | None, +) -> Polygon | None: + """유역 경계를 만든다. + + 측점 + 상류 세류 + 정상부 폐합 등고선을 함께 감싸는 볼록 껍질을 1차 경계로 삼고, + 노선 아래쪽(성토부 방향)은 노선을 경계로 잘라낸다. 세류가 없으면 정상부까지의 + 반경 안에서 만들어지는 범위만 남는다. + """ + parts: list[Any] = [outlet.buffer(5.0)] + parts.extend(uphill) + parts.extend(polygon for polygon, _ in summits) + if len(parts) <= 1: + return None + hull = unary_union(parts).convex_hull + if hull.geom_type != "Polygon": + return None + if route_line is not None: + hull = _clip_downhill(hull, route_line, outlet) + return hull if hull is not None and hull.geom_type == "Polygon" else None + + +def _clip_downhill(hull: Polygon, route_line: LineString, outlet: Point) -> Polygon | None: + """노선을 경계로 유역을 잘라 산 쪽(상류) 조각만 남긴다.""" + try: + pieces = hull.difference(route_line.buffer(0.5)) + except Exception: # noqa: BLE001 + return hull + if pieces.is_empty: + return hull + parts = list(pieces.geoms) if pieces.geom_type == "MultiPolygon" else [pieces] + # 상류 조각 판별이 애매할 때를 대비해 면적이 가장 큰 조각을 채택한다. + best = max(parts, key=lambda part: part.area, default=None) + return best if best is not None and best.geom_type == "Polygon" else hull + + +def _flow_length(outlet: Point, uphill: list[LineString], boundary: Polygon) -> float: + """유하거리: 측점에서 유역 최상단까지의 물길 길이(m). + + 상류 세류가 있으면 그 물길 길이의 최댓값을, 없으면 유역 안 최원점까지의 직선거리를 쓴다. + """ + if uphill: + return float(max(line.length for line in uphill)) + return float(max((outlet.distance(Point(xy)) for xy in boundary.exterior.coords), default=0.0)) diff --git a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py new file mode 100644 index 00000000..fabb361a --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py @@ -0,0 +1,224 @@ +"""배수유역도 API 라우터. + +구조물 측점(관 매설) 후보 제안과 배수유역 산정을 제공한다. 지형 근거는 도엽 등고선·세류선· +표고점 GeoJSON뿐이며, 좌표는 사업지 CRS(m)에서 계산하고 응답만 WGS84로 바꿔 내보낸다. +""" + +import json +import logging +from pathlib import Path +from typing import Any +from uuid import UUID + +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from pyproj import Transformer + +from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import ( + StructureCandidate, + build_route_vertices, + propose_structure_stations, +) +from B05_wf2_Route.B05_wf2_Route_Engine_Drainage_Basin import ContourField, build_basins +from B05_wf2_Route.B05_wf2_Route_Repository import ( + get_latest_route, + get_route_points, + get_surface_crs_epsg, +) +from common_util.common_util_storage import resolve_stored_project_path +from config.config_db import get_db_pool + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B05 Route Drainage"]) + +# 도엽 레이어 파일명 (B04 전처리 산출물과 동일 위치) +_CONTOUR_FILE = "도엽_등고선.geojson" +_STREAM_FILE = "도엽_하천중심선.geojson" +# 도엽 등고선의 표고 속성 키. gpkg 등고선(CTRLN_HG)도 함께 본다. +_ELEVATION_KEYS = ("등고수치", "CTRLN_HG", "elevation", "ELEV") + + +def _sheet_dir(stored_path: str) -> Path: + return Path(resolve_stored_project_path(stored_path)) / "B04_wf1_Surface" / "processed" + + +def _load_features(directory: Path, filename: str) -> list[dict[str, Any]]: + """도엽 GeoJSON을 읽어 피처 목록만 돌려준다. 없으면 빈 목록.""" + path = directory / filename + if not path.exists(): + return [] + try: + with path.open("r", encoding="utf-8") as file: + data = json.load(file) + except (OSError, json.JSONDecodeError): + logger.warning("도엽 GeoJSON을 읽지 못했습니다: %s", path) + return [] + features = data.get("features") + return features if isinstance(features, list) else [] + + +def _reproject_features( + features: list[dict[str, Any]], + transformer: Transformer | None, +) -> list[dict[str, Any]]: + """WGS84 도엽 좌표를 사업지 CRS(m)로 바꾼다. 거리·면적을 미터로 계산하기 위함.""" + if transformer is None: + return features + converted: list[dict[str, Any]] = [] + for feature in features: + geometry = feature.get("geometry") + if not geometry: + continue + coordinates = _map_coordinates(geometry.get("coordinates"), transformer) + if coordinates is None: + continue + converted.append( + { + "type": "Feature", + "properties": feature.get("properties") or {}, + "geometry": {"type": geometry.get("type"), "coordinates": coordinates}, + } + ) + return converted + + +def _map_coordinates(coordinates: Any, transformer: Transformer) -> Any: + """중첩 좌표 배열을 재귀적으로 변환한다.""" + if not isinstance(coordinates, list) or not coordinates: + return None + first = coordinates[0] + if isinstance(first, (int, float)): + x, y = transformer.transform(float(coordinates[0]), float(coordinates[1])) + return [x, y] + mapped = [_map_coordinates(item, transformer) for item in coordinates] + return [item for item in mapped if item is not None] + + +def _candidate_payload( + candidate: StructureCandidate, + to_lonlat: Any, +) -> dict[str, Any]: + lon, lat = to_lonlat(candidate.x, candidate.y) + return { + "chainage_m": round(candidate.chainage_m, 2), + "x": candidate.x, + "y": candidate.y, + "lon": lon, + "lat": lat, + "reason": candidate.reason, + "stream_name": candidate.stream_name, + } + + +async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse: + """노선 정점·도엽 피처·좌표 변환기를 한 번에 준비한다.""" + pool = get_db_pool() + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + route = await get_latest_route(connection, project_id) + if not route: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "확정된 경로가 없습니다."}, + ) + points = await get_route_points(connection, int(route["id"])) + epsg = await get_surface_crs_epsg(connection, project_id) + + vertices = build_route_vertices(points) + if len(vertices) < 2: + return JSONResponse( + status_code=400, + content={"status": "error", "message": "노선 좌표가 부족합니다."}, + ) + + source_crs = epsg or "EPSG:5186" + to_lonlat_transformer = Transformer.from_crs(source_crs, "EPSG:4326", always_xy=True) + to_metric_transformer = Transformer.from_crs("EPSG:4326", source_crs, always_xy=True) + + directory = _sheet_dir(stored_path) + streams = _reproject_features(_load_features(directory, _STREAM_FILE), to_metric_transformer) + contour_features = _reproject_features( + _load_features(directory, _CONTOUR_FILE), to_metric_transformer + ) + return { + "route_id": int(route["id"]), + "vertices": vertices, + "streams": streams, + "contours": contour_features, + "to_lonlat": lambda x, y: to_lonlat_transformer.transform(x, y), + } + + +@router.get("/{project_id}/drainage/candidates", response_model=None) +async def get_structure_candidates(project_id: UUID) -> dict[str, Any] | JSONResponse: + """관 매설 구조물 측점 후보를 제안한다(세류 교차 + 300m 보충, 성토부 제외).""" + prepared = await _prepare(project_id) + if isinstance(prepared, JSONResponse): + return prepared + candidates = propose_structure_stations(prepared["vertices"], prepared["streams"]) + to_lonlat = prepared["to_lonlat"] + return { + "status": "success", + "project_id": str(project_id), + "route_id": prepared["route_id"], + "candidates": [_candidate_payload(candidate, to_lonlat) for candidate in candidates], + } + + +@router.post("/{project_id}/drainage/basins", response_model=None) +async def post_drainage_basins( + project_id: UUID, + payload: dict[str, Any] | None = None, +) -> dict[str, Any] | JSONResponse: + """확정된 구조물 측점별 배수유역을 산정한다. + + payload에 `chainages`(누가거리 목록)를 주면 그 위치로 확정하고, 없으면 자동 제안분을 쓴다. + """ + prepared = await _prepare(project_id) + if isinstance(prepared, JSONResponse): + return prepared + vertices = prepared["vertices"] + chainages = (payload or {}).get("chainages") + if isinstance(chainages, list) and chainages: + candidates = _candidates_from_chainages(vertices, chainages) + else: + candidates = propose_structure_stations(vertices, prepared["streams"]) + + contours = ContourField(prepared["contours"], _ELEVATION_KEYS) + basins = build_basins( + vertices, candidates, contours, prepared["streams"], prepared["to_lonlat"] + ) + return { + "status": "success", + "project_id": str(project_id), + "route_id": prepared["route_id"], + "basins": [ + { + "index": basin.index, + "chainage_m": round(basin.chainage_m, 2), + "polygon_lonlat": basin.polygon_lonlat, + "area_m2": round(basin.area_m2, 1), + "relief_m": round(basin.relief_m, 2), + "flow_length_m": round(basin.flow_length_m, 1), + # 관경 수식 미확정 — 산정 함수가 None을 돌려주면 프론트가 "미정"으로 표기한다. + "pipe_diameter_mm": basin.pipe_diameter_mm, + } + for basin in basins + ], + } + + +def _candidates_from_chainages(vertices: Any, chainages: list[Any]) -> list[StructureCandidate]: + """사용자가 확정한 누가거리 목록을 후보 구조로 되돌린다.""" + from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import _interpolate_vertex + + candidates: list[StructureCandidate] = [] + for value in chainages: + try: + chainage = float(value) + except (TypeError, ValueError): + continue + x, y, _ = _interpolate_vertex(vertices, chainage) + candidates.append(StructureCandidate(chainage_m=chainage, x=x, y=y, reason="confirmed")) + return candidates diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts index 5f3ad08d..5cf7fd33 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -8,15 +8,21 @@ import { import { computeMapRect, createNormalizer, + drawFilledRing, drawPreparedLayer, prepareLayer, prepareMetricPolyline, type GeoJsonCollection, type MapRect, + type Normalizer, type PreparedLayer, type ViewState, } from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender"; -import type { RoutePoint } from "./B05_wf2_Route_Api_Fetch"; +import { + fetchDrainageBasins, + type DrainageBasin, + type RoutePoint, +} from "./B05_wf2_Route_Api_Fetch"; // 배수유역도 패널 — 하단 종단 패널 안쪽 우측에 도킹되는 2단 사이드 패널. // 하단 패널이 닫히면 이 패널도 함께 화면에서 사라진다(부모 안에 들어 있으므로 자동). @@ -42,6 +48,18 @@ const LAYER_LABELS: Record = { const ROUTE_COLOR = "#f97316"; const COLLAPSED_KEY = "b05-route-drainage-collapsed"; +/** 유역 오버레이 파스텔 색상. 번호 순으로 돌려쓴다(사용자 지시: 파스텔톤). */ +const BASIN_COLORS = [ + "rgba(167, 216, 199, 0.45)", + "rgba(247, 208, 168, 0.45)", + "rgba(186, 199, 240, 0.45)", + "rgba(241, 183, 199, 0.45)", + "rgba(214, 226, 168, 0.45)", + "rgba(202, 186, 227, 0.45)", + "rgba(168, 214, 232, 0.45)", + "rgba(240, 219, 168, 0.45)", +] as const; + export interface DrainagePanel { root: HTMLElement; /** 프로젝트가 정해지면 배경지도·도엽 레이어를 불러온다. */ @@ -64,6 +82,13 @@ export function createDrainagePanel(): DrainagePanel { layerButtons.className = "b05-drainage__layers"; header.append(title, layerButtons); + // 유역 산정 실행 버튼 — 후보 제안·유역 산정을 한 번에 돌린다(자동 제안 + 사용자 확인 흐름). + const analyzeButton = document.createElement("button"); + analyzeButton.type = "button"; + analyzeButton.className = "b05-drainage__analyze"; + analyzeButton.textContent = "유역 산정"; + header.append(analyzeButton); + const viewport = document.createElement("div"); viewport.className = "b05-drainage__viewport"; const backgroundImage = document.createElement("img"); @@ -76,7 +101,11 @@ export function createDrainagePanel(): DrainagePanel { status.className = "b05-drainage__status"; status.textContent = "노선을 확정하면 배수유역 배경도가 표시됩니다."; viewport.append(backgroundImage, canvas, status); - root.append(panelHandle.root, header, viewport); + // 유역 제원 목록(면적·표고·유하거리·관경). 관경 수식 미확정이라 당분간 "미정"으로 나온다. + const basinList = document.createElement("div"); + basinList.className = "b05-drainage__basins"; + basinList.hidden = true; + root.append(panelHandle.root, header, viewport, basinList); let projectId: string | null = null; let meta: VWorldMeta | null = null; @@ -84,6 +113,9 @@ export function createDrainagePanel(): DrainagePanel { const activeLayers = new Set(DRAINAGE_LAYERS); let routeLayer: PreparedLayer | null = null; let routePoints: ReadonlyArray = []; + let normalizer: Normalizer | null = null; + let basins: DrainageBasin[] = []; + let selectedBasin: number | null = null; let scale = 1; let offsetX = 0; let offsetY = 0; @@ -136,7 +168,22 @@ export function createDrainagePanel(): DrainagePanel { context.clearRect(0, 0, width, height); const mapRect: MapRect = computeMapRect(meta, width, height); const view: ViewState = { width, height, scale, offsetX, offsetY, mapRect }; - // 등고선을 가장 아래에 얇게 깔고 세류·표고점을 그 위에, 노선을 맨 위에 둔다. + // 유역 채움을 가장 아래에 깔아 등고선·세류 판독을 가리지 않게 한다. + if (normalizer) { + basins.forEach((basin) => { + const color = BASIN_COLORS[(basin.index - 1) % BASIN_COLORS.length]; + drawFilledRing( + context, + { ring: basin.polygon_lonlat, label: String(basin.index) }, + normalizer!, + view, + selectedBasin === null || selectedBasin === basin.index + ? color + : color.replace(/0\.45\)$/, "0.18)"), + ); + }); + } + // 등고선을 얇게 깔고 세류·표고점을 그 위에, 노선을 맨 위에 둔다. DRAINAGE_LAYERS.forEach((layer) => { if (!activeLayers.has(layer)) return; const prepared = preparedLayers.get(layer); @@ -161,6 +208,65 @@ export function createDrainagePanel(): DrainagePanel { }); } + /** 유역 제원 목록을 다시 그린다. 항목을 누르면 해당 유역만 진하게 강조한다. */ + function renderBasinList(): void { + basinList.textContent = ""; + basinList.hidden = basins.length === 0; + basins.forEach((basin) => { + const row = document.createElement("button"); + row.type = "button"; + row.className = "b05-drainage__basin" + (selectedBasin === basin.index ? " is-selected" : ""); + const badge = document.createElement("span"); + badge.className = "b05-drainage__basin-index"; + badge.textContent = String(basin.index); + badge.style.background = BASIN_COLORS[(basin.index - 1) % BASIN_COLORS.length]; + const metrics = document.createElement("span"); + metrics.className = "b05-drainage__basin-metrics"; + // 관경은 수식 미확정이라 백엔드가 null을 주며, 확정 전까지 "미정"으로 표기한다. + const pipe = + basin.pipe_diameter_mm === null ? "미정" : `Ø${Math.round(basin.pipe_diameter_mm)}mm`; + metrics.textContent = + `면적 ${formatArea(basin.area_m2)} · 표고 ${basin.relief_m.toFixed(1)}m · ` + + `유하 ${Math.round(basin.flow_length_m)}m · 관경 ${pipe}`; + row.title = `측점 누가거리 ${basin.chainage_m.toFixed(1)}m`; + row.append(badge, metrics); + row.addEventListener("click", () => { + selectedBasin = selectedBasin === basin.index ? null : basin.index; + renderBasinList(); + scheduleDraw(); + }); + basinList.append(row); + }); + } + + function formatArea(areaM2: number): string { + return areaM2 >= 10000 ? `${(areaM2 / 10000).toFixed(2)}ha` : `${Math.round(areaM2)}㎡`; + } + + /** 구조물 측점 후보 제안 + 유역 산정을 백엔드에 요청한다(계산은 전부 백엔드). */ + async function analyze(): Promise { + if (!projectId) return; + analyzeButton.disabled = true; + status.hidden = false; + status.textContent = "배수유역을 산정하는 중…"; + try { + const response = await fetchDrainageBasins(projectId); + basins = response.basins; + selectedBasin = null; + renderBasinList(); + status.hidden = basins.length > 0; + if (basins.length === 0) status.textContent = "산정된 배수유역이 없습니다."; + scheduleDraw(); + } catch (error) { + status.hidden = false; + status.textContent = error instanceof Error ? error.message : "배수유역 산정에 실패했습니다."; + } finally { + analyzeButton.disabled = false; + } + } + + analyzeButton.addEventListener("click", () => void analyze()); + /** 노선 전체가 보이도록 배율·중심을 맞춘다. 노선이 없으면 도엽 전체를 그대로 보여준다. */ function fitToRoute(): void { scale = 1; @@ -216,12 +322,12 @@ export function createDrainagePanel(): DrainagePanel { ); if (sequence !== loadSequence) return; meta = nextMeta; - const normalizer = createNormalizer(nextMeta); + normalizer = createNormalizer(nextMeta); let featureCount = 0; loaded.forEach(([layer, data]) => { if (!data) return; featureCount += data.features?.length ?? 0; - preparedLayers.set(layer, prepareLayer(data, normalizer)); + preparedLayers.set(layer, prepareLayer(data, normalizer!)); }); backgroundImage.src = `${getVWorldMapUrl(activeProjectId, "satellite")}&_t=${Date.now()}`; if (routePoints.length > 1) routeLayer = prepareMetricPolyline(routePoints, nextMeta); diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index d9d16d5b..ad2e6289 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -930,3 +930,74 @@ font-size: var(--text-caption); pointer-events: none; } + +.b05-drainage__analyze { + margin-left: auto; + padding: 2px var(--spacing-8); + border: 1px solid + color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 55%, transparent); + border-radius: var(--radius-inputs); + background: var(--color-surface); + color: var(--color-text-body); + font-size: var(--text-caption); + cursor: pointer; +} + +.b05-drainage__analyze:disabled { + opacity: 0.45; + cursor: default; +} + +/* 유역 제원 목록 — 면적·유역표고·유하거리·관경(수식 확정 전까지 "미정"). */ +.b05-drainage__basins { + display: flex; + max-height: 34%; + flex: 0 0 auto; + flex-direction: column; + gap: 2px; + overflow-y: auto; + padding: var(--spacing-8); + border-top: 1px solid var(--color-border); +} + +.b05-drainage__basin { + display: flex; + align-items: center; + gap: var(--spacing-8); + padding: var(--spacing-4) var(--spacing-8); + border: 1px solid transparent; + border-radius: var(--radius-inputs); + background: none; + color: var(--color-text-body); + font-size: var(--text-caption); + text-align: left; + cursor: pointer; +} + +.b05-drainage__basin:hover, +.b05-drainage__basin.is-selected { + border-color: var(--color-border); + background: var(--color-surface); +} + +/* 지도 위 서클 번호와 같은 파스텔 색을 써서 목록 항목과 유역을 눈으로 잇는다. */ +.b05-drainage__basin-index { + display: inline-flex; + width: 20px; + height: 20px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border: 1px solid var(--color-border); + border-radius: 50%; + color: #1f2937; + font-size: 11px; + font-weight: 600; +} + +.b05-drainage__basin-metrics { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/main.py b/main.py index e32c62ea..50680a2a 100644 --- a/main.py +++ b/main.py @@ -32,6 +32,7 @@ from B04_wf1_Surface.B04_wf1_Surface_Router_Contour import router as b04_surface from B04_wf1_Surface.B04_wf1_Surface_Router_GIS import router as b04_surface_gis_router from B04_wf1_Surface.B04_wf1_Surface_Router_GIS import tiles_router from B05_wf2_Route.B05_wf2_Route_Router import router as b05_route_router +from B05_wf2_Route.B05_wf2_Route_Router_Drainage import router as b05_drainage_router from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Router import router as b06_section_router from B07_wf4_DesignDetail.B07_wf4_DesignDetail_Router import router as b07_design_router from common_util.common_util_auth import require_company, verify_session @@ -274,6 +275,7 @@ app.include_router(b04_surface_contour_router, dependencies=protected_with_compa app.include_router(b04_surface_gis_router, dependencies=protected_with_company) app.include_router(tiles_router, dependencies=protected_with_company) app.include_router(b05_route_router, dependencies=protected_with_company) +app.include_router(b05_drainage_router, dependencies=protected_with_company) app.include_router(b06_section_router, dependencies=protected_with_company) app.include_router(b07_design_router, dependencies=protected_with_company) From fa5e08c2ce3cfbba44d1710bd961bdaf220a9c20 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 28 Jul 2026 19:36:27 +0900 Subject: [PATCH 12/61] auto: 2026-07-28 19:36 (EOMSANGDON-HOME) --- B05_wf2_Route/B05_wf2_Route_Router_Drainage.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py index fabb361a..359fe649 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py +++ b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py @@ -123,7 +123,10 @@ async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse: content={"status": "error", "message": "확정된 경로가 없습니다."}, ) points = await get_route_points(connection, int(route["id"])) - epsg = await get_surface_crs_epsg(connection, project_id) + surface_model_id = route.get("surface_model_id") + epsg = await get_surface_crs_epsg( + connection, project_id, int(surface_model_id) if surface_model_id else 0 + ) vertices = build_route_vertices(points) if len(vertices) < 2: @@ -132,7 +135,7 @@ async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse: content={"status": "error", "message": "노선 좌표가 부족합니다."}, ) - source_crs = epsg or "EPSG:5186" + source_crs = f"EPSG:{epsg}" if epsg else "EPSG:5186" to_lonlat_transformer = Transformer.from_crs(source_crs, "EPSG:4326", always_xy=True) to_metric_transformer = Transformer.from_crs("EPSG:4326", source_crs, always_xy=True) From cb35e8c1cf2fffa6517f37bd3c880b568f291890 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 28 Jul 2026 19:53:22 +0900 Subject: [PATCH 13/61] auto: 2026-07-28 19:53 (EOMSANGDON-HOME) --- .../B04_wf1_Surface_UI_MapRender.ts | 27 ++ .../B05_wf2_Route_Engine_Drainage_Basin.py | 257 ---------- ...B05_wf2_Route_Engine_Drainage_Watershed.py | 449 ++++++++++++++++++ .../B05_wf2_Route_Router_Drainage.py | 20 +- .../B05_wf2_Route_UI_Drainage_Panel.ts | 20 + 5 files changed, 509 insertions(+), 264 deletions(-) delete mode 100644 B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py create mode 100644 B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts index 3892c42e..ad2a9efe 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts @@ -521,3 +521,30 @@ export function drawFilledRing( context.fillStyle = "#1f2937"; context.fillText(entry.label, centerX, centerY); } + +/** 유역 경계(분수령=능선)를 능선 스타일(갈색 파선)로 강조해 그린다. */ +export function drawRidgeRing( + context: CanvasRenderingContext2D, + ring: ReadonlyArray, + normalizer: Normalizer, + view: ViewState, +): void { + if (ring.length < 3) return; + const affine = affineOf(view); + context.beginPath(); + ring.forEach(([lon, lat], index) => { + const nx = (lon - normalizer.lonMin) / normalizer.lonRange; + const ny = 1 - (lat - normalizer.latMin) / normalizer.latRange; + const x = nx * affine.ax + affine.bx; + const y = ny * affine.ay + affine.by; + if (index === 0) context.moveTo(x, y); + else context.lineTo(x, y); + }); + context.closePath(); + context.save(); + context.strokeStyle = "#92400e"; + context.lineWidth = 1.8; + context.setLineDash([7, 4]); + context.stroke(); + context.restore(); +} diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py deleted file mode 100644 index 854dd34f..00000000 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py +++ /dev/null @@ -1,257 +0,0 @@ -"""배수유역 경계 산정. - -구조물 측점(관 매설 지점)에서 산정상부까지 역추적해 밀폐된 유역 경계를 만든다. -지형 판단 근거는 도엽 등고선·세류선·표고점뿐이다(3D 미사용, 2026-07-28 사용자 지시). - -정상부 판정 규칙(사용자 지시): - 표고점 데이터는 산 정상부가 아닌 경우가 많다. 따라서 **등고선의 동심 폐합 패턴** - (안쪽으로 갈수록 표고가 높아지는 폐합 등고선의 최내곽)으로 정상부를 먼저 판단하고, - 표고점은 그 판정을 보조·검증하는 용도로만 쓴다. -""" - -from __future__ import annotations - -import logging -import math -from typing import Any - -from shapely.geometry import LineString, Point, Polygon, shape -from shapely.ops import unary_union - -from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import ( - MAX_BASIN_RADIUS_M, - DrainageBasin, - RouteVertex, - StructureCandidate, - estimate_pipe_diameter_mm, -) - -logger = logging.getLogger(__name__) - -# 등고선을 폐합으로 볼 때 허용하는 시종점 이격(m). 도엽 경계에서 잘린 선을 걸러낸다. -CLOSED_TOLERANCE_M = 1.0 -# 폐합 등고선이 정상부 후보가 되는 최대 둘레(m). 이보다 크면 산체 전체라 정상부로 보지 않는다. -MAX_SUMMIT_PERIMETER_M = 1200.0 - - -class ContourField: - """도엽 등고선 피처 모음을 표고 조회·정상부 판정에 쓸 수 있게 감싼 것.""" - - def __init__(self, features: list[dict[str, Any]], elevation_keys: tuple[str, ...]): - self.lines: list[tuple[LineString, float]] = [] - self.closed: list[tuple[Polygon, float]] = [] - for feature in features: - elevation = _read_elevation(feature.get("properties") or {}, elevation_keys) - if elevation is None: - continue - geometry = feature.get("geometry") - if not geometry: - continue - try: - geom = shape(geometry) - except Exception: # noqa: BLE001 - 손상된 피처는 건너뛴다 - continue - for line in _iter_lines(geom): - self.lines.append((line, elevation)) - polygon = _as_closed_polygon(line) - if polygon is not None: - self.closed.append((polygon, elevation)) - - def elevation_at(self, x: float, y: float, radius_m: float = 200.0) -> float | None: - """가장 가까운 등고선의 표고를 그 지점의 표고로 본다.""" - point = Point(x, y) - best: tuple[float, float] | None = None - for line, elevation in self.lines: - distance = line.distance(point) - if distance > radius_m: - continue - if best is None or distance < best[0]: - best = (distance, elevation) - return None if best is None else best[1] - - def find_summits(self, near: Point, radius_m: float) -> list[tuple[Polygon, float]]: - """주변의 정상부 후보를 찾는다. - - 동심 폐합 등고선 중 **자기보다 높은 폐합 등고선을 안에 품지 않은 것**이 최내곽, - 즉 정상부다. 도엽 경계에서 잘린 선과 산체 전체를 감싸는 큰 폐합은 제외한다. - """ - nearby = [ - (polygon, elevation) - for polygon, elevation in self.closed - if polygon.length <= MAX_SUMMIT_PERIMETER_M and polygon.distance(near) <= radius_m - ] - summits: list[tuple[Polygon, float]] = [] - for polygon, elevation in nearby: - has_higher_inside = any( - other_elevation > elevation and polygon.contains(other.representative_point()) - for other, other_elevation in nearby - if other is not polygon - ) - if not has_higher_inside: - summits.append((polygon, elevation)) - return summits - - -def _read_elevation(properties: dict[str, Any], keys: tuple[str, ...]) -> float | None: - for key in keys: - value = properties.get(key) - if value is None: - continue - try: - return float(value) - except (TypeError, ValueError): - continue - return None - - -def _iter_lines(geometry: Any) -> list[LineString]: - if geometry.geom_type == "LineString": - return [geometry] - if geometry.geom_type == "MultiLineString": - return list(geometry.geoms) - return [] - - -def _as_closed_polygon(line: LineString) -> Polygon | None: - coords = list(line.coords) - if len(coords) < 4: - return None - if math.dist(coords[0], coords[-1]) > CLOSED_TOLERANCE_M: - return None - try: - polygon = Polygon(coords) - except Exception: # noqa: BLE001 - return None - return polygon if polygon.is_valid and polygon.area > 0 else None - - -def build_basins( - vertices: list[RouteVertex], - candidates: list[StructureCandidate], - contours: ContourField, - stream_features: list[dict[str, Any]], - to_lonlat: Any, -) -> list[DrainageBasin]: - """확정된 구조물 측점별 배수유역을 만든다. - - 측점에 물을 보내는 세류 가지를 따라 위로 올라가 정상부 폐합 등고선까지 닿는 범위를 - 유역으로 본다. 정상부는 ContourField.find_summits가 등고선 폐합 패턴으로 판정한다. - 번호는 노선 시점에 가까운 순서(측점 누가거리 오름차순)로 1부터 매긴다. - """ - route_line = ( - LineString([(vertex.x, vertex.y) for vertex in vertices]) if len(vertices) > 1 else None - ) - streams = _stream_lines(stream_features) - basins: list[DrainageBasin] = [] - for index, candidate in enumerate( - sorted(candidates, key=lambda item: item.chainage_m), start=1 - ): - outlet = Point(candidate.x, candidate.y) - uphill = _uphill_streams(outlet, streams, contours) - summits = contours.find_summits(outlet, MAX_BASIN_RADIUS_M) - boundary = _basin_polygon(outlet, uphill, summits, route_line) - if boundary is None or boundary.is_empty: - continue - outlet_elevation = contours.elevation_at(candidate.x, candidate.y) or 0.0 - top_elevation = max((elevation for _, elevation in summits), default=outlet_elevation) - basin = DrainageBasin( - index=index, - chainage_m=candidate.chainage_m, - outlet_x=candidate.x, - outlet_y=candidate.y, - polygon_lonlat=[list(to_lonlat(x, y)) for x, y in boundary.exterior.coords], - area_m2=float(boundary.area), - relief_m=float(max(0.0, top_elevation - outlet_elevation)), - flow_length_m=_flow_length(outlet, uphill, boundary), - ) - basin.pipe_diameter_mm = estimate_pipe_diameter_mm( - basin.area_m2, basin.relief_m, basin.flow_length_m - ) - basins.append(basin) - return basins - - -def _stream_lines(features: list[dict[str, Any]]) -> list[LineString]: - lines: list[LineString] = [] - for feature in features: - geometry = feature.get("geometry") - if not geometry: - continue - try: - lines.extend(_iter_lines(shape(geometry))) - except Exception: # noqa: BLE001 - continue - return lines - - -def _uphill_streams( - outlet: Point, - streams: list[LineString], - contours: ContourField, - tolerance_m: float = 30.0, -) -> list[LineString]: - """측점에 연결된 세류 가지 중 위쪽(표고가 높아지는 방향)으로 뻗은 것만 모은다.""" - connected = [line for line in streams if line.distance(outlet) <= tolerance_m] - uphill: list[LineString] = [] - outlet_elevation = contours.elevation_at(outlet.x, outlet.y) - for line in connected: - far = _far_end(line, outlet) - far_elevation = contours.elevation_at(far.x, far.y) - if outlet_elevation is None or far_elevation is None or far_elevation >= outlet_elevation: - uphill.append(line) - return uphill - - -def _far_end(line: LineString, outlet: Point) -> Point: - start = Point(line.coords[0]) - end = Point(line.coords[-1]) - return end if start.distance(outlet) <= end.distance(outlet) else start - - -def _basin_polygon( - outlet: Point, - uphill: list[LineString], - summits: list[tuple[Polygon, float]], - route_line: LineString | None, -) -> Polygon | None: - """유역 경계를 만든다. - - 측점 + 상류 세류 + 정상부 폐합 등고선을 함께 감싸는 볼록 껍질을 1차 경계로 삼고, - 노선 아래쪽(성토부 방향)은 노선을 경계로 잘라낸다. 세류가 없으면 정상부까지의 - 반경 안에서 만들어지는 범위만 남는다. - """ - parts: list[Any] = [outlet.buffer(5.0)] - parts.extend(uphill) - parts.extend(polygon for polygon, _ in summits) - if len(parts) <= 1: - return None - hull = unary_union(parts).convex_hull - if hull.geom_type != "Polygon": - return None - if route_line is not None: - hull = _clip_downhill(hull, route_line, outlet) - return hull if hull is not None and hull.geom_type == "Polygon" else None - - -def _clip_downhill(hull: Polygon, route_line: LineString, outlet: Point) -> Polygon | None: - """노선을 경계로 유역을 잘라 산 쪽(상류) 조각만 남긴다.""" - try: - pieces = hull.difference(route_line.buffer(0.5)) - except Exception: # noqa: BLE001 - return hull - if pieces.is_empty: - return hull - parts = list(pieces.geoms) if pieces.geom_type == "MultiPolygon" else [pieces] - # 상류 조각 판별이 애매할 때를 대비해 면적이 가장 큰 조각을 채택한다. - best = max(parts, key=lambda part: part.area, default=None) - return best if best is not None and best.geom_type == "Polygon" else hull - - -def _flow_length(outlet: Point, uphill: list[LineString], boundary: Polygon) -> float: - """유하거리: 측점에서 유역 최상단까지의 물길 길이(m). - - 상류 세류가 있으면 그 물길 길이의 최댓값을, 없으면 유역 안 최원점까지의 직선거리를 쓴다. - """ - if uphill: - return float(max(line.length for line in uphill)) - return float(max((outlet.distance(Point(xy)) for xy in boundary.exterior.coords), default=0.0)) diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py new file mode 100644 index 00000000..bdcfe002 --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py @@ -0,0 +1,449 @@ +"""배수유역 능선(분수령) 기반 산정 엔진. + +목적: 도로(관 매설 지점)로 모이는 물의 양을 알기 위한 유역 산정. 유역 경계는 반드시 +분수령(능선)을 따라야 하므로, 도엽 등고선·표고점을 격자 DEM으로 보간한 뒤 D8 흐름 +방향으로 "각 셀의 물이 어느 측점으로 흘러가는가"를 직접 추적한다. + +- 3D 라이다는 산 전체를 계측하지 않으므로 쓰지 않는다. 도엽 데이터만 사용(사용자 확정). +- 함몰 보정은 Whitebox `fill_depressions`를 쓰고(기존 Engine_Skeleton과 동일 패턴), + 실패하면 원본 DEM으로 진행한다(결과 저하 가능하나 계산은 지속). +- 유역 폴리곤 외곽선이 곧 분수령(능선)이며 프론트가 능선 스타일로 표시한다. +""" + +from __future__ import annotations + +import logging +import math +import tempfile +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import numpy as np +from scipy.interpolate import griddata +from shapely.geometry import shape + +from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import ( + StructureCandidate, + estimate_pipe_diameter_mm, +) + +logger = logging.getLogger(__name__) + +# 격자 해상도(m)와 최대 격자 크기. 도엽 9매 범위라도 이 상한 안에서 해상도를 낮춰 계산한다. +GRID_RES_M = 10.0 +MAX_GRID_CELLS = 1_400_000 +# 유역 계산 범위: 측점 bbox + 여유폭(m). 주변 8도엽까지 확보되어 있어 넉넉히 잡는다. +BBOX_MARGIN_M = 1500.0 +# pour point 스냅 반경(m): 측점을 주변 흐름 누적 최대 셀로 옮겨 세류 격자 정합 오차를 흡수. +SNAP_RADIUS_M = 50.0 +# DEM 보간 표본 상한(속도 확보용 간축). 초과 시 균등 간격으로 추린다. +MAX_SAMPLE_POINTS = 250_000 + +# D8 이웃: (행 오프셋, 열 오프셋, 거리 계수) +_D8 = ( + (-1, -1, math.sqrt(2.0)), + (-1, 0, 1.0), + (-1, 1, math.sqrt(2.0)), + (0, -1, 1.0), + (0, 1, 1.0), + (1, -1, math.sqrt(2.0)), + (1, 0, 1.0), + (1, 1, math.sqrt(2.0)), +) + + +@dataclass +class WatershedBasin: + """능선 기반으로 산정된 배수유역 1개.""" + + index: int + chainage_m: float + outlet_x: float + outlet_y: float + # 유역 경계(사업지 좌표계 m). 외곽선이 곧 분수령(능선). + boundary_xy: list[list[float]] = field(default_factory=list) + area_m2: float = 0.0 + relief_m: float = 0.0 + flow_length_m: float = 0.0 + pipe_diameter_mm: float | None = None + + +def _collect_samples( + contour_features: list[dict[str, Any]], + spot_features: list[dict[str, Any]], + elevation_keys: tuple[str, ...], +) -> np.ndarray: + """등고선 정점·표고점을 (x, y, z) 표본 배열로 모은다.""" + xs: list[float] = [] + ys: list[float] = [] + zs: list[float] = [] + + def _walk(coordinates: Any, elevation: float) -> None: + if not isinstance(coordinates, list) or not coordinates: + return + if isinstance(coordinates[0], (int, float)): + xs.append(float(coordinates[0])) + ys.append(float(coordinates[1])) + zs.append(elevation) + return + for item in coordinates: + _walk(item, elevation) + + for feature in [*contour_features, *spot_features]: + properties = feature.get("properties") or {} + elevation: float | None = None + for key in elevation_keys: + value = properties.get(key) + if value is None: + continue + try: + elevation = float(value) + break + except (TypeError, ValueError): + continue + if elevation is None: + continue + geometry = feature.get("geometry") or {} + _walk(geometry.get("coordinates"), elevation) + + if not xs: + return np.empty((0, 3)) + samples = np.column_stack([xs, ys, zs]) + if len(samples) > MAX_SAMPLE_POINTS: + step = len(samples) // MAX_SAMPLE_POINTS + 1 + samples = samples[::step] + return samples + + +def _build_dem( + samples: np.ndarray, + candidates: list[StructureCandidate], +) -> tuple[np.ndarray, np.ndarray, np.ndarray, float] | None: + """표본을 격자 DEM으로 보간한다. 반환: (dem, x좌표, y좌표, 해상도).""" + if len(samples) < 10 or not candidates: + return None + min_x = min(candidate.x for candidate in candidates) - BBOX_MARGIN_M + max_x = max(candidate.x for candidate in candidates) + BBOX_MARGIN_M + min_y = min(candidate.y for candidate in candidates) - BBOX_MARGIN_M + max_y = max(candidate.y for candidate in candidates) + BBOX_MARGIN_M + # 표본 범위 밖으로는 나가지 않는다(외삽 방지). + min_x = max(min_x, float(samples[:, 0].min())) + max_x = min(max_x, float(samples[:, 0].max())) + min_y = max(min_y, float(samples[:, 1].min())) + max_y = min(max_y, float(samples[:, 1].max())) + if max_x - min_x < GRID_RES_M * 4 or max_y - min_y < GRID_RES_M * 4: + return None + + resolution = GRID_RES_M + while ((max_x - min_x) / resolution) * ((max_y - min_y) / resolution) > MAX_GRID_CELLS: + resolution *= 1.5 + x_coords = np.arange(min_x, max_x + resolution, resolution) + y_coords = np.arange(min_y, max_y + resolution, resolution) + grid_x, grid_y = np.meshgrid(x_coords, y_coords) + + points = samples[:, :2] + values = samples[:, 2] + dem = griddata(points, values, (grid_x, grid_y), method="linear") + # linear 보간 밖(볼록 껍질 바깥)은 nearest로 메워 유역 추적이 끊기지 않게 한다. + holes = ~np.isfinite(dem) + if holes.any(): + dem[holes] = griddata(points, values, (grid_x[holes], grid_y[holes]), method="nearest") + return dem.astype(np.float64), x_coords, y_coords, resolution + + +def _fill_depressions(dem: np.ndarray, resolution: float) -> np.ndarray: + """Whitebox로 함몰을 메운다. 실패하면 원본 그대로 진행한다.""" + try: + import rasterio + from rasterio.transform import from_origin + from whitebox import WhiteboxTools + except Exception: # noqa: BLE001 + return dem + rows, cols = dem.shape + try: + with tempfile.TemporaryDirectory(prefix="wbt_drain_") as tmp: + tmp_path = Path(tmp) + transform = from_origin(0.0, rows * resolution, resolution, resolution) + with rasterio.open( + tmp_path / "dem.tif", + "w", + driver="GTiff", + height=rows, + width=cols, + count=1, + dtype="float32", + nodata=-9999.0, + crs="EPSG:3857", + transform=transform, + ) as dst: + dst.write(dem.astype(np.float32)[::-1, :], 1) + wbt = WhiteboxTools() + wbt.set_verbose_mode(False) + wbt.set_working_dir(str(tmp_path)) + if wbt.fill_depressions("dem.tif", "filled.tif") != 0: + raise RuntimeError("fill_depressions 실패") + with rasterio.open(tmp_path / "filled.tif") as src: + filled = src.read(1).astype(np.float64)[::-1, :] + return np.where(np.isfinite(filled), filled, dem) + except Exception: # noqa: BLE001 + logger.warning("Whitebox 함몰 보정 실패 — 원본 DEM으로 진행") + return dem + + +def _d8_pointer(dem: np.ndarray) -> np.ndarray: + """각 셀의 최급강하 이웃 인덱스(0~7, 배수구 없으면 -1).""" + rows, cols = dem.shape + pointer = np.full((rows, cols), -1, dtype=np.int8) + best_drop = np.zeros((rows, cols), dtype=np.float64) + for direction, (dr, dc, distance) in enumerate(_D8): + shifted = np.full_like(dem, np.inf) + r_src = slice(max(0, -dr), rows - max(0, dr)) + c_src = slice(max(0, -dc), cols - max(0, dc)) + r_dst = slice(max(0, dr), rows - max(0, -dr)) + c_dst = slice(max(0, dc), cols - max(0, -dc)) + shifted[r_src, c_src] = dem[r_dst, c_dst] + drop = (dem - shifted) / distance + better = drop > best_drop + pointer[better] = direction + best_drop[better] = drop[better] + return pointer + + +def _flow_accumulation(pointer: np.ndarray) -> np.ndarray: + """D8 포인터 기반 흐름 누적(자기 자신 포함 셀 수). 위상 순서로 한 번에 계산.""" + rows, cols = pointer.shape + accumulation = np.ones((rows, cols), dtype=np.float64) + indegree = np.zeros((rows, cols), dtype=np.int32) + for direction, (dr, dc, _) in enumerate(_D8): + sources = np.argwhere(pointer == direction) + for r, c in sources: + nr, nc = r + dr, c + dc + if 0 <= nr < rows and 0 <= nc < cols: + indegree[nr, nc] += 1 + stack = [tuple(cell) for cell in np.argwhere(indegree == 0)] + while stack: + r, c = stack.pop() + direction = pointer[r, c] + if direction < 0: + continue + dr, dc, _ = _D8[direction] + nr, nc = r + dr, c + dc + if not (0 <= nr < rows and 0 <= nc < cols): + continue + accumulation[nr, nc] += accumulation[r, c] + indegree[nr, nc] -= 1 + if indegree[nr, nc] == 0: + stack.append((nr, nc)) + return accumulation + + +def _snap_outlet( + accumulation: np.ndarray, + row: int, + col: int, + radius_cells: int, +) -> tuple[int, int]: + """측점 주변 반경 안에서 흐름 누적이 가장 큰 셀로 옮긴다(물길 위로 스냅).""" + rows, cols = accumulation.shape + r0 = max(0, row - radius_cells) + r1 = min(rows, row + radius_cells + 1) + c0 = max(0, col - radius_cells) + c1 = min(cols, col + radius_cells + 1) + window = accumulation[r0:r1, c0:c1] + local = np.unravel_index(int(np.argmax(window)), window.shape) + return r0 + int(local[0]), c0 + int(local[1]) + + +def _label_basins( + pointer: np.ndarray, + outlets: dict[tuple[int, int], int], +) -> np.ndarray: + """각 셀이 흐름을 따라 처음 만나는 pour point의 라벨을 붙인다(경로 메모이제이션).""" + rows, cols = pointer.shape + labels = np.zeros((rows, cols), dtype=np.int32) # 0 = 미소속 + for (r, c), label in outlets.items(): + labels[r, c] = label + flat_pointer = pointer.ravel() + flat_labels = labels.ravel() + for start in range(flat_labels.size): + if flat_labels[start] != 0: + continue + path: list[int] = [] + current = start + label = 0 + while True: + if flat_labels[current] != 0: + label = flat_labels[current] + break + direction = flat_pointer[current] + if direction < 0: + label = -1 # 배수구 없음(격자 밖 유출) — 어떤 유역에도 속하지 않음 + break + path.append(current) + dr, dc, _ = _D8[direction] + r, c = divmod(current, cols) + nr, nc = r + dr, c + dc + if not (0 <= nr < rows and 0 <= nc < cols): + label = -1 + break + current = nr * cols + nc + for cell in path: + flat_labels[cell] = label + return labels + + +def _flow_lengths(pointer: np.ndarray, labels: np.ndarray, resolution: float) -> dict[int, float]: + """라벨별 최장 흐름 경로(셀→해당 pour point) 길이.""" + rows, cols = pointer.shape + distance = np.full((rows, cols), -1.0, dtype=np.float64) + # pour point 셀은 자기 라벨의 시작점이므로 거리 0. + longest: dict[int, float] = {} + flat_pointer = pointer.ravel() + flat_labels = labels.ravel() + flat_distance = distance.ravel() + + def _resolve(start: int) -> float: + chain: list[int] = [] + current = start + total = 0.0 + while True: + if flat_distance[current] >= 0: + total = flat_distance[current] + break + direction = flat_pointer[current] + if direction < 0: + total = 0.0 + break + r, c = divmod(current, cols) + dr, dc, factor = _D8[direction] + nr, nc = r + dr, c + dc + if not (0 <= nr < rows and 0 <= nc < cols): + total = 0.0 + break + next_cell = nr * cols + nc + # 다음 셀이 다른 라벨이면(=pour point 통과) 여기서 경로가 끝난 것으로 본다. + chain.append(current) + if flat_labels[next_cell] != flat_labels[current]: + total = 0.0 + break + current = next_cell + # 뒤에서부터 거리를 되채운다. + for cell in reversed(chain): + direction = flat_pointer[cell] + factor = _D8[direction][2] if direction >= 0 else 0.0 + total += factor * resolution + flat_distance[cell] = total + return total + + for start in range(flat_labels.size): + label = int(flat_labels[start]) + if label <= 0: + continue + length = _resolve(start) + if length > longest.get(label, 0.0): + longest[label] = length + return longest + + +def _vectorize_basin( + labels: np.ndarray, + label: int, + x_coords: np.ndarray, + y_coords: np.ndarray, + resolution: float, +) -> list[list[float]]: + """유역 셀 집합을 폴리곤 외곽 링(사업지 좌표계)으로 벡터화한다.""" + try: + from rasterio import features as rio_features + from rasterio.transform import from_origin + except Exception: # noqa: BLE001 + return [] + mask = (labels == label).astype(np.uint8) + if mask.sum() == 0: + return [] + transform = from_origin( + float(x_coords[0]) - resolution / 2.0, + float(y_coords[-1]) + resolution / 2.0, + resolution, + resolution, + ) + shapes = rio_features.shapes(mask[::-1, :], mask=mask[::-1, :] > 0, transform=transform) + polygons = [shape(geometry) for geometry, value in shapes if value == 1] + if not polygons: + return [] + merged = max(polygons, key=lambda polygon: polygon.area) + simplified = merged.simplify(resolution, preserve_topology=True) + if simplified.is_empty or simplified.geom_type != "Polygon": + simplified = merged + return [[float(x), float(y)] for x, y in simplified.exterior.coords] + + +def build_watershed_basins( + candidates: list[StructureCandidate], + contour_features: list[dict[str, Any]], + spot_features: list[dict[str, Any]], + elevation_keys: tuple[str, ...], +) -> list[WatershedBasin]: + """능선(분수령) 기반 배수유역을 산정한다. + + 반환된 boundary_xy 외곽선이 곧 분수령(능선)이다. 번호는 노선 시점에 가까운 순. + """ + if not candidates: + return [] + samples = _collect_samples(contour_features, spot_features, elevation_keys) + built = _build_dem(samples, candidates) + if built is None: + logger.warning("DEM 보간 실패 — 표본 %d개", len(samples)) + return [] + dem, x_coords, y_coords, resolution = built + dem = _fill_depressions(dem, resolution) + pointer = _d8_pointer(dem) + accumulation = _flow_accumulation(pointer) + + ordered = sorted(candidates, key=lambda item: item.chainage_m) + radius_cells = max(1, int(SNAP_RADIUS_M / resolution)) + outlets: dict[tuple[int, int], int] = {} + outlet_cells: dict[int, tuple[int, int]] = {} + for label, candidate in enumerate(ordered, start=1): + col = int(round((candidate.x - float(x_coords[0])) / resolution)) + row = int(round((candidate.y - float(y_coords[0])) / resolution)) + if not (0 <= row < dem.shape[0] and 0 <= col < dem.shape[1]): + continue + snapped = _snap_outlet(accumulation, row, col, radius_cells) + outlets[snapped] = label + outlet_cells[label] = snapped + + if not outlets: + return [] + labels = _label_basins(pointer, outlets) + lengths = _flow_lengths(pointer, labels, resolution) + + basins: list[WatershedBasin] = [] + for label, candidate in enumerate(ordered, start=1): + cell = outlet_cells.get(label) + if cell is None: + continue + mask = labels == label + cell_count = int(mask.sum()) + if cell_count < 4: + continue + boundary = _vectorize_basin(labels, label, x_coords, y_coords, resolution) + if len(boundary) < 4: + continue + outlet_z = float(dem[cell[0], cell[1]]) + basin = WatershedBasin( + index=label, + chainage_m=candidate.chainage_m, + outlet_x=candidate.x, + outlet_y=candidate.y, + boundary_xy=boundary, + area_m2=cell_count * resolution * resolution, + relief_m=max(0.0, float(dem[mask].max()) - outlet_z), + flow_length_m=lengths.get(label, 0.0), + ) + basin.pipe_diameter_mm = estimate_pipe_diameter_mm( + basin.area_m2, basin.relief_m, basin.flow_length_m + ) + basins.append(basin) + return basins diff --git a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py index 359fe649..3d137c3f 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py +++ b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py @@ -20,7 +20,7 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import ( build_route_vertices, propose_structure_stations, ) -from B05_wf2_Route.B05_wf2_Route_Engine_Drainage_Basin import ContourField, build_basins +from B05_wf2_Route.B05_wf2_Route_Engine_Drainage_Watershed import build_watershed_basins from B05_wf2_Route.B05_wf2_Route_Repository import ( get_latest_route, get_route_points, @@ -35,8 +35,9 @@ router = APIRouter(prefix="/api/projects", tags=["B05 Route Drainage"]) # 도엽 레이어 파일명 (B04 전처리 산출물과 동일 위치) _CONTOUR_FILE = "도엽_등고선.geojson" _STREAM_FILE = "도엽_하천중심선.geojson" -# 도엽 등고선의 표고 속성 키. gpkg 등고선(CTRLN_HG)도 함께 본다. -_ELEVATION_KEYS = ("등고수치", "CTRLN_HG", "elevation", "ELEV") +_SPOT_FILE = "도엽_표고점.geojson" +# 표고 속성 키: 도엽 등고선(등고수치)·gpkg 등고선(CTRLN_HG)·표고점(수치/표고) 통합. +_ELEVATION_KEYS = ("등고수치", "CTRLN_HG", "수치", "표고", "높이", "elevation", "ELEV") def _sheet_dir(stored_path: str) -> Path: @@ -144,11 +145,15 @@ async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse: contour_features = _reproject_features( _load_features(directory, _CONTOUR_FILE), to_metric_transformer ) + spot_features = _reproject_features( + _load_features(directory, _SPOT_FILE), to_metric_transformer + ) return { "route_id": int(route["id"]), "vertices": vertices, "streams": streams, "contours": contour_features, + "spots": spot_features, "to_lonlat": lambda x, y: to_lonlat_transformer.transform(x, y), } @@ -188,10 +193,10 @@ async def post_drainage_basins( else: candidates = propose_structure_stations(vertices, prepared["streams"]) - contours = ContourField(prepared["contours"], _ELEVATION_KEYS) - basins = build_basins( - vertices, candidates, contours, prepared["streams"], prepared["to_lonlat"] + basins = build_watershed_basins( + candidates, prepared["contours"], prepared["spots"], _ELEVATION_KEYS ) + to_lonlat = prepared["to_lonlat"] return { "status": "success", "project_id": str(project_id), @@ -200,7 +205,8 @@ async def post_drainage_basins( { "index": basin.index, "chainage_m": round(basin.chainage_m, 2), - "polygon_lonlat": basin.polygon_lonlat, + # 유역 경계 외곽선 = 분수령(능선). 프론트가 파스텔 채움 + 능선 파선으로 표시한다. + "polygon_lonlat": [list(to_lonlat(x, y)) for x, y in basin.boundary_xy], "area_m2": round(basin.area_m2, 1), "relief_m": round(basin.relief_m, 2), "flow_length_m": round(basin.flow_length_m, 1), diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts index 5cf7fd33..ac1f419a 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -10,6 +10,7 @@ import { createNormalizer, drawFilledRing, drawPreparedLayer, + drawRidgeRing, prepareLayer, prepareMetricPolyline, type GeoJsonCollection, @@ -116,6 +117,8 @@ export function createDrainagePanel(): DrainagePanel { let normalizer: Normalizer | null = null; let basins: DrainageBasin[] = []; let selectedBasin: number | null = null; + // 유역 경계 외곽선 = 분수령(능선). 사용자 지시로 기본 표시. + let showRidge = true; let scale = 1; let offsetX = 0; let offsetY = 0; @@ -144,6 +147,21 @@ export function createDrainagePanel(): DrainagePanel { layerButtons.append(button); }); + // 능선(분수령) 표시 토글 — 유역 경계 파선. 기본 켜짐(사용자 지시). + const ridgeButton = document.createElement("button"); + ridgeButton.type = "button"; + ridgeButton.className = "b05-drainage__layer-button is-active"; + ridgeButton.textContent = "능선"; + ridgeButton.style.setProperty("--b05-layer-color", "#92400e"); + ridgeButton.setAttribute("aria-pressed", "true"); + ridgeButton.addEventListener("click", () => { + showRidge = !showRidge; + ridgeButton.classList.toggle("is-active", showRidge); + ridgeButton.setAttribute("aria-pressed", String(showRidge)); + scheduleDraw(); + }); + layerButtons.append(ridgeButton); + function updateImageTransform(): void { backgroundImage.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`; } @@ -181,6 +199,8 @@ export function createDrainagePanel(): DrainagePanel { ? color : color.replace(/0\.45\)$/, "0.18)"), ); + // 유역 경계 = 분수령이므로 그 외곽선을 능선 파선으로 강조한다. + if (showRidge) drawRidgeRing(context, basin.polygon_lonlat, normalizer!, view); }); } // 등고선을 얇게 깔고 세류·표고점을 그 위에, 노선을 맨 위에 둔다. From 121d20ef80a45f5f3dce7a53784bbdbdb8556bd9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 28 Jul 2026 20:03:32 +0900 Subject: [PATCH 14/61] auto: 2026-07-28 20:03 (EOMSANGDON-HOME) --- ...B05_wf2_Route_Engine_Drainage_Watershed.py | 193 ++++++++++-------- .../B05_wf2_Route_Router_Drainage.py | 2 +- 2 files changed, 114 insertions(+), 81 deletions(-) diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py index bdcfe002..ebf41f7b 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py @@ -1,13 +1,15 @@ -"""배수유역 능선(분수령) 기반 산정 엔진. +"""배수유역 도로(노선) 기준 산정 엔진 — 측구 흐름 모델. -목적: 도로(관 매설 지점)로 모이는 물의 양을 알기 위한 유역 산정. 유역 경계는 반드시 -분수령(능선)을 따라야 하므로, 도엽 등고선·표고점을 격자 DEM으로 보간한 뒤 D8 흐름 -방향으로 "각 셀의 물이 어느 측점으로 흘러가는가"를 직접 추적한다. +목적: 도로로 오는 물의 양 산정. 참고 도면(2026-07-28 사용자 제공)과 같이 **도로 산측 +사면 전체를 관(구조물 측점) 개수만큼 빈틈없이 분할**한다: -- 3D 라이다는 산 전체를 계측하지 않으므로 쓰지 않는다. 도엽 데이터만 사용(사용자 확정). -- 함몰 보정은 Whitebox `fill_depressions`를 쓰고(기존 Engine_Skeleton과 동일 패턴), - 실패하면 원본 DEM으로 진행한다(결과 저하 가능하나 계산은 지속). -- 유역 폴리곤 외곽선이 곧 분수령(능선)이며 프론트가 능선 스타일로 표시한다. +- 도엽 등고선·표고점을 격자 DEM으로 보간 (3D 라이다는 산 전체를 계측하지 않아 미사용). +- 노선을 도로 셀로 래스터화하고, 관 사이 종단 최고점(물갈림 고개)을 경계로 도로 셀마다 + 담당 관을 배정 — "도로에 닿은 물은 측구를 타고 내리막의 첫 관으로 들어간다". +- 사면 각 셀은 D8 흐름으로 내려가 처음 닿는 도로 셀의 관을 물려받는다. 도로를 만나지 + 못하는 셀(도로 하측 사면, 능선 너머)은 자동 제외. +- 함몰 보정은 Whitebox `fill_depressions`(Engine_Skeleton과 동일 패턴), 실패 시 원본 진행. +- 유역 경계의 산측이 분수령(능선)·지능선이고 하측이 도로선이다. 프론트가 능선 파선 표시. """ from __future__ import annotations @@ -33,10 +35,8 @@ logger = logging.getLogger(__name__) # 격자 해상도(m)와 최대 격자 크기. 도엽 9매 범위라도 이 상한 안에서 해상도를 낮춰 계산한다. GRID_RES_M = 10.0 MAX_GRID_CELLS = 1_400_000 -# 유역 계산 범위: 측점 bbox + 여유폭(m). 주변 8도엽까지 확보되어 있어 넉넉히 잡는다. +# 유역 계산 범위: 노선 bbox + 여유폭(m). 주변 8도엽까지 확보되어 있어 넉넉히 잡는다. BBOX_MARGIN_M = 1500.0 -# pour point 스냅 반경(m): 측점을 주변 흐름 누적 최대 셀로 옮겨 세류 격자 정합 오차를 흡수. -SNAP_RADIUS_M = 50.0 # DEM 보간 표본 상한(속도 확보용 간축). 초과 시 균등 간격으로 추린다. MAX_SAMPLE_POINTS = 250_000 @@ -118,15 +118,20 @@ def _collect_samples( def _build_dem( samples: np.ndarray, - candidates: list[StructureCandidate], + anchors_x: list[float], + anchors_y: list[float], ) -> tuple[np.ndarray, np.ndarray, np.ndarray, float] | None: - """표본을 격자 DEM으로 보간한다. 반환: (dem, x좌표, y좌표, 해상도).""" - if len(samples) < 10 or not candidates: + """표본을 격자 DEM으로 보간한다. 반환: (dem, x좌표, y좌표, 해상도). + + 범위는 앵커(노선 전체 정점) bbox + 여유폭 — 유역이 노선 전 연장을 덮어야 하므로 + 측점 bbox가 아니라 노선 bbox를 쓴다. + """ + if len(samples) < 10 or not anchors_x: return None - min_x = min(candidate.x for candidate in candidates) - BBOX_MARGIN_M - max_x = max(candidate.x for candidate in candidates) + BBOX_MARGIN_M - min_y = min(candidate.y for candidate in candidates) - BBOX_MARGIN_M - max_y = max(candidate.y for candidate in candidates) + BBOX_MARGIN_M + min_x = min(anchors_x) - BBOX_MARGIN_M + max_x = max(anchors_x) + BBOX_MARGIN_M + min_y = min(anchors_y) - BBOX_MARGIN_M + max_y = max(anchors_y) + BBOX_MARGIN_M # 표본 범위 밖으로는 나가지 않는다(외삽 방지). min_x = max(min_x, float(samples[:, 0].min())) max_x = min(max_x, float(samples[:, 0].max())) @@ -210,49 +215,75 @@ def _d8_pointer(dem: np.ndarray) -> np.ndarray: return pointer -def _flow_accumulation(pointer: np.ndarray) -> np.ndarray: - """D8 포인터 기반 흐름 누적(자기 자신 포함 셀 수). 위상 순서로 한 번에 계산.""" - rows, cols = pointer.shape - accumulation = np.ones((rows, cols), dtype=np.float64) - indegree = np.zeros((rows, cols), dtype=np.int32) - for direction, (dr, dc, _) in enumerate(_D8): - sources = np.argwhere(pointer == direction) - for r, c in sources: - nr, nc = r + dr, c + dc - if 0 <= nr < rows and 0 <= nc < cols: - indegree[nr, nc] += 1 - stack = [tuple(cell) for cell in np.argwhere(indegree == 0)] - while stack: - r, c = stack.pop() - direction = pointer[r, c] - if direction < 0: - continue - dr, dc, _ = _D8[direction] - nr, nc = r + dr, c + dc - if not (0 <= nr < rows and 0 <= nc < cols): - continue - accumulation[nr, nc] += accumulation[r, c] - indegree[nr, nc] -= 1 - if indegree[nr, nc] == 0: - stack.append((nr, nc)) - return accumulation +def _ditch_intervals( + vertices: list[Any], + ordered: list[StructureCandidate], +) -> list[tuple[float, float, int]]: + """측구(도로변 배수로) 흐름 모델의 담당 구간을 만든다. + + 도로에 닿은 물은 종단 내리막 방향으로 흘러 첫 번째 관으로 들어간다. 따라서 인접한 + 두 관 사이 **종단 계획선의 최고점(물갈림 고개)**이 유역 분할점이 된다. + 반환: (구간 시작 chainage, 구간 끝 chainage, 관 라벨) 목록. + """ + total_length = vertices[-1].chainage_m if vertices else 0.0 + divides: list[float] = [] + for left, right in zip(ordered, ordered[1:]): + window = [ + vertex for vertex in vertices if left.chainage_m < vertex.chainage_m < right.chainage_m + ] + if window: + divides.append(max(window, key=lambda vertex: vertex.z).chainage_m) + else: + divides.append((left.chainage_m + right.chainage_m) / 2.0) + intervals: list[tuple[float, float, int]] = [] + start = 0.0 + for label, divide in enumerate(divides, start=1): + intervals.append((start, divide, label)) + start = divide + intervals.append((start, total_length + 1.0, len(ordered))) + return intervals -def _snap_outlet( - accumulation: np.ndarray, - row: int, - col: int, - radius_cells: int, -) -> tuple[int, int]: - """측점 주변 반경 안에서 흐름 누적이 가장 큰 셀로 옮긴다(물길 위로 스냅).""" - rows, cols = accumulation.shape - r0 = max(0, row - radius_cells) - r1 = min(rows, row + radius_cells + 1) - c0 = max(0, col - radius_cells) - c1 = min(cols, col + radius_cells + 1) - window = accumulation[r0:r1, c0:c1] - local = np.unravel_index(int(np.argmax(window)), window.shape) - return r0 + int(local[0]), c0 + int(local[1]) +def _rasterize_road( + vertices: list[Any], + intervals: list[tuple[float, float, int]], + x_coords: np.ndarray, + y_coords: np.ndarray, + resolution: float, + shape_rc: tuple[int, int], +) -> dict[tuple[int, int], int]: + """노선 폴리라인을 격자에 새겨 도로 셀마다 담당 관 라벨을 붙인다. + + 대각 누수(도로가 셀 사이 대각으로 지나가 물이 새는 것)를 막으려고 도로 셀 주변 + 3×3을 함께 같은 라벨로 칠한다. + """ + rows, cols = shape_rc + + def _label_of(chainage: float) -> int: + for start, end, label in intervals: + if start <= chainage < end: + return label + return intervals[-1][2] if intervals else 0 + + road: dict[tuple[int, int], int] = {} + step = resolution / 2.0 + for previous, current in zip(vertices, vertices[1:]): + span = math.dist((previous.x, previous.y), (current.x, current.y)) + count = max(1, int(span / step)) + for i in range(count + 1): + ratio = i / count + x = previous.x + (current.x - previous.x) * ratio + y = previous.y + (current.y - previous.y) * ratio + chainage = previous.chainage_m + (current.chainage_m - previous.chainage_m) * ratio + col = int(round((x - float(x_coords[0])) / resolution)) + row = int(round((y - float(y_coords[0])) / resolution)) + label = _label_of(chainage) + for dr in (-1, 0, 1): + for dc in (-1, 0, 1): + r, c = row + dr, col + dc + if 0 <= r < rows and 0 <= c < cols and (r, c) not in road: + road[(r, c)] = label + return road def _label_basins( @@ -380,40 +411,41 @@ def _vectorize_basin( def build_watershed_basins( + vertices: list[Any], candidates: list[StructureCandidate], contour_features: list[dict[str, Any]], spot_features: list[dict[str, Any]], elevation_keys: tuple[str, ...], ) -> list[WatershedBasin]: - """능선(분수령) 기반 배수유역을 산정한다. + """도로(노선) 기준 배수유역을 산정한다 — 측구 흐름 모델. - 반환된 boundary_xy 외곽선이 곧 분수령(능선)이다. 번호는 노선 시점에 가까운 순. + "도로에 닿은 물은 측구를 타고 종단 내리막 방향으로 흘러 첫 번째 관으로 들어간다"를 + 전제로, 도로 산측 사면 전체를 관 개수만큼 빈틈없이 분할한다(참고 도면과 동일 개념): + ① 노선 전체를 도로 셀로 래스터화하고, 관 사이 종단 최고점(물갈림 고개)을 경계로 + 각 도로 셀에 담당 관 라벨을 붙인다. + ② 사면 각 셀은 D8 흐름을 따라 내려가 처음 닿는 도로 셀의 관 라벨을 물려받는다. + 도로를 만나지 못하고 격자 밖으로 빠지는 셀(도로 하측 성토부 등)은 제외된다. + 반환된 boundary_xy 외곽선의 산측이 곧 분수령(능선)이며, 하측은 도로선을 따른다. + 번호는 노선 시점에 가까운 순. """ - if not candidates: + if not candidates or len(vertices) < 2: return [] samples = _collect_samples(contour_features, spot_features, elevation_keys) - built = _build_dem(samples, candidates) + built = _build_dem( + samples, + [vertex.x for vertex in vertices], + [vertex.y for vertex in vertices], + ) if built is None: logger.warning("DEM 보간 실패 — 표본 %d개", len(samples)) return [] dem, x_coords, y_coords, resolution = built dem = _fill_depressions(dem, resolution) pointer = _d8_pointer(dem) - accumulation = _flow_accumulation(pointer) ordered = sorted(candidates, key=lambda item: item.chainage_m) - radius_cells = max(1, int(SNAP_RADIUS_M / resolution)) - outlets: dict[tuple[int, int], int] = {} - outlet_cells: dict[int, tuple[int, int]] = {} - for label, candidate in enumerate(ordered, start=1): - col = int(round((candidate.x - float(x_coords[0])) / resolution)) - row = int(round((candidate.y - float(y_coords[0])) / resolution)) - if not (0 <= row < dem.shape[0] and 0 <= col < dem.shape[1]): - continue - snapped = _snap_outlet(accumulation, row, col, radius_cells) - outlets[snapped] = label - outlet_cells[label] = snapped - + intervals = _ditch_intervals(vertices, ordered) + outlets = _rasterize_road(vertices, intervals, x_coords, y_coords, resolution, dem.shape) if not outlets: return [] labels = _label_basins(pointer, outlets) @@ -421,9 +453,6 @@ def build_watershed_basins( basins: list[WatershedBasin] = [] for label, candidate in enumerate(ordered, start=1): - cell = outlet_cells.get(label) - if cell is None: - continue mask = labels == label cell_count = int(mask.sum()) if cell_count < 4: @@ -431,7 +460,11 @@ def build_watershed_basins( boundary = _vectorize_basin(labels, label, x_coords, y_coords, resolution) if len(boundary) < 4: continue - outlet_z = float(dem[cell[0], cell[1]]) + # 측점(관) 위치의 표고를 기준으로 낙차를 계산한다. + col = int(round((candidate.x - float(x_coords[0])) / resolution)) + row = int(round((candidate.y - float(y_coords[0])) / resolution)) + in_grid = 0 <= row < dem.shape[0] and 0 <= col < dem.shape[1] + outlet_z = float(dem[row, col]) if in_grid else float(dem[mask].min()) basin = WatershedBasin( index=label, chainage_m=candidate.chainage_m, diff --git a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py index 3d137c3f..ecb3a54d 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py +++ b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py @@ -194,7 +194,7 @@ async def post_drainage_basins( candidates = propose_structure_stations(vertices, prepared["streams"]) basins = build_watershed_basins( - candidates, prepared["contours"], prepared["spots"], _ELEVATION_KEYS + vertices, candidates, prepared["contours"], prepared["spots"], _ELEVATION_KEYS ) to_lonlat = prepared["to_lonlat"] return { From 8b853e8deca3eb30268993bac7216969ba9eecd4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 29 Jul 2026 18:07:39 +0900 Subject: [PATCH 15/61] auto: 2026-07-29 18:07 (EOMSANGDON-HOME) --- .../B05_wf2_Route_Engine_Drainage.py | 43 +- ...B05_wf2_Route_Engine_Drainage_Watershed.py | 653 +++++++----------- .../B05_wf2_Route_Engine_Watershed_Trace.py | 271 ++++++++ .../B05_wf2_Route_Router_Drainage.py | 7 +- 4 files changed, 571 insertions(+), 403 deletions(-) create mode 100644 B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Trace.py diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage.py index 99be8d85..87de9cce 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage.py @@ -228,12 +228,18 @@ def _fill_spacing( start_m: float, end_m: float, ) -> list[StructureCandidate]: - """[start, end] 구간이 300m를 넘으면 절토부 지점에 보충 측점을 만든다.""" + """[start, end] 구간이 300m를 넘으면 보충 측점을 만든다. + + 종단도상 상대적으로 물이 모일 것으로 예상되는 지점(절토부 내 종단 저점)을 + 우선 배치한다(2026-07-29 사용자 지시). 저점이 없으면 목표 인근 절토부로 대체한다. + """ added: list[StructureCandidate] = [] cursor = start_m while end_m - cursor > MAX_STRUCTURE_SPACING_M: target = cursor + MAX_STRUCTURE_SPACING_M - placed = _nearest_uphill(vertices, target, end_m) + placed = _gather_low_point(vertices, cursor, target, end_m) + if placed is None: + placed = _nearest_uphill(vertices, target, end_m) if placed is None: break x, y, _ = _interpolate_vertex(vertices, placed) @@ -242,6 +248,39 @@ def _fill_spacing( return added +def _gather_low_point( + vertices: list[RouteVertex], + cursor_m: float, + target_m: float, + limit_m: float, + step_m: float = 10.0, +) -> float | None: + """탐색창 [cursor+150, target] 안 절토부의 종단 국소 저점(사그) 중 가장 낮은 지점. + + 창 하한을 간격의 절반으로 두어 보충 측점이 과밀하게 몰리지 않게 하고, + 국소 저점만 인정해 일정 오르막에서는 None(300m 규칙 폴백)을 돌려준다. + """ + window_start = cursor_m + MAX_STRUCTURE_SPACING_M / 2.0 + probes: list[float] = [] + probe = window_start - step_m + while probe <= target_m + step_m: + probes.append(probe) + probe += step_m + heights = [_interpolate_vertex(vertices, position)[2] for position in probes] + best: tuple[float, float] | None = None # (계획고 z, 누가거리) + for i in range(1, len(probes) - 1): + position = probes[i] + if position >= limit_m or position > target_m or position < window_start: + continue + # 국소 저점(양쪽이 같거나 높음) = 물이 모여 더 못 흐르는 지점. 앞쪽이 오르막인 + # 조건을 내포하므로 별도의 절토부(is_uphill_at) 판정은 두지 않는다. + if heights[i] > heights[i - 1] or heights[i] > heights[i + 1]: + continue + if best is None or heights[i] < best[0]: + best = (heights[i], position) + return best[1] if best else None + + def _nearest_uphill( vertices: list[RouteVertex], target_m: float, diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py index ebf41f7b..5b676eaf 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py @@ -1,56 +1,50 @@ -"""배수유역 도로(노선) 기준 산정 엔진 — 측구 흐름 모델. +"""배수유역 산정 엔진 — 세류 기반 등고선 기하 직접 분석 (2026-07-29 합의). -목적: 도로로 오는 물의 양 산정. 참고 도면(2026-07-28 사용자 제공)과 같이 **도로 산측 -사면 전체를 관(구조물 측점) 개수만큼 빈틈없이 분할**한다: +DEM 보간·D8 전역 흐름분석을 쓰지 않는다. 계산 순서(사용자 정의 7단계): +① 도로(노선)가 유역의 하측 경계 ② 도로를 가로지르는 세류 교차점에서 출발 +③ 세류 상류망을 추적하고 연관 등고선만 분석해 메인 유역 선정(하류 무의미) +④ 세류 교차점 = 관매설 지점 ⑤ 300m 초과 구간은 종단 저점에 보충(제안 엔진 담당) +⑥ 관 사이 물갈림 고개에서 오르는 분할선(능선 근사)으로 유역을 세분화하고 + 번호·면적·표고차·유하장을 산출 ⑦ 관 추가·경로 변경 시 재호출로 재분석. -- 도엽 등고선·표고점을 격자 DEM으로 보간 (3D 라이다는 산 전체를 계측하지 않아 미사용). -- 노선을 도로 셀로 래스터화하고, 관 사이 종단 최고점(물갈림 고개)을 경계로 도로 셀마다 - 담당 관을 배정 — "도로에 닿은 물은 측구를 타고 내리막의 첫 관으로 들어간다". -- 사면 각 셀은 D8 흐름으로 내려가 처음 닿는 도로 셀의 관을 물려받는다. 도로를 만나지 - 못하는 셀(도로 하측 사면, 능선 너머)은 자동 제외. -- 함몰 보정은 Whitebox `fill_depressions`(Engine_Skeleton과 동일 패턴), 실패 시 원본 진행. -- 유역 경계의 산측이 분수령(능선)·지능선이고 하측이 도로선이다. 프론트가 능선 파선 표시. +유역 폴리곤 = 도로 구간(하측) + 좌우 분할선 + 최상위 공통 등고선 아크(상측)로 폐합. +등고선은 STRtree에서 필요한 것만 꺼내므로 분석량이 유역 크기에 비례한다(도엽 매수 무관). """ from __future__ import annotations import logging import math -import tempfile from dataclasses import dataclass, field -from pathlib import Path from typing import Any -import numpy as np -from scipy.interpolate import griddata -from shapely.geometry import shape +from shapely.geometry import LineString, Point, Polygon +from shapely.ops import substring, unary_union from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import ( StructureCandidate, + _interpolate_vertex, estimate_pipe_diameter_mm, ) +from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Trace import ( + LOCAL_MAX_STEPS, + LOCAL_SEARCH_RADIUS_M, + STREAM_JOIN_TOL_M, + ContourIndex, + DividerStep, + _explode_lines, + trace_divider, + trace_upstream_network, +) logger = logging.getLogger(__name__) -# 격자 해상도(m)와 최대 격자 크기. 도엽 9매 범위라도 이 상한 안에서 해상도를 낮춰 계산한다. -GRID_RES_M = 10.0 -MAX_GRID_CELLS = 1_400_000 -# 유역 계산 범위: 노선 bbox + 여유폭(m). 주변 8도엽까지 확보되어 있어 넉넉히 잡는다. -BBOX_MARGIN_M = 1500.0 -# DEM 보간 표본 상한(속도 확보용 간축). 초과 시 균등 간격으로 추린다. -MAX_SAMPLE_POINTS = 250_000 - -# D8 이웃: (행 오프셋, 열 오프셋, 거리 계수) -_D8 = ( - (-1, -1, math.sqrt(2.0)), - (-1, 0, 1.0), - (-1, 1, math.sqrt(2.0)), - (0, -1, 1.0), - (0, 1, 1.0), - (1, -1, math.sqrt(2.0)), - (1, 0, 1.0), - (1, 1, math.sqrt(2.0)), -) +# 유효 유역 최소 면적(m²)과 상류망 커버 보정 버퍼(m). +MIN_BASIN_AREA_M2 = 100.0 +STREAM_COVER_BUFFER_M = 20.0 +# 산측 판정: 도로 접선의 좌우 수직 오프셋(m)과 표고 조회 반경(m). +UPHILL_PROBE_OFFSET_M = 40.0 +UPHILL_PROBE_RADIUS_M = 60.0 @dataclass @@ -61,7 +55,7 @@ class WatershedBasin: chainage_m: float outlet_x: float outlet_y: float - # 유역 경계(사업지 좌표계 m). 외곽선이 곧 분수령(능선). + # 유역 경계(사업지 좌표계 m). 외곽선의 산측이 곧 분수령(능선), 하측이 도로선. boundary_xy: list[list[float]] = field(default_factory=list) area_m2: float = 0.0 relief_m: float = 0.0 @@ -69,164 +63,13 @@ class WatershedBasin: pipe_diameter_mm: float | None = None -def _collect_samples( - contour_features: list[dict[str, Any]], - spot_features: list[dict[str, Any]], - elevation_keys: tuple[str, ...], -) -> np.ndarray: - """등고선 정점·표고점을 (x, y, z) 표본 배열로 모은다.""" - xs: list[float] = [] - ys: list[float] = [] - zs: list[float] = [] +def _divide_chainages(vertices: list[Any], ordered: list[StructureCandidate]) -> list[float]: + """유역 분할 누가거리 목록(양끝 포함, 관 개수+1개). - def _walk(coordinates: Any, elevation: float) -> None: - if not isinstance(coordinates, list) or not coordinates: - return - if isinstance(coordinates[0], (int, float)): - xs.append(float(coordinates[0])) - ys.append(float(coordinates[1])) - zs.append(elevation) - return - for item in coordinates: - _walk(item, elevation) - - for feature in [*contour_features, *spot_features]: - properties = feature.get("properties") or {} - elevation: float | None = None - for key in elevation_keys: - value = properties.get(key) - if value is None: - continue - try: - elevation = float(value) - break - except (TypeError, ValueError): - continue - if elevation is None: - continue - geometry = feature.get("geometry") or {} - _walk(geometry.get("coordinates"), elevation) - - if not xs: - return np.empty((0, 3)) - samples = np.column_stack([xs, ys, zs]) - if len(samples) > MAX_SAMPLE_POINTS: - step = len(samples) // MAX_SAMPLE_POINTS + 1 - samples = samples[::step] - return samples - - -def _build_dem( - samples: np.ndarray, - anchors_x: list[float], - anchors_y: list[float], -) -> tuple[np.ndarray, np.ndarray, np.ndarray, float] | None: - """표본을 격자 DEM으로 보간한다. 반환: (dem, x좌표, y좌표, 해상도). - - 범위는 앵커(노선 전체 정점) bbox + 여유폭 — 유역이 노선 전 연장을 덮어야 하므로 - 측점 bbox가 아니라 노선 bbox를 쓴다. + 인접한 두 관 사이 **종단 계획선의 최고점(물갈림 고개)**이 분할점이다 — + "도로에 닿은 물은 측구를 타고 내리막의 첫 관으로 들어간다". """ - if len(samples) < 10 or not anchors_x: - return None - min_x = min(anchors_x) - BBOX_MARGIN_M - max_x = max(anchors_x) + BBOX_MARGIN_M - min_y = min(anchors_y) - BBOX_MARGIN_M - max_y = max(anchors_y) + BBOX_MARGIN_M - # 표본 범위 밖으로는 나가지 않는다(외삽 방지). - min_x = max(min_x, float(samples[:, 0].min())) - max_x = min(max_x, float(samples[:, 0].max())) - min_y = max(min_y, float(samples[:, 1].min())) - max_y = min(max_y, float(samples[:, 1].max())) - if max_x - min_x < GRID_RES_M * 4 or max_y - min_y < GRID_RES_M * 4: - return None - - resolution = GRID_RES_M - while ((max_x - min_x) / resolution) * ((max_y - min_y) / resolution) > MAX_GRID_CELLS: - resolution *= 1.5 - x_coords = np.arange(min_x, max_x + resolution, resolution) - y_coords = np.arange(min_y, max_y + resolution, resolution) - grid_x, grid_y = np.meshgrid(x_coords, y_coords) - - points = samples[:, :2] - values = samples[:, 2] - dem = griddata(points, values, (grid_x, grid_y), method="linear") - # linear 보간 밖(볼록 껍질 바깥)은 nearest로 메워 유역 추적이 끊기지 않게 한다. - holes = ~np.isfinite(dem) - if holes.any(): - dem[holes] = griddata(points, values, (grid_x[holes], grid_y[holes]), method="nearest") - return dem.astype(np.float64), x_coords, y_coords, resolution - - -def _fill_depressions(dem: np.ndarray, resolution: float) -> np.ndarray: - """Whitebox로 함몰을 메운다. 실패하면 원본 그대로 진행한다.""" - try: - import rasterio - from rasterio.transform import from_origin - from whitebox import WhiteboxTools - except Exception: # noqa: BLE001 - return dem - rows, cols = dem.shape - try: - with tempfile.TemporaryDirectory(prefix="wbt_drain_") as tmp: - tmp_path = Path(tmp) - transform = from_origin(0.0, rows * resolution, resolution, resolution) - with rasterio.open( - tmp_path / "dem.tif", - "w", - driver="GTiff", - height=rows, - width=cols, - count=1, - dtype="float32", - nodata=-9999.0, - crs="EPSG:3857", - transform=transform, - ) as dst: - dst.write(dem.astype(np.float32)[::-1, :], 1) - wbt = WhiteboxTools() - wbt.set_verbose_mode(False) - wbt.set_working_dir(str(tmp_path)) - if wbt.fill_depressions("dem.tif", "filled.tif") != 0: - raise RuntimeError("fill_depressions 실패") - with rasterio.open(tmp_path / "filled.tif") as src: - filled = src.read(1).astype(np.float64)[::-1, :] - return np.where(np.isfinite(filled), filled, dem) - except Exception: # noqa: BLE001 - logger.warning("Whitebox 함몰 보정 실패 — 원본 DEM으로 진행") - return dem - - -def _d8_pointer(dem: np.ndarray) -> np.ndarray: - """각 셀의 최급강하 이웃 인덱스(0~7, 배수구 없으면 -1).""" - rows, cols = dem.shape - pointer = np.full((rows, cols), -1, dtype=np.int8) - best_drop = np.zeros((rows, cols), dtype=np.float64) - for direction, (dr, dc, distance) in enumerate(_D8): - shifted = np.full_like(dem, np.inf) - r_src = slice(max(0, -dr), rows - max(0, dr)) - c_src = slice(max(0, -dc), cols - max(0, dc)) - r_dst = slice(max(0, dr), rows - max(0, -dr)) - c_dst = slice(max(0, dc), cols - max(0, -dc)) - shifted[r_src, c_src] = dem[r_dst, c_dst] - drop = (dem - shifted) / distance - better = drop > best_drop - pointer[better] = direction - best_drop[better] = drop[better] - return pointer - - -def _ditch_intervals( - vertices: list[Any], - ordered: list[StructureCandidate], -) -> list[tuple[float, float, int]]: - """측구(도로변 배수로) 흐름 모델의 담당 구간을 만든다. - - 도로에 닿은 물은 종단 내리막 방향으로 흘러 첫 번째 관으로 들어간다. 따라서 인접한 - 두 관 사이 **종단 계획선의 최고점(물갈림 고개)**이 유역 분할점이 된다. - 반환: (구간 시작 chainage, 구간 끝 chainage, 관 라벨) 목록. - """ - total_length = vertices[-1].chainage_m if vertices else 0.0 - divides: list[float] = [] + divides = [vertices[0].chainage_m] for left, right in zip(ordered, ordered[1:]): window = [ vertex for vertex in vertices if left.chainage_m < vertex.chainage_m < right.chainage_m @@ -235,179 +78,137 @@ def _ditch_intervals( divides.append(max(window, key=lambda vertex: vertex.z).chainage_m) else: divides.append((left.chainage_m + right.chainage_m) / 2.0) - intervals: list[tuple[float, float, int]] = [] - start = 0.0 - for label, divide in enumerate(divides, start=1): - intervals.append((start, divide, label)) - start = divide - intervals.append((start, total_length + 1.0, len(ordered))) - return intervals + divides.append(vertices[-1].chainage_m) + return divides -def _rasterize_road( - vertices: list[Any], - intervals: list[tuple[float, float, int]], - x_coords: np.ndarray, - y_coords: np.ndarray, - resolution: float, - shape_rc: tuple[int, int], -) -> dict[tuple[int, int], int]: - """노선 폴리라인을 격자에 새겨 도로 셀마다 담당 관 라벨을 붙인다. - - 대각 누수(도로가 셀 사이 대각으로 지나가 물이 새는 것)를 막으려고 도로 셀 주변 - 3×3을 함께 같은 라벨로 칠한다. - """ - rows, cols = shape_rc - - def _label_of(chainage: float) -> int: - for start, end, label in intervals: - if start <= chainage < end: - return label - return intervals[-1][2] if intervals else 0 - - road: dict[tuple[int, int], int] = {} - step = resolution / 2.0 - for previous, current in zip(vertices, vertices[1:]): - span = math.dist((previous.x, previous.y), (current.x, current.y)) - count = max(1, int(span / step)) - for i in range(count + 1): - ratio = i / count - x = previous.x + (current.x - previous.x) * ratio - y = previous.y + (current.y - previous.y) * ratio - chainage = previous.chainage_m + (current.chainage_m - previous.chainage_m) * ratio - col = int(round((x - float(x_coords[0])) / resolution)) - row = int(round((y - float(y_coords[0])) / resolution)) - label = _label_of(chainage) - for dr in (-1, 0, 1): - for dc in (-1, 0, 1): - r, c = row + dr, col + dc - if 0 <= r < rows and 0 <= c < cols and (r, c) not in road: - road[(r, c)] = label - return road - - -def _label_basins( - pointer: np.ndarray, - outlets: dict[tuple[int, int], int], -) -> np.ndarray: - """각 셀이 흐름을 따라 처음 만나는 pour point의 라벨을 붙인다(경로 메모이제이션).""" - rows, cols = pointer.shape - labels = np.zeros((rows, cols), dtype=np.int32) # 0 = 미소속 - for (r, c), label in outlets.items(): - labels[r, c] = label - flat_pointer = pointer.ravel() - flat_labels = labels.ravel() - for start in range(flat_labels.size): - if flat_labels[start] != 0: - continue - path: list[int] = [] - current = start - label = 0 - while True: - if flat_labels[current] != 0: - label = flat_labels[current] - break - direction = flat_pointer[current] - if direction < 0: - label = -1 # 배수구 없음(격자 밖 유출) — 어떤 유역에도 속하지 않음 - break - path.append(current) - dr, dc, _ = _D8[direction] - r, c = divmod(current, cols) - nr, nc = r + dr, c + dc - if not (0 <= nr < rows and 0 <= nc < cols): - label = -1 - break - current = nr * cols + nc - for cell in path: - flat_labels[cell] = label - return labels - - -def _flow_lengths(pointer: np.ndarray, labels: np.ndarray, resolution: float) -> dict[int, float]: - """라벨별 최장 흐름 경로(셀→해당 pour point) 길이.""" - rows, cols = pointer.shape - distance = np.full((rows, cols), -1.0, dtype=np.float64) - # pour point 셀은 자기 라벨의 시작점이므로 거리 0. - longest: dict[int, float] = {} - flat_pointer = pointer.ravel() - flat_labels = labels.ravel() - flat_distance = distance.ravel() - - def _resolve(start: int) -> float: - chain: list[int] = [] - current = start - total = 0.0 - while True: - if flat_distance[current] >= 0: - total = flat_distance[current] - break - direction = flat_pointer[current] - if direction < 0: - total = 0.0 - break - r, c = divmod(current, cols) - dr, dc, factor = _D8[direction] - nr, nc = r + dr, c + dc - if not (0 <= nr < rows and 0 <= nc < cols): - total = 0.0 - break - next_cell = nr * cols + nc - # 다음 셀이 다른 라벨이면(=pour point 통과) 여기서 경로가 끝난 것으로 본다. - chain.append(current) - if flat_labels[next_cell] != flat_labels[current]: - total = 0.0 - break - current = next_cell - # 뒤에서부터 거리를 되채운다. - for cell in reversed(chain): - direction = flat_pointer[cell] - factor = _D8[direction][2] if direction >= 0 else 0.0 - total += factor * resolution - flat_distance[cell] = total - return total - - for start in range(flat_labels.size): - label = int(flat_labels[start]) - if label <= 0: - continue - length = _resolve(start) - if length > longest.get(label, 0.0): - longest[label] = length - return longest - - -def _vectorize_basin( - labels: np.ndarray, - label: int, - x_coords: np.ndarray, - y_coords: np.ndarray, - resolution: float, -) -> list[list[float]]: - """유역 셀 집합을 폴리곤 외곽 링(사업지 좌표계)으로 벡터화한다.""" - try: - from rasterio import features as rio_features - from rasterio.transform import from_origin - except Exception: # noqa: BLE001 - return [] - mask = (labels == label).astype(np.uint8) - if mask.sum() == 0: - return [] - transform = from_origin( - float(x_coords[0]) - resolution / 2.0, - float(y_coords[-1]) + resolution / 2.0, - resolution, - resolution, +def _uphill_sign_at(vertices: list[Any], chainage_m: float, contour_index: ContourIndex) -> int: + """해당 측점의 산측이 도로 진행방향 기준 좌(+1)인지 우(-1)인지. 불명이면 0.""" + x, y, _ = _interpolate_vertex(vertices, chainage_m) + back = _interpolate_vertex(vertices, max(0.0, chainage_m - 10.0)) + forward = _interpolate_vertex(vertices, chainage_m + 10.0) + dx, dy = forward[0] - back[0], forward[1] - back[1] + norm = math.hypot(dx, dy) + if norm < 1e-6: + return 0 + dx, dy = dx / norm, dy / norm + # 좌측 법선 (-dy, dx) 방향 오프셋이 side_sign +1에 대응한다. + left_z = contour_index.nearest_elevation( + Point(x - dy * UPHILL_PROBE_OFFSET_M, y + dx * UPHILL_PROBE_OFFSET_M), + UPHILL_PROBE_RADIUS_M, ) - shapes = rio_features.shapes(mask[::-1, :], mask=mask[::-1, :] > 0, transform=transform) - polygons = [shape(geometry) for geometry, value in shapes if value == 1] - if not polygons: + right_z = contour_index.nearest_elevation( + Point(x + dy * UPHILL_PROBE_OFFSET_M, y - dx * UPHILL_PROBE_OFFSET_M), + UPHILL_PROBE_RADIUS_M, + ) + if left_z is None or right_z is None or left_z == right_z: + return 0 + return 1 if left_z > right_z else -1 + + +def _road_segment_coords( + vertices: list[Any], start_m: float, end_m: float +) -> list[tuple[float, float]]: + """분할점 사이 도로 구간의 평면 좌표열(유역 폴리곤의 하측 경계).""" + sx, sy, _ = _interpolate_vertex(vertices, start_m) + ex, ey, _ = _interpolate_vertex(vertices, end_m) + coords = [(sx, sy)] + coords.extend( + (vertex.x, vertex.y) for vertex in vertices if start_m < vertex.chainage_m < end_m + ) + coords.append((ex, ey)) + return coords + + +def _contour_arc( + line: Any, p_from: Point, p_to: Point, road_line: LineString +) -> list[tuple[float, float]]: + """등고선에서 두 분할선 접점 사이 아크(상측 경계)를 뽑는다. + + 폐합 등고선은 두 방향 아크가 생기므로 도로와 교차하지 않는(=산측) 쪽을 고른다. + """ + t1, t2 = sorted((line.project(p_from), line.project(p_to))) + arcs = [] + inner = substring(line, t1, t2) + if inner.geom_type == "LineString" and len(inner.coords) >= 2: + arcs.append(inner) + if getattr(line, "is_closed", False): + head = substring(line, t2, line.length) + tail = substring(line, 0.0, t1) + coords = list(head.coords) + list(tail.coords)[1:] + if len(coords) >= 2: + arcs.append(LineString(coords)) + if not arcs: return [] - merged = max(polygons, key=lambda polygon: polygon.area) - simplified = merged.simplify(resolution, preserve_topology=True) - if simplified.is_empty or simplified.geom_type != "Polygon": - simplified = merged - return [[float(x), float(y)] for x, y in simplified.exterior.coords] + scored = [] + for arc in arcs: + crosses = arc.crosses(road_line) + midpoint = arc.interpolate(0.5, normalized=True) + scored.append((crosses, -road_line.distance(midpoint), arc)) + scored.sort(key=lambda item: (item[0], item[1])) + arc = scored[0][2] + coords = list(arc.coords) + if Point(coords[0]).distance(p_from) > Point(coords[-1]).distance(p_from): + coords.reverse() + return [(float(x), float(y)) for x, y in coords] + + +def _junction(left: list[DividerStep], right: list[DividerStep]) -> tuple[int, int, int] | None: + """두 분할선이 같은 등고선 지오메트리를 밟은 최고 표고 지점(좌 idx, 우 idx, geom idx).""" + left_keys = { + (step.z, step.geom_index): position + for position, step in enumerate(left) + if step.geom_index >= 0 + } + best: tuple[float, int, int, int] | None = None + for position, step in enumerate(right): + if step.geom_index < 0: + continue + left_position = left_keys.get((step.z, step.geom_index)) + if left_position is None: + continue + if best is None or step.z > best[0]: + best = (step.z, left_position, position, step.geom_index) + if best is None: + return None + return best[1], best[2], best[3] + + +def _assemble_polygon( + vertices: list[Any], + start_m: float, + end_m: float, + left: list[DividerStep], + right: list[DividerStep], + contour_index: ContourIndex, + road_line: LineString, +) -> Polygon | None: + """도로 구간 + 우측 분할선 + 상측 등고선 아크 + 좌측 분할선으로 폴리곤을 폐합한다.""" + ring = _road_segment_coords(vertices, start_m, end_m) + junction = _junction(left, right) + if junction is not None: + left_position, right_position, geom_index = junction + left_used = left[: left_position + 1] + right_used = right[: right_position + 1] + arc = _contour_arc( + contour_index.geoms[geom_index], + right_used[-1].point, + left_used[-1].point, + road_line, + ) + else: + left_used, right_used, arc = left, right, [] + ring.extend((step.point.x, step.point.y) for step in right_used[1:]) + ring.extend(arc) + ring.extend((step.point.x, step.point.y) for step in reversed(left_used[1:])) + if len(ring) < 4: + return None + polygon = Polygon(ring).buffer(0) + if polygon.geom_type == "MultiPolygon": + polygon = max(polygon.geoms, key=lambda part: part.area) + if polygon.is_empty or polygon.geom_type != "Polygon": + return None + return polygon def build_watershed_basins( @@ -416,64 +217,116 @@ def build_watershed_basins( contour_features: list[dict[str, Any]], spot_features: list[dict[str, Any]], elevation_keys: tuple[str, ...], + stream_features: list[dict[str, Any]] | None = None, ) -> list[WatershedBasin]: - """도로(노선) 기준 배수유역을 산정한다 — 측구 흐름 모델. + """관 지점 배치를 기준으로 메인 배수유역을 세분화해 산정한다. - "도로에 닿은 물은 측구를 타고 종단 내리막 방향으로 흘러 첫 번째 관으로 들어간다"를 - 전제로, 도로 산측 사면 전체를 관 개수만큼 빈틈없이 분할한다(참고 도면과 동일 개념): - ① 노선 전체를 도로 셀로 래스터화하고, 관 사이 종단 최고점(물갈림 고개)을 경계로 - 각 도로 셀에 담당 관 라벨을 붙인다. - ② 사면 각 셀은 D8 흐름을 따라 내려가 처음 닿는 도로 셀의 관 라벨을 물려받는다. - 도로를 만나지 못하고 격자 밖으로 빠지는 셀(도로 하측 성토부 등)은 제외된다. - 반환된 boundary_xy 외곽선의 산측이 곧 분수령(능선)이며, 하측은 도로선을 따른다. + 세류 교차 관("stream")은 상류망을 추적해 넓은 한계로, 세류 없는 관은 도로 상측 + 첫 능선까지 소범위 한계로 분할선을 올린다(작은 유역, 영역 선정 주의 — 사용자 지시). 번호는 노선 시점에 가까운 순. """ if not candidates or len(vertices) < 2: return [] - samples = _collect_samples(contour_features, spot_features, elevation_keys) - built = _build_dem( - samples, - [vertex.x for vertex in vertices], - [vertex.y for vertex in vertices], - ) - if built is None: - logger.warning("DEM 보간 실패 — 표본 %d개", len(samples)) + contour_index = ContourIndex(contour_features, elevation_keys) + if contour_index.tree is None: + logger.warning("표고 속성이 있는 등고선이 없어 유역을 산정하지 못했습니다.") return [] - dem, x_coords, y_coords, resolution = built - dem = _fill_depressions(dem, resolution) - pointer = _d8_pointer(dem) + spot_index = ContourIndex(spot_features, elevation_keys) + road_line = LineString([(vertex.x, vertex.y) for vertex in vertices]) ordered = sorted(candidates, key=lambda item: item.chainage_m) - intervals = _ditch_intervals(vertices, ordered) - outlets = _rasterize_road(vertices, intervals, x_coords, y_coords, resolution, dem.shape) - if not outlets: - return [] - labels = _label_basins(pointer, outlets) - lengths = _flow_lengths(pointer, labels, resolution) + divides = _divide_chainages(vertices, ordered) + signs = [_uphill_sign_at(vertices, item.chainage_m, contour_index) for item in ordered] + majority = 1 if sum(signs) >= 0 else -1 + signs = [sign or majority for sign in signs] + # 사용자 확정("confirmed") 측점도 세류에 닿아 있으면 세류 유역으로 취급한다. + stream_lines = _explode_lines(stream_features) if stream_features else [] + is_stream = [ + item.reason == "stream" + or any(line.distance(Point(item.x, item.y)) <= STREAM_JOIN_TOL_M for line in stream_lines) + for item in ordered + ] + + # 분할선은 인접 유역과 공유하므로 분할점마다 1회만 추적한다. + dividers: list[list[DividerStep]] = [] + for position, chainage in enumerate(divides): + neighbor_streams = [] + if position > 0: + neighbor_streams.append(is_stream[position - 1]) + if position < len(ordered): + neighbor_streams.append(is_stream[position]) + wide = any(neighbor_streams) + sign = signs[position - 1] if position > 0 else signs[0] + x, y, _ = _interpolate_vertex(vertices, chainage) + if wide: + steps = trace_divider(Point(x, y), contour_index, road_line, sign) + else: + steps = trace_divider( + Point(x, y), + contour_index, + road_line, + sign, + radius_m=LOCAL_SEARCH_RADIUS_M, + max_steps=LOCAL_MAX_STEPS, + ) + dividers.append(steps) basins: list[WatershedBasin] = [] - for label, candidate in enumerate(ordered, start=1): - mask = labels == label - cell_count = int(mask.sum()) - if cell_count < 4: + for position, candidate in enumerate(ordered): + outlet = Point(candidate.x, candidate.y) + network: list[Any] = [] + flow_length = 0.0 + if is_stream[position] and stream_features: + network, flow_length = trace_upstream_network( + outlet, stream_features, road_line, signs[position] + ) + polygon = _assemble_polygon( + vertices, + divides[position], + divides[position + 1], + dividers[position], + dividers[position + 1], + contour_index, + road_line, + ) + if polygon is None: continue - boundary = _vectorize_basin(labels, label, x_coords, y_coords, resolution) - if len(boundary) < 4: + if network: + # 상류망이 폴리곤 밖으로 뻗은 경우까지 유역이 덮도록 보정한다. + covered = polygon.union(unary_union(network).buffer(STREAM_COVER_BUFFER_M)).buffer(0) + if covered.geom_type == "MultiPolygon": + covered = max(covered.geoms, key=lambda part: part.area) + if covered.geom_type == "Polygon" and not covered.is_empty: + polygon = covered + if polygon.area < MIN_BASIN_AREA_M2: continue - # 측점(관) 위치의 표고를 기준으로 낙차를 계산한다. - col = int(round((candidate.x - float(x_coords[0])) / resolution)) - row = int(round((candidate.y - float(y_coords[0])) / resolution)) - in_grid = 0 <= row < dem.shape[0] and 0 <= col < dem.shape[1] - outlet_z = float(dem[row, col]) if in_grid else float(dem[mask].min()) + + outlet_z = contour_index.nearest_elevation(outlet, UPHILL_PROBE_RADIUS_M) + if outlet_z is None: + outlet_z = _interpolate_vertex(vertices, candidate.chainage_m)[2] + top_z = max( + contour_index.max_elevation_within(polygon) or outlet_z, + spot_index.max_elevation_within(polygon) or outlet_z, + ) + boundary_line = polygon.simplify(5.0, preserve_topology=True) + if boundary_line.is_empty or boundary_line.geom_type != "Polygon": + boundary_line = polygon + boundary = [[float(x), float(y)] for x, y in boundary_line.exterior.coords] + if flow_length <= 0.0: + flow_length = max( + (math.dist((candidate.x, candidate.y), point) for point in boundary), + default=0.0, + ) + basin = WatershedBasin( - index=label, + index=len(basins) + 1, chainage_m=candidate.chainage_m, outlet_x=candidate.x, outlet_y=candidate.y, boundary_xy=boundary, - area_m2=cell_count * resolution * resolution, - relief_m=max(0.0, float(dem[mask].max()) - outlet_z), - flow_length_m=lengths.get(label, 0.0), + area_m2=float(polygon.area), + relief_m=max(0.0, float(top_z) - float(outlet_z)), + flow_length_m=float(flow_length), ) basin.pipe_diameter_mm = estimate_pipe_diameter_mm( basin.area_m2, basin.relief_m, basin.flow_length_m diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Trace.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Trace.py new file mode 100644 index 00000000..842a477a --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Trace.py @@ -0,0 +1,271 @@ +"""배수유역 추적 유틸 — 등고선 공간 인덱스·세류 상류망·분수령(능선) 추적. + +등고선 기하 직접 분석(2026-07-29 합의)의 하위 도구 모음. DEM 보간 없이: +- `ContourIndex`: 등고선(선)·표고점(점)을 STRtree에 1회 적재하고 필요한 것만 꺼낸다. +- `trace_upstream_network`: 세류 교차점에서 도로 산측 상류망만 추적한다(하류 무시). +- `trace_divider`: 물갈림 지점에서 상향 등고선을 한 겹씩 따라 오르는 유역 분할선(능선 근사). + +전체 등고선을 순회하는 연산을 두지 않아 분석량이 유역 크기에 비례한다(도엽 매수와 무관). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from shapely.geometry import LineString, Point, shape +from shapely.ops import nearest_points, substring +from shapely.strtree import STRtree + +# 분할선(능선 근사) 추적: 다음 상위 등고선을 찾는 탐색 반경(m)과 최대 단계 수. +DIVIDER_SEARCH_RADIUS_M = 120.0 +DIVIDER_MAX_STEPS = 60 +# 세류 없는 소규모 유역: 도로 상측 첫 능선까지만 오르도록 좁힌 한계(영역 선정 주의). +LOCAL_SEARCH_RADIUS_M = 80.0 +LOCAL_MAX_STEPS = 12 +# 세류 연결 판정 이격(m)과 상류망 총연장 상한(m). +STREAM_JOIN_TOL_M = 15.0 +MAX_UPSTREAM_TOTAL_M = 5000.0 + + +@dataclass +class DividerStep: + """분할선의 한 단계 — 어느 등고선(geom_index)의 어느 지점을 밟았는지.""" + + point: Point + z: float + geom_index: int + + +class ContourIndex: + """표고 속성이 있는 등고선·표고점 피처의 STRtree 래퍼.""" + + def __init__(self, features: list[dict[str, Any]], elevation_keys: tuple[str, ...]) -> None: + self.geoms: list[Any] = [] + self.zs: list[float] = [] + for feature in features: + elevation = _feature_elevation(feature, elevation_keys) + if elevation is None: + continue + geometry = feature.get("geometry") or {} + try: + geom = shape(geometry) + except Exception: # noqa: BLE001 - 손상된 피처는 건너뛴다 + continue + if geom.is_empty: + continue + parts = list(geom.geoms) if geom.geom_type.startswith("Multi") else [geom] + for part in parts: + self.geoms.append(part) + self.zs.append(elevation) + self.tree = STRtree(self.geoms) if self.geoms else None + + def query(self, geometry: Any) -> list[int]: + """geometry 근방(bbox 교차) 피처의 인덱스만 돌려준다.""" + if self.tree is None: + return [] + return [int(i) for i in self.tree.query(geometry)] + + def nearest_elevation(self, point: Point, radius_m: float) -> float | None: + """point에서 radius 안 가장 가까운 피처의 표고. 없으면 None.""" + best_z: float | None = None + best_distance = radius_m + for index in self.query(point.buffer(radius_m)): + distance = self.geoms[index].distance(point) + if distance <= best_distance: + best_distance = distance + best_z = self.zs[index] + return best_z + + def max_elevation_within(self, polygon: Any) -> float | None: + """polygon과 실제로 교차하는 피처들의 최고 표고.""" + best: float | None = None + for index in self.query(polygon): + if not polygon.intersects(self.geoms[index]): + continue + if best is None or self.zs[index] > best: + best = self.zs[index] + return best + + +def _feature_elevation(feature: dict[str, Any], elevation_keys: tuple[str, ...]) -> float | None: + properties = feature.get("properties") or {} + for key in elevation_keys: + value = properties.get(key) + if value is None: + continue + try: + return float(value) + except (TypeError, ValueError): + continue + return None + + +def side_sign(road_line: LineString, point: Point) -> int: + """도로선 기준 point가 어느 쪽인지(+1/-1, 선상이면 0). 국소 접선과의 외적 부호.""" + t = road_line.project(point) + a = road_line.interpolate(max(0.0, t - 5.0)) + b = road_line.interpolate(min(road_line.length, t + 5.0)) + cross = (b.x - a.x) * (point.y - a.y) - (b.y - a.y) * (point.x - a.x) + if cross > 0: + return 1 + if cross < 0: + return -1 + return 0 + + +def trace_divider( + start: Point, + contour_index: ContourIndex, + road_line: LineString, + uphill_sign: int, + radius_m: float = DIVIDER_SEARCH_RADIUS_M, + max_steps: int = DIVIDER_MAX_STEPS, +) -> list[DividerStep]: + """물갈림 지점에서 상향 등고선을 한 겹씩 밟아 오르는 분할선을 만든다. + + 각 단계에서 반경 안의 "현재보다 높은 등고선 중 가장 낮은 것"의 최근접점으로 이동한다. + 도로 산측(uphill_sign)을 벗어나거나 도로 쪽으로 되돌아가는 이동은 막는다. + 더 높은 등고선이 반경 안에 없으면 능선(분수령)에 닿은 것으로 보고 멈춘다. + """ + z = contour_index.nearest_elevation(start, radius_m) + if z is None: + return [] + steps = [DividerStep(point=start, z=z, geom_index=-1)] + current = start + road_distance = road_line.distance(start) + for _ in range(max_steps): + best: tuple[float, float, Point, int] | None = None + for index in contour_index.query(current.buffer(radius_m)): + candidate_z = contour_index.zs[index] + if candidate_z <= z + 0.01: + continue + if best is not None and candidate_z > best[0]: + continue + point = nearest_points(contour_index.geoms[index], current)[0] + distance = current.distance(point) + if distance > radius_m: + continue + # 도로 반대편·도로 방향 후퇴 금지 — 분할선은 산측으로만 오른다. + if side_sign(road_line, point) == -uphill_sign: + continue + if road_line.distance(point) + 1.0 < road_distance: + continue + if ( + best is None + or candidate_z < best[0] + or (candidate_z == best[0] and distance < best[1]) + ): + best = (candidate_z, distance, point, index) + if best is None: + break + z, _, current, geom_index = best + road_distance = max(road_distance, road_line.distance(current)) + steps.append(DividerStep(point=current, z=z, geom_index=geom_index)) + return steps + + +def _explode_lines(stream_features: list[dict[str, Any]]) -> list[LineString]: + lines: list[LineString] = [] + for feature in stream_features: + geometry = feature.get("geometry") or {} + try: + geom = shape(geometry) + except Exception: # noqa: BLE001 + continue + if geom.is_empty: + continue + parts = list(geom.geoms) if geom.geom_type.startswith("Multi") else [geom] + lines.extend(part for part in parts if part.geom_type == "LineString") + return lines + + +def _oriented_from(line: LineString, origin: Point) -> LineString: + """origin에 가까운 끝이 시작점이 되도록 방향을 맞춘다.""" + if Point(line.coords[0]).distance(origin) <= Point(line.coords[-1]).distance(origin): + return line + return LineString(list(line.coords)[::-1]) + + +def _clip_uphill(line: LineString, road_line: LineString, origin: Point) -> LineString | None: + """도로를 다시 가로지르면 교차 지점에서 잘라 origin 쪽 조각만 남긴다.""" + if not line.crosses(road_line): + return line + t = line.project(nearest_points(line.intersection(road_line), origin)[0]) + piece = substring(line, 0.0, t) if line.project(origin) < t else substring(line, t, line.length) + if piece.geom_type != "LineString" or piece.length < 1.0: + return None + return piece + + +def trace_upstream_network( + crossing: Point, + stream_features: list[dict[str, Any]], + road_line: LineString, + uphill_sign: int, +) -> tuple[list[LineString], float]: + """세류 교차점에서 도로 산측으로 뻗는 상류망을 추적한다. + + ① 교차한 세류를 교차점에서 잘라 산측 조각을 뿌리로 삼는다. + ② 끝점이 기존 망에 근접(STREAM_JOIN_TOL_M)한 세류를 반복 편입한다(분기 포함). + 도로를 다시 가로지르는 조각은 절단하고, 총연장 상한을 두어 폭주를 막는다. + 반환: (상류망 폴리라인 목록, 최장 유하 경로 길이 m). + """ + lines = _explode_lines(stream_features) + network: list[tuple[LineString, float]] = [] # (폴리라인, 뿌리에서 시작점까지 누적거리) + used: set[int] = set() + total = 0.0 + + # ① 뿌리: 교차점을 지나는 세류의 산측 조각. + for index, line in enumerate(lines): + if line.distance(crossing) > 1.0: + continue + t = line.project(crossing) + for piece in (substring(line, 0.0, t), substring(line, t, line.length)): + if piece.geom_type != "LineString" or piece.length < STREAM_JOIN_TOL_M: + continue + oriented = _oriented_from(piece, crossing) + probe = oriented.interpolate(min(10.0, oriented.length)) + if side_sign(road_line, probe) != uphill_sign: + continue + clipped = _clip_uphill(oriented, road_line, crossing) + if clipped is None: + continue + network.append((clipped, 0.0)) + total += clipped.length + used.add(index) + if not network: + return [], 0.0 + + # ② 편입 반복: 끝점이 망에 닿는 세류를 상류로 붙인다. + grew = True + while grew and total < MAX_UPSTREAM_TOTAL_M: + grew = False + for index, line in enumerate(lines): + if index in used: + continue + attach: tuple[float, Point, LineString, float] | None = None + for endpoint in (Point(line.coords[0]), Point(line.coords[-1])): + for parent, parent_cum in network: + distance = parent.distance(endpoint) + if distance > STREAM_JOIN_TOL_M: + continue + cum = parent_cum + parent.project(endpoint) + if attach is None or distance < attach[0]: + attach = (distance, endpoint, parent, cum) + if attach is None: + continue + used.add(index) + _, endpoint, _, cum = attach + if side_sign(road_line, line.interpolate(0.5, normalized=True)) == -uphill_sign: + continue + oriented = _oriented_from(line, endpoint) + clipped = _clip_uphill(oriented, road_line, endpoint) + if clipped is None: + continue + network.append((clipped, cum)) + total += clipped.length + grew = True + + flow_length = max((cum + line.length for line, cum in network), default=0.0) + return [line for line, _ in network], flow_length diff --git a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py index ecb3a54d..71979888 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py +++ b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py @@ -194,7 +194,12 @@ async def post_drainage_basins( candidates = propose_structure_stations(vertices, prepared["streams"]) basins = build_watershed_basins( - vertices, candidates, prepared["contours"], prepared["spots"], _ELEVATION_KEYS + vertices, + candidates, + prepared["contours"], + prepared["spots"], + _ELEVATION_KEYS, + stream_features=prepared["streams"], ) to_lonlat = prepared["to_lonlat"] return { From 368764190f52d3d8d3a20204ba1e2acd48a89500 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 29 Jul 2026 18:23:23 +0900 Subject: [PATCH 16/61] auto: 2026-07-29 18:23 (EOMSANGDON-HOME) --- .../B05_wf2_Route_Engine_Drainage.py | 4 +- ...B05_wf2_Route_Engine_Drainage_Watershed.py | 54 ++++++++++++++++++- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage.py index 87de9cce..3075ac86 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage.py @@ -241,7 +241,9 @@ def _fill_spacing( if placed is None: placed = _nearest_uphill(vertices, target, end_m) if placed is None: - break + # 도로 연장 기준 300m 규칙 — 저점·절토부가 없어도 관 배치는 보장한다 + # (2026-07-29 사용자 지시: 도로 340m면 최소 1개). + placed = min(target, (cursor + end_m) / 2.0) x, y, _ = _interpolate_vertex(vertices, placed) added.append(StructureCandidate(chainage_m=placed, x=x, y=y, reason="spacing")) cursor = placed diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py index 5b676eaf..70ed3d31 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py @@ -19,7 +19,7 @@ from dataclasses import dataclass, field from typing import Any from shapely.geometry import LineString, Point, Polygon -from shapely.ops import substring, unary_union +from shapely.ops import split, substring, unary_union from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import ( StructureCandidate, @@ -33,6 +33,7 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Trace import ( ContourIndex, DividerStep, _explode_lines, + side_sign, trace_divider, trace_upstream_network, ) @@ -42,6 +43,8 @@ logger = logging.getLogger(__name__) # 유효 유역 최소 면적(m²)과 상류망 커버 보정 버퍼(m). MIN_BASIN_AREA_M2 = 100.0 STREAM_COVER_BUFFER_M = 20.0 +# 도로 절단용 노선 양끝 연장 길이(m). 노선 끝 너머로 새는 하류측 조각까지 잘라낸다. +ROAD_EXTEND_M = 500.0 # 산측 판정: 도로 접선의 좌우 수직 오프셋(m)과 표고 조회 반경(m). UPHILL_PROBE_OFFSET_M = 40.0 UPHILL_PROBE_RADIUS_M = 60.0 @@ -211,6 +214,50 @@ def _assemble_polygon( return polygon +def _extended_road_line(vertices: list[Any]) -> LineString: + """양끝 접선 방향으로 연장한 도로선 — 폴리곤 절단이 끝에서 끊기지 않게 한다.""" + coords = [(vertex.x, vertex.y) for vertex in vertices] + hx, hy = coords[0] + dx, dy = coords[1][0] - hx, coords[1][1] - hy + norm = math.hypot(dx, dy) or 1.0 + head = (hx - dx / norm * ROAD_EXTEND_M, hy - dy / norm * ROAD_EXTEND_M) + tx, ty = coords[-1] + dx, dy = tx - coords[-2][0], ty - coords[-2][1] + norm = math.hypot(dx, dy) or 1.0 + tail = (tx + dx / norm * ROAD_EXTEND_M, ty + dy / norm * ROAD_EXTEND_M) + return LineString([head, *coords, tail]) + + +def _clip_to_uphill( + polygon: Polygon, + road_line: LineString, + extended_road: LineString, + uphill_sign: int, +) -> Polygon | None: + """폴리곤을 도로선으로 절단해 산측 조각만 남긴다 — 도로가 유역의 한쪽 경계. + + 도로 하류측(성토부 아래)은 유역에 포함하지 않는다(2026-07-29 사용자 지시). + """ + try: + pieces = split(polygon, extended_road) + except Exception: # noqa: BLE001 - 절단 실패 시 원본 유지 + return polygon + kept = [ + piece + for piece in getattr(pieces, "geoms", [pieces]) + if piece.geom_type == "Polygon" + and side_sign(road_line, piece.representative_point()) == uphill_sign + ] + if not kept: + return None + merged = unary_union(kept) + if merged.geom_type == "MultiPolygon": + merged = max(merged.geoms, key=lambda part: part.area) + if merged.is_empty or merged.geom_type != "Polygon": + return None + return merged + + def build_watershed_basins( vertices: list[Any], candidates: list[StructureCandidate], @@ -233,6 +280,7 @@ def build_watershed_basins( return [] spot_index = ContourIndex(spot_features, elevation_keys) road_line = LineString([(vertex.x, vertex.y) for vertex in vertices]) + extended_road = _extended_road_line(vertices) ordered = sorted(candidates, key=lambda item: item.chainage_m) divides = _divide_chainages(vertices, ordered) @@ -298,7 +346,9 @@ def build_watershed_basins( covered = max(covered.geoms, key=lambda part: part.area) if covered.geom_type == "Polygon" and not covered.is_empty: polygon = covered - if polygon.area < MIN_BASIN_AREA_M2: + # 마지막에 도로선으로 절단해 하류측을 제거한다 — 도로가 유역의 한쪽 경계. + polygon = _clip_to_uphill(polygon, road_line, extended_road, signs[position]) + if polygon is None or polygon.area < MIN_BASIN_AREA_M2: continue outlet_z = contour_index.nearest_elevation(outlet, UPHILL_PROBE_RADIUS_M) From 067de3a25f221d76de3d3c30c7fc9fc826b665fd Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 29 Jul 2026 18:40:45 +0900 Subject: [PATCH 17/61] auto: 2026-07-29 18:40 (EOMSANGDON-HOME) --- ...B05_wf2_Route_Engine_Drainage_Watershed.py | 152 ++++++++++++++---- .../B05_wf2_Route_Engine_Watershed_Trace.py | 18 ++- 2 files changed, 137 insertions(+), 33 deletions(-) diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py index 70ed3d31..33327c66 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py @@ -19,7 +19,7 @@ from dataclasses import dataclass, field from typing import Any from shapely.geometry import LineString, Point, Polygon -from shapely.ops import split, substring, unary_union +from shapely.ops import nearest_points, split, substring, unary_union from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import ( StructureCandidate, @@ -45,6 +45,8 @@ MIN_BASIN_AREA_M2 = 100.0 STREAM_COVER_BUFFER_M = 20.0 # 도로 절단용 노선 양끝 연장 길이(m). 노선 끝 너머로 새는 하류측 조각까지 잘라낸다. ROAD_EXTEND_M = 500.0 +# 폐합 등고선 기하 탐색: 분할선 경로와 등고선의 근접 허용 거리(m). +CLOSING_SEARCH_M = 30.0 # 산측 판정: 도로 접선의 좌우 수직 오프셋(m)과 표고 조회 반경(m). UPHILL_PROBE_OFFSET_M = 40.0 UPHILL_PROBE_RADIUS_M = 60.0 @@ -124,11 +126,16 @@ def _road_segment_coords( def _contour_arc( - line: Any, p_from: Point, p_to: Point, road_line: LineString + line: Any, + p_from: Point, + p_to: Point, + road_line: LineString, + network_union: Any = None, ) -> list[tuple[float, float]]: """등고선에서 두 분할선 접점 사이 아크(상측 경계)를 뽑는다. - 폐합 등고선은 두 방향 아크가 생기므로 도로와 교차하지 않는(=산측) 쪽을 고른다. + 폐합 등고선은 두 방향 아크가 생기므로, 세류 상류망을 가로지르지 않고(계곡을 + 자르지 않고) 도로와도 교차하지 않는(=산측) 쪽을 고른다. """ t1, t2 = sorted((line.project(p_from), line.project(p_to))) arcs = [] @@ -145,36 +152,93 @@ def _contour_arc( return [] scored = [] for arc in arcs: - crosses = arc.crosses(road_line) + crosses_stream = bool(network_union is not None and arc.crosses(network_union)) + crosses_road = arc.crosses(road_line) midpoint = arc.interpolate(0.5, normalized=True) - scored.append((crosses, -road_line.distance(midpoint), arc)) - scored.sort(key=lambda item: (item[0], item[1])) - arc = scored[0][2] + scored.append((crosses_stream, crosses_road, -road_line.distance(midpoint), arc)) + scored.sort(key=lambda item: (item[0], item[1], item[2])) + arc = scored[0][3] coords = list(arc.coords) if Point(coords[0]).distance(p_from) > Point(coords[-1]).distance(p_from): coords.reverse() return [(float(x), float(y)) for x, y in coords] -def _junction(left: list[DividerStep], right: list[DividerStep]) -> tuple[int, int, int] | None: - """두 분할선이 같은 등고선 지오메트리를 밟은 최고 표고 지점(좌 idx, 우 idx, geom idx).""" +def _junction( + left: list[DividerStep], + right: list[DividerStep], + min_z: float | None = None, +) -> tuple[int, int, int] | None: + """두 분할선이 같은 등고선 지오메트리를 밟은 폐합 지점(좌 idx, 우 idx, geom idx). + + min_z(세류 상류망 최고 표고)가 있으면 그 **이상인 가장 낮은** 공통 등고선을 고른다 — + "세류로 영역을 지정한 뒤 가까운 등고선으로 바로 올려치면 안 된다"(2026-07-29 사용자 + 지시). 계곡 발원부를 넘긴 첫 등고선이 유역 상측 경계가 된다. 없으면 최고 공통 등고선. + """ left_keys = { (step.z, step.geom_index): position for position, step in enumerate(left) if step.geom_index >= 0 } - best: tuple[float, int, int, int] | None = None + matches: list[tuple[float, int, int, int]] = [] for position, step in enumerate(right): if step.geom_index < 0: continue left_position = left_keys.get((step.z, step.geom_index)) if left_position is None: continue - if best is None or step.z > best[0]: - best = (step.z, left_position, position, step.geom_index) + matches.append((step.z, left_position, position, step.geom_index)) + if not matches: + return None + if min_z is not None: + above = [match for match in matches if match[0] >= min_z] + if above: + best = min(above) + return best[1], best[2], best[3] + best = max(matches) + return best[1], best[2], best[3] + + +def _closing_contour( + left: list[DividerStep], + right: list[DividerStep], + contour_index: ContourIndex, + min_z: float, +) -> tuple[int, int, int, Point, Point] | None: + """두 분할선 경로에 모두 근접한 등고선 중 min_z 이상 최저를 찾는다. + + 분할선이 같은 스텝에서 같은 지오메트리를 밟지 못해도(도엽 분할 등) 계곡 발원부 + 위를 지나는 폐합 등고선을 기하적으로 찾아낸다. + 반환: (좌 절단 idx, 우 절단 idx, 등고선 geom idx, 좌 접점, 우 접점). + """ + if len(left) < 2 or len(right) < 2: + return None + left_line = LineString([step.point for step in left]) + right_line = LineString([step.point for step in right]) + shared = set(contour_index.query(left_line.buffer(CLOSING_SEARCH_M))) & set( + contour_index.query(right_line.buffer(CLOSING_SEARCH_M)) + ) + best: tuple[float, int] | None = None + for index in shared: + z = contour_index.zs[index] + if z < min_z: + continue + geom = contour_index.geoms[index] + if ( + geom.distance(left_line) > CLOSING_SEARCH_M + or geom.distance(right_line) > CLOSING_SEARCH_M + ): + continue + if best is None or z < best[0]: + best = (z, index) if best is None: return None - return best[1], best[2], best[3] + geom = contour_index.geoms[best[1]] + left_touch = nearest_points(geom, left_line)[0] + right_touch = nearest_points(geom, right_line)[0] + left_position = min(range(len(left)), key=lambda i: left[i].point.distance(left_touch)) + right_position = min(range(len(right)), key=lambda i: right[i].point.distance(right_touch)) + return left_position, right_position, best[1], left_touch, right_touch def _assemble_polygon( @@ -185,22 +249,41 @@ def _assemble_polygon( right: list[DividerStep], contour_index: ContourIndex, road_line: LineString, + network_union: Any = None, + valley_top_z: float | None = None, ) -> Polygon | None: - """도로 구간 + 우측 분할선 + 상측 등고선 아크 + 좌측 분할선으로 폴리곤을 폐합한다.""" + """도로 구간 + 우측 분할선 + 상측 등고선 아크 + 좌측 분할선으로 폴리곤을 폐합한다. + + 세류 유역(valley_top_z 지정)은 발원부 위를 지나는 폐합 등고선을 기하 탐색으로 + 먼저 찾고, 실패 시 같은 스텝 매칭(_junction)으로 폐합한다. + """ ring = _road_segment_coords(vertices, start_m, end_m) - junction = _junction(left, right) - if junction is not None: - left_position, right_position, geom_index = junction + left_used, right_used, arc = left, right, [] + closure = ( + _closing_contour(left, right, contour_index, valley_top_z) + if valley_top_z is not None + else None + ) + if closure is not None: + left_position, right_position, geom_index, left_touch, right_touch = closure left_used = left[: left_position + 1] right_used = right[: right_position + 1] arc = _contour_arc( - contour_index.geoms[geom_index], - right_used[-1].point, - left_used[-1].point, - road_line, + contour_index.geoms[geom_index], right_touch, left_touch, road_line, network_union ) else: - left_used, right_used, arc = left, right, [] + junction = _junction(left, right, min_z=valley_top_z) + if junction is not None: + left_position, right_position, geom_index = junction + left_used = left[: left_position + 1] + right_used = right[: right_position + 1] + arc = _contour_arc( + contour_index.geoms[geom_index], + right_used[-1].point, + left_used[-1].point, + road_line, + network_union, + ) ring.extend((step.point.x, step.point.y) for step in right_used[1:]) ring.extend(arc) ring.extend((step.point.x, step.point.y) for step in reversed(left_used[1:])) @@ -230,13 +313,15 @@ def _extended_road_line(vertices: list[Any]) -> LineString: def _clip_to_uphill( polygon: Polygon, - road_line: LineString, extended_road: LineString, uphill_sign: int, + keep_geom: Any = None, ) -> Polygon | None: """폴리곤을 도로선으로 절단해 산측 조각만 남긴다 — 도로가 유역의 한쪽 경계. 도로 하류측(성토부 아래)은 유역에 포함하지 않는다(2026-07-29 사용자 지시). + keep_geom(세류 상류망)이 걸린 조각은 부호와 무관하게 유지한다 — 상류망은 정의상 + 산측 배수인데, 노선 끝을 감아 도는 계곡은 연장 도로선 기준 부호가 뒤집힐 수 있다. """ try: pieces = split(polygon, extended_road) @@ -246,7 +331,11 @@ def _clip_to_uphill( piece for piece in getattr(pieces, "geoms", [pieces]) if piece.geom_type == "Polygon" - and side_sign(road_line, piece.representative_point()) == uphill_sign + and ( + side_sign(extended_road, piece.representative_point()) == uphill_sign + # 교차점(도로 위 시작점) 접촉만으로는 부족 — 상류망이 실제로 지나가야 한다. + or (keep_geom is not None and piece.intersection(keep_geom).length > 5.0) + ) ] if not kept: return None @@ -328,6 +417,13 @@ def build_watershed_basins( network, flow_length = trace_upstream_network( outlet, stream_features, road_line, signs[position] ) + # 계곡 발원부(상류망 최고 표고) — 유역 상측 폐합 등고선은 이보다 높아야 한다. + network_union = unary_union(network) if network else None + valley_top_z = ( + contour_index.max_elevation_within(network_union.buffer(10.0)) + if network_union is not None + else None + ) polygon = _assemble_polygon( vertices, divides[position], @@ -336,18 +432,20 @@ def build_watershed_basins( dividers[position + 1], contour_index, road_line, + network_union, + valley_top_z, ) if polygon is None: continue - if network: + if network_union is not None: # 상류망이 폴리곤 밖으로 뻗은 경우까지 유역이 덮도록 보정한다. - covered = polygon.union(unary_union(network).buffer(STREAM_COVER_BUFFER_M)).buffer(0) + covered = polygon.union(network_union.buffer(STREAM_COVER_BUFFER_M)).buffer(0) if covered.geom_type == "MultiPolygon": covered = max(covered.geoms, key=lambda part: part.area) if covered.geom_type == "Polygon" and not covered.is_empty: polygon = covered # 마지막에 도로선으로 절단해 하류측을 제거한다 — 도로가 유역의 한쪽 경계. - polygon = _clip_to_uphill(polygon, road_line, extended_road, signs[position]) + polygon = _clip_to_uphill(polygon, extended_road, signs[position], network_union) if polygon is None or polygon.area < MIN_BASIN_AREA_M2: continue diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Trace.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Trace.py index 842a477a..2eb965ee 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Trace.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Trace.py @@ -216,13 +216,20 @@ def trace_upstream_network( used: set[int] = set() total = 0.0 - # ① 뿌리: 교차점을 지나는 세류의 산측 조각. + # ① 뿌리: 교차점을 지나는 세류의 산측 조각. 도엽 세류는 교차점 부근에서 별도 + # 피처로 조각나 있는 경우가 많아, 근접(STREAM_JOIN_TOL_M) 조각도 뿌리로 받는다. for index, line in enumerate(lines): - if line.distance(crossing) > 1.0: + distance = line.distance(crossing) + if distance > STREAM_JOIN_TOL_M: continue - t = line.project(crossing) - for piece in (substring(line, 0.0, t), substring(line, t, line.length)): - if piece.geom_type != "LineString" or piece.length < STREAM_JOIN_TOL_M: + used.add(index) + if distance <= 1.0: + t = line.project(crossing) + pieces = [substring(line, 0.0, t), substring(line, t, line.length)] + else: + pieces = [line] + for piece in pieces: + if piece.geom_type != "LineString" or piece.length < 1.0: continue oriented = _oriented_from(piece, crossing) probe = oriented.interpolate(min(10.0, oriented.length)) @@ -233,7 +240,6 @@ def trace_upstream_network( continue network.append((clipped, 0.0)) total += clipped.length - used.add(index) if not network: return [], 0.0 From 07cc96ea37477ce7559e87ac0a4587089dc743bf Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 29 Jul 2026 18:44:53 +0900 Subject: [PATCH 18/61] auto: 2026-07-29 18:44 (EOMSANGDON-HOME) --- ...B05_wf2_Route_Engine_Drainage_Watershed.py | 143 ++++++++++++++++-- 1 file changed, 130 insertions(+), 13 deletions(-) diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py index 33327c66..25d20dd8 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py @@ -47,6 +47,9 @@ STREAM_COVER_BUFFER_M = 20.0 ROAD_EXTEND_M = 500.0 # 폐합 등고선 기하 탐색: 분할선 경로와 등고선의 근접 허용 거리(m). CLOSING_SEARCH_M = 30.0 +# 세류 포함 폐합 등고선 탐색: 상류망과 등고선의 근접 허용 거리(m)와 커버리지 목표. +CLOSING_NEAR_M = 200.0 +CLOSING_COVERAGE_GOAL = 0.95 # 산측 판정: 도로 접선의 좌우 수직 오프셋(m)과 표고 조회 반경(m). UPHILL_PROBE_OFFSET_M = 40.0 UPHILL_PROBE_RADIUS_M = 60.0 @@ -241,6 +244,103 @@ def _closing_contour( return left_position, right_position, best[1], left_touch, right_touch +def _both_arcs(line: Any, p_from: Point, p_to: Point) -> list[list[tuple[float, float]]]: + """등고선 위 두 접점 사이의 가능한 아크(양방향) 좌표열. p_from에서 시작하도록 정렬.""" + t1, t2 = sorted((line.project(p_from), line.project(p_to))) + raw: list[list[Any]] = [] + inner = substring(line, t1, t2) + if inner.geom_type == "LineString" and len(inner.coords) >= 2: + raw.append(list(inner.coords)) + if getattr(line, "is_closed", False): + head = substring(line, t2, line.length) + tail = substring(line, 0.0, t1) + coords = list(head.coords) + list(tail.coords)[1:] + if len(coords) >= 2: + raw.append(coords) + oriented: list[list[tuple[float, float]]] = [] + for coords in raw: + if Point(coords[0]).distance(p_from) > Point(coords[-1]).distance(p_from): + coords = coords[::-1] + oriented.append([(float(x), float(y)) for x, y in coords]) + return oriented + + +def _steps_until_near(steps: list[DividerStep], point: Point) -> list[DividerStep]: + """분할선을 point에 가장 가까운 스텝까지 자른다.""" + if not steps: + return [] + position = min(range(len(steps)), key=lambda i: steps[i].point.distance(point)) + return steps[: position + 1] + + +def _assemble_stream_polygon( + vertices: list[Any], + start_m: float, + end_m: float, + left: list[DividerStep], + right: list[DividerStep], + contour_index: ContourIndex, + network_union: Any, + valley_top_z: float, +) -> tuple[Polygon, float] | None: + """세류 상류망을 **포함하는 등고선**을 유역 상측 경계로 폐합한다. + + "세류가 중심이 되면 안 되고, 세류를 포함하는 등고라인이 경계가 되어야 한다" + (2026-07-29 사용자 지시). 발원부(valley_top_z) 이상이고 세류망을 가로지르지 않으며 + 세류망에 근접한 등고선을 낮은 것부터 시도해, 상류망을 가장 잘 덮는 아크 폐합을 고른다. + 반환: (폴리곤, 상류망 커버리지 0~1). 후보가 없으면 None. + """ + candidates: list[tuple[float, int]] = [] + for index in contour_index.query(network_union.buffer(CLOSING_NEAR_M)): + z = contour_index.zs[index] + if z < valley_top_z: + continue + geom = contour_index.geoms[index] + if geom.distance(network_union) > CLOSING_NEAR_M or geom.intersects(network_union): + continue + candidates.append((z, index)) + if not candidates: + return None + candidates.sort() + road_coords = _road_segment_coords(vertices, start_m, end_m) + left_anchor: Any = ( + LineString([step.point for step in left]) if len(left) > 1 else Point(road_coords[0]) + ) + right_anchor: Any = ( + LineString([step.point for step in right]) if len(right) > 1 else Point(road_coords[-1]) + ) + best: tuple[float, Polygon] | None = None + for _, index in candidates[:12]: + geom = contour_index.geoms[index] + left_touch = nearest_points(geom, left_anchor)[0] + right_touch = nearest_points(geom, right_anchor)[0] + for arc in _both_arcs(geom, right_touch, left_touch): + ring: list[tuple[float, float]] = list(road_coords) + ring.extend( + (step.point.x, step.point.y) for step in _steps_until_near(right, right_touch)[1:] + ) + ring.extend(arc) + ring.extend( + (step.point.x, step.point.y) + for step in reversed(_steps_until_near(left, left_touch)[1:]) + ) + if len(ring) < 4: + continue + polygon = Polygon(ring).buffer(0) + if polygon.geom_type == "MultiPolygon": + polygon = max(polygon.geoms, key=lambda part: part.area) + if polygon.is_empty or polygon.geom_type != "Polygon": + continue + coverage = network_union.intersection(polygon).length / max(network_union.length, 1.0) + if best is None or coverage > best[0]: + best = (coverage, polygon) + if best is not None and best[0] >= CLOSING_COVERAGE_GOAL: + break + if best is None: + return None + return best[1], best[0] + + def _assemble_polygon( vertices: list[Any], start_m: float, @@ -424,21 +524,38 @@ def build_watershed_basins( if network_union is not None else None ) - polygon = _assemble_polygon( - vertices, - divides[position], - divides[position + 1], - dividers[position], - dividers[position + 1], - contour_index, - road_line, - network_union, - valley_top_z, - ) + polygon = None + coverage = 0.0 + if network_union is not None and valley_top_z is not None: + # 세류 유역: 상류망을 포함하는 등고선 아크로 폐합(사용자 지시). + closed = _assemble_stream_polygon( + vertices, + divides[position], + divides[position + 1], + dividers[position], + dividers[position + 1], + contour_index, + network_union, + valley_top_z, + ) + if closed is not None: + polygon, coverage = closed + if polygon is None: + polygon = _assemble_polygon( + vertices, + divides[position], + divides[position + 1], + dividers[position], + dividers[position + 1], + contour_index, + road_line, + network_union, + valley_top_z, + ) if polygon is None: continue - if network_union is not None: - # 상류망이 폴리곤 밖으로 뻗은 경우까지 유역이 덮도록 보정한다. + if network_union is not None and coverage < CLOSING_COVERAGE_GOAL: + # 폐합 등고선이 못 덮은 상류망만 버퍼로 보정한다(최후 폴백). covered = polygon.union(network_union.buffer(STREAM_COVER_BUFFER_M)).buffer(0) if covered.geom_type == "MultiPolygon": covered = max(covered.geoms, key=lambda part: part.area) From afa04d8c231ec333b4d5e6c46ea31b4be42db21a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 29 Jul 2026 19:13:27 +0900 Subject: [PATCH 19/61] auto: 2026-07-29 19:13 (EOMSANGDON-HOME) --- ...B05_wf2_Route_Engine_Drainage_Watershed.py | 263 +++++++----------- .../B05_wf2_Route_Engine_Watershed_Trace.py | 63 ++++- 2 files changed, 153 insertions(+), 173 deletions(-) diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py index 25d20dd8..5593952a 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py @@ -19,7 +19,7 @@ from dataclasses import dataclass, field from typing import Any from shapely.geometry import LineString, Point, Polygon -from shapely.ops import nearest_points, split, substring, unary_union +from shapely.ops import nearest_points, substring, unary_union from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import ( StructureCandidate, @@ -36,6 +36,7 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Trace import ( side_sign, trace_divider, trace_upstream_network, + valley_region_polygon, ) logger = logging.getLogger(__name__) @@ -43,12 +44,11 @@ logger = logging.getLogger(__name__) # 유효 유역 최소 면적(m²)과 상류망 커버 보정 버퍼(m). MIN_BASIN_AREA_M2 = 100.0 STREAM_COVER_BUFFER_M = 20.0 -# 도로 절단용 노선 양끝 연장 길이(m). 노선 끝 너머로 새는 하류측 조각까지 잘라낸다. -ROAD_EXTEND_M = 500.0 +# 도로 양끝에서 하류측으로 뻗는 절단 차단선 길이(m). +DOWNHILL_BARRIER_M = 800.0 # 폐합 등고선 기하 탐색: 분할선 경로와 등고선의 근접 허용 거리(m). CLOSING_SEARCH_M = 30.0 -# 세류 포함 폐합 등고선 탐색: 상류망과 등고선의 근접 허용 거리(m)와 커버리지 목표. -CLOSING_NEAR_M = 200.0 +# 유역이 상류망을 덮어야 하는 커버리지 목표(미달 시 버퍼 폴백). CLOSING_COVERAGE_GOAL = 0.95 # 산측 판정: 도로 접선의 좌우 수직 오프셋(m)과 표고 조회 반경(m). UPHILL_PROBE_OFFSET_M = 40.0 @@ -244,103 +244,6 @@ def _closing_contour( return left_position, right_position, best[1], left_touch, right_touch -def _both_arcs(line: Any, p_from: Point, p_to: Point) -> list[list[tuple[float, float]]]: - """등고선 위 두 접점 사이의 가능한 아크(양방향) 좌표열. p_from에서 시작하도록 정렬.""" - t1, t2 = sorted((line.project(p_from), line.project(p_to))) - raw: list[list[Any]] = [] - inner = substring(line, t1, t2) - if inner.geom_type == "LineString" and len(inner.coords) >= 2: - raw.append(list(inner.coords)) - if getattr(line, "is_closed", False): - head = substring(line, t2, line.length) - tail = substring(line, 0.0, t1) - coords = list(head.coords) + list(tail.coords)[1:] - if len(coords) >= 2: - raw.append(coords) - oriented: list[list[tuple[float, float]]] = [] - for coords in raw: - if Point(coords[0]).distance(p_from) > Point(coords[-1]).distance(p_from): - coords = coords[::-1] - oriented.append([(float(x), float(y)) for x, y in coords]) - return oriented - - -def _steps_until_near(steps: list[DividerStep], point: Point) -> list[DividerStep]: - """분할선을 point에 가장 가까운 스텝까지 자른다.""" - if not steps: - return [] - position = min(range(len(steps)), key=lambda i: steps[i].point.distance(point)) - return steps[: position + 1] - - -def _assemble_stream_polygon( - vertices: list[Any], - start_m: float, - end_m: float, - left: list[DividerStep], - right: list[DividerStep], - contour_index: ContourIndex, - network_union: Any, - valley_top_z: float, -) -> tuple[Polygon, float] | None: - """세류 상류망을 **포함하는 등고선**을 유역 상측 경계로 폐합한다. - - "세류가 중심이 되면 안 되고, 세류를 포함하는 등고라인이 경계가 되어야 한다" - (2026-07-29 사용자 지시). 발원부(valley_top_z) 이상이고 세류망을 가로지르지 않으며 - 세류망에 근접한 등고선을 낮은 것부터 시도해, 상류망을 가장 잘 덮는 아크 폐합을 고른다. - 반환: (폴리곤, 상류망 커버리지 0~1). 후보가 없으면 None. - """ - candidates: list[tuple[float, int]] = [] - for index in contour_index.query(network_union.buffer(CLOSING_NEAR_M)): - z = contour_index.zs[index] - if z < valley_top_z: - continue - geom = contour_index.geoms[index] - if geom.distance(network_union) > CLOSING_NEAR_M or geom.intersects(network_union): - continue - candidates.append((z, index)) - if not candidates: - return None - candidates.sort() - road_coords = _road_segment_coords(vertices, start_m, end_m) - left_anchor: Any = ( - LineString([step.point for step in left]) if len(left) > 1 else Point(road_coords[0]) - ) - right_anchor: Any = ( - LineString([step.point for step in right]) if len(right) > 1 else Point(road_coords[-1]) - ) - best: tuple[float, Polygon] | None = None - for _, index in candidates[:12]: - geom = contour_index.geoms[index] - left_touch = nearest_points(geom, left_anchor)[0] - right_touch = nearest_points(geom, right_anchor)[0] - for arc in _both_arcs(geom, right_touch, left_touch): - ring: list[tuple[float, float]] = list(road_coords) - ring.extend( - (step.point.x, step.point.y) for step in _steps_until_near(right, right_touch)[1:] - ) - ring.extend(arc) - ring.extend( - (step.point.x, step.point.y) - for step in reversed(_steps_until_near(left, left_touch)[1:]) - ) - if len(ring) < 4: - continue - polygon = Polygon(ring).buffer(0) - if polygon.geom_type == "MultiPolygon": - polygon = max(polygon.geoms, key=lambda part: part.area) - if polygon.is_empty or polygon.geom_type != "Polygon": - continue - coverage = network_union.intersection(polygon).length / max(network_union.length, 1.0) - if best is None or coverage > best[0]: - best = (coverage, polygon) - if best is not None and best[0] >= CLOSING_COVERAGE_GOAL: - break - if best is None: - return None - return best[1], best[0] - - def _assemble_polygon( vertices: list[Any], start_m: float, @@ -397,49 +300,72 @@ def _assemble_polygon( return polygon -def _extended_road_line(vertices: list[Any]) -> LineString: - """양끝 접선 방향으로 연장한 도로선 — 폴리곤 절단이 끝에서 끊기지 않게 한다.""" - coords = [(vertex.x, vertex.y) for vertex in vertices] - hx, hy = coords[0] - dx, dy = coords[1][0] - hx, coords[1][1] - hy - norm = math.hypot(dx, dy) or 1.0 - head = (hx - dx / norm * ROAD_EXTEND_M, hy - dy / norm * ROAD_EXTEND_M) - tx, ty = coords[-1] - dx, dy = tx - coords[-2][0], ty - coords[-2][1] - norm = math.hypot(dx, dy) or 1.0 - tail = (tx + dx / norm * ROAD_EXTEND_M, ty + dy / norm * ROAD_EXTEND_M) - return LineString([head, *coords, tail]) - - def _clip_to_uphill( polygon: Polygon, - extended_road: LineString, + road_line: LineString, uphill_sign: int, + contour_index: ContourIndex, keep_geom: Any = None, ) -> Polygon | None: - """폴리곤을 도로선으로 절단해 산측 조각만 남긴다 — 도로가 유역의 한쪽 경계. + """도로 하류측 조각을 잘라낸다 — 도로가 유역의 한쪽 경계(2026-07-29 사용자 지시). - 도로 하류측(성토부 아래)은 유역에 포함하지 않는다(2026-07-29 사용자 지시). - keep_geom(세류 상류망)이 걸린 조각은 부호와 무관하게 유지한다 — 상류망은 정의상 - 산측 배수인데, 노선 끝을 감아 도는 계곡은 연장 도로선 기준 부호가 뒤집힐 수 있다. + 절단선 = 실제 도로선 + 양끝에서 **하류측으로 뻗는 수직 차단선** 2개. (후방 접선 + 연장은 곡선 노선에서 계곡 내부를 관통해 오절단을 일으켰다.) 노선 끝을 감아 도는 + 산측 사면은 남고, 도로 하류측 주머니만 분리된다. + 조각 분류는 표고 기반: 대표점의 등고선 표고 > 최근접 도로 지점 표고 → 산측. + keep_geom(세류 상류망)이 실제로 지나가는 조각은 무조건 유지한다. """ + length = road_line.length + cutters: list[LineString] = [road_line] + for t_end, t_inner in ((0.0, min(30.0, length)), (length, max(0.0, length - 30.0))): + end = road_line.interpolate(t_end) + inner = road_line.interpolate(t_inner) + dx, dy = end.x - inner.x, end.y - inner.y + norm = math.hypot(dx, dy) or 1.0 + for nx, ny in ((-dy / norm, dx / norm), (dy / norm, -dx / norm)): + probe = Point(end.x + nx * 30.0, end.y + ny * 30.0) + if side_sign(road_line, probe) == -uphill_sign: + cutters.append( + LineString( + [ + (end.x, end.y), + (end.x + nx * DOWNHILL_BARRIER_M, end.y + ny * DOWNHILL_BARRIER_M), + ] + ) + ) + break + # split()은 절단선이 폴리곤 경계와 겹치면(도로 = 유역 하측 경계) 동작하지 않는다. + # 얇은 스트립을 차감해 조각을 분리하고, 분류 후 buffer-교집합으로 원형을 복원한다. try: - pieces = split(polygon, extended_road) + strip = unary_union([cutter.buffer(0.5) for cutter in cutters]) + separated = polygon.difference(strip) except Exception: # noqa: BLE001 - 절단 실패 시 원본 유지 return polygon - kept = [ - piece - for piece in getattr(pieces, "geoms", [pieces]) - if piece.geom_type == "Polygon" - and ( - side_sign(extended_road, piece.representative_point()) == uphill_sign - # 교차점(도로 위 시작점) 접촉만으로는 부족 — 상류망이 실제로 지나가야 한다. - or (keep_geom is not None and piece.intersection(keep_geom).length > 5.0) - ) + pieces = [ + part + for part in (separated.geoms if separated.geom_type.startswith("Multi") else [separated]) + if part.geom_type == "Polygon" and not part.is_empty ] + kept = [] + for piece in pieces: + if keep_geom is not None and piece.intersection(keep_geom).length > 5.0: + kept.append(piece) + continue + representative = piece.representative_point() + piece_z = contour_index.nearest_elevation(representative, 2.0 * UPHILL_PROBE_RADIUS_M) + foot = road_line.interpolate(road_line.project(representative)) + road_z = contour_index.nearest_elevation(foot, 2.0 * UPHILL_PROBE_RADIUS_M) + if piece_z is not None and road_z is not None and piece_z != road_z: + if piece_z > road_z: + kept.append(piece) + continue + # 표고 판정 불가(등고선 공백·동일 표고) 시에만 좌우 부호로 판정한다. + if side_sign(road_line, representative) == uphill_sign: + kept.append(piece) if not kept: return None - merged = unary_union(kept) + # 스트립 차감으로 깎인 0.5m를 되붙이되 원본 폴리곤 밖으로는 나가지 않는다. + merged = unary_union(kept).buffer(0.7).intersection(polygon).buffer(0) if merged.geom_type == "MultiPolygon": merged = max(merged.geoms, key=lambda part: part.area) if merged.is_empty or merged.geom_type != "Polygon": @@ -469,7 +395,6 @@ def build_watershed_basins( return [] spot_index = ContourIndex(spot_features, elevation_keys) road_line = LineString([(vertex.x, vertex.y) for vertex in vertices]) - extended_road = _extended_road_line(vertices) ordered = sorted(candidates, key=lambda item: item.chainage_m) divides = _divide_chainages(vertices, ordered) @@ -524,45 +449,43 @@ def build_watershed_basins( if network_union is not None else None ) - polygon = None - coverage = 0.0 - if network_union is not None and valley_top_z is not None: - # 세류 유역: 상류망을 포함하는 등고선 아크로 폐합(사용자 지시). - closed = _assemble_stream_polygon( - vertices, - divides[position], - divides[position + 1], - dividers[position], - dividers[position + 1], - contour_index, - network_union, - valley_top_z, - ) - if closed is not None: - polygon, coverage = closed - if polygon is None: - polygon = _assemble_polygon( - vertices, - divides[position], - divides[position + 1], - dividers[position], - dividers[position + 1], - contour_index, - road_line, - network_union, - valley_top_z, - ) - if polygon is None: + # 도로변 스트립(분할선 폐합) + 세류 계곡 영역(분수령=인접 세류 등거리)을 합친다. + base = _assemble_polygon( + vertices, + divides[position], + divides[position + 1], + dividers[position], + dividers[position + 1], + contour_index, + road_line, + network_union, + valley_top_z, + ) + valley = ( + valley_region_polygon(network_union, stream_features or [], outlet) + if network_union is not None + else None + ) + if base is None and valley is None: continue - if network_union is not None and coverage < CLOSING_COVERAGE_GOAL: - # 폐합 등고선이 못 덮은 상류망만 버퍼로 보정한다(최후 폴백). - covered = polygon.union(network_union.buffer(STREAM_COVER_BUFFER_M)).buffer(0) - if covered.geom_type == "MultiPolygon": - covered = max(covered.geoms, key=lambda part: part.area) - if covered.geom_type == "Polygon" and not covered.is_empty: - polygon = covered + if base is not None and valley is not None: + merged = base.union(valley).buffer(0) + if merged.geom_type == "MultiPolygon": + merged = max(merged.geoms, key=lambda part: part.area) + polygon = merged if merged.geom_type == "Polygon" and not merged.is_empty else base + else: + polygon = base if base is not None else valley + if network_union is not None: + coverage = network_union.intersection(polygon).length / max(network_union.length, 1.0) + if coverage < CLOSING_COVERAGE_GOAL: + # 계곡 영역이 못 덮은 상류망만 버퍼로 보정한다(최후 폴백). + covered = polygon.union(network_union.buffer(STREAM_COVER_BUFFER_M)).buffer(0) + if covered.geom_type == "MultiPolygon": + covered = max(covered.geoms, key=lambda part: part.area) + if covered.geom_type == "Polygon" and not covered.is_empty: + polygon = covered # 마지막에 도로선으로 절단해 하류측을 제거한다 — 도로가 유역의 한쪽 경계. - polygon = _clip_to_uphill(polygon, extended_road, signs[position], network_union) + polygon = _clip_to_uphill(polygon, road_line, signs[position], contour_index, network_union) if polygon is None or polygon.area < MIN_BASIN_AREA_M2: continue diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Trace.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Trace.py index 2eb965ee..25110185 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Trace.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Trace.py @@ -13,8 +13,8 @@ from __future__ import annotations from dataclasses import dataclass from typing import Any -from shapely.geometry import LineString, Point, shape -from shapely.ops import nearest_points, substring +from shapely.geometry import LineString, Point, box, shape +from shapely.ops import nearest_points, substring, unary_union from shapely.strtree import STRtree # 분할선(능선 근사) 추적: 다음 상위 등고선을 찾는 탐색 반경(m)과 최대 단계 수. @@ -26,6 +26,9 @@ LOCAL_MAX_STEPS = 12 # 세류 연결 판정 이격(m)과 상류망 총연장 상한(m). STREAM_JOIN_TOL_M = 15.0 MAX_UPSTREAM_TOTAL_M = 5000.0 +# 계곡 유역 근사 격자: 셀 크기(m)와 상류망에서의 최대 이격(m). +VALLEY_CELL_M = 12.0 +VALLEY_CAP_M = 350.0 @dataclass @@ -198,6 +201,55 @@ def _clip_uphill(line: LineString, road_line: LineString, origin: Point) -> Line return piece +def valley_region_polygon( + network_union: Any, + stream_features: list[dict[str, Any]], + crossing: Point, +) -> Any | None: + """상류망 계곡의 유역 영역 — 분수령(능선)을 인접 세류와의 등거리선으로 근사한다. + + 격자 셀 중심이 ① 인접 계곡 세류보다 우리 상류망에 가깝고 ② 상한 거리 이내이면 + 이 유역에 속한 것으로 본다. 지류 사이 사면(지능선)은 자연히 포함되고, 능선 너머 + 인접 계곡은 자동 제외된다. 반환: 폴리곤(간략화됨) 또는 None. + """ + lines = _explode_lines(stream_features) + others = [line for line in lines if line.distance(network_union) > STREAM_JOIN_TOL_M] + other_tree = STRtree(others) if others else None + min_x, min_y, max_x, max_y = network_union.buffer(VALLEY_CAP_M).bounds + cells = [] + y = min_y + while y < max_y: + x = min_x + while x < max_x: + center = Point(x + VALLEY_CELL_M / 2.0, y + VALLEY_CELL_M / 2.0) + distance = network_union.distance(center) + if distance <= VALLEY_CAP_M: + if other_tree is not None: + nearest = others[int(other_tree.nearest(center))] + if nearest.distance(center) < distance: + x += VALLEY_CELL_M + continue + cells.append(box(x, y, x + VALLEY_CELL_M, y + VALLEY_CELL_M)) + x += VALLEY_CELL_M + y += VALLEY_CELL_M + if not cells: + return None + region = unary_union(cells).buffer(0) + if region.geom_type == "MultiPolygon": + touching = [ + part + for part in region.geoms + if part.intersects(network_union) or part.distance(crossing) < VALLEY_CELL_M * 2 + ] + region = unary_union(touching) if touching else max(region.geoms, key=lambda p: p.area) + if region.geom_type == "MultiPolygon": + region = max(region.geoms, key=lambda p: p.area) + region = region.simplify(VALLEY_CELL_M, preserve_topology=True) + if region.is_empty or region.geom_type != "Polygon": + return None + return region + + def trace_upstream_network( crossing: Point, stream_features: list[dict[str, Any]], @@ -263,7 +315,12 @@ def trace_upstream_network( continue used.add(index) _, endpoint, _, cum = attach - if side_sign(road_line, line.interpolate(0.5, normalized=True)) == -uphill_sign: + # 좌우(산측) 판정은 도로 근처에서만 신뢰 — 노선에서 먼 상류는 투영 기준이 + # 뒤틀려 부호가 뒤집히므로 연결성과 도로 재교차 절단만으로 판단한다. + near_road = road_line.distance(endpoint) < 2.0 * STREAM_JOIN_TOL_M + if near_road and side_sign(road_line, line.interpolate(0.5, normalized=True)) == ( + -uphill_sign + ): continue oriented = _oriented_from(line, endpoint) clipped = _clip_uphill(oriented, road_line, endpoint) From d5ba1f1eb510510d4737b4c841bde3b34bd41a7d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 29 Jul 2026 19:23:04 +0900 Subject: [PATCH 20/61] auto: 2026-07-29 19:23 (EOMSANGDON-HOME) --- ...B05_wf2_Route_Engine_Drainage_Watershed.py | 2 +- .../B05_wf2_Route_Engine_Watershed_Trace.py | 79 +++++++++++++++++-- 2 files changed, 75 insertions(+), 6 deletions(-) diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py index 5593952a..c7697a8e 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py @@ -462,7 +462,7 @@ def build_watershed_basins( valley_top_z, ) valley = ( - valley_region_polygon(network_union, stream_features or [], outlet) + valley_region_polygon(network_union, stream_features or [], outlet, contour_index) if network_union is not None else None ) diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Trace.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Trace.py index 25110185..7f880eab 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Trace.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Trace.py @@ -13,7 +13,7 @@ from __future__ import annotations from dataclasses import dataclass from typing import Any -from shapely.geometry import LineString, Point, box, shape +from shapely.geometry import LineString, Point, Polygon, box, shape from shapely.ops import nearest_points, substring, unary_union from shapely.strtree import STRtree @@ -29,6 +29,9 @@ MAX_UPSTREAM_TOTAL_M = 5000.0 # 계곡 유역 근사 격자: 셀 크기(m)와 상류망에서의 최대 이격(m). VALLEY_CELL_M = 12.0 VALLEY_CAP_M = 350.0 +# 능선 스냅: 경계 정점에서 등고선 탐색 반경(m)과 등고선 위 능선 꼭짓점 탐색 폭(m). +RIDGE_SNAP_M = 40.0 +RIDGE_WALK_M = 80.0 @dataclass @@ -205,12 +208,17 @@ def valley_region_polygon( network_union: Any, stream_features: list[dict[str, Any]], crossing: Point, + contour_index: Any = None, ) -> Any | None: - """상류망 계곡의 유역 영역 — 분수령(능선)을 인접 세류와의 등거리선으로 근사한다. + """상류망 계곡의 유역 영역 — 경계는 등고선을 참고한 능선(분수령)으로 긋는다. - 격자 셀 중심이 ① 인접 계곡 세류보다 우리 상류망에 가깝고 ② 상한 거리 이내이면 - 이 유역에 속한 것으로 본다. 지류 사이 사면(지능선)은 자연히 포함되고, 능선 너머 - 인접 계곡은 자동 제외된다. 반환: 폴리곤(간략화됨) 또는 None. + ① 등거리 1차 근사: 격자 셀 중심이 인접 계곡 세류보다 우리 상류망에 가깝고 상한 + 거리 이내이면 유역 소속. 지류 사이 사면(지능선) 포함, 인접 계곡 자동 제외. + ② 등고선 스냅(2026-07-29 사용자 지시): 경계는 세류들 사이 중간 어딘가가 아니라 + **등고선을 참고해** 그어야 한다 — 각 경계 정점을 근처 등고선 위에서 두 세류 + 모두로부터 가장 먼 지점(능선 꼭짓점)으로 이동. 단 우리 세류를 침범하거나 + 인접 세류 너머로 나가지 않는다. + 반환: 폴리곤(간략화됨) 또는 None. """ lines = _explode_lines(stream_features) others = [line for line in lines if line.distance(network_union) > STREAM_JOIN_TOL_M] @@ -247,9 +255,70 @@ def valley_region_polygon( region = region.simplify(VALLEY_CELL_M, preserve_topology=True) if region.is_empty or region.geom_type != "Polygon": return None + if contour_index is not None: + region = _snap_boundary_to_ridge(region, contour_index, network_union, others, other_tree) return region +def _snap_boundary_to_ridge( + region: Any, + contour_index: Any, + network_union: Any, + others: list[LineString], + other_tree: STRtree | None, +) -> Any: + """등거리 경계 정점을 등고선 위 능선 꼭짓점으로 스냅한다. + + 능선 꼭짓점 = 근처 등고선을 따라 걸었을 때 두 세류(우리 상류망·인접 세류) + 모두로부터의 최소거리가 최대가 되는 지점. 우리 세류 침범(근접)과 인접 세류 + 이탈(인접이 더 가까워짐)은 금지한다. 실패 시 원본 경계를 유지한다. + """ + + def _score(point: Point) -> tuple[float, float]: + to_network = network_union.distance(point) + to_other = ( + others[int(other_tree.nearest(point))].distance(point) + if other_tree is not None + else float("inf") + ) + return to_network, to_other + + snapped: list[tuple[float, float]] = [] + for x, y in list(region.exterior.coords)[:-1]: + vertex = Point(x, y) + best = vertex + best_net, best_other = _score(vertex) + best_value = min(best_net, best_other) + for index in contour_index.query(vertex.buffer(RIDGE_SNAP_M)): + geom = contour_index.geoms[index] + if geom.distance(vertex) > RIDGE_SNAP_M: + continue + t0 = geom.project(vertex) + steps = int(RIDGE_WALK_M / 10.0) + for offset in [0.0] + [s * 10.0 for k in range(1, steps + 1) for s in (k, -k)]: + t = min(max(t0 + offset, 0.0), geom.length) + candidate = geom.interpolate(t) + if candidate.distance(vertex) > RIDGE_SNAP_M + RIDGE_WALK_M: + continue + to_network, to_other = _score(candidate) + if to_network < STREAM_JOIN_TOL_M: + continue # 우리 세류 침범 금지 + if to_other < to_network: + continue # 인접 세류 쪽으로 이탈 금지 + if min(to_network, to_other) > best_value: + best_value = min(to_network, to_other) + best = candidate + snapped.append((best.x, best.y)) + if len(snapped) < 4: + return region + polygon = Polygon(snapped).buffer(0) + if polygon.geom_type == "MultiPolygon": + polygon = max(polygon.geoms, key=lambda part: part.area) + if polygon.is_empty or polygon.geom_type != "Polygon": + return region + return polygon + + def trace_upstream_network( crossing: Point, stream_features: list[dict[str, Any]], From 5354e722580291c937fbd00ab5dd5fa767222126 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 29 Jul 2026 19:31:38 +0900 Subject: [PATCH 21/61] auto: 2026-07-29 19:31 (EOMSANGDON-HOME) --- .../B05_wf2_Route_Engine_Watershed_Trace.py | 81 ++++++++++++++----- 1 file changed, 59 insertions(+), 22 deletions(-) diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Trace.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Trace.py index 7f880eab..c5877c23 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Trace.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Trace.py @@ -10,6 +10,7 @@ from __future__ import annotations +import bisect from dataclasses import dataclass from typing import Any @@ -256,23 +257,42 @@ def valley_region_polygon( if region.is_empty or region.geom_type != "Polygon": return None if contour_index is not None: - region = _snap_boundary_to_ridge(region, contour_index, network_union, others, other_tree) + region = _contour_chain_boundary(region, contour_index, network_union, others, other_tree) return region -def _snap_boundary_to_ridge( +def _collect_intersection_points(geometry: Any) -> list[Point]: + """교차 결과에서 대표 점들을 뽑는다.""" + if geometry.is_empty: + return [] + if geometry.geom_type == "Point": + return [geometry] + if geometry.geom_type in {"MultiPoint", "GeometryCollection"}: + points: list[Point] = [] + for part in geometry.geoms: + points.extend(_collect_intersection_points(part)) + return points + if geometry.geom_type in {"LineString", "MultiLineString"}: + return [geometry.interpolate(0.5, normalized=True)] + return [] + + +def _contour_chain_boundary( region: Any, contour_index: Any, network_union: Any, others: list[LineString], other_tree: STRtree | None, ) -> Any: - """등거리 경계 정점을 등고선 위 능선 꼭짓점으로 스냅한다. + """유역 경계를 **등고선마다 능선 포인트 1개씩 찍어 연결**한 체인으로 재구성한다. - 능선 꼭짓점 = 근처 등고선을 따라 걸었을 때 두 세류(우리 상류망·인접 세류) - 모두로부터의 최소거리가 최대가 되는 지점. 우리 세류 침범(근접)과 인접 세류 - 이탈(인접이 더 가까워짐)은 금지한다. 실패 시 원본 경계를 유지한다. + (2026-07-29 사용자 지시: 정점 스냅은 점이 듬성듬성해 등고선을 건너뛴다.) + 등거리 1차 경계 링은 순서 뼈대로만 쓴다: 링을 가로지르는 모든 등고선 교차점마다 + 그 등고선 위에서 두 세류(우리 상류망·인접 세류) 모두로부터 가장 먼 지점(능선 + 꼭짓점)을 정제해 포인트를 얻고, 링 위 위치 순으로 연결한다. 등고선이 없는 구간은 + 원래 링 정점으로 메운다. 제약: 우리 세류 침범·인접 세류 이탈 금지. """ + ring = LineString(region.exterior.coords) def _score(point: Point) -> tuple[float, float]: to_network = network_union.distance(point) @@ -283,22 +303,26 @@ def _snap_boundary_to_ridge( ) return to_network, to_other - snapped: list[tuple[float, float]] = [] - for x, y in list(region.exterior.coords)[:-1]: - vertex = Point(x, y) - best = vertex - best_net, best_other = _score(vertex) - best_value = min(best_net, best_other) - for index in contour_index.query(vertex.buffer(RIDGE_SNAP_M)): - geom = contour_index.geoms[index] - if geom.distance(vertex) > RIDGE_SNAP_M: - continue - t0 = geom.project(vertex) + # 링을 가로지르는 등고선 교차점마다 능선 꼭짓점 1개. + chained: list[tuple[float, float, float]] = [] # (링 위치 s, x, y) + for index in contour_index.query(ring.buffer(1.0)): + geom = contour_index.geoms[index] + try: + crossings = _collect_intersection_points(geom.intersection(ring)) + except Exception: # noqa: BLE001 + continue + for crossing in crossings: + s = ring.project(crossing) + t0 = geom.project(crossing) + best = None + best_value = -1.0 steps = int(RIDGE_WALK_M / 10.0) - for offset in [0.0] + [s * 10.0 for k in range(1, steps + 1) for s in (k, -k)]: + for offset in [0.0] + [ + sign * k * 10.0 for k in range(1, steps + 1) for sign in (1, -1) + ]: t = min(max(t0 + offset, 0.0), geom.length) candidate = geom.interpolate(t) - if candidate.distance(vertex) > RIDGE_SNAP_M + RIDGE_WALK_M: + if candidate.distance(crossing) > RIDGE_WALK_M + RIDGE_SNAP_M: continue to_network, to_other = _score(candidate) if to_network < STREAM_JOIN_TOL_M: @@ -308,10 +332,23 @@ def _snap_boundary_to_ridge( if min(to_network, to_other) > best_value: best_value = min(to_network, to_other) best = candidate - snapped.append((best.x, best.y)) - if len(snapped) < 4: + if best is not None: + chained.append((s, best.x, best.y)) + if len(chained) < 4: return region - polygon = Polygon(snapped).buffer(0) + chained.sort() + # 등고선 공백 구간(교차점 사이가 먼 곳)은 원래 링 정점으로 메운다. + positions = [s for s, _, _ in chained] + filled: list[tuple[float, float, float]] = list(chained) + for x, y in list(region.exterior.coords)[:-1]: + s = ring.project(Point(x, y)) + slot = bisect.bisect_left(positions, s) + before = positions[slot - 1] if slot > 0 else positions[-1] - ring.length + after = positions[slot] if slot < len(positions) else positions[0] + ring.length + if min(s - before, after - s) > VALLEY_CELL_M * 2.5: + filled.append((s, x, y)) + filled.sort() + polygon = Polygon([(x, y) for _, x, y in filled]).buffer(0) if polygon.geom_type == "MultiPolygon": polygon = max(polygon.geoms, key=lambda part: part.area) if polygon.is_empty or polygon.geom_type != "Polygon": From 077da60065edf0b7687a5e16135e8a17bde78ad2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 29 Jul 2026 19:41:58 +0900 Subject: [PATCH 22/61] auto: 2026-07-29 19:41 (EOMSANGDON-HOME) --- .../B05_wf2_Route_Engine_Watershed_Trace.py | 84 ++++++++++++++++--- 1 file changed, 73 insertions(+), 11 deletions(-) diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Trace.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Trace.py index c5877c23..8604b8ca 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Trace.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Trace.py @@ -291,6 +291,11 @@ def _contour_chain_boundary( 그 등고선 위에서 두 세류(우리 상류망·인접 세류) 모두로부터 가장 먼 지점(능선 꼭짓점)을 정제해 포인트를 얻고, 링 위 위치 순으로 연결한다. 등고선이 없는 구간은 원래 링 정점으로 메운다. 제약: 우리 세류 침범·인접 세류 이탈 금지. + + 2차 보정(2026-07-29 사용자 채택, "2번 방식"): 능선은 등고선의 **직교 궤적**이므로, + 각 포인트를 등고선 위에서 미세 이동해 경계선이 그 등고선과 수직으로 교차하도록 + 반복 조정한다(TOPOG/TAPES-C 계열 개념). 능선 점수(세류 최소거리)가 꼭짓점 대비 + 크게 떨어지는 이동은 막는다. """ ring = LineString(region.exterior.coords) @@ -303,8 +308,9 @@ def _contour_chain_boundary( ) return to_network, to_other - # 링을 가로지르는 등고선 교차점마다 능선 꼭짓점 1개. - chained: list[tuple[float, float, float]] = [] # (링 위치 s, x, y) + # ① 링을 가로지르는 등고선 교차점마다 능선 꼭짓점 1개. + # entry = [링 위치 s, Point, geom_index(-1=링 정점), 등고선 파라미터 t, 꼭짓점 점수] + entries: list[list[Any]] = [] for index in contour_index.query(ring.buffer(1.0)): geom = contour_index.geoms[index] try: @@ -315,6 +321,7 @@ def _contour_chain_boundary( s = ring.project(crossing) t0 = geom.project(crossing) best = None + best_t = t0 best_value = -1.0 steps = int(RIDGE_WALK_M / 10.0) for offset in [0.0] + [ @@ -332,23 +339,25 @@ def _contour_chain_boundary( if min(to_network, to_other) > best_value: best_value = min(to_network, to_other) best = candidate + best_t = t if best is not None: - chained.append((s, best.x, best.y)) - if len(chained) < 4: + entries.append([s, best, index, best_t, best_value]) + if len(entries) < 4: return region - chained.sort() - # 등고선 공백 구간(교차점 사이가 먼 곳)은 원래 링 정점으로 메운다. - positions = [s for s, _, _ in chained] - filled: list[tuple[float, float, float]] = list(chained) + entries.sort(key=lambda entry: entry[0]) + # ② 등고선 공백 구간(교차점 사이가 먼 곳)은 원래 링 정점으로 메운다. + positions = [entry[0] for entry in entries] for x, y in list(region.exterior.coords)[:-1]: s = ring.project(Point(x, y)) slot = bisect.bisect_left(positions, s) before = positions[slot - 1] if slot > 0 else positions[-1] - ring.length after = positions[slot] if slot < len(positions) else positions[0] + ring.length if min(s - before, after - s) > VALLEY_CELL_M * 2.5: - filled.append((s, x, y)) - filled.sort() - polygon = Polygon([(x, y) for _, x, y in filled]).buffer(0) + entries.append([s, Point(x, y), -1, 0.0, 0.0]) + entries.sort(key=lambda entry: entry[0]) + # ③ 직교 보정: 경계 진행방향과 등고선 접선이 수직이 되도록 포인트를 미세 이동. + entries = _orthogonalize_chain(entries, contour_index, _score) + polygon = Polygon([(entry[1].x, entry[1].y) for entry in entries]).buffer(0) if polygon.geom_type == "MultiPolygon": polygon = max(polygon.geoms, key=lambda part: part.area) if polygon.is_empty or polygon.geom_type != "Polygon": @@ -356,6 +365,59 @@ def _contour_chain_boundary( return polygon +def _orthogonalize_chain( + entries: list[list[Any]], + contour_index: Any, + score: Any, +) -> list[list[Any]]: + """체인 포인트를 등고선 위에서 이동해 경계가 등고선과 직교하게 만든다. + + 각 포인트에서 |등고선 접선 · 체인 진행방향| (수직이면 0)을 최소화한다. 이동 허용 + 조건: 세류 침범·이탈 금지 + 능선 점수(두 세류 최소거리)가 꼭짓점 값의 70% 이상. + 2회 반복으로 이웃 이동의 영향을 수렴시킨다. + """ + count = len(entries) + for _ in range(2): + for i, entry in enumerate(entries): + geom_index = entry[2] + if geom_index < 0: + continue + geom = contour_index.geoms[geom_index] + previous = entries[i - 1][1] + following = entries[(i + 1) % count][1] + dx, dy = following.x - previous.x, following.y - previous.y + norm = (dx * dx + dy * dy) ** 0.5 + if norm < 1.0: + continue + dx, dy = dx / norm, dy / norm + floor = 0.7 * entry[4] + best_t = entry[3] + best_point = entry[1] + best_dot = None + for offset in range(-int(RIDGE_SNAP_M), int(RIDGE_SNAP_M) + 1, 5): + t = min(max(entry[3] + float(offset), 0.0), geom.length) + candidate = geom.interpolate(t) + ahead = geom.interpolate(min(t + 4.0, geom.length)) + behind = geom.interpolate(max(t - 4.0, 0.0)) + tx, ty = ahead.x - behind.x, ahead.y - behind.y + tangent_norm = (tx * tx + ty * ty) ** 0.5 + if tangent_norm < 0.5: + continue + to_network, to_other = score(candidate) + if to_network < STREAM_JOIN_TOL_M or to_other < to_network: + continue + if min(to_network, to_other) < floor: + continue + dot = abs((tx * dx + ty * dy) / tangent_norm) + if best_dot is None or dot < best_dot: + best_dot = dot + best_t = t + best_point = candidate + entry[1] = best_point + entry[3] = best_t + return entries + + def trace_upstream_network( crossing: Point, stream_features: list[dict[str, Any]], From 4fbaf63a602a2d3d90391dd81c31733da3e72415 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 29 Jul 2026 20:00:50 +0900 Subject: [PATCH 23/61] auto: 2026-07-29 20:00 (EOMSANGDON-HOME) --- ...B05_wf2_Route_Engine_Drainage_Watershed.py | 129 ++++++++++++--- .../B05_wf2_Route_Engine_Watershed_Trace.py | 155 ++++++++++++++++++ 2 files changed, 258 insertions(+), 26 deletions(-) diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py index c7697a8e..3a77ccdd 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py @@ -20,6 +20,7 @@ from typing import Any from shapely.geometry import LineString, Point, Polygon from shapely.ops import nearest_points, substring, unary_union +from shapely.strtree import STRtree from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import ( StructureCandidate, @@ -33,8 +34,10 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Trace import ( ContourIndex, DividerStep, _explode_lines, + rim_walk, side_sign, trace_divider, + trace_ridge_march, trace_upstream_network, valley_region_polygon, ) @@ -373,6 +376,65 @@ def _clip_to_uphill( return merged +def _assemble_march_polygon( + vertices: list[Any], + start_m: float, + end_m: float, + contour_index: ContourIndex, + road_line: LineString, + uphill_sign: int, + network_union: Any, + stream_lines: list[Any], + valley_top_z: float, +) -> Polygon | None: + """개선 2안 — 도로 양끝 물갈림점에서 능선 행진으로 경계를 직접 조립한다. + + 좌·우 능선 행진 체인(세류 제약이 분수령을 강제) + 발원부 위 공통 등고선 아크로 + 폐합(기존 `_assemble_polygon` 재사용). 상류망 커버리지가 목표 미달이면 None을 + 돌려 1안(등거리+체인) 폴백을 태운다. + """ + others = [line for line in stream_lines if line.distance(network_union) > STREAM_JOIN_TOL_M] + other_tree = STRtree(others) if others else None + sx, sy, _ = _interpolate_vertex(vertices, start_m) + ex, ey, _ = _interpolate_vertex(vertices, end_m) + left = trace_ridge_march( + Point(sx, sy), contour_index, network_union, others, other_tree, road_line, uphill_sign + ) + right = trace_ridge_march( + Point(ex, ey), contour_index, network_union, others, other_tree, road_line, uphill_sign + ) + if len(left) < 5 or len(right) < 5: + return None + # 상측 폐합: 우측 정상 → 좌측 정상을 능선마루 보행으로 잇는다. + rim = rim_walk( + right[-1].point, + left[-1].point, + contour_index, + network_union, + others, + other_tree, + road_line, + uphill_sign, + ) + if rim is None: + return None + ring = _road_segment_coords(vertices, start_m, end_m) + ring.extend((step.point.x, step.point.y) for step in right[1:]) + ring.extend((point.x, point.y) for point in rim) + ring.extend((step.point.x, step.point.y) for step in reversed(left[1:])) + if len(ring) < 4: + return None + polygon = Polygon(ring).buffer(0) + if polygon.geom_type == "MultiPolygon": + polygon = max(polygon.geoms, key=lambda part: part.area) + if polygon.is_empty or polygon.geom_type != "Polygon": + return None + coverage = network_union.intersection(polygon).length / max(network_union.length, 1.0) + if coverage < CLOSING_COVERAGE_GOAL: + return None + return polygon + + def build_watershed_basins( vertices: list[Any], candidates: list[StructureCandidate], @@ -449,32 +511,47 @@ def build_watershed_basins( if network_union is not None else None ) - # 도로변 스트립(분할선 폐합) + 세류 계곡 영역(분수령=인접 세류 등거리)을 합친다. - base = _assemble_polygon( - vertices, - divides[position], - divides[position + 1], - dividers[position], - dividers[position + 1], - contour_index, - road_line, - network_union, - valley_top_z, - ) - valley = ( - valley_region_polygon(network_union, stream_features or [], outlet, contour_index) - if network_union is not None - else None - ) - if base is None and valley is None: - continue - if base is not None and valley is not None: - merged = base.union(valley).buffer(0) - if merged.geom_type == "MultiPolygon": - merged = max(merged.geoms, key=lambda part: part.area) - polygon = merged if merged.geom_type == "Polygon" and not merged.is_empty else base - else: - polygon = base if base is not None else valley + # 개선 2안: 도로 양끝 물갈림점에서 능선 행진으로 경계를 직접 그린다. + polygon = None + if network_union is not None and valley_top_z is not None: + polygon = _assemble_march_polygon( + vertices, + divides[position], + divides[position + 1], + contour_index, + road_line, + signs[position], + network_union, + stream_lines, + valley_top_z, + ) + if polygon is None: + # 개선 1안(폴백): 도로변 스트립 + 세류 계곡 영역(등거리+등고선 체인) 합집합. + base = _assemble_polygon( + vertices, + divides[position], + divides[position + 1], + dividers[position], + dividers[position + 1], + contour_index, + road_line, + network_union, + valley_top_z, + ) + valley = ( + valley_region_polygon(network_union, stream_features or [], outlet, contour_index) + if network_union is not None + else None + ) + if base is None and valley is None: + continue + if base is not None and valley is not None: + merged = base.union(valley).buffer(0) + if merged.geom_type == "MultiPolygon": + merged = max(merged.geoms, key=lambda part: part.area) + polygon = merged if merged.geom_type == "Polygon" and not merged.is_empty else base + else: + polygon = base if base is not None else valley if network_union is not None: coverage = network_union.intersection(polygon).length / max(network_union.length, 1.0) if coverage < CLOSING_COVERAGE_GOAL: diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Trace.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Trace.py index 8604b8ca..a6de8c24 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Trace.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Trace.py @@ -33,6 +33,15 @@ VALLEY_CAP_M = 350.0 # 능선 스냅: 경계 정점에서 등고선 탐색 반경(m)과 등고선 위 능선 꼭짓점 탐색 폭(m). RIDGE_SNAP_M = 40.0 RIDGE_WALK_M = 80.0 +# 능선 행진(개선 2안): 다음 상위 등고선 탐색 반경(m)·등고선 위 꼭짓점 탐색 폭(m)·최대 단계. +# 탐색 폭을 좁게 유지해야 체인이 자기 능선을 국소 추종한다(넓으면 이웃 능선으로 가로 이탈). +MARCH_RADIUS_M = 120.0 +MARCH_WALK_M = 40.0 +MARCH_MAX_STEPS = 150 +# 첫 스텝(시드)만 넓게 탐색 — 물갈림점이 능선 위가 아닐 수 있어 국소 분수령을 먼저 찾는다. +MARCH_SEED_WALK_M = 250.0 +# 이탈 제약 완화 비율 — 등거리선(dn=do) 부근에서 체인이 멈추지 않게 소폭 허용. +MARCH_OTHER_RATIO = 0.7 @dataclass @@ -418,6 +427,152 @@ def _orthogonalize_chain( return entries +def trace_ridge_march( + start: Point, + contour_index: Any, + network_union: Any, + others: list[LineString], + other_tree: STRtree | None, + road_line: LineString, + uphill_sign: int, +) -> list[DividerStep]: + """능선 행진(개선 2안) — 상위 등고선마다 능선 꼭짓점을 한 칸씩 밟아 오른다. + + 수작업 유역도 작도법의 자동화: 물갈림점에서 출발해 매 단계 "반경 안 현재보다 높은 + 등고선 중 가장 낮은 것" 위에서 **|우리 상류망까지 거리 − 인접 세류까지 거리|가 + 최소인 지점**(분수령 = 두 세류망 등거리점)으로 이동한다. 두 세류에서 가장 먼 점을 + 고르면 우리 지류들 사이 내부 지능선으로 새므로, 등거리 조건이 바깥 분수령을 강제 + 한다. 꼭짓점 연결선은 등고선과 자연히 직교한다. + 제약: 도로 산측 유지, 우리 세류 침범(15m)·인접 세류 과이탈 금지. 더 높은 등고선이 + 없으면(능선 정상) 자연 종료. 반환은 DividerStep 목록 — 기존 폐합 로직과 호환. + """ + + def _score(point: Point) -> tuple[float, float]: + to_network = network_union.distance(point) + to_other = ( + others[int(other_tree.nearest(point))].distance(point) + if other_tree is not None + else float("inf") + ) + return to_network, to_other + + z = contour_index.nearest_elevation(start, MARCH_RADIUS_M) + if z is None: + return [] + steps_out = [DividerStep(point=start, z=z, geom_index=-1)] + current = start + for step_no in range(MARCH_MAX_STEPS): + walk_m = MARCH_SEED_WALK_M if step_no == 0 else MARCH_WALK_M + reach_m = max(MARCH_RADIUS_M, walk_m) + best: tuple[float, float, Point, int] | None = None # (레벨, |dn-do|, 지점, geom idx) + for index in contour_index.query(current.buffer(reach_m)): + level = contour_index.zs[index] + if level <= z + 0.01: + continue + if best is not None and level > best[0]: + continue + geom = contour_index.geoms[index] + if geom.distance(current) > reach_m: + continue + t0 = geom.project(current) + walk = int(walk_m / 10.0) + for offset in [0.0] + [sign * k * 10.0 for k in range(1, walk + 1) for sign in (1, -1)]: + t = min(max(t0 + offset, 0.0), geom.length) + candidate = geom.interpolate(t) + if candidate.distance(current) > reach_m: + continue + # 도로 하류측 이탈 금지 — 단, 노선 끝 너머(투영이 끝점에 걸림)는 좌우 + # 부호가 무의미하므로 세류 제약에만 맡긴다(끝을 감아 도는 분수령 허용). + projection = road_line.project(candidate) + if ( + 5.0 < projection < road_line.length - 5.0 + and side_sign(road_line, candidate) == -uphill_sign + ): + continue + to_network, to_other = _score(candidate) + if to_network < STREAM_JOIN_TOL_M: + continue # 우리 세류 침범 금지 + if to_other < to_network * MARCH_OTHER_RATIO: + continue # 인접 세류 쪽 과이탈 금지(등거리선 부근 소폭 허용) + balance = abs(to_network - to_other) + if best is None or level < best[0] or (level == best[0] and balance < best[1]): + best = (level, balance, candidate, index) + if best is None: + break + z = best[0] + current = best[2] + steps_out.append(DividerStep(point=current, z=z, geom_index=best[3])) + return steps_out + + +def rim_walk( + start: Point, + target: Point, + contour_index: Any, + network_union: Any, + others: list[LineString], + other_tree: STRtree | None, + road_line: LineString, + uphill_sign: int, +) -> list[Point] | None: + """능선마루를 따라 두 행진 정상을 잇는다(개선 2안 상측 폐합). + + 좌·우 능선 정상 높이가 달라 단일 등고선 아크로 못 닫는 경우, 매 단계 target에 + 가까워지는 등고선 위 지점 중 |우리 세류 거리 − 인접 세류 거리|가 최소인 곳 + (분수령)으로 이동한다. 레벨 제한 없음(마루는 오르내린다). 막히면 None. + """ + + def _score(point: Point) -> tuple[float, float]: + to_network = network_union.distance(point) + to_other = ( + others[int(other_tree.nearest(point))].distance(point) + if other_tree is not None + else float("inf") + ) + return to_network, to_other + + points: list[Point] = [] + current = start + remaining = current.distance(target) + for _ in range(MARCH_MAX_STEPS): + if remaining <= MARCH_RADIUS_M: + return points + best: tuple[float, Point] | None = None # (|dn-do|, 지점) + for index in contour_index.query(current.buffer(MARCH_RADIUS_M)): + geom = contour_index.geoms[index] + if geom.distance(current) > MARCH_RADIUS_M: + continue + t0 = geom.project(current) + walk = int(MARCH_WALK_M / 10.0) + for offset in [0.0] + [sign * k * 10.0 for k in range(1, walk + 1) for sign in (1, -1)]: + t = min(max(t0 + offset, 0.0), geom.length) + candidate = geom.interpolate(t) + if candidate.distance(current) > MARCH_RADIUS_M: + continue + if candidate.distance(target) > remaining - 5.0: + continue # target에 실질적으로 가까워지는 이동만 허용 + projection = road_line.project(candidate) + if ( + 5.0 < projection < road_line.length - 5.0 + and side_sign(road_line, candidate) == -uphill_sign + ): + continue + to_network, to_other = _score(candidate) + if to_network < STREAM_JOIN_TOL_M: + continue + if to_other < to_network * MARCH_OTHER_RATIO: + continue + balance = abs(to_network - to_other) + if best is None or balance < best[0]: + best = (balance, candidate) + if best is None: + return None + current = best[1] + remaining = current.distance(target) + points.append(current) + return None + + def trace_upstream_network( crossing: Point, stream_features: list[dict[str, Any]], From f72017a6ee76712d204deb866651090fe2c0e767 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 29 Jul 2026 20:06:57 +0900 Subject: [PATCH 24/61] auto: 2026-07-29 20:06 (EOMSANGDON-HOME) --- ...B05_wf2_Route_Engine_Drainage_Watershed.py | 56 +++++++++++++++++-- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py index 3a77ccdd..3b2f12ba 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py @@ -435,6 +435,51 @@ def _assemble_march_polygon( return polygon +def _refine_road_edge(polygon: Polygon, road_line: LineString) -> Polygon: + """경계의 도로변 구간을 도로선 원해상도 좌표로 치환한다. + + 단순화(simplify)가 도로변 경계를 뭉개 도로를 가로지르는 것을 막는다 + (2026-07-29 사용자 지시: 경계 참조를 도로선 해상도와 매칭). + """ + coords = list(polygon.exterior.coords)[:-1] + count = len(coords) + near = [road_line.distance(Point(c)) < 6.0 for c in coords] + if not any(near) or all(near): + return polygon + start = next(i for i in range(count) if not near[i]) + coords = coords[start:] + coords[:start] + near = near[start:] + near[:start] + ring: list[tuple[float, float]] = [] + i = 0 + while i < count: + if not near[i]: + ring.append(coords[i]) + i += 1 + continue + j = i + while j < count and near[j]: + j += 1 + t1 = road_line.project(Point(coords[i])) + t2 = road_line.project(Point(coords[j - 1])) + segment = substring(road_line, min(t1, t2), max(t1, t2)) + if segment.geom_type == "LineString" and len(segment.coords) >= 2: + segment_coords = list(segment.coords) + if t1 > t2: + segment_coords.reverse() + ring.extend(segment_coords) + else: + ring.extend(coords[i:j]) + i = j + if len(ring) < 4: + return polygon + refined = Polygon(ring).buffer(0) + if refined.geom_type == "MultiPolygon": + refined = max(refined.geoms, key=lambda part: part.area) + if refined.is_empty or refined.geom_type != "Polygon": + return polygon + return refined + + def build_watershed_basins( vertices: list[Any], candidates: list[StructureCandidate], @@ -455,7 +500,8 @@ def build_watershed_basins( if contour_index.tree is None: logger.warning("표고 속성이 있는 등고선이 없어 유역을 산정하지 못했습니다.") return [] - spot_index = ContourIndex(spot_features, elevation_keys) + # 표고점은 참조하지 않는다(2026-07-29 사용자 지시: 계측 측점 데이터라 오류 유입). + _ = spot_features road_line = LineString([(vertex.x, vertex.y) for vertex in vertices]) ordered = sorted(candidates, key=lambda item: item.chainage_m) @@ -569,13 +615,13 @@ def build_watershed_basins( outlet_z = contour_index.nearest_elevation(outlet, UPHILL_PROBE_RADIUS_M) if outlet_z is None: outlet_z = _interpolate_vertex(vertices, candidate.chainage_m)[2] - top_z = max( - contour_index.max_elevation_within(polygon) or outlet_z, - spot_index.max_elevation_within(polygon) or outlet_z, - ) + # 표고차는 등고선만으로 계산한다(표고점 미참조 — 사용자 지시). + top_z = contour_index.max_elevation_within(polygon) or outlet_z boundary_line = polygon.simplify(5.0, preserve_topology=True) if boundary_line.is_empty or boundary_line.geom_type != "Polygon": boundary_line = polygon + # 도로변 경계는 단순화 없이 도로선 해상도를 유지한다. + boundary_line = _refine_road_edge(boundary_line, road_line) boundary = [[float(x), float(y)] for x, y in boundary_line.exterior.coords] if flow_length <= 0.0: flow_length = max( From 12f166650618104dc0d1a44db61a12476c96ff22 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 30 Jul 2026 17:25:13 +0900 Subject: [PATCH 25/61] auto: 2026-07-30 17:25 (EOMSANGDON-HOME) --- B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts | 4 + ...B05_wf2_Route_Engine_Drainage_Watershed.py | 488 ++++++++---------- ...B05_wf2_Route_Engine_Watershed_Assemble.py | 205 ++++++++ ...05_wf2_Route_Engine_Watershed_Subdivide.py | 171 ++++++ .../B05_wf2_Route_Router_Drainage.py | 7 + .../B05_wf2_Route_UI_Drainage_Panel.ts | 102 +++- .../B05_wf2_Route_UI_Drainage_Pipes.ts | 246 +++++++++ B05_wf2_Route/B05_wf2_Route_UI_Style.css | 15 + 8 files changed, 953 insertions(+), 285 deletions(-) create mode 100644 B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Assemble.py create mode 100644 B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Subdivide.py create mode 100644 B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index e6c23a42..aff1d624 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -272,6 +272,8 @@ export interface DrainageCandidateResponse { export interface DrainageBasin { index: number; chainage_m: number; + /** 관(배관) 매설 지점 좌표 — 계획선 위 마커 렌더용. */ + outlet_lonlat: [number, number]; polygon_lonlat: Array<[number, number]>; area_m2: number; relief_m: number; @@ -283,6 +285,8 @@ export interface DrainageBasinResponse { status: string; project_id: string; route_id: number; + /** 산정에 실제 사용된 배관 지점 — 유역이 없는 관도 포함(마커 동기화용). */ + pipes: DrainageCandidate[]; basins: DrainageBasin[]; } diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py index 3b2f12ba..1966d8b8 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py @@ -19,7 +19,7 @@ from dataclasses import dataclass, field from typing import Any from shapely.geometry import LineString, Point, Polygon -from shapely.ops import nearest_points, substring, unary_union +from shapely.ops import substring, unary_union from shapely.strtree import STRtree from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import ( @@ -27,6 +27,11 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import ( _interpolate_vertex, estimate_pipe_diameter_mm, ) +from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Assemble import ( + _assemble_polygon, + _road_segment_coords, +) +from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Subdivide import subdivide_main_polygon from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Trace import ( LOCAL_MAX_STEPS, LOCAL_SEARCH_RADIUS_M, @@ -49,8 +54,6 @@ MIN_BASIN_AREA_M2 = 100.0 STREAM_COVER_BUFFER_M = 20.0 # 도로 양끝에서 하류측으로 뻗는 절단 차단선 길이(m). DOWNHILL_BARRIER_M = 800.0 -# 폐합 등고선 기하 탐색: 분할선 경로와 등고선의 근접 허용 거리(m). -CLOSING_SEARCH_M = 30.0 # 유역이 상류망을 덮어야 하는 커버리지 목표(미달 시 버퍼 폴백). CLOSING_COVERAGE_GOAL = 0.95 # 산측 판정: 도로 접선의 좌우 수직 오프셋(m)과 표고 조회 반경(m). @@ -117,192 +120,6 @@ def _uphill_sign_at(vertices: list[Any], chainage_m: float, contour_index: Conto return 1 if left_z > right_z else -1 -def _road_segment_coords( - vertices: list[Any], start_m: float, end_m: float -) -> list[tuple[float, float]]: - """분할점 사이 도로 구간의 평면 좌표열(유역 폴리곤의 하측 경계).""" - sx, sy, _ = _interpolate_vertex(vertices, start_m) - ex, ey, _ = _interpolate_vertex(vertices, end_m) - coords = [(sx, sy)] - coords.extend( - (vertex.x, vertex.y) for vertex in vertices if start_m < vertex.chainage_m < end_m - ) - coords.append((ex, ey)) - return coords - - -def _contour_arc( - line: Any, - p_from: Point, - p_to: Point, - road_line: LineString, - network_union: Any = None, -) -> list[tuple[float, float]]: - """등고선에서 두 분할선 접점 사이 아크(상측 경계)를 뽑는다. - - 폐합 등고선은 두 방향 아크가 생기므로, 세류 상류망을 가로지르지 않고(계곡을 - 자르지 않고) 도로와도 교차하지 않는(=산측) 쪽을 고른다. - """ - t1, t2 = sorted((line.project(p_from), line.project(p_to))) - arcs = [] - inner = substring(line, t1, t2) - if inner.geom_type == "LineString" and len(inner.coords) >= 2: - arcs.append(inner) - if getattr(line, "is_closed", False): - head = substring(line, t2, line.length) - tail = substring(line, 0.0, t1) - coords = list(head.coords) + list(tail.coords)[1:] - if len(coords) >= 2: - arcs.append(LineString(coords)) - if not arcs: - return [] - scored = [] - for arc in arcs: - crosses_stream = bool(network_union is not None and arc.crosses(network_union)) - crosses_road = arc.crosses(road_line) - midpoint = arc.interpolate(0.5, normalized=True) - scored.append((crosses_stream, crosses_road, -road_line.distance(midpoint), arc)) - scored.sort(key=lambda item: (item[0], item[1], item[2])) - arc = scored[0][3] - coords = list(arc.coords) - if Point(coords[0]).distance(p_from) > Point(coords[-1]).distance(p_from): - coords.reverse() - return [(float(x), float(y)) for x, y in coords] - - -def _junction( - left: list[DividerStep], - right: list[DividerStep], - min_z: float | None = None, -) -> tuple[int, int, int] | None: - """두 분할선이 같은 등고선 지오메트리를 밟은 폐합 지점(좌 idx, 우 idx, geom idx). - - min_z(세류 상류망 최고 표고)가 있으면 그 **이상인 가장 낮은** 공통 등고선을 고른다 — - "세류로 영역을 지정한 뒤 가까운 등고선으로 바로 올려치면 안 된다"(2026-07-29 사용자 - 지시). 계곡 발원부를 넘긴 첫 등고선이 유역 상측 경계가 된다. 없으면 최고 공통 등고선. - """ - left_keys = { - (step.z, step.geom_index): position - for position, step in enumerate(left) - if step.geom_index >= 0 - } - matches: list[tuple[float, int, int, int]] = [] - for position, step in enumerate(right): - if step.geom_index < 0: - continue - left_position = left_keys.get((step.z, step.geom_index)) - if left_position is None: - continue - matches.append((step.z, left_position, position, step.geom_index)) - if not matches: - return None - if min_z is not None: - above = [match for match in matches if match[0] >= min_z] - if above: - best = min(above) - return best[1], best[2], best[3] - best = max(matches) - return best[1], best[2], best[3] - - -def _closing_contour( - left: list[DividerStep], - right: list[DividerStep], - contour_index: ContourIndex, - min_z: float, -) -> tuple[int, int, int, Point, Point] | None: - """두 분할선 경로에 모두 근접한 등고선 중 min_z 이상 최저를 찾는다. - - 분할선이 같은 스텝에서 같은 지오메트리를 밟지 못해도(도엽 분할 등) 계곡 발원부 - 위를 지나는 폐합 등고선을 기하적으로 찾아낸다. - 반환: (좌 절단 idx, 우 절단 idx, 등고선 geom idx, 좌 접점, 우 접점). - """ - if len(left) < 2 or len(right) < 2: - return None - left_line = LineString([step.point for step in left]) - right_line = LineString([step.point for step in right]) - shared = set(contour_index.query(left_line.buffer(CLOSING_SEARCH_M))) & set( - contour_index.query(right_line.buffer(CLOSING_SEARCH_M)) - ) - best: tuple[float, int] | None = None - for index in shared: - z = contour_index.zs[index] - if z < min_z: - continue - geom = contour_index.geoms[index] - if ( - geom.distance(left_line) > CLOSING_SEARCH_M - or geom.distance(right_line) > CLOSING_SEARCH_M - ): - continue - if best is None or z < best[0]: - best = (z, index) - if best is None: - return None - geom = contour_index.geoms[best[1]] - left_touch = nearest_points(geom, left_line)[0] - right_touch = nearest_points(geom, right_line)[0] - left_position = min(range(len(left)), key=lambda i: left[i].point.distance(left_touch)) - right_position = min(range(len(right)), key=lambda i: right[i].point.distance(right_touch)) - return left_position, right_position, best[1], left_touch, right_touch - - -def _assemble_polygon( - vertices: list[Any], - start_m: float, - end_m: float, - left: list[DividerStep], - right: list[DividerStep], - contour_index: ContourIndex, - road_line: LineString, - network_union: Any = None, - valley_top_z: float | None = None, -) -> Polygon | None: - """도로 구간 + 우측 분할선 + 상측 등고선 아크 + 좌측 분할선으로 폴리곤을 폐합한다. - - 세류 유역(valley_top_z 지정)은 발원부 위를 지나는 폐합 등고선을 기하 탐색으로 - 먼저 찾고, 실패 시 같은 스텝 매칭(_junction)으로 폐합한다. - """ - ring = _road_segment_coords(vertices, start_m, end_m) - left_used, right_used, arc = left, right, [] - closure = ( - _closing_contour(left, right, contour_index, valley_top_z) - if valley_top_z is not None - else None - ) - if closure is not None: - left_position, right_position, geom_index, left_touch, right_touch = closure - left_used = left[: left_position + 1] - right_used = right[: right_position + 1] - arc = _contour_arc( - contour_index.geoms[geom_index], right_touch, left_touch, road_line, network_union - ) - else: - junction = _junction(left, right, min_z=valley_top_z) - if junction is not None: - left_position, right_position, geom_index = junction - left_used = left[: left_position + 1] - right_used = right[: right_position + 1] - arc = _contour_arc( - contour_index.geoms[geom_index], - right_used[-1].point, - left_used[-1].point, - road_line, - network_union, - ) - ring.extend((step.point.x, step.point.y) for step in right_used[1:]) - ring.extend(arc) - ring.extend((step.point.x, step.point.y) for step in reversed(left_used[1:])) - if len(ring) < 4: - return None - polygon = Polygon(ring).buffer(0) - if polygon.geom_type == "MultiPolygon": - polygon = max(polygon.geoms, key=lambda part: part.area) - if polygon.is_empty or polygon.geom_type != "Polygon": - return None - return polygon - - def _clip_to_uphill( polygon: Polygon, road_line: LineString, @@ -480,6 +297,150 @@ def _refine_road_edge(polygon: Polygon, road_line: LineString) -> Polygon: return refined +def _main_watershed_polygon( + vertices: list[Any], + divides: list[float], + dividers: list[list[DividerStep]], + contour_index: ContourIndex, + road_line: LineString, + uphill_sign: int, + network_union: Any, + stream_lines: list[Any], + stream_features: list[dict[str, Any]], + outlet: Point, +) -> Polygon | None: + """메인 배수유역 폴리곤 1회 산정 — f72017a 채택 산식 그대로. + + 불변 조건(2026-07-30 사용자 지시): 이 함수의 산식은 전체 유역 경계를 결정하므로 + 변경 금지. 세분화는 이 결과를 내부에서만 쪼갠다(`subdivide_main_polygon`). + """ + valley_top_z = contour_index.max_elevation_within(network_union.buffer(10.0)) + # 개선 2안: 도로 양끝 물갈림점에서 능선 행진으로 경계를 직접 그린다. + polygon = None + if valley_top_z is not None: + polygon = _assemble_march_polygon( + vertices, + divides[0], + divides[-1], + contour_index, + road_line, + uphill_sign, + network_union, + stream_lines, + valley_top_z, + ) + if polygon is None: + # 개선 1안(폴백): 도로변 스트립 + 세류 계곡 영역(등거리+등고선 체인) 합집합. + base = _assemble_polygon( + vertices, + divides[0], + divides[-1], + dividers[0], + dividers[-1], + contour_index, + road_line, + network_union, + valley_top_z, + ) + valley = valley_region_polygon(network_union, stream_features, outlet, contour_index) + if base is None and valley is None: + return None + if base is not None and valley is not None: + merged = base.union(valley).buffer(0) + if merged.geom_type == "MultiPolygon": + merged = max(merged.geoms, key=lambda part: part.area) + polygon = merged if merged.geom_type == "Polygon" and not merged.is_empty else base + else: + polygon = base if base is not None else valley + coverage = network_union.intersection(polygon).length / max(network_union.length, 1.0) + if coverage < CLOSING_COVERAGE_GOAL: + # 계곡 영역이 못 덮은 상류망만 버퍼로 보정한다(최후 폴백). + covered = polygon.union(network_union.buffer(STREAM_COVER_BUFFER_M)).buffer(0) + if covered.geom_type == "MultiPolygon": + covered = max(covered.geoms, key=lambda part: part.area) + if covered.geom_type == "Polygon" and not covered.is_empty: + polygon = covered + # 마지막에 도로선으로 절단해 하류측을 제거한다 — 도로가 유역의 한쪽 경계. + polygon = _clip_to_uphill(polygon, road_line, uphill_sign, contour_index, network_union) + if polygon is None or polygon.area < MIN_BASIN_AREA_M2: + return None + return polygon + + +def _local_polygon( + vertices: list[Any], + divides: list[float], + dividers: list[list[DividerStep]], + position: int, + contour_index: ContourIndex, + road_line: LineString, + uphill_sign: int, +) -> Polygon | None: + """세류 없는 관 구간의 소범위 유역 — 도로 상측 첫 능선까지(기존 경로 유지).""" + base = _assemble_polygon( + vertices, + divides[position], + divides[position + 1], + dividers[position], + dividers[position + 1], + contour_index, + road_line, + None, + None, + ) + if base is None: + return None + return _clip_to_uphill(base, road_line, uphill_sign, contour_index, None) + + +def _basin_from_polygon( + polygon: Polygon, + candidate: StructureCandidate, + index: int, + flow_length: float, + contour_index: ContourIndex, + road_line: LineString, + vertices: list[Any], + simplify: bool = True, +) -> WatershedBasin: + """확정된 유역 폴리곤에서 산출값(면적·표고차·유하장·관경)을 계산한다. + + 세분화 조각(simplify=False)은 단순화하지 않는다 — 조각별 독립 단순화는 공유 + 분할선 경계를 어긋나게 해 겹침·틈을 만든다(배타적 타일링 유지). + """ + outlet = Point(candidate.x, candidate.y) + outlet_z = contour_index.nearest_elevation(outlet, UPHILL_PROBE_RADIUS_M) + if outlet_z is None: + outlet_z = _interpolate_vertex(vertices, candidate.chainage_m)[2] + # 표고차는 등고선만으로 계산한다(표고점 미참조 — 사용자 지시). + top_z = contour_index.max_elevation_within(polygon) or outlet_z + boundary_line = polygon.simplify(5.0, preserve_topology=True) if simplify else polygon + if boundary_line.is_empty or boundary_line.geom_type != "Polygon": + boundary_line = polygon + # 도로변 경계는 단순화 없이 도로선 해상도를 유지한다. + boundary_line = _refine_road_edge(boundary_line, road_line) + boundary = [[float(x), float(y)] for x, y in boundary_line.exterior.coords] + if flow_length <= 0.0: + flow_length = max( + (math.dist((candidate.x, candidate.y), point) for point in boundary), + default=0.0, + ) + basin = WatershedBasin( + index=index, + chainage_m=candidate.chainage_m, + outlet_x=candidate.x, + outlet_y=candidate.y, + boundary_xy=boundary, + area_m2=float(polygon.area), + relief_m=max(0.0, float(top_z) - float(outlet_z)), + flow_length_m=float(flow_length), + ) + basin.pipe_diameter_mm = estimate_pipe_diameter_mm( + basin.area_m2, basin.relief_m, basin.flow_length_m + ) + return basin + + def build_watershed_basins( vertices: list[Any], candidates: list[StructureCandidate], @@ -488,10 +449,11 @@ def build_watershed_basins( elevation_keys: tuple[str, ...], stream_features: list[dict[str, Any]] | None = None, ) -> list[WatershedBasin]: - """관 지점 배치를 기준으로 메인 배수유역을 세분화해 산정한다. + """메인 배수유역을 1회 산정하고 관 지점 기준으로 내부 세분화한다. - 세류 교차 관("stream")은 상류망을 추적해 넓은 한계로, 세류 없는 관은 도로 상측 - 첫 능선까지 소범위 한계로 분할선을 올린다(작은 유역, 영역 선정 주의 — 사용자 지시). + 메인 유역 경계는 관 개수와 무관하게 항상 동일하다(불변 조건 — 2026-07-30 사용자 + 지시). 세부유역 = 메인 폴리곤을 관 사이 분할선으로 쪼갠 조각(배타적, 합집합 = + 메인). 세류 없는 관이 메인 범위 밖이면 소범위 유역을 별도 생성(기존 동작). 번호는 노선 시점에 가까운 순. """ if not candidates or len(vertices) < 2: @@ -541,106 +503,70 @@ def build_watershed_basins( ) dividers.append(steps) - basins: list[WatershedBasin] = [] + # 관별 상류망은 1회만 추적한다(유하장 계산에도 사용). + networks: list[list[Any]] = [] + flows: list[float] = [] for position, candidate in enumerate(ordered): - outlet = Point(candidate.x, candidate.y) network: list[Any] = [] flow_length = 0.0 if is_stream[position] and stream_features: network, flow_length = trace_upstream_network( - outlet, stream_features, road_line, signs[position] + Point(candidate.x, candidate.y), stream_features, road_line, signs[position] ) - # 계곡 발원부(상류망 최고 표고) — 유역 상측 폐합 등고선은 이보다 높아야 한다. - network_union = unary_union(network) if network else None - valley_top_z = ( - contour_index.max_elevation_within(network_union.buffer(10.0)) - if network_union is not None - else None + networks.append(network) + flows.append(flow_length) + + main_polygon = None + combined = [line for network in networks for line in network] + first_stream = next((position for position in range(len(ordered)) if networks[position]), None) + if combined and first_stream is not None: + main_polygon = _main_watershed_polygon( + vertices, + divides, + dividers, + contour_index, + road_line, + majority, + unary_union(combined), + stream_lines, + stream_features or [], + Point(ordered[first_stream].x, ordered[first_stream].y), ) - # 개선 2안: 도로 양끝 물갈림점에서 능선 행진으로 경계를 직접 그린다. - polygon = None - if network_union is not None and valley_top_z is not None: - polygon = _assemble_march_polygon( - vertices, - divides[position], - divides[position + 1], - contour_index, - road_line, - signs[position], - network_union, - stream_lines, - valley_top_z, - ) + + pieces: list[Polygon | None] = [None] * len(ordered) + if main_polygon is not None: + divide_points = [ + Point(*_interpolate_vertex(vertices, chainage)[:2]) for chainage in divides + ] + pieces = subdivide_main_polygon(main_polygon, divide_points, dividers, road_line) + + basins: list[WatershedBasin] = [] + for position, candidate in enumerate(ordered): + polygon = pieces[position] if position < len(pieces) else None + from_subdivision = polygon is not None and len(ordered) > 1 if polygon is None: - # 개선 1안(폴백): 도로변 스트립 + 세류 계곡 영역(등거리+등고선 체인) 합집합. - base = _assemble_polygon( - vertices, - divides[position], - divides[position + 1], - dividers[position], - dividers[position + 1], - contour_index, - road_line, - network_union, - valley_top_z, - ) - valley = ( - valley_region_polygon(network_union, stream_features or [], outlet, contour_index) - if network_union is not None - else None - ) - if base is None and valley is None: + if networks[position] and main_polygon is not None: + logger.warning( + "세류 관(%.0fm) 구간에 세부유역 조각이 없습니다 — 건너뜁니다.", + candidate.chainage_m, + ) continue - if base is not None and valley is not None: - merged = base.union(valley).buffer(0) - if merged.geom_type == "MultiPolygon": - merged = max(merged.geoms, key=lambda part: part.area) - polygon = merged if merged.geom_type == "Polygon" and not merged.is_empty else base - else: - polygon = base if base is not None else valley - if network_union is not None: - coverage = network_union.intersection(polygon).length / max(network_union.length, 1.0) - if coverage < CLOSING_COVERAGE_GOAL: - # 계곡 영역이 못 덮은 상류망만 버퍼로 보정한다(최후 폴백). - covered = polygon.union(network_union.buffer(STREAM_COVER_BUFFER_M)).buffer(0) - if covered.geom_type == "MultiPolygon": - covered = max(covered.geoms, key=lambda part: part.area) - if covered.geom_type == "Polygon" and not covered.is_empty: - polygon = covered - # 마지막에 도로선으로 절단해 하류측을 제거한다 — 도로가 유역의 한쪽 경계. - polygon = _clip_to_uphill(polygon, road_line, signs[position], contour_index, network_union) + # 메인 유역 밖(또는 상류망 없음) 관은 소범위 유역을 별도 생성한다. + polygon = _local_polygon( + vertices, divides, dividers, position, contour_index, road_line, signs[position] + ) if polygon is None or polygon.area < MIN_BASIN_AREA_M2: continue - - outlet_z = contour_index.nearest_elevation(outlet, UPHILL_PROBE_RADIUS_M) - if outlet_z is None: - outlet_z = _interpolate_vertex(vertices, candidate.chainage_m)[2] - # 표고차는 등고선만으로 계산한다(표고점 미참조 — 사용자 지시). - top_z = contour_index.max_elevation_within(polygon) or outlet_z - boundary_line = polygon.simplify(5.0, preserve_topology=True) - if boundary_line.is_empty or boundary_line.geom_type != "Polygon": - boundary_line = polygon - # 도로변 경계는 단순화 없이 도로선 해상도를 유지한다. - boundary_line = _refine_road_edge(boundary_line, road_line) - boundary = [[float(x), float(y)] for x, y in boundary_line.exterior.coords] - if flow_length <= 0.0: - flow_length = max( - (math.dist((candidate.x, candidate.y), point) for point in boundary), - default=0.0, + basins.append( + _basin_from_polygon( + polygon, + candidate, + len(basins) + 1, + flows[position], + contour_index, + road_line, + vertices, + simplify=not from_subdivision, ) - - basin = WatershedBasin( - index=len(basins) + 1, - chainage_m=candidate.chainage_m, - outlet_x=candidate.x, - outlet_y=candidate.y, - boundary_xy=boundary, - area_m2=float(polygon.area), - relief_m=max(0.0, float(top_z) - float(outlet_z)), - flow_length_m=float(flow_length), ) - basin.pipe_diameter_mm = estimate_pipe_diameter_mm( - basin.area_m2, basin.relief_m, basin.flow_length_m - ) - basins.append(basin) return basins diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Assemble.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Assemble.py new file mode 100644 index 00000000..8bff4e40 --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Assemble.py @@ -0,0 +1,205 @@ +"""배수유역 폴리곤 폐합 조립 — 도로 구간 + 분할선 + 등고선 아크 (700줄 분리, 2026-07-30). + +`B05_wf2_Route_Engine_Drainage_Watershed.py`에서 산식 변경 없이 그대로 옮겨온 +개선 1안(등거리+체인) 폐합 헬퍼 모음이다. 불변 조건: 산식 수정 금지(메인 유역 +경계가 바뀐다 — 2026-07-30 사용자 지시). +""" + +from __future__ import annotations + +from typing import Any + +from shapely.geometry import LineString, Point, Polygon +from shapely.ops import nearest_points, substring + +from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import _interpolate_vertex +from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Trace import ContourIndex, DividerStep + +# 폐합 등고선 기하 탐색: 분할선 경로와 등고선의 근접 허용 거리(m). +CLOSING_SEARCH_M = 30.0 + + +def _road_segment_coords( + vertices: list[Any], start_m: float, end_m: float +) -> list[tuple[float, float]]: + """분할점 사이 도로 구간의 평면 좌표열(유역 폴리곤의 하측 경계).""" + sx, sy, _ = _interpolate_vertex(vertices, start_m) + ex, ey, _ = _interpolate_vertex(vertices, end_m) + coords = [(sx, sy)] + coords.extend( + (vertex.x, vertex.y) for vertex in vertices if start_m < vertex.chainage_m < end_m + ) + coords.append((ex, ey)) + return coords + + +def _contour_arc( + line: Any, + p_from: Point, + p_to: Point, + road_line: LineString, + network_union: Any = None, +) -> list[tuple[float, float]]: + """등고선에서 두 분할선 접점 사이 아크(상측 경계)를 뽑는다. + + 폐합 등고선은 두 방향 아크가 생기므로, 세류 상류망을 가로지르지 않고(계곡을 + 자르지 않고) 도로와도 교차하지 않는(=산측) 쪽을 고른다. + """ + t1, t2 = sorted((line.project(p_from), line.project(p_to))) + arcs = [] + inner = substring(line, t1, t2) + if inner.geom_type == "LineString" and len(inner.coords) >= 2: + arcs.append(inner) + if getattr(line, "is_closed", False): + head = substring(line, t2, line.length) + tail = substring(line, 0.0, t1) + coords = list(head.coords) + list(tail.coords)[1:] + if len(coords) >= 2: + arcs.append(LineString(coords)) + if not arcs: + return [] + scored = [] + for arc in arcs: + crosses_stream = bool(network_union is not None and arc.crosses(network_union)) + crosses_road = arc.crosses(road_line) + midpoint = arc.interpolate(0.5, normalized=True) + scored.append((crosses_stream, crosses_road, -road_line.distance(midpoint), arc)) + scored.sort(key=lambda item: (item[0], item[1], item[2])) + arc = scored[0][3] + coords = list(arc.coords) + if Point(coords[0]).distance(p_from) > Point(coords[-1]).distance(p_from): + coords.reverse() + return [(float(x), float(y)) for x, y in coords] + + +def _junction( + left: list[DividerStep], + right: list[DividerStep], + min_z: float | None = None, +) -> tuple[int, int, int] | None: + """두 분할선이 같은 등고선 지오메트리를 밟은 폐합 지점(좌 idx, 우 idx, geom idx). + + min_z(세류 상류망 최고 표고)가 있으면 그 **이상인 가장 낮은** 공통 등고선을 고른다 — + "세류로 영역을 지정한 뒤 가까운 등고선으로 바로 올려치면 안 된다"(2026-07-29 사용자 + 지시). 계곡 발원부를 넘긴 첫 등고선이 유역 상측 경계가 된다. 없으면 최고 공통 등고선. + """ + left_keys = { + (step.z, step.geom_index): position + for position, step in enumerate(left) + if step.geom_index >= 0 + } + matches: list[tuple[float, int, int, int]] = [] + for position, step in enumerate(right): + if step.geom_index < 0: + continue + left_position = left_keys.get((step.z, step.geom_index)) + if left_position is None: + continue + matches.append((step.z, left_position, position, step.geom_index)) + if not matches: + return None + if min_z is not None: + above = [match for match in matches if match[0] >= min_z] + if above: + best = min(above) + return best[1], best[2], best[3] + best = max(matches) + return best[1], best[2], best[3] + + +def _closing_contour( + left: list[DividerStep], + right: list[DividerStep], + contour_index: ContourIndex, + min_z: float, +) -> tuple[int, int, int, Point, Point] | None: + """두 분할선 경로에 모두 근접한 등고선 중 min_z 이상 최저를 찾는다. + + 분할선이 같은 스텝에서 같은 지오메트리를 밟지 못해도(도엽 분할 등) 계곡 발원부 + 위를 지나는 폐합 등고선을 기하적으로 찾아낸다. + 반환: (좌 절단 idx, 우 절단 idx, 등고선 geom idx, 좌 접점, 우 접점). + """ + if len(left) < 2 or len(right) < 2: + return None + left_line = LineString([step.point for step in left]) + right_line = LineString([step.point for step in right]) + shared = set(contour_index.query(left_line.buffer(CLOSING_SEARCH_M))) & set( + contour_index.query(right_line.buffer(CLOSING_SEARCH_M)) + ) + best: tuple[float, int] | None = None + for index in shared: + z = contour_index.zs[index] + if z < min_z: + continue + geom = contour_index.geoms[index] + if ( + geom.distance(left_line) > CLOSING_SEARCH_M + or geom.distance(right_line) > CLOSING_SEARCH_M + ): + continue + if best is None or z < best[0]: + best = (z, index) + if best is None: + return None + geom = contour_index.geoms[best[1]] + left_touch = nearest_points(geom, left_line)[0] + right_touch = nearest_points(geom, right_line)[0] + left_position = min(range(len(left)), key=lambda i: left[i].point.distance(left_touch)) + right_position = min(range(len(right)), key=lambda i: right[i].point.distance(right_touch)) + return left_position, right_position, best[1], left_touch, right_touch + + +def _assemble_polygon( + vertices: list[Any], + start_m: float, + end_m: float, + left: list[DividerStep], + right: list[DividerStep], + contour_index: ContourIndex, + road_line: LineString, + network_union: Any = None, + valley_top_z: float | None = None, +) -> Polygon | None: + """도로 구간 + 우측 분할선 + 상측 등고선 아크 + 좌측 분할선으로 폴리곤을 폐합한다. + + 세류 유역(valley_top_z 지정)은 발원부 위를 지나는 폐합 등고선을 기하 탐색으로 + 먼저 찾고, 실패 시 같은 스텝 매칭(_junction)으로 폐합한다. + """ + ring = _road_segment_coords(vertices, start_m, end_m) + left_used, right_used, arc = left, right, [] + closure = ( + _closing_contour(left, right, contour_index, valley_top_z) + if valley_top_z is not None + else None + ) + if closure is not None: + left_position, right_position, geom_index, left_touch, right_touch = closure + left_used = left[: left_position + 1] + right_used = right[: right_position + 1] + arc = _contour_arc( + contour_index.geoms[geom_index], right_touch, left_touch, road_line, network_union + ) + else: + junction = _junction(left, right, min_z=valley_top_z) + if junction is not None: + left_position, right_position, geom_index = junction + left_used = left[: left_position + 1] + right_used = right[: right_position + 1] + arc = _contour_arc( + contour_index.geoms[geom_index], + right_used[-1].point, + left_used[-1].point, + road_line, + network_union, + ) + ring.extend((step.point.x, step.point.y) for step in right_used[1:]) + ring.extend(arc) + ring.extend((step.point.x, step.point.y) for step in reversed(left_used[1:])) + if len(ring) < 4: + return None + polygon = Polygon(ring).buffer(0) + if polygon.geom_type == "MultiPolygon": + polygon = max(polygon.geoms, key=lambda part: part.area) + if polygon.is_empty or polygon.geom_type != "Polygon": + return None + return polygon diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Subdivide.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Subdivide.py new file mode 100644 index 00000000..8efdb68f --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Subdivide.py @@ -0,0 +1,171 @@ +"""메인 배수유역 내부 세분화 — 분할선으로 폴리곤을 쪼갠다 (2026-07-30). + +불변 조건(사용자 지시): 전체(메인) 배수유역 경계는 절대 변경하지 않는다. +세분화는 확정된 메인 유역 폴리곤을 관 사이 분할선(물갈림 고개에서 오르는 +능선 근사선)으로 **내부에서만** 쪼개는 방식이다 — 외곽 재추적 금지. +따라서 세부유역은 서로 배타적이고 합집합은 항상 메인 유역과 동일하다. +""" + +from __future__ import annotations + +import logging +import math + +from shapely.geometry import LineString, Point, Polygon +from shapely.ops import split as shapely_split +from shapely.ops import substring, unary_union + +from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Trace import DividerStep + +logger = logging.getLogger(__name__) + +# 분할 절단선 연장: 도로 하류측(m)과 능선 너머(폴리곤 대각선 배수). +CUT_ROAD_TAIL_M = 40.0 +CUT_RIDGE_EXTEND_RATIO = 1.5 + + +def _cut_line( + road_point: Point, + steps: list[DividerStep], + road_line: LineString, + main_polygon: Polygon, +) -> LineString | None: + """분할선 스텝을 절단선으로 확장한다 — 도로 하류측과 능선 너머까지 관통. + + split()은 절단선이 폴리곤 경계를 완전히 넘어야 동작하므로 양끝을 연장한다. + 스텝이 없으면(등고선 공백) 도로 법선 직선으로 폴백한다. + """ + bounds = main_polygon.bounds + reach = CUT_RIDGE_EXTEND_RATIO * math.hypot(bounds[2] - bounds[0], bounds[3] - bounds[1]) + points = [(road_point.x, road_point.y)] + points.extend((step.point.x, step.point.y) for step in steps if step.geom_index >= 0) + if len(points) < 2: + # 폴백: 도로 접선의 법선 방향으로 폴리곤을 관통하는 직선. + t = road_line.project(road_point) + a = road_line.interpolate(max(0.0, t - 5.0)) + b = road_line.interpolate(min(road_line.length, t + 5.0)) + dx, dy = b.x - a.x, b.y - a.y + norm = math.hypot(dx, dy) + if norm < 1e-6: + return None + nx, ny = -dy / norm, dx / norm + head = (road_point.x + nx * reach, road_point.y + ny * reach) + tail = (road_point.x - nx * reach, road_point.y - ny * reach) + return LineString([tail, (road_point.x, road_point.y), head]) + # 능선 너머 연장: 마지막 진행 방향 유지. + (px, py), (qx, qy) = points[-2], points[-1] + dx, dy = qx - px, qy - py + norm = math.hypot(dx, dy) + if norm >= 1e-6: + points.append((qx + dx / norm * reach, qy + dy / norm * reach)) + # 도로 하류측 연장: 첫 스텝 → 도로점 방향을 그대로 지나쳐 내려간다. + (fx, fy) = points[1] + dx, dy = road_point.x - fx, road_point.y - fy + norm = math.hypot(dx, dy) + if norm >= 1e-6: + points.insert( + 0, + ( + road_point.x + dx / norm * CUT_ROAD_TAIL_M, + road_point.y + dy / norm * CUT_ROAD_TAIL_M, + ), + ) + return LineString(points) + + +def _interval_index(piece: Polygon, road_line: LineString, divide_ts: list[float]) -> int: + """조각이 어느 관 구간(k)에 속하는지 — 구간 도로와 맞닿는 길이가 최대인 곳. + + 대표점 투영은 대형 계곡 조각에서 오판한다(상류로 길게 뻗은 조각의 대표점이 + 엉뚱한 구간에 떨어짐). 도로 접촉이 전혀 없는 조각만 대표점 투영으로 폴백. + """ + strip = piece.buffer(1.0) + best_k, best_length = -1, 0.0 + for k in range(len(divide_ts) - 1): + segment = substring(road_line, divide_ts[k], divide_ts[k + 1]) + if segment.is_empty: + continue + length = segment.intersection(strip).length + if length > best_length: + best_k, best_length = k, length + if best_k >= 0: + return best_k + t = road_line.project(piece.representative_point()) + for k in range(len(divide_ts) - 1): + if divide_ts[k] <= t <= divide_ts[k + 1]: + return k + return 0 if t < divide_ts[0] else len(divide_ts) - 2 + + +def subdivide_main_polygon( + main_polygon: Polygon, + divide_points: list[Point], + dividers: list[list[DividerStep]], + road_line: LineString, +) -> list[Polygon | None]: + """메인 유역 폴리곤을 내부 분할선으로 쪼개 관 구간별 조각을 돌려준다. + + 반환 길이 = 관 개수(구간 수). 조각이 없는 구간은 None. + split() 기반이므로 조각들은 배타적이고 합집합 == 메인 폴리곤이 보장된다. + 구간에 여러 조각이 잡히면(절단선 재진입) 모두 합쳐 가장 큰 폴리곤을 쓴다. + """ + interval_count = len(divide_points) - 1 + if interval_count <= 1: + return [main_polygon] + pieces: list[Polygon] = [main_polygon] + for position in range(1, interval_count): + cut = _cut_line(divide_points[position], dividers[position], road_line, main_polygon) + if cut is None: + logger.warning("분할 절단선 생성 실패(구간 %d) — 해당 분할을 건너뜁니다.", position) + continue + next_pieces: list[Polygon] = [] + for piece in pieces: + try: + parts = shapely_split(piece, cut) + except Exception: # noqa: BLE001 - 절단 실패 시 조각 유지 + next_pieces.append(piece) + continue + split_parts = [ + part + for part in getattr(parts, "geoms", [parts]) + if part.geom_type == "Polygon" and not part.is_empty + ] + next_pieces.extend(split_parts if split_parts else [piece]) + pieces = next_pieces + divide_ts = [road_line.project(point) for point in divide_points] + assigned: list[list[Polygon]] = [[] for _ in range(interval_count)] + for piece in pieces: + assigned[_interval_index(piece, road_line, divide_ts)].append(piece) + # 구간별 대표 조각 = 최대 조각과 그에 붙는 조각들. 비연결 잔여 조각은 버리지 + # 않고(합집합 불변 조건) 맞닿는 인접 구간으로 재배정한다. + result: list[Polygon | None] = [] + leftovers: list[Polygon] = [] + for group in assigned: + merged = _merge_touching(group) + result.append(merged[0] if merged else None) + leftovers.extend(merged[1:]) + for extra in leftovers: + for position in sorted( + range(interval_count), + key=lambda k: extra.distance(result[k]) if result[k] is not None else math.inf, + ): + base = result[position] + if base is None or not extra.touches(base): + continue + candidate = base.union(extra).buffer(0) + if candidate.geom_type == "Polygon": + result[position] = candidate + break + else: + logger.warning("세분화 잔여 조각(%.0f m²)을 재배정하지 못해 제외합니다.", extra.area) + return result + + +def _merge_touching(group: list[Polygon]) -> list[Polygon]: + """조각 묶음을 서로 맞닿는 것끼리 합쳐 면적 내림차순으로 돌려준다.""" + if not group: + return [] + merged = unary_union(group).buffer(0) + parts = list(merged.geoms) if merged.geom_type == "MultiPolygon" else [merged] + parts = [part for part in parts if part.geom_type == "Polygon" and not part.is_empty] + return sorted(parts, key=lambda part: part.area, reverse=True) diff --git a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py index 71979888..f489eed8 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py +++ b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py @@ -206,10 +206,17 @@ async def post_drainage_basins( "status": "success", "project_id": str(project_id), "route_id": prepared["route_id"], + # 계획선 위 배관(관 매설) 지점 — 유역이 없는 관도 마커로 표시해야 하므로 별도 목록. + "pipes": [ + _candidate_payload(candidate, to_lonlat) + for candidate in sorted(candidates, key=lambda item: item.chainage_m) + ], "basins": [ { "index": basin.index, "chainage_m": round(basin.chainage_m, 2), + # 관(배관) 매설 지점 좌표 — 계획선 위 마커 렌더용. + "outlet_lonlat": list(to_lonlat(basin.outlet_x, basin.outlet_y)), # 유역 경계 외곽선 = 분수령(능선). 프론트가 파스텔 채움 + 능선 파선으로 표시한다. "polygon_lonlat": [list(to_lonlat(x, y)) for x, y in basin.boundary_xy], "area_m2": round(basin.area_m2, 1), diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts index ac1f419a..8f6a6391 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -24,6 +24,7 @@ import { type DrainageBasin, type RoutePoint, } from "./B05_wf2_Route_Api_Fetch"; +import { createPipeEditor } from "./B05_wf2_Route_UI_Drainage_Pipes"; // 배수유역도 패널 — 하단 종단 패널 안쪽 우측에 도킹되는 2단 사이드 패널. // 하단 패널이 닫히면 이 패널도 함께 화면에서 사라진다(부모 안에 들어 있으므로 자동). @@ -88,7 +89,24 @@ export function createDrainagePanel(): DrainagePanel { analyzeButton.type = "button"; analyzeButton.className = "b05-drainage__analyze"; analyzeButton.textContent = "유역 산정"; - header.append(analyzeButton); + // 배관 편집 토글 — 켜면 계획선 클릭으로 배관 추가, 마커 드래그로 이동. + const editButton = document.createElement("button"); + editButton.type = "button"; + editButton.className = "b05-drainage__analyze b05-drainage__tool"; + editButton.textContent = "배관 편집"; + editButton.setAttribute("aria-pressed", "false"); + // 선택된 배관 삭제 — 편집 모드에서 마커를 선택해야 활성화된다. + const deleteButton = document.createElement("button"); + deleteButton.type = "button"; + deleteButton.className = "b05-drainage__analyze b05-drainage__tool"; + deleteButton.textContent = "선택 삭제"; + deleteButton.disabled = true; + // 자동 제안으로 되돌리기 — 편집한 배관 배치를 버리고 백엔드 자동 제안으로 재산정. + const autoButton = document.createElement("button"); + autoButton.type = "button"; + autoButton.className = "b05-drainage__analyze b05-drainage__tool"; + autoButton.textContent = "자동 제안"; + header.append(analyzeButton, editButton, deleteButton, autoButton); const viewport = document.createElement("div"); viewport.className = "b05-drainage__viewport"; @@ -117,6 +135,12 @@ export function createDrainagePanel(): DrainagePanel { let normalizer: Normalizer | null = null; let basins: DrainageBasin[] = []; let selectedBasin: number | null = null; + let editMode = false; + // 배관 편집기 — 마커 선택/추가/이동/삭제 시 재그리기와 버튼 상태만 갱신한다. + const pipeEditor = createPipeEditor(() => { + syncPipeSelection(); + scheduleDraw(); + }); // 유역 경계 외곽선 = 분수령(능선). 사용자 지시로 기본 표시. let showRidge = true; let scale = 1; @@ -217,9 +241,38 @@ export function createDrainagePanel(): DrainagePanel { context.strokeStyle = ROUTE_COLOR; drawPreparedLayer(context, routeLayer, view, "dot"); } + // 배관(관 매설) 마커 — 계획선 위 최상단. + pipeEditor.draw(context, view, pipeColor); updateImageTransform(); } + /** 현재 프레임 뷰 상태 (draw()와 동일 계산 — 포인터 히트 판정용). */ + function currentView(): ViewState { + const rect = viewport.getBoundingClientRect(); + const width = Math.max(1, Math.floor(rect.width)); + const height = Math.max(1, Math.floor(rect.height)); + return { width, height, scale, offsetX, offsetY, mapRect: computeMapRect(meta, width, height) }; + } + + /** 배관 마커 색 — 같은 누가거리 유역의 파스텔색(불투명). 유역이 없으면 회색. */ + function pipeColor(chainage: number): string { + const basin = basins.find((item) => Math.abs(item.chainage_m - chainage) < 0.51); + if (!basin) return "#e5e7eb"; + return BASIN_COLORS[(basin.index - 1) % BASIN_COLORS.length].replace(/0\.45\)$/, "1)"); + } + + /** 마커 선택 ↔ 유역 목록 선택 동기화 + 삭제 버튼 활성화. */ + function syncPipeSelection(): void { + const index = pipeEditor.selected(); + deleteButton.disabled = !editMode || index === null; + const pipe = index === null ? null : pipeEditor.pipes()[index]; + const basin = pipe + ? basins.find((item) => Math.abs(item.chainage_m - pipe.chainage_m) < 0.51) + : null; + selectedBasin = basin ? basin.index : null; + renderBasinList(); + } + function scheduleDraw(): void { if (frameHandle) return; frameHandle = window.requestAnimationFrame(() => { @@ -263,17 +316,27 @@ export function createDrainagePanel(): DrainagePanel { return areaM2 >= 10000 ? `${(areaM2 / 10000).toFixed(2)}ha` : `${Math.round(areaM2)}㎡`; } - /** 구조물 측점 후보 제안 + 유역 산정을 백엔드에 요청한다(계산은 전부 백엔드). */ - async function analyze(): Promise { + /** 구조물 측점 후보 제안 + 유역 산정을 백엔드에 요청한다(계산은 전부 백엔드). + * 편집된 배관이 있으면 그 누가거리로 확정 산정하고, auto=true면 자동 제안으로 되돌린다. */ + async function analyze(auto = false): Promise { if (!projectId) return; analyzeButton.disabled = true; status.hidden = false; status.textContent = "배수유역을 산정하는 중…"; try { - const response = await fetchDrainageBasins(projectId); + const chainages = !auto && pipeEditor.pipes().length > 0 ? pipeEditor.chainages() : undefined; + const response = await fetchDrainageBasins(projectId, chainages); basins = response.basins; + // 실제 사용된 배관 목록으로 마커를 동기화한다(유역 없는 관 포함). + pipeEditor.setPipes( + (response.pipes ?? []).map((pipe) => ({ + chainage_m: pipe.chainage_m, + reason: pipe.reason, + })), + ); selectedBasin = null; renderBasinList(); + syncPipeSelection(); status.hidden = basins.length > 0; if (basins.length === 0) status.textContent = "산정된 배수유역이 없습니다."; scheduleDraw(); @@ -286,6 +349,17 @@ export function createDrainagePanel(): DrainagePanel { } analyzeButton.addEventListener("click", () => void analyze()); + editButton.addEventListener("click", () => { + editMode = !editMode; + editButton.classList.toggle("is-active", editMode); + editButton.setAttribute("aria-pressed", String(editMode)); + syncPipeSelection(); + }); + deleteButton.addEventListener("click", () => void pipeEditor.deleteSelected()); + autoButton.addEventListener("click", () => { + pipeEditor.setPipes([]); + void analyze(true); + }); /** 노선 전체가 보이도록 배율·중심을 맞춘다. 노선이 없으면 도엽 전체를 그대로 보여준다. */ function fitToRoute(): void { @@ -351,6 +425,7 @@ export function createDrainagePanel(): DrainagePanel { }); backgroundImage.src = `${getVWorldMapUrl(activeProjectId, "satellite")}&_t=${Date.now()}`; if (routePoints.length > 1) routeLayer = prepareMetricPolyline(routePoints, nextMeta); + pipeEditor.setContext(nextMeta, routePoints); status.hidden = featureCount > 0; if (featureCount === 0) status.textContent = "도엽 레이어가 없습니다. B04에서 임포트하세요."; fitToRoute(); @@ -382,16 +457,34 @@ export function createDrainagePanel(): DrainagePanel { viewport.addEventListener("pointerdown", (event) => { // 중간 버튼 기본 동작(페이지 자동 스크롤)이 지도 팬과 겹치지 않게 막는다. if (event.button === 1) event.preventDefault(); + const rect = viewport.getBoundingClientRect(); + // 배관 마커 클릭/추가가 처리되면 지도 팬은 시작하지 않는다. + if ( + pipeEditor.handleDown( + currentView(), + event.clientX - rect.left, + event.clientY - rect.top, + editMode, + ) + ) { + viewport.setPointerCapture(event.pointerId); + return; + } dragStart = { x: event.clientX, y: event.clientY, offsetX, offsetY }; viewport.setPointerCapture(event.pointerId); }); viewport.addEventListener("pointermove", (event) => { + const rect = viewport.getBoundingClientRect(); + // 배관 드래그 중이면 마커 이동(계획선 스냅)만 처리한다. + if (pipeEditor.handleMove(currentView(), event.clientX - rect.left, event.clientY - rect.top)) + return; if (!dragStart) return; offsetX = dragStart.offsetX + event.clientX - dragStart.x; offsetY = dragStart.offsetY + event.clientY - dragStart.y; scheduleDraw(); }); const stopDragging = (): void => { + pipeEditor.handleUp(); dragStart = null; }; viewport.addEventListener("pointerup", stopDragging); @@ -421,6 +514,7 @@ export function createDrainagePanel(): DrainagePanel { setRoute(points) { routePoints = points; routeLayer = meta && points.length > 1 ? prepareMetricPolyline(points, meta) : null; + pipeEditor.setContext(meta, points); if (routeLayer) fitToRoute(); scheduleDraw(); }, diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts new file mode 100644 index 00000000..a7ab7101 --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts @@ -0,0 +1,246 @@ +import type { VWorldMeta } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch"; +import type { ViewState } from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender"; + +// 배관(관 매설) 지점 편집기 — 배수유역 패널의 계획선 위 마커 표시·추가·이동·삭제. +// 마커 위치의 단일 소스는 누가거리(chainage)다. 화면 좌표는 매 프레임 노선 +// 폴리라인(사업지 좌표계 m)을 따라 보간해 구하므로 확대/이동과 무관하게 정확하다. + +/** 배관 지점 1개. reason: stream(세류 교차)/spacing(300m 보충)/confirmed(사용자 확정). */ +export interface PipePoint { + chainage_m: number; + reason: string; +} + +interface RoutePointLike { + x: number; + y: number; + chainage_m?: number; +} + +/** 마커 히트 판정 반경(px)과 계획선 추가 클릭 허용 거리(px). */ +const HIT_RADIUS_PX = 12; +const ADD_SNAP_PX = 14; + +export interface PipeEditor { + setContext(meta: VWorldMeta | null, points: ReadonlyArray): void; + setPipes(pipes: ReadonlyArray): void; + pipes(): ReadonlyArray; + chainages(): number[]; + selected(): number | null; + select(index: number | null): void; + deleteSelected(): boolean; + /** 편집 상호작용. 처리했으면 true(패널은 지도 팬을 생략한다). */ + handleDown(view: ViewState, screenX: number, screenY: number, editMode: boolean): boolean; + handleMove(view: ViewState, screenX: number, screenY: number): boolean; + handleUp(): boolean; + draw( + context: CanvasRenderingContext2D, + view: ViewState, + colorOf: (chainage: number, position: number) => string, + ): void; +} + +export function createPipeEditor(onChange: () => void): PipeEditor { + let meta: VWorldMeta | null = null; + let route: Array<{ x: number; y: number; chainage: number }> = []; + let totalChainage = 0; + let pipeList: PipePoint[] = []; + let selectedIndex: number | null = null; + let draggingIndex: number | null = null; + let dragMoved = false; + + /** 화면 → 사업지 좌표계 m (MapRender affine의 역변환). */ + function screenToMetric( + view: ViewState, + sx: number, + sy: number, + ): { x: number; y: number } | null { + if (!meta) return null; + const ax = view.mapRect.width * view.scale; + const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX; + const ay = view.mapRect.height * view.scale; + const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY; + if (!ax || !ay) return null; + const nx = (sx - bx) / ax; + const ny = (sy - by) / ay; + return { + x: meta.x_min + nx * (meta.width_meters || 1), + y: meta.y_min + (1 - ny) * (meta.height_meters || 1), + }; + } + + function metricToScreen(view: ViewState, x: number, y: number): { x: number; y: number } | null { + if (!meta) return null; + const nx = (x - meta.x_min) / (meta.width_meters || 1); + const ny = 1 - (y - meta.y_min) / (meta.height_meters || 1); + const ax = view.mapRect.width * view.scale; + const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX; + const ay = view.mapRect.height * view.scale; + const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY; + return { x: nx * ax + bx, y: ny * ay + by }; + } + + /** 1m가 화면에서 몇 px인지 (거리 판정용). */ + function pxPerMeter(view: ViewState): number { + if (!meta) return 1; + return (view.mapRect.width * view.scale) / (meta.width_meters || 1); + } + + function chainageToXY(chainage: number): { x: number; y: number } | null { + if (route.length < 2) return null; + if (chainage <= route[0].chainage) return { x: route[0].x, y: route[0].y }; + for (let i = 1; i < route.length; i += 1) { + const prev = route[i - 1]; + const next = route[i]; + if (chainage > next.chainage) continue; + const span = next.chainage - prev.chainage || 1; + const t = (chainage - prev.chainage) / span; + return { x: prev.x + (next.x - prev.x) * t, y: prev.y + (next.y - prev.y) * t }; + } + const last = route[route.length - 1]; + return { x: last.x, y: last.y }; + } + + /** 사업지 좌표에서 노선 최근접 지점의 누가거리와 이탈 거리(m). */ + function nearestChainage(x: number, y: number): { chainage: number; distance: number } | null { + if (route.length < 2) return null; + let best: { chainage: number; distance: number } | null = null; + for (let i = 1; i < route.length; i += 1) { + const a = route[i - 1]; + const b = route[i]; + const dx = b.x - a.x; + const dy = b.y - a.y; + const lengthSq = dx * dx + dy * dy || 1; + const t = Math.max(0, Math.min(1, ((x - a.x) * dx + (y - a.y) * dy) / lengthSq)); + const px = a.x + dx * t; + const py = a.y + dy * t; + const distance = Math.hypot(x - px, y - py); + const chainage = a.chainage + (b.chainage - a.chainage) * t; + if (!best || distance < best.distance) best = { chainage, distance }; + } + return best; + } + + function sortPipes(): void { + const selected = selectedIndex === null ? null : pipeList[selectedIndex]; + pipeList.sort((a, b) => a.chainage_m - b.chainage_m); + selectedIndex = selected === null ? null : pipeList.indexOf(selected); + } + + return { + setContext(nextMeta, points) { + meta = nextMeta; + let cumulative = 0; + route = points.map((point, index) => { + if (index > 0) { + const prev = points[index - 1]; + cumulative += Math.hypot(point.x - prev.x, point.y - prev.y); + } + return { x: point.x, y: point.y, chainage: point.chainage_m ?? cumulative }; + }); + totalChainage = route.length > 0 ? route[route.length - 1].chainage : 0; + }, + setPipes(pipes) { + pipeList = pipes.map((pipe) => ({ ...pipe })); + sortPipes(); + selectedIndex = null; + draggingIndex = null; + }, + pipes: () => pipeList, + chainages: () => pipeList.map((pipe) => Math.round(pipe.chainage_m * 100) / 100), + selected: () => selectedIndex, + select(index) { + selectedIndex = index; + }, + deleteSelected() { + if (selectedIndex === null) return false; + pipeList.splice(selectedIndex, 1); + selectedIndex = null; + onChange(); + return true; + }, + handleDown(view, screenX, screenY, editMode) { + dragMoved = false; + // 마커 클릭: 선택 (편집 모드 여부 무관), 편집 모드면 드래그 시작. + for (let i = pipeList.length - 1; i >= 0; i -= 1) { + const xy = chainageToXY(pipeList[i].chainage_m); + if (!xy) continue; + const screen = metricToScreen(view, xy.x, xy.y); + if (!screen) continue; + if (Math.hypot(screenX - screen.x, screenY - screen.y) <= HIT_RADIUS_PX) { + selectedIndex = i; + if (editMode) draggingIndex = i; + onChange(); + return true; + } + } + if (!editMode) return false; + // 계획선 클릭: 그 지점에 배관 추가. + const metric = screenToMetric(view, screenX, screenY); + if (!metric) return false; + const nearest = nearestChainage(metric.x, metric.y); + if (!nearest || nearest.distance * pxPerMeter(view) > ADD_SNAP_PX) return false; + pipeList.push({ chainage_m: nearest.chainage, reason: "confirmed" }); + sortPipes(); + selectedIndex = pipeList.findIndex( + (pipe) => Math.abs(pipe.chainage_m - nearest.chainage) < 1e-6, + ); + draggingIndex = selectedIndex; + onChange(); + return true; + }, + handleMove(view, screenX, screenY) { + if (draggingIndex === null) return false; + const metric = screenToMetric(view, screenX, screenY); + if (!metric) return true; + const nearest = nearestChainage(metric.x, metric.y); + if (!nearest) return true; + const clamped = Math.max(0, Math.min(totalChainage, nearest.chainage)); + pipeList[draggingIndex].chainage_m = clamped; + pipeList[draggingIndex].reason = "confirmed"; + dragMoved = true; + onChange(); + return true; + }, + handleUp() { + if (draggingIndex === null) return false; + draggingIndex = null; + if (dragMoved) { + sortPipes(); + onChange(); + } + return true; + }, + draw(context, view, colorOf) { + pipeList.forEach((pipe, position) => { + const xy = chainageToXY(pipe.chainage_m); + if (!xy) return; + const screen = metricToScreen(view, xy.x, xy.y); + if (!screen) return; + const isSelected = position === selectedIndex; + const radius = isSelected ? 9 : 7; + context.beginPath(); + context.arc(screen.x, screen.y, radius, 0, Math.PI * 2); + context.fillStyle = colorOf(pipe.chainage_m, position); + context.fill(); + context.lineWidth = isSelected ? 2.5 : 1.5; + context.strokeStyle = isSelected ? "#111827" : "#374151"; + context.stroke(); + context.fillStyle = "#111827"; + context.font = "bold 10px sans-serif"; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillText(String(position + 1), screen.x, screen.y); + // 누가거리 라벨 — 마커 우상단. + context.font = "10px sans-serif"; + context.textAlign = "left"; + context.fillStyle = "#1f2937"; + context.fillText( + `${pipe.chainage_m.toFixed(0)}m`, + screen.x + radius + 3, + screen.y - radius, + ); + }); + }, + }; +} diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index ad2e6289..85d09e4d 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -948,6 +948,21 @@ cursor: default; } +/* 배관 편집 도구 버튼 — "유역 산정" 우측에 나란히(auto 마진 해제). */ +.b05-drainage__tool { + margin-left: var(--spacing-4, 4px); +} + +/* 배관 편집 토글 활성 상태. */ +.b05-drainage__analyze.is-active { + background: color-mix( + in srgb, + var(--color-royal-amethyst, rgb(109 40 217)) 18%, + var(--color-surface) + ); + border-color: var(--color-royal-amethyst, rgb(109 40 217)); +} + /* 유역 제원 목록 — 면적·유역표고·유하거리·관경(수식 확정 전까지 "미정"). */ .b05-drainage__basins { display: flex; From 9c8a2f2b171fe0bdf83fa6fad8f53af423b7be8b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 30 Jul 2026 18:03:35 +0900 Subject: [PATCH 26/61] =?UTF-8?q?=E3=85=87=E3=85=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- B05_wf2_Route/B05_wf2_Route_Engine_Drainage.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage.py index 3075ac86..5121832c 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage.py @@ -21,11 +21,11 @@ from shapely.geometry import LineString, Point, shape logger = logging.getLogger(__name__) # 구조물 측점 사이 최대 허용 간격(m). 세류 교차가 없어도 이 간격을 넘으면 절토부에 추가 배치한다. -MAX_STRUCTURE_SPACING_M = 300.0 +MAX_STRUCTURE_SPACING_M = 300 # 같은 세류 교차로 볼 최소 이격(m). 이보다 가까운 교차점은 하나로 묶는다. -MIN_STRUCTURE_SPACING_M = 20.0 +MIN_STRUCTURE_SPACING_M = 5.0 # 유역 경계 탐색 반경(m). 측점에서 이 거리를 넘는 지형은 해당 유역으로 보지 않는다. -MAX_BASIN_RADIUS_M = 800.0 +MAX_BASIN_RADIUS_M = 1000.0 @dataclass From c1f139527d0f15898d111d37d267f937d9ff0f21 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 31 Jul 2026 16:51:47 +0900 Subject: [PATCH 27/61] =?UTF-8?q?feat(B05):=20=EB=B0=B0=EC=88=98=EC=9C=A0?= =?UTF-8?q?=EC=97=AD=EC=9D=84=20=EA=B2=A9=EC=9E=90=20=ED=9D=90=EB=A6=84=20?= =?UTF-8?q?=ED=95=B4=EC=84=9D=EC=9C=BC=EB=A1=9C=20=EC=A0=84=EB=A9=B4=20?= =?UTF-8?q?=EC=9E=AC=EC=84=A4=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 등고선 아크 추적 + 능선 행진 방식이 능선/계곡을 안정적으로 분리하지 못해 D8 물 방향 + 도로 기준 상류 추적 방식으로 교체한다. - 능선을 따로 탐지하지 않는다. 물길을 따라가 도로에 닿는 셀만 유역이고, 그 경계가 곧 능선이다. 유역 내부 봉우리는 자동으로 포함된다. - 등고선 TIN 보간 후 웅덩이 채움(형태학적 재구성) + 평탄면 미세경사로 가짜 웅덩이/평탄 삼각형에서 흐름이 끊기는 문제를 없앤다. - 상류 추적은 포인터 더블링으로 전 셀을 한 번에 푼다. 셀의 흐름 종착 도로 셀(root)이 유역 판정·흐름 강도·세부유역 라벨의 공통 근거가 되어, 관을 옮겨도 격자 해석 없이 측구 라우팅만 다시 돌면 된다(.npz 캐시). - 활성 셀이 격자 최외곽에 닿은 방향으로만 확장하고, 경계 링이 전부 비활성이 되면(띠 폐합) 멈춘다. 변경 사항 - 신규 엔진 3종: Engine_Watershed_Grid / _Flow / _Basin - 폐기 엔진 4종은 _legacy_watershed/ 로 원본 보관(ruff 제외) - config_system.py §5-3-1 에 DRAINAGE_* 파라미터 18개 (격자 1m, 반경 300m) - 표고점 데이터 사용 중단(유효 데이터 부족), 프론트 능선 토글 제거 (전체 유역 외곽선과 같은 선이므로 중복) - 응답에 main_polygon_lonlat / strength_profile 추가, 계획선 위 흐름 강도 표기 합성 지형 검증: 유역 179,919㎡ vs 이론 180,000㎡ (오차 0.04%), 능선 자동 검출, 확장 3회 후 자동 정지, 캐시 재사용 1.2s -> 0.1s Co-Authored-By: Claude Fable 5 --- B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts | 8 +- .../B05_wf2_Route_Engine_Drainage.py | 6 +- .../B05_wf2_Route_Engine_Watershed_Basin.py | 562 ++++++++++++++++++ .../B05_wf2_Route_Engine_Watershed_Flow.py | 224 +++++++ .../B05_wf2_Route_Engine_Watershed_Grid.py | 503 ++++++++++++++++ .../B05_wf2_Route_Router_Drainage.py | 79 +-- .../B05_wf2_Route_UI_Drainage_Panel.ts | 48 +- .../B05_wf2_Route_UI_Drainage_Pipes.ts | 40 ++ ...B05_wf2_Route_Engine_Drainage_Watershed.py | 0 ...B05_wf2_Route_Engine_Watershed_Assemble.py | 0 ...05_wf2_Route_Engine_Watershed_Subdivide.py | 0 .../B05_wf2_Route_Engine_Watershed_Trace.py | 0 B05_wf2_Route/_legacy_watershed/README.md | 16 + config/config_system.py | 43 ++ pyproject.toml | 2 + 15 files changed, 1460 insertions(+), 71 deletions(-) create mode 100644 B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py create mode 100644 B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Flow.py create mode 100644 B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py rename B05_wf2_Route/{ => _legacy_watershed}/B05_wf2_Route_Engine_Drainage_Watershed.py (100%) rename B05_wf2_Route/{ => _legacy_watershed}/B05_wf2_Route_Engine_Watershed_Assemble.py (100%) rename B05_wf2_Route/{ => _legacy_watershed}/B05_wf2_Route_Engine_Watershed_Subdivide.py (100%) rename B05_wf2_Route/{ => _legacy_watershed}/B05_wf2_Route_Engine_Watershed_Trace.py (100%) create mode 100644 B05_wf2_Route/_legacy_watershed/README.md diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index aff1d624..d28e705b 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -268,7 +268,7 @@ export interface DrainageCandidateResponse { candidates: DrainageCandidate[]; } -/** 배수유역 1개. 관경(pipe_diameter_mm)은 수식 미확정이라 당분간 항상 null이다. */ +/** 관 1개가 받는 세부 배수유역. 관경(pipe_diameter_mm)은 수식 미확정이라 당분간 항상 null이다. */ export interface DrainageBasin { index: number; chainage_m: number; @@ -287,6 +287,12 @@ export interface DrainageBasinResponse { route_id: number; /** 산정에 실제 사용된 배관 지점 — 유역이 없는 관도 포함(마커 동기화용). */ pipes: DrainageCandidate[]; + /** 2차 전체 배수유역 외곽선 = 분수령. 세부유역은 전부 이 안쪽이라 능선을 따로 그리지 않는다. */ + main_polygon_lonlat: Array<[number, number]>; + /** 도로 위 흐름 강도 — [누가거리 m, 그 구간으로 모이는 상류 면적 ㎡]. 관 추가 판단 근거. */ + strength_profile: Array<[number, number]>; + /** 해석에 실제 사용된 격자 한 변(m). 셀 수 상한에 걸리면 백엔드가 키워서 돌려준다. */ + grid_cell_m: number; basins: DrainageBasin[]; } diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage.py index 5121832c..c46b037a 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage.py @@ -1,8 +1,10 @@ """배수유역 산정 엔진. 관 매설 구조물 측점 후보를 제안하고, 각 측점이 받는 배수유역 경계를 산정한다. -지형 판단은 **도엽 등고선·세류선(하천중심선)·표고점**만 사용한다 — 3D 포인트클라우드나 -지형 메시는 쓰지 않는다(2026-07-28 사용자 지시). +지형 판단은 **도엽 등고선·세류선(하천중심선)**만 사용한다 — 3D 포인트클라우드나 지형 +메시는 쓰지 않고(2026-07-28 사용자 지시), 표고점도 유효 데이터가 적어 뺐다(2026-07-31). +유역 경계 산정 자체는 격자 흐름 해석(`..._Engine_Watershed_Basin`)이 맡고, 이 모듈은 +측점 후보 제안과 노선 정점·누가거리 보간만 담당한다. 유역을 나누는 최종 목적은 각 지점의 파이프 관경 결정이다. 유역 경사면에 100년 강우빈도를 적용해 모이는 물의 양을 산정하고 그 유량으로 관경을 정한다. 관경 수식은 아직 미확정이라 diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py new file mode 100644 index 00000000..b3a54450 --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py @@ -0,0 +1,562 @@ +"""배수유역 산정 오케스트레이터 — 격자 해석 · 관 배치 · 세부유역 조립. + +전체 흐름 + ① 등고선 정리 → 상류 세류선 추출 → 1차 격자 범위(반경 버퍼 bbox) + ② 격자 지형 해석(TIN 보간 · 채움 · D8) → 도로에서 상류 추적 + ③ 활성 셀이 격자 최외곽에 닿으면 그 방향으로만 넓혀 다시 해석 (경계 링이 전부 + 비활성이 되면 정지 — 하드 반경 상한이 아니라 흐름 자체가 종료 조건이다) + ④ 도로 셀별 흐름 강도(상류 셀 수) 산출 → 2차 전체 배수유역 외곽선 확정 + ⑤ 관 배치: 세류 교차점이 기본, 간격이 최대치를 넘으면 흐름 강도·종단 저점을 보고 + **최소 개수**만 보충 + ⑥ 측구 흐름(종단 내리막)으로 도로 셀 → 담당 관을 정하고, 셀이 도달한 도로 셀의 + 담당 관을 그대로 그 셀의 유역 번호로 삼아 세부유역을 나눈다 + +②~④는 관 배치와 무관하므로 `.npz`로 캐시한다. 사용자가 관을 옮기거나 추가하면 +⑥만 다시 돌면 되고 격자 해석은 재사용한다. +""" + +from __future__ import annotations + +import hashlib +import logging +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import numpy as np +from shapely.geometry import LineString + +from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import ( + RouteVertex, + StructureCandidate, + _interpolate_vertex, + estimate_pipe_diameter_mm, + find_stream_crossings, + is_uphill_at, +) +from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Flow import ( + border_contact, + largest_ring, + outer_boundary, + polygonize_labels, + rasterize_road, + trace_flow, +) +from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import ( + GridSpec, + build_contour_cloud, + build_grid_spec, + build_terrain_grid, + expand_grid_spec, + route_elevation_floor, + select_upstream_streams, +) +from config.config_system import ( + DRAINAGE_DITCH_SAMPLE_M, + DRAINAGE_EXPAND_STEP_M, + DRAINAGE_GRID_SIZE_M, + DRAINAGE_INITIAL_RADIUS_M, + DRAINAGE_MAX_EXPAND_ROUNDS, + DRAINAGE_PIPE_MAX_SPACING_M, + DRAINAGE_PIPE_MIN_SPACING_M, + DRAINAGE_ROAD_WIDTH_M, +) + +logger = logging.getLogger(__name__) + +# 강도 곡선 응답 간격(m). 도로 위 흐름 강도 히트 표기는 이 간격으로 내보낸다. +_STRENGTH_OUTPUT_STEP_M = 5.0 +# 관 위치 선정 점수 배분 — 흐름 강도가 주, 종단 저점이 보조. +_SCORE_WEIGHT_STRENGTH = 0.7 +_SCORE_WEIGHT_SAG = 0.3 +# 성토부(내리막)는 물이 노선 밖으로 빠지므로 관 위치로 덜 선호한다. +_SCORE_FILL_PENALTY = 0.5 + + +@dataclass +class WatershedBasin: + """관 하나가 받는 세부 배수유역.""" + + index: int + chainage_m: float + outlet_x: float + outlet_y: float + boundary_xy: list[tuple[float, float]] = field(default_factory=list) + area_m2: float = 0.0 + relief_m: float = 0.0 + flow_length_m: float = 0.0 + pipe_diameter_mm: float | None = None + + +@dataclass +class WatershedResult: + """배수유역 산정 결과 일체.""" + + basins: list[WatershedBasin] = field(default_factory=list) + pipes: list[StructureCandidate] = field(default_factory=list) + # 2차 전체 배수유역 외곽선(= 분수령). 세부유역 경계는 이 안쪽에서만 그어진다. + main_boundary_xy: list[tuple[float, float]] = field(default_factory=list) + # 도로 위 흐름 강도 곡선 — (누가거리 m, 그 지점으로 모이는 상류 면적 ㎡). + strength_profile: list[tuple[float, float]] = field(default_factory=list) + grid_cell_m: float = DRAINAGE_GRID_SIZE_M + + +@dataclass +class _GridSolution: + """관 배치와 무관한 격자 해석 결과 묶음(캐시 대상).""" + + spec: GridSpec + elevation: np.ndarray # (R*C,) float32 + road_cell_index: np.ndarray # (K,) int32 + road_chainage: np.ndarray # (K,) float64 + road_slot: np.ndarray # (R*C,) int32 — 셀이 도달한 도로 셀 슬롯(−1=미도달) + path_length: np.ndarray # (R*C,) float32 + strength: np.ndarray # (K,) int64 + active: np.ndarray # (R*C,) bool + signature: str + + +# ── 진입점 ────────────────────────────────────────────────────────────────── + + +def build_drainage_watershed( + vertices: list[RouteVertex], + contour_features: list[dict[str, Any]], + stream_features: list[dict[str, Any]], + confirmed_chainages: list[float] | None = None, + cache_path: Path | None = None, +) -> WatershedResult: + """배수유역과 관 배치를 산정한다. + + `confirmed_chainages`를 주면 그 위치를 관으로 확정하고(사용자 편집), 비우면 세류 + 교차 + 최소 보충으로 자동 배치한다. 두 경우 모두 격자 해석은 캐시를 재사용한다. + """ + if len(vertices) < 2: + return WatershedResult() + route_line = LineString([(vertex.x, vertex.y) for vertex in vertices]) + solution = _solve_grid(vertices, route_line, contour_features, stream_features, cache_path) + if solution is None or solution.road_cell_index.size == 0: + return WatershedResult() + + strength_area = solution.strength.astype(np.float64) * solution.spec.cell_area_m2 + strength_curve = _strength_by_chainage(solution.road_chainage, strength_area, route_line.length) + + if confirmed_chainages: + pipes = _pipes_from_chainages(vertices, confirmed_chainages) + else: + pipes = _place_pipes(vertices, stream_features, strength_curve) + if not pipes: + return WatershedResult( + main_boundary_xy=_main_boundary(solution), + strength_profile=_downsample_strength(strength_curve), + grid_cell_m=solution.spec.cell_m, + ) + + pipe_of_slot = _assign_road_cells_to_pipes(vertices, pipes, solution.road_chainage) + basins = _assemble_basins(solution, pipes, pipe_of_slot) + return WatershedResult( + basins=basins, + pipes=pipes, + main_boundary_xy=_main_boundary(solution), + strength_profile=_downsample_strength(strength_curve), + grid_cell_m=solution.spec.cell_m, + ) + + +# ── ①~④ 격자 해석 (캐시 대상) ─────────────────────────────────────────────── + + +def _solve_grid( + vertices: list[RouteVertex], + route_line: LineString, + contour_features: list[dict[str, Any]], + stream_features: list[dict[str, Any]], + cache_path: Path | None, +) -> _GridSolution | None: + signature = _signature(vertices, len(contour_features), len(stream_features)) + cached = _load_cache(cache_path, signature) + if cached is not None: + logger.info("배수유역: 격자 캐시 재사용 (%s)", cache_path) + return cached + + floor = route_elevation_floor([vertex.z for vertex in vertices]) + cloud = build_contour_cloud(contour_features, floor) + if cloud.is_empty: + logger.warning("배수유역: 등고선이 없어 격자 해석을 건너뜁니다.") + return None + streams = select_upstream_streams(route_line, stream_features, cloud) + spec = build_grid_spec(route_line, streams, DRAINAGE_INITIAL_RADIUS_M, DRAINAGE_GRID_SIZE_M) + + terrain = road = flow = None + for round_index in range(DRAINAGE_MAX_EXPAND_ROUNDS + 1): + started = time.perf_counter() + terrain = build_terrain_grid(spec, cloud) + road = rasterize_road(spec, route_line, DRAINAGE_ROAD_WIDTH_M) + flow = trace_flow(terrain, road) + # 격자 크기(config DRAINAGE_GRID_SIZE_M)를 조정할 근거가 되도록 회차별 소요를 남긴다. + logger.info( + "배수유역: %d회차 해석 %.1fs (셀 %d개)", + round_index + 1, + time.perf_counter() - started, + spec.size, + ) + contact = border_contact(flow.active) + if not any(contact.values()): + break + if round_index == DRAINAGE_MAX_EXPAND_ROUNDS: + logger.warning( + "배수유역: 확장 상한(%d회)에 도달 — 경계 %s가 아직 활성입니다.", + DRAINAGE_MAX_EXPAND_ROUNDS, + [side for side, touched in contact.items() if touched], + ) + break + widened = expand_grid_spec(spec, contact, DRAINAGE_EXPAND_STEP_M) + if widened == spec: + break + logger.info( + "배수유역: 경계 %s 활성 — %.0fm 확장", + [side for side, touched in contact.items() if touched], + DRAINAGE_EXPAND_STEP_M, + ) + spec = widened + + assert terrain is not None and road is not None and flow is not None + solution = _GridSolution( + spec=spec, + elevation=terrain.elevation.reshape(-1), + road_cell_index=road.cell_index, + road_chainage=road.chainage, + road_slot=flow.road_slot, + path_length=flow.path_length, + strength=flow.strength, + active=flow.active.reshape(-1), + signature=signature, + ) + _save_cache(cache_path, solution) + return solution + + +def _main_boundary(solution: _GridSolution) -> list[tuple[float, float]]: + boundary = outer_boundary( + solution.spec, solution.active.reshape(solution.spec.n_rows, solution.spec.n_cols) + ) + return largest_ring(boundary) if boundary is not None else [] + + +# ── 흐름 강도 곡선 ────────────────────────────────────────────────────────── + + +def _strength_by_chainage( + road_chainage: np.ndarray, strength_area: np.ndarray, total_length: float +) -> np.ndarray: + """도로 셀 강도를 1m 누가거리 구간으로 합산한 곡선(㎡/m 구간 합).""" + bins = max(1, int(np.ceil(total_length)) + 1) + if road_chainage.size == 0: + return np.zeros(bins) + index = np.clip(np.round(road_chainage).astype(np.int64), 0, bins - 1) + return np.bincount(index, weights=strength_area, minlength=bins) + + +def _downsample_strength(curve: np.ndarray) -> list[tuple[float, float]]: + """응답용으로 강도 곡선을 일정 간격으로 줄인다(구간 합 유지). + + 끝자락을 잘라내면 종점 부근 유입 면적이 통째로 사라지므로 0으로 채워 맞춘다. + """ + step = max(1, int(_STRENGTH_OUTPUT_STEP_M)) + if curve.size == 0: + return [] + padding = (-curve.size) % step + padded = np.append(curve, np.zeros(padding)) if padding else curve + summed = padded.reshape(-1, step).sum(axis=1) + return [ + (float(position * step), float(value)) for position, value in enumerate(summed) if value > 0 + ] + + +# ── ⑤ 관 배치 ─────────────────────────────────────────────────────────────── + + +def _place_pipes( + vertices: list[RouteVertex], + stream_features: list[dict[str, Any]], + strength_curve: np.ndarray, +) -> list[StructureCandidate]: + """세류 교차점을 기본 관 위치로 두고, 최대 간격을 넘는 구간만 최소 개수로 보충한다. + + 교차점은 종단 절·성토를 가리지 않고 모두 관으로 둔다. 하류측 세류선은 이미 격자 + 해석 전에 제거되었으므로, 남은 교차점은 전부 상류에서 물이 실제로 들어오는 지점이다. + """ + total_length = vertices[-1].chainage_m + base: list[StructureCandidate] = [] + for candidate in find_stream_crossings(vertices, stream_features): + if base and candidate.chainage_m - base[-1].chainage_m < DRAINAGE_PIPE_MIN_SPACING_M: + continue + base.append(candidate) + + filled: list[StructureCandidate] = [] + previous = 0.0 + for candidate in [*base, None]: + boundary = candidate.chainage_m if candidate else total_length + filled.extend(_fill_gap(vertices, strength_curve, previous, boundary)) + if candidate: + filled.append(candidate) + previous = candidate.chainage_m + else: + previous = boundary + filled.sort(key=lambda item: item.chainage_m) + return filled + + +def _fill_gap( + vertices: list[RouteVertex], + strength_curve: np.ndarray, + start_m: float, + end_m: float, +) -> list[StructureCandidate]: + """[start, end] 구간에 최대 간격을 지키는 **최소 개수**의 관을 배치한다. + + 필요 개수 n은 구간 길이로 정해지고(ceil(L/max) − 1), 각 관은 등분 위치를 중심으로 + 허용 여유(slack) 안에서만 움직인다. 그래서 개수는 늘지 않으면서도 흐름 강도가 크고 + 종단이 낮은 지점으로 붙는다. + """ + span = end_m - start_m + if span <= DRAINAGE_PIPE_MAX_SPACING_M: + return [] + count = int(np.ceil(span / DRAINAGE_PIPE_MAX_SPACING_M)) - 1 + if count <= 0: + return [] + spacing = span / (count + 1) + slack = max(0.0, (DRAINAGE_PIPE_MAX_SPACING_M - spacing) / 2.0) + placed: list[StructureCandidate] = [] + for order in range(1, count + 1): + nominal = start_m + spacing * order + low = max(start_m + DRAINAGE_PIPE_MIN_SPACING_M, nominal - slack) + high = min(end_m - DRAINAGE_PIPE_MIN_SPACING_M, nominal + slack) + chosen = _best_position(vertices, strength_curve, low, high, nominal) + x, y, _ = _interpolate_vertex(vertices, chosen) + placed.append(StructureCandidate(chainage_m=chosen, x=x, y=y, reason="spacing")) + return placed + + +def _best_position( + vertices: list[RouteVertex], + strength_curve: np.ndarray, + low_m: float, + high_m: float, + fallback_m: float, +) -> float: + """허용 구간 안에서 흐름 강도가 크고 종단이 낮은 위치를 고른다.""" + if high_m <= low_m: + return fallback_m + positions = np.arange(low_m, high_m + 1.0, 1.0) + if positions.size == 0: + return fallback_m + index = np.clip(np.round(positions).astype(np.int64), 0, strength_curve.size - 1) + strength = strength_curve[index] + heights = np.array([_interpolate_vertex(vertices, float(p))[2] for p in positions]) + + strength_score = strength / strength.max() if strength.max() > 0 else np.zeros_like(strength) + height_span = float(heights.max() - heights.min()) + sag_score = ( + (heights.max() - heights) / height_span if height_span > 1e-6 else np.zeros_like(heights) + ) + score = _SCORE_WEIGHT_STRENGTH * strength_score + _SCORE_WEIGHT_SAG * sag_score + for order, position in enumerate(positions): + if not is_uphill_at(vertices, float(position)): + score[order] *= _SCORE_FILL_PENALTY + return float(positions[int(np.argmax(score))]) + + +def _pipes_from_chainages( + vertices: list[RouteVertex], chainages: list[float] +) -> list[StructureCandidate]: + """사용자가 확정·편집한 누가거리 목록을 관 후보로 되돌린다. + + 노선 밖 값은 시·종점으로 당긴다. 그대로 두면 마커는 끝점에 찍히는데 라벨만 −50m처럼 + 나와 좌표와 표기가 어긋난다. + """ + total_length = vertices[-1].chainage_m + clamped = {min(max(round(float(item), 2), 0.0), total_length) for item in chainages} + pipes: list[StructureCandidate] = [] + for value in sorted(clamped): + x, y, _ = _interpolate_vertex(vertices, value) + pipes.append(StructureCandidate(chainage_m=value, x=x, y=y, reason="confirmed")) + return pipes + + +# ── ⑥ 측구 흐름으로 도로 셀 → 담당 관 ─────────────────────────────────────── + + +def _assign_road_cells_to_pipes( + vertices: list[RouteVertex], + pipes: list[StructureCandidate], + road_chainage: np.ndarray, +) -> np.ndarray: + """도로 셀마다 물이 실제로 흘러가는 담당 관 번호를 정한다. + + 노면 물은 측구를 타고 종단 내리막으로 흐르므로, 종단 계획선을 1차원 지형으로 보고 + 같은 방식(내리막 추적 + 관에서 흡수)으로 푼다. 관이 없는 사그(저점)에 갇힌 구간은 + 가장 가까운 관이 받는 것으로 본다. + """ + total_length = vertices[-1].chainage_m + step = max(DRAINAGE_DITCH_SAMPLE_M, 0.5) + stations = np.arange(0.0, total_length + step, step) + heights = np.array([_interpolate_vertex(vertices, float(s))[2] for s in stations]) + pipe_chainages = np.array([pipe.chainage_m for pipe in pipes]) + pipe_station = np.clip(np.round(pipe_chainages / step).astype(np.int64), 0, stations.size - 1) + + # 앞뒤 이웃 중 더 낮은 쪽으로 흘려보낸다(양쪽 다 높으면 사그 = 제자리). + back_z = np.full(stations.size, np.inf) + back_z[1:] = heights[:-1] + forward_z = np.full(stations.size, np.inf) + forward_z[:-1] = heights[1:] + go_back = (back_z < heights) & (back_z <= forward_z) + go_forward = (forward_z < heights) & ~go_back + receiver = np.arange(stations.size, dtype=np.int64) + receiver[go_back] -= 1 + receiver[go_forward] += 1 + receiver[pipe_station] = pipe_station # 관은 물을 흡수한다 + + owner = np.full(stations.size, -1, dtype=np.int64) + owner[pipe_station] = np.arange(pipe_chainages.size) + jump = receiver + for _ in range(40): + next_jump = jump[jump] + if np.array_equal(next_jump, jump): + break + jump = next_jump + resolved = owner[jump] + # 관 없는 사그에 갇힌 구간은 가장 가까운 관에 붙인다. + orphan = resolved < 0 + if orphan.any() and pipe_chainages.size: + nearest = np.abs(stations[orphan, None] - pipe_chainages[None, :]).argmin(axis=1) + resolved[orphan] = nearest + + slot_station = np.clip(np.round(road_chainage / step).astype(np.int64), 0, stations.size - 1) + return resolved[slot_station].astype(np.int32) + + +# ── 세부유역 조립 ─────────────────────────────────────────────────────────── + + +def _assemble_basins( + solution: _GridSolution, + pipes: list[StructureCandidate], + pipe_of_slot: np.ndarray, +) -> list[WatershedBasin]: + """셀이 도달한 도로 셀의 담당 관을 그대로 유역 번호로 삼아 세부유역을 만든다.""" + spec = solution.spec + labels = np.full(spec.size, -1, dtype=np.int32) + reached = solution.road_slot >= 0 + labels[reached] = pipe_of_slot[solution.road_slot[reached]] + + polygons = polygonize_labels(spec, labels) + cell_area = spec.cell_area_m2 + basins: list[WatershedBasin] = [] + for order, pipe in enumerate(pipes): + member = labels == order + count = int(member.sum()) + if count == 0: + continue + geometry = polygons.get(order) + elevations = solution.elevation[member] + highest = float(np.nanmax(elevations)) if np.isfinite(elevations).any() else 0.0 + outlet_z = _outlet_elevation(solution, order, pipe_of_slot) + area = count * cell_area + relief = max(0.0, highest - outlet_z) + flow_length = float(solution.path_length[member].max()) + basins.append( + WatershedBasin( + index=len(basins) + 1, + chainage_m=pipe.chainage_m, + outlet_x=pipe.x, + outlet_y=pipe.y, + boundary_xy=largest_ring(geometry) if geometry is not None else [], + area_m2=area, + relief_m=relief, + flow_length_m=flow_length, + pipe_diameter_mm=estimate_pipe_diameter_mm(area, relief, flow_length), + ) + ) + return basins + + +def _outlet_elevation(solution: _GridSolution, pipe_order: int, pipe_of_slot: np.ndarray) -> float: + """관이 담당하는 도로 셀들의 최저 표고 = 유역 출구 표고.""" + slots = np.flatnonzero(pipe_of_slot == pipe_order) + if slots.size == 0: + return 0.0 + elevations = solution.elevation[solution.road_cell_index[slots]] + finite = elevations[np.isfinite(elevations)] + return float(finite.min()) if finite.size else 0.0 + + +# ── 캐시 ──────────────────────────────────────────────────────────────────── + + +def _signature(vertices: list[RouteVertex], contour_count: int, stream_count: int) -> str: + """노선 기하와 해석 파라미터가 바뀌면 캐시를 버리도록 하는 지문.""" + digest = hashlib.sha1() + for vertex in vertices: + digest.update(f"{vertex.x:.2f},{vertex.y:.2f},{vertex.z:.2f};".encode()) + digest.update( + f"|{contour_count}|{stream_count}|{DRAINAGE_GRID_SIZE_M}|{DRAINAGE_INITIAL_RADIUS_M}" + f"|{DRAINAGE_EXPAND_STEP_M}|{DRAINAGE_ROAD_WIDTH_M}".encode() + ) + return digest.hexdigest() + + +def _load_cache(cache_path: Path | None, signature: str) -> _GridSolution | None: + if cache_path is None or not cache_path.exists(): + return None + try: + with np.load(cache_path, allow_pickle=False) as data: + if str(data["signature"]) != signature: + return None + spec = GridSpec( + x_min=float(data["x_min"]), + y_max=float(data["y_max"]), + cell_m=float(data["cell_m"]), + n_rows=int(data["n_rows"]), + n_cols=int(data["n_cols"]), + ) + return _GridSolution( + spec=spec, + elevation=data["elevation"], + road_cell_index=data["road_cell_index"], + road_chainage=data["road_chainage"], + road_slot=data["road_slot"], + path_length=data["path_length"], + strength=data["strength"], + active=data["active"], + signature=signature, + ) + except (OSError, KeyError, ValueError): + logger.warning("배수유역: 격자 캐시를 읽지 못해 다시 계산합니다 (%s).", cache_path) + return None + + +def _save_cache(cache_path: Path | None, solution: _GridSolution) -> None: + if cache_path is None: + return + try: + cache_path.parent.mkdir(parents=True, exist_ok=True) + np.savez_compressed( + cache_path, + signature=solution.signature, + x_min=solution.spec.x_min, + y_max=solution.spec.y_max, + cell_m=solution.spec.cell_m, + n_rows=solution.spec.n_rows, + n_cols=solution.spec.n_cols, + elevation=solution.elevation, + road_cell_index=solution.road_cell_index, + road_chainage=solution.road_chainage, + road_slot=solution.road_slot, + path_length=solution.path_length, + strength=solution.strength, + active=solution.active, + ) + except OSError: + logger.warning("배수유역: 격자 캐시를 저장하지 못했습니다 (%s).", cache_path) diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Flow.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Flow.py new file mode 100644 index 00000000..bc567ca5 --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Flow.py @@ -0,0 +1,224 @@ +"""배수유역 흐름 해석 — 도로 굽기 · 상류 추적(포인터 더블링) · 유역 폴리곤화. + +핵심은 하나다: **물길을 따라가 도로에 닿는 셀만 유역이다.** +셀마다 D8 수신 셀을 따라가 종착점(root)을 구하고, 그 종착점이 도로 셀이면 활성이다. + +이 방식은 능선을 따로 찾지 않는다. 능선 너머 셀의 물은 다른 계곡으로 빠져 도로에 +닿지 못하므로 자동으로 비활성이 되고, 그 경계선이 곧 능선이다. 유역 안쪽 봉우리는 +물이 결국 도로로 흘러 자동으로 포함된다. + +종착점은 세부유역 라벨의 근거로도 그대로 쓴다 — 셀이 도달한 도로 셀이 정해지면 +그 도로 셀을 담당하는 관이 곧 그 셀의 유역 번호다. 관 배치가 바뀌어도 격자 해석을 +다시 돌릴 필요 없이 "도로 셀 → 관" 대응만 다시 계산하면 된다. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass + +import numpy as np +from rasterio.features import rasterize, shapes +from rasterio.transform import from_origin +from scipy.spatial import cKDTree +from shapely.geometry import LineString, MultiPolygon, Polygon, shape +from shapely.ops import unary_union + +from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import GridSpec, TerrainGrid +from config.config_system import ( + DRAINAGE_MIN_BASIN_AREA_M2, + DRAINAGE_POLYGON_SIMPLIFY_M, + DRAINAGE_ROAD_WIDTH_M, +) + +logger = logging.getLogger(__name__) + +# 포인터 더블링 반복 상한. 한 번에 경로 길이가 2배가 되므로 2^40 스텝이면 어떤 격자도 덮는다. +_MAX_DOUBLING_ROUNDS = 40 + + +@dataclass +class RoadRaster: + """격자에 구운 도로. 도로 셀은 흐름을 흡수하는 종착점이 된다.""" + + mask: np.ndarray # (R, C) bool + cell_index: np.ndarray # (K,) int32 — 도로 셀의 평탄 인덱스 + chainage: np.ndarray # (K,) float64 — 도로 셀의 누가거리(m) + slot_of_cell: np.ndarray # (R*C,) int32 — 도로 셀이면 K 내 위치, 아니면 −1 + + @property + def count(self) -> int: + return int(self.cell_index.size) + + +@dataclass +class FlowResult: + """상류 추적 결과.""" + + root: np.ndarray # (R*C,) int32 — 흐름 종착 셀의 평탄 인덱스 + road_slot: np.ndarray # (R*C,) int32 — 도달한 도로 셀 슬롯, 도달 못하면 −1 + active: np.ndarray # (R, C) bool — 도로에 물이 닿는 셀 + path_length: np.ndarray # (R*C,) float32 — 종착점까지 물길 길이(m) + strength: np.ndarray # (K,) int64 — 도로 셀별 상류 셀 수(흐름 강도) + + +def grid_transform(spec: GridSpec): + """rasterio 아핀 변환. 행 0이 북쪽(y_max)이다.""" + return from_origin(spec.x_min, spec.y_max, spec.cell_m, spec.cell_m) + + +# ── 도로 굽기 ─────────────────────────────────────────────────────────────── + + +def rasterize_road( + spec: GridSpec, + route_line: LineString, + width_m: float = DRAINAGE_ROAD_WIDTH_M, +) -> RoadRaster: + """노선을 노폭만큼 두껍게 격자에 굽고, 각 도로 셀에 누가거리를 붙인다. + + 폭을 주는 이유는 실제 노면이 물을 받기 때문이기도 하지만, 1셀 선으로 구우면 D8 + 대각 이동이 도로를 건너뛰어 상류 물이 도로를 지나쳐 버리기 때문이다. 3셀 이상 두께면 + 내리막 물길이 반드시 도로 셀을 한 번은 밟는다. + """ + half_width = max(width_m / 2.0, spec.cell_m) + burned = rasterize( + [(route_line.buffer(half_width), 1)], + out_shape=(spec.n_rows, spec.n_cols), + transform=grid_transform(spec), + fill=0, + dtype="uint8", + all_touched=True, + ).astype(bool) + + slot_of_cell = np.full(spec.size, -1, dtype=np.int32) + cell_index = np.flatnonzero(burned.reshape(-1)).astype(np.int32) + if cell_index.size == 0: + logger.warning("배수유역: 노선이 격자 범위 밖입니다 — 도로 셀 0개.") + return RoadRaster(burned, cell_index, np.zeros(0), slot_of_cell) + + # 도로 셀 누가거리는 노선을 촘촘히 샘플링해 가장 가까운 샘플의 누가거리로 준다. + step = max(spec.cell_m / 2.0, 0.25) + positions = np.arange(0.0, route_line.length + step, step) + samples = np.array([list(route_line.interpolate(p).coords)[0] for p in positions]) + rows = (cell_index // spec.n_cols).astype(np.float64) + cols = (cell_index % spec.n_cols).astype(np.float64) + centers = np.column_stack( + ( + spec.x_min + (cols + 0.5) * spec.cell_m, + spec.y_max - (rows + 0.5) * spec.cell_m, + ) + ) + _, nearest = cKDTree(samples).query(centers) + chainage = np.minimum(positions[nearest], route_line.length) + slot_of_cell[cell_index] = np.arange(cell_index.size, dtype=np.int32) + return RoadRaster(burned, cell_index, chainage, slot_of_cell) + + +# ── 상류 추적 ─────────────────────────────────────────────────────────────── + + +def trace_flow(terrain: TerrainGrid, road: RoadRaster) -> FlowResult: + """모든 셀의 물길 종착점을 구하고 도로 도달 여부(=유역 포함 여부)를 판정한다. + + 포인터 더블링으로 한 번에 경로 길이를 2배씩 늘려 종착점을 찾는다. 채움·평탄해소를 + 거친 표고에서는 흐름을 따라 표고가 단조 감소하므로 순환이 없고, 반복은 항상 끝난다. + """ + spec = terrain.spec + receiver = terrain.receiver.astype(np.int32, copy=True) + step_length = terrain.step_length.astype(np.float32, copy=True) + + # 도로 셀은 흐름을 흡수한다 — 물이 도로에 닿으면 거기서 끝난다. + receiver[road.cell_index] = road.cell_index + step_length[road.cell_index] = 0.0 + + jump = receiver + path_length = step_length + for _ in range(_MAX_DOUBLING_ROUNDS): + next_jump = jump[jump] + if np.array_equal(next_jump, jump): + break + path_length = path_length + path_length[jump] + jump = next_jump + + road_slot = road.slot_of_cell[jump] + active_flat = road_slot >= 0 + strength = ( + np.bincount(road_slot[active_flat], minlength=max(road.count, 1)).astype(np.int64) + if road.count + else np.zeros(0, dtype=np.int64) + ) + logger.info( + "배수유역: 활성 셀 %d / %d (도로 셀 %d)", int(active_flat.sum()), spec.size, road.count + ) + return FlowResult( + root=jump, + road_slot=road_slot, + active=active_flat.reshape(spec.n_rows, spec.n_cols), + path_length=path_length, + strength=strength, + ) + + +def border_contact(active: np.ndarray) -> dict[str, bool]: + """활성 셀이 격자 최외곽에 닿은 방향. 전부 False면 유역이 능선 안에서 닫힌 것이다.""" + return { + "north": bool(active[0, :].any()), + "south": bool(active[-1, :].any()), + "west": bool(active[:, 0].any()), + "east": bool(active[:, -1].any()), + } + + +# ── 폴리곤화 ──────────────────────────────────────────────────────────────── + + +def polygonize_labels( + spec: GridSpec, + labels: np.ndarray, + min_area_m2: float = DRAINAGE_MIN_BASIN_AREA_M2, +) -> dict[int, Polygon | MultiPolygon]: + """라벨 격자를 라벨별 폴리곤으로 바꾼다. 음수 라벨은 배경으로 무시한다.""" + label_grid = np.ascontiguousarray(labels.reshape(spec.n_rows, spec.n_cols), dtype=np.int32) + valid_mask = label_grid >= 0 + if not valid_mask.any(): + return {} + collected: dict[int, list[Polygon]] = {} + for geometry, value in shapes( + label_grid, mask=valid_mask, transform=grid_transform(spec), connectivity=4 + ): + polygon = shape(geometry) + if polygon.is_empty or polygon.area < min_area_m2: + continue + collected.setdefault(int(value), []).append(polygon) + + merged: dict[int, Polygon | MultiPolygon] = {} + for label, parts in collected.items(): + union = unary_union(parts) + if union.is_empty: + continue + simplified = union.simplify(DRAINAGE_POLYGON_SIMPLIFY_M, preserve_topology=True) + merged[label] = simplified if not simplified.is_empty else union + return merged + + +def largest_ring(geometry: Polygon | MultiPolygon) -> list[tuple[float, float]]: + """폴리곤(또는 멀티폴리곤)에서 가장 큰 조각의 외곽 링 좌표를 뽑는다.""" + if geometry.is_empty: + return [] + if geometry.geom_type == "MultiPolygon": + geometry = max(geometry.geoms, key=lambda part: part.area) + return [(float(x), float(y)) for x, y in geometry.exterior.coords] + + +def outer_boundary( + spec: GridSpec, active: np.ndarray, min_area_m2: float = DRAINAGE_MIN_BASIN_AREA_M2 +) -> Polygon | MultiPolygon | None: + """활성 셀 전체의 외곽 = 2차 전체 배수유역 경계. + + 비활성 셀이 격자 최외곽에 띠로 완성되면 활성 영역이 그 안에 닫힌다. 그 닫힌 영역의 + 바깥선이 곧 분수령이므로 능선을 따로 그릴 필요가 없다. + """ + labels = np.where(active.reshape(-1), 0, -1).astype(np.int32) + polygons = polygonize_labels(spec, labels, min_area_m2) + return polygons.get(0) diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py new file mode 100644 index 00000000..54f4a7ad --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py @@ -0,0 +1,503 @@ +"""배수유역 해석 격자 생성 — 등고선 TIN 보간 · 웅덩이 채움 · D8 물 방향. + +지형 근거는 **도엽 등고선**뿐이다. 라이다 DEM은 노선 주변만 커버해 유역 산정에 필요한 +상류 범위를 담지 못하므로 쓰지 않는다(2026-07-31 사용자 지시). 표고점도 쓰지 않는다. + +처리 순서 + ① 계획선 최저점 아래 등고선·짧은 파편 제거 + ② 세류선을 노선 교차점에서 잘라 상류측만 남김 + ③ 남은 세류선 + 노선을 반경 버퍼한 범위의 bbox로 격자 생성 + ④ 등고선 정점 Delaunay TIN 선형보간으로 셀 표고 산출 + ⑤ 웅덩이 채움(형태학적 재구성) + 평탄면 미세경사 부여 + ⑥ D8(8방향 최급강하) 수신 셀 인덱스 산출 + +⑤가 없으면 등고선 TIN 특유의 가짜 웅덩이·평탄 삼각형에서 흐름이 끊겨 상류 추적이 +도중에 멈춘다. 능선 탐지는 하지 않는다 — 흐름이 도로에 닿는지 여부만으로 유역이 정해진다. +""" + +from __future__ import annotations + +import logging +import math +from dataclasses import dataclass +from typing import Any + +import numpy as np +from scipy.interpolate import LinearNDInterpolator +from scipy.ndimage import distance_transform_edt +from scipy.spatial import cKDTree +from shapely import segmentize +from shapely.geometry import LineString, shape +from shapely.ops import substring, unary_union +from skimage.morphology import reconstruction + +from config.config_system import ( + DRAINAGE_CONTOUR_MARGIN_M, + DRAINAGE_CONTOUR_MIN_LENGTH_M, + DRAINAGE_CONTOUR_RESAMPLE_M, + DRAINAGE_FLAT_EPSILON_M, + DRAINAGE_GRID_SIZE_M, + DRAINAGE_MAX_GRID_CELLS, +) + +logger = logging.getLogger(__name__) + +# 표고 속성 키: 도엽 등고선(등고수치)·gpkg 등고선(CTRLN_HG) 통합. +ELEVATION_KEYS = ("등고수치", "CTRLN_HG", "수치", "표고", "높이", "elevation", "ELEV") + +# D8 이웃 (행 증분, 열 증분, 거리계수). 행은 아래로 증가(북 → 남). +_NEIGHBORS = ( + (-1, 0, 1.0), + (1, 0, 1.0), + (0, -1, 1.0), + (0, 1, 1.0), + (-1, -1, math.sqrt(2.0)), + (-1, 1, math.sqrt(2.0)), + (1, -1, math.sqrt(2.0)), + (1, 1, math.sqrt(2.0)), +) + + +@dataclass(frozen=True) +class GridSpec: + """해석 격자 기하. (0,0) 셀 중심이 (x_min + cell/2, y_max − cell/2)에 놓인다.""" + + x_min: float + y_max: float + cell_m: float + n_rows: int + n_cols: int + + @property + def size(self) -> int: + return self.n_rows * self.n_cols + + @property + def cell_area_m2(self) -> float: + return self.cell_m * self.cell_m + + def world_to_rc(self, x: np.ndarray, y: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """세계좌표(m)를 격자 행·열로 바꾼다. 범위를 벗어나면 −1을 돌려준다.""" + col = np.floor((x - self.x_min) / self.cell_m).astype(np.int64) + row = np.floor((self.y_max - y) / self.cell_m).astype(np.int64) + outside = (col < 0) | (col >= self.n_cols) | (row < 0) | (row >= self.n_rows) + col[outside] = -1 + row[outside] = -1 + return row, col + + def cell_centers_x(self) -> np.ndarray: + return self.x_min + (np.arange(self.n_cols, dtype=np.float64) + 0.5) * self.cell_m + + def cell_centers_y(self) -> np.ndarray: + return self.y_max - (np.arange(self.n_rows, dtype=np.float64) + 0.5) * self.cell_m + + +@dataclass +class ContourCloud: + """등고선에서 뽑은 정점 구름. TIN 보간과 세류 상·하류 판정에 함께 쓴다.""" + + xy: np.ndarray # (N, 2) float64 + z: np.ndarray # (N,) float64 + + @property + def is_empty(self) -> bool: + return self.xy.shape[0] < 3 + + +@dataclass +class TerrainGrid: + """격자 지형 해석 결과.""" + + spec: GridSpec + elevation: np.ndarray # (R, C) float32 — 채움·평탄해소 후 표고, 무효 셀은 NaN + valid: np.ndarray # (R, C) bool — 등고선 TIN 내부 여부 + receiver: np.ndarray # (R*C,) int32 — D8 수신 셀의 평탄 인덱스, 싱크는 자기 자신 + step_length: np.ndarray # (R*C,) float32 — 수신 셀까지 거리(m), 싱크는 0 + + +# ── ① 등고선 정리 ──────────────────────────────────────────────────────────── + + +def _feature_elevation(properties: dict[str, Any]) -> float | None: + for key in ELEVATION_KEYS: + value = properties.get(key) + if value is None: + continue + try: + return float(value) + except (TypeError, ValueError): + continue + return None + + +def _iter_linestrings(geometry: Any) -> list[LineString]: + if geometry.geom_type == "LineString": + return [geometry] + if geometry.geom_type in {"MultiLineString", "GeometryCollection"}: + lines: list[LineString] = [] + for part in geometry.geoms: + lines.extend(_iter_linestrings(part)) + return lines + return [] + + +def build_contour_cloud( + contour_features: list[dict[str, Any]], + elevation_floor_m: float | None = None, +) -> ContourCloud: + """등고선 피처를 표고가 붙은 정점 구름으로 바꾼다. + + `elevation_floor_m` 아래 등고선은 계획선 최저점보다 낮아 상류 기여가 불가능하므로 + 버린다. 길이가 짧은 파편도 노이즈로 보고 버리되, 임계 이상인 봉우리 폐합 등고선은 + 남긴다(봉우리 표고가 사라지면 그 일대 흐름 방향이 통째로 틀어진다). + """ + xs: list[np.ndarray] = [] + ys: list[np.ndarray] = [] + zs: list[np.ndarray] = [] + dropped_low = 0 + dropped_short = 0 + for feature in contour_features: + geometry = feature.get("geometry") + if not geometry: + continue + elevation = _feature_elevation(feature.get("properties") or {}) + if elevation is None: + continue + if elevation_floor_m is not None and elevation < elevation_floor_m: + dropped_low += 1 + continue + try: + parsed = shape(geometry) + except Exception: # noqa: BLE001 - 손상된 피처는 건너뛴다 + continue + for line in _iter_linestrings(parsed): + if line.length < DRAINAGE_CONTOUR_MIN_LENGTH_M: + dropped_short += 1 + continue + coords = np.asarray(segmentize(line, DRAINAGE_CONTOUR_RESAMPLE_M).coords) + if coords.shape[0] < 2: + continue + xs.append(coords[:, 0]) + ys.append(coords[:, 1]) + zs.append(np.full(coords.shape[0], elevation, dtype=np.float64)) + if not xs: + logger.warning( + "배수유역: 사용할 등고선이 없습니다(저지대 %d, 파편 %d 제외).", + dropped_low, + dropped_short, + ) + return ContourCloud(np.zeros((0, 2)), np.zeros(0)) + xy = np.column_stack((np.concatenate(xs), np.concatenate(ys))) + z = np.concatenate(zs) + logger.info( + "배수유역: 등고선 정점 %d개 (저지대 %d, 파편 %d 제외)", + xy.shape[0], + dropped_low, + dropped_short, + ) + return ContourCloud(xy, z) + + +# ── ② 세류선 상류측만 남기기 ──────────────────────────────────────────────── + + +def select_upstream_streams( + route_line: LineString, + stream_features: list[dict[str, Any]], + cloud: ContourCloud, +) -> list[LineString]: + """노선과 교차하는 세류선을 교차점에서 잘라 상류(고지대)측만 돌려준다. + + 노선과 만나지 않는 세류선은 판단 근거가 없으므로 그대로 남긴다 — 어차피 격자 해석에서 + 도로에 물이 닿지 않으면 비활성 처리된다. 상·하류 판정은 가장 가까운 등고선 정점의 + 표고 평균으로 한다(TIN은 이 시점에 아직 없다). + """ + tree = cKDTree(cloud.xy) if not cloud.is_empty else None + kept: list[LineString] = [] + for feature in stream_features: + geometry = feature.get("geometry") + if not geometry: + continue + try: + parsed = shape(geometry) + except Exception: # noqa: BLE001 + continue + for line in _iter_linestrings(parsed): + if line.is_empty or line.length <= 0: + continue + if not line.intersects(route_line): + kept.append(line) + continue + kept.extend(_upstream_parts(line, route_line, tree, cloud)) + return kept + + +def _upstream_parts( + line: LineString, + route_line: LineString, + tree: cKDTree | None, + cloud: ContourCloud, +) -> list[LineString]: + """세류선을 노선 교차점에서 잘라 평균 표고가 높은 조각만 남긴다.""" + cuts = sorted( + { + line.project(point) + for point in _intersection_points(line.intersection(route_line)) + if 0.0 < line.project(point) < line.length + } + ) + if not cuts: + return [line] + bounds = [0.0, *cuts, line.length] + parts: list[tuple[float, LineString]] = [] + for start, end in zip(bounds, bounds[1:]): + if end - start < 1.0: + continue + piece = _substring(line, start, end) + if piece is None: + continue + parts.append((_mean_elevation(piece, tree, cloud), piece)) + if not parts: + return [] + highest = max(value for value, _ in parts) + # 최상류 조각과 표고가 비슷한(1m 이내) 조각까지 상류로 본다. 나머지는 하류이므로 버린다. + return [piece for value, piece in parts if highest - value <= 1.0] + + +def _intersection_points(geometry: Any) -> list[Any]: + if geometry.is_empty: + return [] + if geometry.geom_type == "Point": + return [geometry] + if geometry.geom_type in {"MultiPoint", "GeometryCollection", "MultiLineString"}: + points: list[Any] = [] + for part in geometry.geoms: + points.extend(_intersection_points(part)) + return points + if geometry.geom_type == "LineString": + return [geometry.interpolate(0.5, normalized=True)] + return [] + + +def _substring(line: LineString, start: float, end: float) -> LineString | None: + """선형 위 [start, end] 구간을 잘라낸다.""" + piece = substring(line, start, end) + if piece.is_empty or piece.geom_type != "LineString" or piece.length <= 0: + return None + return piece + + +def _mean_elevation(line: LineString, tree: cKDTree | None, cloud: ContourCloud) -> float: + if tree is None: + return 0.0 + samples = max(2, int(line.length // 10.0) + 1) + positions = np.linspace(0.0, line.length, samples) + points = np.array([list(line.interpolate(position).coords)[0] for position in positions]) + _, indices = tree.query(points) + return float(np.mean(cloud.z[indices])) + + +# ── ③ 격자 범위 ───────────────────────────────────────────────────────────── + + +def build_grid_spec( + route_line: LineString, + streams: list[LineString], + radius_m: float, + cell_m: float = DRAINAGE_GRID_SIZE_M, +) -> GridSpec: + """노선과 상류 세류선을 반경 버퍼한 범위의 bbox로 격자를 잡는다. + + 셀 수가 상한을 넘으면 셀 크기를 자동으로 키워 맞춘다(메모리 보호). 실제 유역 모양은 + 격자가 아니라 흐름 해석이 정한다 — 여기서는 넉넉한 사각 범위만 확보하면 된다. + """ + geometries = [route_line.buffer(radius_m)] + geometries.extend(line.buffer(radius_m) for line in streams) + x_min, y_min, x_max, y_max = unary_union(geometries).bounds + return _spec_from_bounds(x_min, y_min, x_max, y_max, cell_m) + + +def _spec_from_bounds( + x_min: float, y_min: float, x_max: float, y_max: float, cell_m: float +) -> GridSpec: + width = max(x_max - x_min, cell_m) + height = max(y_max - y_min, cell_m) + while (width / cell_m) * (height / cell_m) > DRAINAGE_MAX_GRID_CELLS: + cell_m *= 2.0 + logger.warning("배수유역: 셀 수 상한 초과 — 격자 크기를 %.1fm로 키웁니다.", cell_m) + n_cols = int(math.ceil(width / cell_m)) + n_rows = int(math.ceil(height / cell_m)) + return GridSpec( + x_min=x_min, y_max=y_min + n_rows * cell_m, cell_m=cell_m, n_rows=n_rows, n_cols=n_cols + ) + + +def expand_grid_spec(spec: GridSpec, sides: dict[str, bool], step_m: float) -> GridSpec: + """활성 셀이 닿은 방향으로만 격자를 넓힌다.""" + x_min = spec.x_min - (step_m if sides.get("west") else 0.0) + x_max = spec.x_min + spec.n_cols * spec.cell_m + (step_m if sides.get("east") else 0.0) + y_max = spec.y_max + (step_m if sides.get("north") else 0.0) + y_min = spec.y_max - spec.n_rows * spec.cell_m - (step_m if sides.get("south") else 0.0) + return _spec_from_bounds(x_min, y_min, x_max, y_max, spec.cell_m) + + +# ── ④ TIN 보간 ────────────────────────────────────────────────────────────── + + +def interpolate_elevation(spec: GridSpec, cloud: ContourCloud) -> np.ndarray: + """등고선 정점 Delaunay TIN으로 셀 표고를 선형보간한다. 외부는 NaN.""" + surface = np.full((spec.n_rows, spec.n_cols), np.nan, dtype=np.float32) + if cloud.is_empty: + return surface + interpolator = LinearNDInterpolator(cloud.xy, cloud.z) + xs = spec.cell_centers_x() + ys = spec.cell_centers_y() + # 행 묶음 단위로 평가해 (행×열) 좌표 배열을 한 번에 들고 있지 않게 한다. + chunk = max(1, int(4_000_000 // max(spec.n_cols, 1))) + for start in range(0, spec.n_rows, chunk): + stop = min(start + chunk, spec.n_rows) + grid_x, grid_y = np.meshgrid(xs, ys[start:stop]) + surface[start:stop] = interpolator(grid_x, grid_y).astype(np.float32) + return surface + + +# ── ⑤ 웅덩이 채움 + 평탄면 해소 ───────────────────────────────────────────── + + +def condition_surface(surface: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """가짜 웅덩이를 채우고 평탄면에 미세 경사를 준다. + + 등고선 TIN은 같은 표고 정점 3개로 이루어진 평탄 삼각형과 계단형 가짜 웅덩이를 + 필연적으로 만든다. 그대로 D8을 돌리면 흐름이 거기서 끊겨 상류 추적이 멈춘다. + + 채움은 형태학적 재구성(erosion)으로 한다. 배출구는 격자 최외곽과 TIN 경계(무효 셀에 + 맞닿은 유효 셀)로 둔다 — 그래야 유효 영역 전체가 하나의 평탄면으로 잠기지 않는다. + """ + valid = np.isfinite(surface) + if not valid.any(): + return surface, valid + ceiling = float(np.nanmax(surface)) + 1000.0 + mask = np.where(valid, surface, ceiling).astype(np.float32) + + open_boundary = np.zeros_like(valid) + open_boundary[0, :] = True + open_boundary[-1, :] = True + open_boundary[:, 0] = True + open_boundary[:, -1] = True + open_boundary |= _dilate(~valid) & valid + open_boundary &= valid + if not open_boundary.any(): + open_boundary = valid & _dilate(~valid) + + seed = np.full_like(mask, ceiling) + seed[open_boundary] = mask[open_boundary] + filled = reconstruction(seed, mask, method="erosion", footprint=np.ones((3, 3), dtype=bool)) + filled = filled.astype(np.float32) + + # 채움 뒤 더 낮은 이웃이 없는 셀 = 평탄면. 가장 가까운 비평탄 셀 쪽으로 미세 경사를 준다. + flat = valid & ~_has_lower_neighbour(filled, valid) + if flat.any(): + distance = distance_transform_edt(flat).astype(np.float32) + filled = filled + distance * np.float32(DRAINAGE_FLAT_EPSILON_M) + filled[~valid] = np.nan + return filled, valid + + +def _dilate(mask: np.ndarray) -> np.ndarray: + """8이웃 1스텝 팽창(외부는 False).""" + padded = np.zeros((mask.shape[0] + 2, mask.shape[1] + 2), dtype=bool) + padded[1:-1, 1:-1] = mask + result = np.zeros_like(mask) + for row_shift in (0, 1, 2): + for col_shift in (0, 1, 2): + result |= padded[ + row_shift : row_shift + mask.shape[0], col_shift : col_shift + mask.shape[1] + ] + return result + + +def _has_lower_neighbour(surface: np.ndarray, valid: np.ndarray) -> np.ndarray: + """8이웃 중 자기보다 낮은 셀이 하나라도 있는지. 무효 셀은 +∞로 보아 제외한다.""" + rows, cols = surface.shape + padded = np.full((rows + 2, cols + 2), np.inf, dtype=np.float32) + padded[1:-1, 1:-1] = np.where(valid, surface, np.inf) + result = np.zeros((rows, cols), dtype=bool) + center = padded[1:-1, 1:-1] + for row_shift, col_shift, _ in _NEIGHBORS: + neighbour = padded[ + 1 + row_shift : 1 + row_shift + rows, 1 + col_shift : 1 + col_shift + cols + ] + result |= neighbour < center + return result & valid + + +# ── ⑥ D8 물 방향 ──────────────────────────────────────────────────────────── + + +def compute_receivers( + spec: GridSpec, surface: np.ndarray, valid: np.ndarray +) -> tuple[np.ndarray, np.ndarray]: + """셀마다 8방향 최급강하 이웃(수신 셀)을 정한다. + + 돌려주는 `receiver`는 평탄 인덱스(row * n_cols + col)다. 더 낮은 이웃이 없는 셀(싱크)과 + 무효 셀은 자기 자신을 가리켜 흐름이 그 자리에서 멈춘다. + """ + rows, cols = spec.n_rows, spec.n_cols + padded = np.full((rows + 2, cols + 2), np.inf, dtype=np.float32) + padded[1:-1, 1:-1] = np.where(valid, surface, np.inf) + center = padded[1:-1, 1:-1] + + flat_index = np.arange(rows * cols, dtype=np.int32).reshape(rows, cols) + padded_index = np.full((rows + 2, cols + 2), -1, dtype=np.int32) + padded_index[1:-1, 1:-1] = flat_index + + best_slope = np.zeros((rows, cols), dtype=np.float32) + receiver = flat_index.copy() + step = np.zeros((rows, cols), dtype=np.float32) + + for row_shift, col_shift, factor in _NEIGHBORS: + neighbour = padded[ + 1 + row_shift : 1 + row_shift + rows, 1 + col_shift : 1 + col_shift + cols + ] + distance = np.float32(factor * spec.cell_m) + # 무효 셀끼리는 ∞−∞ = NaN이 되지만 아래 isfinite에서 걸러진다. + with np.errstate(invalid="ignore"): + slope = (center - neighbour) / distance + better = np.isfinite(slope) & (slope > best_slope) + if not better.any(): + continue + best_slope = np.where(better, slope, best_slope) + neighbour_index = padded_index[ + 1 + row_shift : 1 + row_shift + rows, 1 + col_shift : 1 + col_shift + cols + ] + receiver = np.where(better, neighbour_index, receiver) + step = np.where(better, distance, step) + return receiver.reshape(-1), step.reshape(-1) + + +# ── 오케스트레이션 ────────────────────────────────────────────────────────── + + +def build_terrain_grid(spec: GridSpec, cloud: ContourCloud) -> TerrainGrid: + """격자 범위와 등고선 구름으로 지형 해석 격자를 만든다.""" + surface = interpolate_elevation(spec, cloud) + conditioned, valid = condition_surface(surface) + receiver, step = compute_receivers(spec, conditioned, valid) + logger.info( + "배수유역: 격자 %d×%d (%.1fm), 유효 셀 %d개", + spec.n_rows, + spec.n_cols, + spec.cell_m, + int(valid.sum()), + ) + return TerrainGrid( + spec=spec, elevation=conditioned, valid=valid, receiver=receiver, step_length=step + ) + + +def route_elevation_floor(route_z_values: list[float]) -> float | None: + """계획선 최저점에서 여유를 뺀 등고선 하한. 값이 없으면 None(필터 미적용).""" + finite = [value for value in route_z_values if math.isfinite(value) and value != 0.0] + if not finite: + return None + return min(finite) - DRAINAGE_CONTOUR_MARGIN_M diff --git a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py index f489eed8..01b8b369 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py +++ b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py @@ -1,9 +1,11 @@ """배수유역도 API 라우터. -구조물 측점(관 매설) 후보 제안과 배수유역 산정을 제공한다. 지형 근거는 도엽 등고선·세류선· -표고점 GeoJSON뿐이며, 좌표는 사업지 CRS(m)에서 계산하고 응답만 WGS84로 바꿔 내보낸다. +구조물 측점(관 매설) 후보 제안과 배수유역 산정을 제공한다. 지형 근거는 **도엽 등고선과 +세류선 GeoJSON**뿐이며(표고점은 유효 데이터가 적어 2026-07-31 사용자 지시로 제외), +좌표는 사업지 CRS(m)에서 계산하고 응답만 WGS84로 바꿔 내보낸다. """ +import asyncio import json import logging from pathlib import Path @@ -20,7 +22,7 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import ( build_route_vertices, propose_structure_stations, ) -from B05_wf2_Route.B05_wf2_Route_Engine_Drainage_Watershed import build_watershed_basins +from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Basin import build_drainage_watershed from B05_wf2_Route.B05_wf2_Route_Repository import ( get_latest_route, get_route_points, @@ -28,6 +30,7 @@ from B05_wf2_Route.B05_wf2_Route_Repository import ( ) from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool +from config.config_system import DRAINAGE_CACHE_DIRNAME, DRAINAGE_CACHE_FILENAME logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B05 Route Drainage"]) @@ -35,15 +38,18 @@ router = APIRouter(prefix="/api/projects", tags=["B05 Route Drainage"]) # 도엽 레이어 파일명 (B04 전처리 산출물과 동일 위치) _CONTOUR_FILE = "도엽_등고선.geojson" _STREAM_FILE = "도엽_하천중심선.geojson" -_SPOT_FILE = "도엽_표고점.geojson" -# 표고 속성 키: 도엽 등고선(등고수치)·gpkg 등고선(CTRLN_HG)·표고점(수치/표고) 통합. -_ELEVATION_KEYS = ("등고수치", "CTRLN_HG", "수치", "표고", "높이", "elevation", "ELEV") def _sheet_dir(stored_path: str) -> Path: return Path(resolve_stored_project_path(stored_path)) / "B04_wf1_Surface" / "processed" +def _cache_path(stored_path: str) -> Path: + """격자 해석 캐시(.npz) 경로. 관을 옮겨도 격자를 다시 풀지 않게 여기에 남긴다.""" + root = Path(resolve_stored_project_path(stored_path)) / "B05_wf2_Route" + return root / DRAINAGE_CACHE_DIRNAME / DRAINAGE_CACHE_FILENAME + + def _load_features(directory: Path, filename: str) -> list[dict[str, Any]]: """도엽 GeoJSON을 읽어 피처 목록만 돌려준다. 없으면 빈 목록.""" path = directory / filename @@ -145,15 +151,12 @@ async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse: contour_features = _reproject_features( _load_features(directory, _CONTOUR_FILE), to_metric_transformer ) - spot_features = _reproject_features( - _load_features(directory, _SPOT_FILE), to_metric_transformer - ) return { "route_id": int(route["id"]), "vertices": vertices, "streams": streams, "contours": contour_features, - "spots": spot_features, + "cache_path": _cache_path(stored_path), "to_lonlat": lambda x, y: to_lonlat_transformer.transform(x, y), } @@ -179,27 +182,26 @@ async def post_drainage_basins( project_id: UUID, payload: dict[str, Any] | None = None, ) -> dict[str, Any] | JSONResponse: - """확정된 구조물 측점별 배수유역을 산정한다. + """격자 흐름 해석으로 배수유역과 관 배치를 산정한다. - payload에 `chainages`(누가거리 목록)를 주면 그 위치로 확정하고, 없으면 자동 제안분을 쓴다. + payload에 `chainages`(누가거리 목록)를 주면 그 위치로 관을 확정하고, 없으면 세류 + 교차 + 최소 보충으로 자동 배치한다. 격자 해석은 `.npz` 캐시를 재사용하므로 관만 + 옮기는 재요청은 세부유역 분할만 다시 돈다. """ prepared = await _prepare(project_id) if isinstance(prepared, JSONResponse): return prepared - vertices = prepared["vertices"] - chainages = (payload or {}).get("chainages") - if isinstance(chainages, list) and chainages: - candidates = _candidates_from_chainages(vertices, chainages) - else: - candidates = propose_structure_stations(vertices, prepared["streams"]) + raw_chainages = (payload or {}).get("chainages") + confirmed = _parse_chainages(raw_chainages) if isinstance(raw_chainages, list) else [] - basins = build_watershed_basins( - vertices, - candidates, + # 격자 해석은 수백만 셀 numpy 연산이라 이벤트 루프를 막지 않도록 스레드로 뺀다. + result = await asyncio.to_thread( + build_drainage_watershed, + prepared["vertices"], prepared["contours"], - prepared["spots"], - _ELEVATION_KEYS, - stream_features=prepared["streams"], + prepared["streams"], + confirmed, + prepared["cache_path"], ) to_lonlat = prepared["to_lonlat"] return { @@ -207,17 +209,20 @@ async def post_drainage_basins( "project_id": str(project_id), "route_id": prepared["route_id"], # 계획선 위 배관(관 매설) 지점 — 유역이 없는 관도 마커로 표시해야 하므로 별도 목록. - "pipes": [ - _candidate_payload(candidate, to_lonlat) - for candidate in sorted(candidates, key=lambda item: item.chainage_m) + "pipes": [_candidate_payload(candidate, to_lonlat) for candidate in result.pipes], + # 2차 전체 배수유역 외곽선 = 분수령. 세부유역은 전부 이 안쪽에 들어간다. + "main_polygon_lonlat": [list(to_lonlat(x, y)) for x, y in result.main_boundary_xy], + # 도로 위 흐름 강도 — [누가거리 m, 그 지점으로 모이는 상류 면적 ㎡]. + "strength_profile": [ + [round(chainage, 1), round(area, 1)] for chainage, area in result.strength_profile ], + "grid_cell_m": result.grid_cell_m, "basins": [ { "index": basin.index, "chainage_m": round(basin.chainage_m, 2), # 관(배관) 매설 지점 좌표 — 계획선 위 마커 렌더용. "outlet_lonlat": list(to_lonlat(basin.outlet_x, basin.outlet_y)), - # 유역 경계 외곽선 = 분수령(능선). 프론트가 파스텔 채움 + 능선 파선으로 표시한다. "polygon_lonlat": [list(to_lonlat(x, y)) for x, y in basin.boundary_xy], "area_m2": round(basin.area_m2, 1), "relief_m": round(basin.relief_m, 2), @@ -225,21 +230,17 @@ async def post_drainage_basins( # 관경 수식 미확정 — 산정 함수가 None을 돌려주면 프론트가 "미정"으로 표기한다. "pipe_diameter_mm": basin.pipe_diameter_mm, } - for basin in basins + for basin in result.basins ], } -def _candidates_from_chainages(vertices: Any, chainages: list[Any]) -> list[StructureCandidate]: - """사용자가 확정한 누가거리 목록을 후보 구조로 되돌린다.""" - from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import _interpolate_vertex - - candidates: list[StructureCandidate] = [] - for value in chainages: +def _parse_chainages(values: list[Any]) -> list[float]: + """사용자가 확정·편집한 누가거리 목록을 숫자로 정리한다.""" + parsed: list[float] = [] + for value in values: try: - chainage = float(value) + parsed.append(float(value)) except (TypeError, ValueError): continue - x, y, _ = _interpolate_vertex(vertices, chainage) - candidates.append(StructureCandidate(chainage_m=chainage, x=x, y=y, reason="confirmed")) - return candidates + return parsed diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts index 8f6a6391..eba48bd3 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -31,20 +31,19 @@ import { createPipeEditor } from "./B05_wf2_Route_UI_Drainage_Pipes"; // 지도는 B04에서 분리한 렌더 엔진(B04_wf1_Surface_UI_MapRender)을 그대로 재사용해 // 사전 투영·LOD·뷰포트 컬링·커서 중심 줌 동작을 동일하게 얻는다. -/** 배수유역 산정의 근거가 되는 도엽 레이어. 3D는 쓰지 않는다(사용자 지시). */ -const DRAINAGE_LAYERS = ["도엽_등고선", "도엽_하천중심선", "도엽_표고점"] as const; +/** 배수유역 산정의 근거가 되는 도엽 레이어. 3D는 쓰지 않는다(사용자 지시). + * 표고점은 유효 데이터가 적어 산정에서 제외했으므로 배경에도 띄우지 않는다(2026-07-31). */ +const DRAINAGE_LAYERS = ["도엽_등고선", "도엽_하천중심선"] as const; type DrainageLayer = (typeof DRAINAGE_LAYERS)[number]; const LAYER_COLORS: Record = { 도엽_등고선: "#a5b4fc", 도엽_하천중심선: "#2563eb", - 도엽_표고점: "#f9a8d4", }; const LAYER_LABELS: Record = { 도엽_등고선: "등고선", 도엽_하천중심선: "세류", - 도엽_표고점: "표고점", }; const ROUTE_COLOR = "#f97316"; @@ -84,11 +83,13 @@ export function createDrainagePanel(): DrainagePanel { layerButtons.className = "b05-drainage__layers"; header.append(title, layerButtons); - // 유역 산정 실행 버튼 — 후보 제안·유역 산정을 한 번에 돌린다(자동 제안 + 사용자 확인 흐름). + // 배수유역 계산 실행 — 등고선 격자 흐름 해석으로 유역·관 위치를 한 번에 산정한다. + // (격자 해석 결과는 백엔드가 캐시하므로 관만 바꾼 재계산은 즉시 끝난다.) const analyzeButton = document.createElement("button"); analyzeButton.type = "button"; analyzeButton.className = "b05-drainage__analyze"; - analyzeButton.textContent = "유역 산정"; + analyzeButton.textContent = "배수유역 계산"; + analyzeButton.title = "등고선·세류선으로 배수유역과 관 매설 위치를 다시 계산합니다."; // 배관 편집 토글 — 켜면 계획선 클릭으로 배관 추가, 마커 드래그로 이동. const editButton = document.createElement("button"); editButton.type = "button"; @@ -141,8 +142,9 @@ export function createDrainagePanel(): DrainagePanel { syncPipeSelection(); scheduleDraw(); }); - // 유역 경계 외곽선 = 분수령(능선). 사용자 지시로 기본 표시. - let showRidge = true; + // 2차 전체 배수유역 외곽선 = 분수령. 별도 "능선" 레이어를 두지 않고 이 선 하나로 표시한다 + // (전체 유역 외곽선과 능선이 같은 선이므로 — 2026-07-31 사용자 지시). + let mainBoundary: Array<[number, number]> = []; let scale = 1; let offsetX = 0; let offsetY = 0; @@ -171,21 +173,6 @@ export function createDrainagePanel(): DrainagePanel { layerButtons.append(button); }); - // 능선(분수령) 표시 토글 — 유역 경계 파선. 기본 켜짐(사용자 지시). - const ridgeButton = document.createElement("button"); - ridgeButton.type = "button"; - ridgeButton.className = "b05-drainage__layer-button is-active"; - ridgeButton.textContent = "능선"; - ridgeButton.style.setProperty("--b05-layer-color", "#92400e"); - ridgeButton.setAttribute("aria-pressed", "true"); - ridgeButton.addEventListener("click", () => { - showRidge = !showRidge; - ridgeButton.classList.toggle("is-active", showRidge); - ridgeButton.setAttribute("aria-pressed", String(showRidge)); - scheduleDraw(); - }); - layerButtons.append(ridgeButton); - function updateImageTransform(): void { backgroundImage.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`; } @@ -210,7 +197,7 @@ export function createDrainagePanel(): DrainagePanel { context.clearRect(0, 0, width, height); const mapRect: MapRect = computeMapRect(meta, width, height); const view: ViewState = { width, height, scale, offsetX, offsetY, mapRect }; - // 유역 채움을 가장 아래에 깔아 등고선·세류 판독을 가리지 않게 한다. + // 세부유역 채움을 가장 아래에 깔아 등고선·세류 판독을 가리지 않게 한다. if (normalizer) { basins.forEach((basin) => { const color = BASIN_COLORS[(basin.index - 1) % BASIN_COLORS.length]; @@ -223,25 +210,26 @@ export function createDrainagePanel(): DrainagePanel { ? color : color.replace(/0\.45\)$/, "0.18)"), ); - // 유역 경계 = 분수령이므로 그 외곽선을 능선 파선으로 강조한다. - if (showRidge) drawRidgeRing(context, basin.polygon_lonlat, normalizer!, view); }); + // 전체 유역 외곽선 = 분수령(능선). 세부유역 경계와 구분되게 파선 한 겹만 얹는다. + if (mainBoundary.length > 2) drawRidgeRing(context, mainBoundary, normalizer, view); } - // 등고선을 얇게 깔고 세류·표고점을 그 위에, 노선을 맨 위에 둔다. + // 등고선을 얇게 깔고 세류를 그 위에, 노선을 맨 위에 둔다. DRAINAGE_LAYERS.forEach((layer) => { if (!activeLayers.has(layer)) return; const prepared = preparedLayers.get(layer); if (!prepared) return; context.lineWidth = layer === "도엽_등고선" ? 0.7 : 1.5; context.strokeStyle = LAYER_COLORS[layer]; - drawPreparedLayer(context, prepared, view, layer === "도엽_표고점" ? "x" : "dot"); + drawPreparedLayer(context, prepared, view, "dot"); }); if (routeLayer) { context.lineWidth = 2.4; context.strokeStyle = ROUTE_COLOR; drawPreparedLayer(context, routeLayer, view, "dot"); } - // 배관(관 매설) 마커 — 계획선 위 최상단. + // 계획선 위 흐름 강도 띠 → 그 위에 배관 마커. + pipeEditor.drawStrength(context, view); pipeEditor.draw(context, view, pipeColor); updateImageTransform(); } @@ -327,6 +315,7 @@ export function createDrainagePanel(): DrainagePanel { const chainages = !auto && pipeEditor.pipes().length > 0 ? pipeEditor.chainages() : undefined; const response = await fetchDrainageBasins(projectId, chainages); basins = response.basins; + mainBoundary = response.main_polygon_lonlat ?? []; // 실제 사용된 배관 목록으로 마커를 동기화한다(유역 없는 관 포함). pipeEditor.setPipes( (response.pipes ?? []).map((pipe) => ({ @@ -334,6 +323,7 @@ export function createDrainagePanel(): DrainagePanel { reason: pipe.reason, })), ); + pipeEditor.setStrength(response.strength_profile ?? []); selectedBasin = null; renderBasinList(); syncPipeSelection(); diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts index a7ab7101..52c14e29 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts @@ -24,6 +24,8 @@ const ADD_SNAP_PX = 14; export interface PipeEditor { setContext(meta: VWorldMeta | null, points: ReadonlyArray): void; setPipes(pipes: ReadonlyArray): void; + /** 도로 위 흐름 강도 [누가거리 m, 상류 면적 ㎡]. 관 추가 판단 근거로 계획선에 덧그린다. */ + setStrength(profile: ReadonlyArray): void; pipes(): ReadonlyArray; chainages(): number[]; selected(): number | null; @@ -33,6 +35,8 @@ export interface PipeEditor { handleDown(view: ViewState, screenX: number, screenY: number, editMode: boolean): boolean; handleMove(view: ViewState, screenX: number, screenY: number): boolean; handleUp(): boolean; + /** 계획선 위 흐름 강도 띠. 마커보다 아래에 깔아야 하므로 draw()와 따로 호출한다. */ + drawStrength(context: CanvasRenderingContext2D, view: ViewState): void; draw( context: CanvasRenderingContext2D, view: ViewState, @@ -48,6 +52,9 @@ export function createPipeEditor(onChange: () => void): PipeEditor { let selectedIndex: number | null = null; let draggingIndex: number | null = null; let dragMoved = false; + let strength: Array = []; + let strengthPeak = 0; + let strengthSpan = 5; /** 화면 → 사업지 좌표계 m (MapRender affine의 역변환). */ function screenToMetric( @@ -146,6 +153,17 @@ export function createPipeEditor(onChange: () => void): PipeEditor { selectedIndex = null; draggingIndex = null; }, + setStrength(profile) { + strength = profile.map((entry) => [entry[0], entry[1]] as const); + strengthPeak = strength.reduce((peak, entry) => Math.max(peak, entry[1]), 0); + // 표본 간격은 백엔드 출력 간격을 그대로 따른다(값이 0인 구간은 빠져 있으므로 최소 간격 사용). + let span = Infinity; + for (let i = 1; i < strength.length; i += 1) { + const gap = strength[i][0] - strength[i - 1][0]; + if (gap > 0 && gap < span) span = gap; + } + strengthSpan = Number.isFinite(span) ? span : 5; + }, pipes: () => pipeList, chainages: () => pipeList.map((pipe) => Math.round(pipe.chainage_m * 100) / 100), selected: () => selectedIndex, @@ -211,6 +229,28 @@ export function createPipeEditor(onChange: () => void): PipeEditor { } return true; }, + drawStrength(context, view) { + if (strengthPeak <= 0 || route.length < 2) return; + context.save(); + context.lineCap = "butt"; + strength.forEach(([chainage, area]) => { + const from = chainageToXY(chainage); + const to = chainageToXY(Math.min(totalChainage, chainage + strengthSpan)); + if (!from || !to) return; + const start = metricToScreen(view, from.x, from.y); + const end = metricToScreen(view, to.x, to.y); + if (!start || !end) return; + // 강도는 편차가 커서(계곡 한 점에 수십 배 집중) 제곱근으로 눌러 표시한다. + const intensity = Math.sqrt(area / strengthPeak); + context.beginPath(); + context.moveTo(start.x, start.y); + context.lineTo(end.x, end.y); + context.lineWidth = 3 + 9 * intensity; + context.strokeStyle = `rgba(37, 99, 235, ${(0.15 + 0.5 * intensity).toFixed(3)})`; + context.stroke(); + }); + context.restore(); + }, draw(context, view, colorOf) { pipeList.forEach((pipe, position) => { const xy = chainageToXY(pipe.chainage_m); diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py b/B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Drainage_Watershed.py similarity index 100% rename from B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Watershed.py rename to B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Drainage_Watershed.py diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Assemble.py b/B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Watershed_Assemble.py similarity index 100% rename from B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Assemble.py rename to B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Watershed_Assemble.py diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Subdivide.py b/B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Watershed_Subdivide.py similarity index 100% rename from B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Subdivide.py rename to B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Watershed_Subdivide.py diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Trace.py b/B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Watershed_Trace.py similarity index 100% rename from B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Trace.py rename to B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Watershed_Trace.py diff --git a/B05_wf2_Route/_legacy_watershed/README.md b/B05_wf2_Route/_legacy_watershed/README.md new file mode 100644 index 00000000..7eab5969 --- /dev/null +++ b/B05_wf2_Route/_legacy_watershed/README.md @@ -0,0 +1,16 @@ +# _legacy_watershed (보관용, 실행 경로 아님) + +2026-07-31 배수유역 전면 재설계로 폐기된 **등고선 아크 추적 + 능선 행진** 방식 엔진 4종이다. +능선/계곡 분리가 안정적이지 않아 격자 흐름(D8 + 상류 BFS) 방식으로 교체되었다. + +| 파일 | 폐기 당시 역할 | +|---|---| +| `B05_wf2_Route_Engine_Drainage_Watershed.py` | 유역 산정 오케스트레이터 (`build_watershed_basins`) | +| `B05_wf2_Route_Engine_Watershed_Trace.py` | 등고선 아크 인덱싱·분수계 행진 | +| `B05_wf2_Route_Engine_Watershed_Assemble.py` | 아크+능선+도로선 폐합 폴리곤 조립 | +| `B05_wf2_Route_Engine_Watershed_Subdivide.py` | 메인 유역 내부 세부유역 분할 | + +**주의** +- 내용은 이동 당시 그대로이며 수정하지 않는다. 서로를 `B05_wf2_Route.B05_wf2_Route_Engine_Watershed_*` + 경로로 import하므로 이 폴더에서는 그대로 실행되지 않는다(의도된 상태 — 참고용 보관). +- 현행 엔진: `B05_wf2_Route_Engine_Watershed_Grid.py` / `_Flow.py` / `_Basin.py`. diff --git a/config/config_system.py b/config/config_system.py index 9a5bd69f..8710eb23 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -232,6 +232,49 @@ SKELETON_MAIN_RIDGE_ACC_THRESHOLD_CELLS = int( SKELETON_NODE_SPACING_M = float(os.getenv("SKELETON_NODE_SPACING_M", "10.0")) +# ───────────────────────────────────────────────────────────────────────── +# 5-3-1. 배수유역 격자 해석 파라미터 (B05 WF2 — 2026-07-31 전면 재설계) +# +# 도엽 등고선 TIN 보간 → 웅덩이 채움 → D8 물 방향 → 도로에서 상류 BFS(포인터 더블링) +# 순서로 유역을 정한다. 능선을 따로 탐지하지 않는다 — 도로로 물이 도달하는지 여부가 +# 유일한 판정 기준이며, 그 경계가 곧 능선이다. +# 라이다 DEM은 노선 주변만 커버해 유역 산정에 부족하므로 쓰지 않는다(사용자 지시). +# ───────────────────────────────────────────────────────────────────────── +# 해석 격자 한 변(m). 작을수록 정밀하나 셀 수가 제곱으로 늘어난다. +DRAINAGE_GRID_SIZE_M = float(os.getenv("DRAINAGE_GRID_SIZE_M", "1.0")) +# 1차 배수유역 반경(m). 정리된 세류선과 노선을 이 반경으로 버퍼해 초기 해석 범위를 잡는다. +DRAINAGE_INITIAL_RADIUS_M = float(os.getenv("DRAINAGE_INITIAL_RADIUS_M", "300.0")) +# 활성 셀이 격자 최외곽에 닿았을 때 한 번에 넓히는 폭(m). +DRAINAGE_EXPAND_STEP_M = float(os.getenv("DRAINAGE_EXPAND_STEP_M", "200.0")) +# 확장 반복 상한. 경계 링이 전부 비활성이 되면 그 전에 스스로 멈춘다(안전핀). +DRAINAGE_MAX_EXPAND_ROUNDS = int(os.getenv("DRAINAGE_MAX_EXPAND_ROUNDS", "6")) +# 격자 셀 수 상한. 초과하면 셀 크기를 자동으로 키워 맞춘다(메모리 보호). +DRAINAGE_MAX_GRID_CELLS = int(os.getenv("DRAINAGE_MAX_GRID_CELLS", "16000000")) +# 도로 폭(m). 이 폭으로 노선을 격자에 구워 D8 흐름이 도로를 대각선으로 건너뛰지 못하게 한다. +DRAINAGE_ROAD_WIDTH_M = float(os.getenv("DRAINAGE_ROAD_WIDTH_M", "4.0")) +# 계획선 최저점보다 이만큼 아래인 등고선은 상류 기여가 불가능하므로 보간에서 제외한다. +DRAINAGE_CONTOUR_MARGIN_M = float(os.getenv("DRAINAGE_CONTOUR_MARGIN_M", "10.0")) +# 이보다 짧은 등고선 파편은 노이즈로 보고 버린다. 봉우리 폐합 등고선은 이 값 이상이면 남는다. +DRAINAGE_CONTOUR_MIN_LENGTH_M = float(os.getenv("DRAINAGE_CONTOUR_MIN_LENGTH_M", "20.0")) +# 등고선 정점 재샘플 간격(m). 조밀할수록 TIN이 정확하나 Delaunay 비용이 커진다. +DRAINAGE_CONTOUR_RESAMPLE_M = float(os.getenv("DRAINAGE_CONTOUR_RESAMPLE_M", "5.0")) +# 평탄면 해소용 미세 경사(m/셀). 채움 후 흐름 방향이 없는 셀에 출구 쪽 경사를 만들어 준다. +DRAINAGE_FLAT_EPSILON_M = float(os.getenv("DRAINAGE_FLAT_EPSILON_M", "0.001")) +# 관 매설 최대 간격(m). 이 간격을 넘으면 흐름 강도가 가장 큰 지점에 관을 보충한다. +DRAINAGE_PIPE_MAX_SPACING_M = float(os.getenv("DRAINAGE_PIPE_MAX_SPACING_M", "300.0")) +# 관끼리 이보다 가까우면 같은 계곡으로 보고 하나로 합친다. +DRAINAGE_PIPE_MIN_SPACING_M = float(os.getenv("DRAINAGE_PIPE_MIN_SPACING_M", "20.0")) +# 측구 흐름(도로 셀 → 담당 관) 판정용 종단 계획선 샘플 간격(m). +DRAINAGE_DITCH_SAMPLE_M = float(os.getenv("DRAINAGE_DITCH_SAMPLE_M", "1.0")) +# 유역 폴리곤 단순화 허용오차(m). 격자 계단 경계를 매끄럽게 줄여 응답 크기를 낮춘다. +DRAINAGE_POLYGON_SIMPLIFY_M = float(os.getenv("DRAINAGE_POLYGON_SIMPLIFY_M", "2.0")) +# 이 면적(㎡) 미만의 유역 조각은 버린다(격자 노이즈 제거). +DRAINAGE_MIN_BASIN_AREA_M2 = float(os.getenv("DRAINAGE_MIN_BASIN_AREA_M2", "100.0")) +# 격자 해석 결과 캐시 파일명. 프로젝트 저장소의 B05_wf2_Route/drainage/ 아래에 놓인다. +DRAINAGE_CACHE_DIRNAME = "drainage" +DRAINAGE_CACHE_FILENAME = "watershed_grid.npz" + + # ───────────────────────────────────────────────────────────────────────── # 5-4. 종횡단 생성 파라미터 (B06 WF3) # ───────────────────────────────────────────────────────────────────────── diff --git a/pyproject.toml b/pyproject.toml index e3b1d9df..831e3b93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,8 @@ [tool.ruff] target-version = "py313" line-length = 100 +# 폐기 엔진 보관 폴더는 이동 당시 원본 그대로 두기로 했으므로 린트/포맷 대상에서 뺀다. +extend-exclude = ["B05_wf2_Route/_legacy_watershed"] [tool.ruff.lint] select = ["E", "F", "I"] From e1b410c391dc07acc39ec9f4d33b55f93651c0a5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 31 Jul 2026 17:44:53 +0900 Subject: [PATCH 28/61] =?UTF-8?q?feat(B05):=201=EC=B0=A8=20=EB=B0=B0?= =?UTF-8?q?=EC=88=98=EC=9C=A0=EC=97=AD=EC=9D=84=20=EC=83=81=EB=A5=98=20?= =?UTF-8?q?=EC=84=B8=EB=A5=98=EB=A7=9D=20=EA=B8=B0=EC=A4=80=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9E=AC=EC=A0=95=EC=9D=98=20+=20=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=20=EA=B2=80=EC=A6=9D=20=ED=99=94=EB=A9=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1차 영역이 도로 전체 버퍼까지 포함해 도로 아래(하류)로 퍼지고, 세류선을 피처 단위로만 갈라 상류망이 통째로 누락되던 문제를 고친다. - 1차 영역 = 도로 교차점 상류로 이어진 세류망만 반경 버퍼. 노선은 버퍼하지 않음. 노선이 영역 밖으로 나간 길이(road_outside_m)를 재서 반경 판단 근거로 노출. - 상류 판정을 연결망 기준으로 교체: unary_union 노딩 -> 도로에서 절단 -> 끝점 그래프 -> 도로 교차 노드를 통과하지 않는 확산. T자로 붙은 지류와 2단계 이상 이어진 지류까지 상류망으로 따라간다. - 상하류 판정 표고를 최근접 등고선 정점에서 TIN 선형보간으로 교체 (ElevationSampler). 최근접 정점은 오차가 등고선 간격만큼 나서 계곡 교차점이 한 등고선 위로 잡히고 상류 조각이 전부 하류로 오판됐다. - 격자 크기 자동 강등 삭제 - config 값을 그대로 쓴다. 셀 수가 많으면 경고만. 자동 강등이 도로 굽기 두께 전제를 조용히 깨뜨렸다. - TIN 삼각망을 격자 범위 + 여유로 클리핑. 결과 동일, 속도만 개선. - 기본 반경 300m -> 50m (단계 검증용). 단계 검증 수단 - GET /drainage/primary-region : TIN/흐름 계산 없이 상류망/하류망/1차영역/격자만 반환 - 프론트 1차영역 버튼 : 상류망(굵은 파랑), 하류망(회색 파선), 1차 영역(초록 채움), 해석 격자를 실제 셀 눈금으로 렌더. 켤 때마다 재요청한다. - 클릭 시 storage/{project}/B05_wf2_Route/drainage/primary_region.geojson 저장 합성 검증(T자 지류 + 2단계 지류 + 하류 지류 + 고아 세류): 상류망 4조각 채택 / 하류망 3조각 / 미연결 1개 제외 - 전부 기대대로. Co-Authored-By: Claude Fable 5 --- B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts | 38 ++ .../B05_wf2_Route_Engine_Watershed_Basin.py | 61 +++- .../B05_wf2_Route_Engine_Watershed_Grid.py | 335 ++++++++++++++---- .../B05_wf2_Route_Router_Drainage.py | 132 ++++++- .../B05_wf2_Route_UI_Drainage_Panel.ts | 185 +++++++++- config/config_system.py | 13 +- 6 files changed, 685 insertions(+), 79 deletions(-) diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index d28e705b..75e6841a 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -296,6 +296,44 @@ export interface DrainageBasinResponse { basins: DrainageBasin[]; } +/** 1차 배수유역 근거(단계 검증용). TIN·흐름 계산 없이 세류 상·하류 판정과 격자 범위만 준다. */ +export interface DrainagePrimaryRegion { + status: string; + project_id: string; + route_id: number; + radius_m: number; + /** 도로와 만난 세류선의 상류측 = 1차 영역의 기준선. */ + upstream_lines: Array>; + /** 교차했으나 하류로 판정해 제외한 조각. 판정이 맞는지 눈으로 대조하는 용도. */ + downstream_lines: Array>; + /** 상·하류 어느 망에도 이어지지 않아 제외한 세류 조각 수. */ + no_contact_count: number; + /** 노선이 1차 영역 밖으로 나간 길이(m). 크면 반경을 올려야 한다는 신호. */ + road_outside_m: number; + /** 1차 영역(상류 세류망 버퍼 합집합)의 외곽 링 목록. */ + region_rings: Array>; + grid: { + cell_m: number; + rows: number; + cols: number; + cells: number; + width_m: number; + height_m: number; + /** 격자 bbox 링. 화면은 여기에 rows×cols 간격으로 실제 셀을 그린다. */ + bbox_lonlat: Array<[number, number]>; + }; + /** 영구저장소에 남긴 검증용 GeoJSON 경로. */ + saved_to: string | null; +} + +export async function fetchDrainagePrimaryRegion( + projectId: string, +): Promise { + return requestJson(`/projects/${projectId}/drainage/primary-region`, { + method: "GET", + }); +} + export async function fetchDrainageCandidates( projectId: string, ): Promise { diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py index b3a54450..d98a9fea 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py @@ -45,12 +45,12 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Flow import ( ) from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import ( GridSpec, + PrimaryRegion, build_contour_cloud, - build_grid_spec, + build_primary_region, build_terrain_grid, expand_grid_spec, route_elevation_floor, - select_upstream_streams, ) from config.config_system import ( DRAINAGE_DITCH_SAMPLE_M, @@ -164,7 +164,44 @@ def build_drainage_watershed( ) -# ── ①~④ 격자 해석 (캐시 대상) ─────────────────────────────────────────────── +# ── ①~② 1차 배수유역 (단계 검증 대상) ────────────────────────────────────── + + +def resolve_primary_region( + vertices: list[RouteVertex], + route_line: LineString, + contour_features: list[dict[str, Any]], + stream_features: list[dict[str, Any]], +) -> PrimaryRegion | None: + """도로 교차 세류선(상류측)과 노선을 반경 버퍼한 1차 배수유역과 격자 범위를 정한다. + + 상·하류 판정에 쓸 등고선은 노선 주변만 있으면 된다(교차점이 전부 노선 위이므로). + 도엽 전체를 읽으면 이 단계에서만 수십 초가 날아간다. + """ + floor = route_elevation_floor([vertex.z for vertex in vertices]) + near_bounds = route_line.buffer(DRAINAGE_INITIAL_RADIUS_M * 2.0).bounds + cloud = build_contour_cloud(contour_features, floor, near_bounds) + if cloud.is_empty: + logger.warning("배수유역: 노선 주변에 등고선이 없어 1차 영역을 정할 수 없습니다.") + return None + return build_primary_region( + route_line, stream_features, cloud, DRAINAGE_INITIAL_RADIUS_M, DRAINAGE_GRID_SIZE_M + ) + + +def preview_primary_region( + vertices: list[RouteVertex], + contour_features: list[dict[str, Any]], + stream_features: list[dict[str, Any]], +) -> PrimaryRegion | None: + """단계 검증용 — TIN·흐름 계산 없이 1차 배수유역 근거만 뽑는다.""" + if len(vertices) < 2: + return None + route_line = LineString([(vertex.x, vertex.y) for vertex in vertices]) + return resolve_primary_region(vertices, route_line, contour_features, stream_features) + + +# ── ③~④ 격자 해석 (캐시 대상) ─────────────────────────────────────────────── def _solve_grid( @@ -180,13 +217,21 @@ def _solve_grid( logger.info("배수유역: 격자 캐시 재사용 (%s)", cache_path) return cached - floor = route_elevation_floor([vertex.z for vertex in vertices]) - cloud = build_contour_cloud(contour_features, floor) + region = resolve_primary_region(vertices, route_line, contour_features, stream_features) + if region is None: + return None + spec = region.spec + # TIN용 등고선은 격자가 확장될 여지까지 한 번에 읽어 두고, 회차마다 범위 안쪽만 골라 쓴다. + reach = DRAINAGE_EXPAND_STEP_M * DRAINAGE_MAX_EXPAND_ROUNDS + x_min, y_min, x_max, y_max = region.area.bounds + cloud = build_contour_cloud( + contour_features, + route_elevation_floor([vertex.z for vertex in vertices]), + (x_min - reach, y_min - reach, x_max + reach, y_max + reach), + ) if cloud.is_empty: - logger.warning("배수유역: 등고선이 없어 격자 해석을 건너뜁니다.") + logger.warning("배수유역: 1차 영역 안에 등고선이 없어 격자 해석을 건너뜁니다.") return None - streams = select_upstream_streams(route_line, stream_features, cloud) - spec = build_grid_spec(route_line, streams, DRAINAGE_INITIAL_RADIUS_M, DRAINAGE_GRID_SIZE_M) terrain = road = flow = None for round_index in range(DRAINAGE_MAX_EXPAND_ROUNDS + 1): diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py index 54f4a7ad..b8c2c2e3 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py @@ -19,7 +19,7 @@ from __future__ import annotations import logging import math -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any import numpy as np @@ -27,11 +27,12 @@ from scipy.interpolate import LinearNDInterpolator from scipy.ndimage import distance_transform_edt from scipy.spatial import cKDTree from shapely import segmentize -from shapely.geometry import LineString, shape +from shapely.geometry import LineString, MultiPolygon, Polygon, shape from shapely.ops import substring, unary_union from skimage.morphology import reconstruction from config.config_system import ( + DRAINAGE_CONTOUR_CLIP_MARGIN_M, DRAINAGE_CONTOUR_MARGIN_M, DRAINAGE_CONTOUR_MIN_LENGTH_M, DRAINAGE_CONTOUR_RESAMPLE_M, @@ -144,18 +145,23 @@ def _iter_linestrings(geometry: Any) -> list[LineString]: def build_contour_cloud( contour_features: list[dict[str, Any]], elevation_floor_m: float | None = None, + clip_bounds: tuple[float, float, float, float] | None = None, ) -> ContourCloud: """등고선 피처를 표고가 붙은 정점 구름으로 바꾼다. `elevation_floor_m` 아래 등고선은 계획선 최저점보다 낮아 상류 기여가 불가능하므로 버린다. 길이가 짧은 파편도 노이즈로 보고 버리되, 임계 이상인 봉우리 폐합 등고선은 남긴다(봉우리 표고가 사라지면 그 일대 흐름 방향이 통째로 틀어진다). + + `clip_bounds`(x_min, y_min, x_max, y_max)를 주면 그 밖 등고선은 읽지 않는다. 도엽 + 전체 등고선을 다 물고 가면 TIN 삼각망 비용만 커지고 결과는 같다. """ xs: list[np.ndarray] = [] ys: list[np.ndarray] = [] zs: list[np.ndarray] = [] dropped_low = 0 dropped_short = 0 + dropped_outside = 0 for feature in contour_features: geometry = feature.get("geometry") if not geometry: @@ -170,6 +176,9 @@ def build_contour_cloud( parsed = shape(geometry) except Exception: # noqa: BLE001 - 손상된 피처는 건너뛴다 continue + if clip_bounds is not None and _outside_bounds(parsed.bounds, clip_bounds): + dropped_outside += 1 + continue for line in _iter_linestrings(parsed): if line.length < DRAINAGE_CONTOUR_MIN_LENGTH_M: dropped_short += 1 @@ -182,22 +191,30 @@ def build_contour_cloud( zs.append(np.full(coords.shape[0], elevation, dtype=np.float64)) if not xs: logger.warning( - "배수유역: 사용할 등고선이 없습니다(저지대 %d, 파편 %d 제외).", + "배수유역: 사용할 등고선이 없습니다(저지대 %d, 파편 %d, 범위밖 %d 제외).", dropped_low, dropped_short, + dropped_outside, ) return ContourCloud(np.zeros((0, 2)), np.zeros(0)) xy = np.column_stack((np.concatenate(xs), np.concatenate(ys))) z = np.concatenate(zs) logger.info( - "배수유역: 등고선 정점 %d개 (저지대 %d, 파편 %d 제외)", + "배수유역: 등고선 정점 %d개 (저지대 %d, 파편 %d, 범위밖 %d 제외)", xy.shape[0], dropped_low, dropped_short, + dropped_outside, ) return ContourCloud(xy, z) +def _outside_bounds( + bounds: tuple[float, float, float, float], clip: tuple[float, float, float, float] +) -> bool: + return bounds[2] < clip[0] or bounds[0] > clip[2] or bounds[3] < clip[1] or bounds[1] > clip[3] + + # ── ② 세류선 상류측만 남기기 ──────────────────────────────────────────────── @@ -206,14 +223,36 @@ def select_upstream_streams( stream_features: list[dict[str, Any]], cloud: ContourCloud, ) -> list[LineString]: - """노선과 교차하는 세류선을 교차점에서 잘라 상류(고지대)측만 돌려준다. + """도로 교차점 기준 상류측으로 이어진 세류 연결망 전체를 돌려준다.""" + return split_streams_at_road(route_line, stream_features, cloud).upstream - 노선과 만나지 않는 세류선은 판단 근거가 없으므로 그대로 남긴다 — 어차피 격자 해석에서 - 도로에 물이 닿지 않으면 비활성 처리된다. 상·하류 판정은 가장 가까운 등고선 정점의 - 표고 평균으로 한다(TIN은 이 시점에 아직 없다). + +@dataclass +class StreamSplit: + """세류망을 도로 교차점에서 상·하류로 가른 결과. 검증 화면이 그대로 그린다.""" + + upstream: list[LineString] = field(default_factory=list) # 채택 — 1차 영역의 기준 + downstream: list[LineString] = field(default_factory=list) # 도로 아래로 이어진 망 + no_contact: int = 0 # 어느 쪽에도 이어지지 않아 제외한 조각 수 + + +def split_streams_at_road( + route_line: LineString, + stream_features: list[dict[str, Any]], + cloud: ContourCloud, +) -> StreamSplit: + """세류망을 도로에서 끊고, 교차점 상류측으로 **이어진 망 전체**를 채택한다. + + 도엽 하천중심선은 피처가 잘게 쪼개져 있고 지류가 본류 중간에 T자로 붙는다. 그래서 + 피처 단위로 보면 상류망이 통째로 빠진다. 순서를 이렇게 잡는다: + + ① 세류선끼리 `unary_union`으로 노딩 — 중간에서 만나는 지류도 연결로 인식된다 + ② 도로 교차점에서 한 번 더 잘라 상·하류 조각을 물리적으로 분리한다 + ③ 끝점 그래프를 만들고, **도로 교차 노드는 통과하지 못하게** 막는다 + ④ 도로에 접한 조각을 교차점 표고와 비교해 상·하류 씨앗으로 정한다 + ⑤ 씨앗에서 퍼뜨려 이어진 망 전체를 채택 — 도로를 넘어가지 못하므로 상·하류가 섞이지 않는다 """ - tree = cKDTree(cloud.xy) if not cloud.is_empty else None - kept: list[LineString] = [] + lines: list[LineString] = [] for feature in stream_features: geometry = feature.get("geometry") if not geometry: @@ -222,46 +261,148 @@ def select_upstream_streams( parsed = shape(geometry) except Exception: # noqa: BLE001 continue - for line in _iter_linestrings(parsed): - if line.is_empty or line.length <= 0: - continue - if not line.intersects(route_line): - kept.append(line) - continue - kept.extend(_upstream_parts(line, route_line, tree, cloud)) - return kept + lines.extend(line for line in _iter_linestrings(parsed) if line.length > 0) + if not lines: + return StreamSplit() + pieces, crossing_nodes = _cut_network_at_road(lines, route_line) + if not pieces: + return StreamSplit() -def _upstream_parts( - line: LineString, - route_line: LineString, - tree: cKDTree | None, - cloud: ContourCloud, -) -> list[LineString]: - """세류선을 노선 교차점에서 잘라 평균 표고가 높은 조각만 남긴다.""" - cuts = sorted( - { - line.project(point) - for point in _intersection_points(line.intersection(route_line)) - if 0.0 < line.project(point) < line.length - } + node_edges: dict[tuple[float, float], list[int]] = {} + ends: list[tuple[tuple[float, float], tuple[float, float]]] = [] + for index, piece in enumerate(pieces): + head = _node_key(*piece.coords[0]) + tail = _node_key(*piece.coords[-1]) + ends.append((head, tail)) + node_edges.setdefault(head, []).append(index) + node_edges.setdefault(tail, []).append(index) + + sampler = ElevationSampler(cloud) + upper_seeds: set[int] = set() + lower_seeds: set[int] = set() + for index, piece in enumerate(pieces): + touching = [node for node in ends[index] if node in crossing_nodes] + if not touching: + continue + crossing_z = float(np.min(sampler.at(np.array(touching, dtype=np.float64)))) + if _mean_elevation(piece, sampler) > crossing_z: + upper_seeds.add(index) + else: + lower_seeds.add(index) + + upstream = _spread_network(upper_seeds, ends, node_edges, crossing_nodes) + downstream = _spread_network(lower_seeds, ends, node_edges, crossing_nodes) - upstream + logger.info( + "배수유역: 세류 조각 %d개 → 상류망 %d개 채택 / 하류망 %d개 · 미연결 %d개 제외", + len(pieces), + len(upstream), + len(downstream), + len(pieces) - len(upstream) - len(downstream), ) - if not cuts: - return [line] - bounds = [0.0, *cuts, line.length] - parts: list[tuple[float, LineString]] = [] - for start, end in zip(bounds, bounds[1:]): - if end - start < 1.0: + return StreamSplit( + upstream=[pieces[index] for index in sorted(upstream)], + downstream=[pieces[index] for index in sorted(downstream)], + no_contact=len(pieces) - len(upstream) - len(downstream), + ) + + +def _cut_network_at_road( + lines: list[LineString], route_line: LineString +) -> tuple[list[LineString], set[tuple[float, float]]]: + """세류망을 노딩한 뒤 도로 교차점에서 자르고, 그 교차 노드를 함께 돌려준다.""" + noded = unary_union(lines) + pieces: list[LineString] = [] + crossing_nodes: set[tuple[float, float]] = set() + for piece in _iter_linestrings(noded): + if not piece.intersects(route_line): + pieces.append(piece) continue - piece = _substring(line, start, end) - if piece is None: + hits = _intersection_points(piece.intersection(route_line)) + positions = sorted( + { + position + for position in (piece.project(point) for point in hits) + if 0.0 < position < piece.length + } + ) + for point in hits: + crossing_nodes.add(_node_key(point.x, point.y)) + if not positions: + # 끝점이 도로에 닿은 경우 — 자를 필요는 없고 그 끝점이 곧 교차 노드다. + pieces.append(piece) continue - parts.append((_mean_elevation(piece, tree, cloud), piece)) - if not parts: - return [] - highest = max(value for value, _ in parts) - # 최상류 조각과 표고가 비슷한(1m 이내) 조각까지 상류로 본다. 나머지는 하류이므로 버린다. - return [piece for value, piece in parts if highest - value <= 1.0] + bounds = [0.0, *positions, piece.length] + for start, end in zip(bounds, bounds[1:]): + if end - start <= 0: + continue + cut = _substring(piece, start, end) + if cut is not None: + pieces.append(cut) + return pieces, crossing_nodes + + +def _spread_network( + seeds: set[int], + ends: list[tuple[tuple[float, float], tuple[float, float]]], + node_edges: dict[tuple[float, float], list[int]], + blocked: set[tuple[float, float]], +) -> set[int]: + """씨앗 조각에서 끝점을 타고 퍼진다. 도로 교차 노드는 통과하지 않는다.""" + reached = set(seeds) + queue = list(seeds) + while queue: + index = queue.pop() + for node in ends[index]: + if node in blocked: + continue + for neighbour in node_edges.get(node, ()): + if neighbour not in reached: + reached.add(neighbour) + queue.append(neighbour) + return reached + + +def _node_key(x: float, y: float) -> tuple[float, float]: + """끝점 일치 판정용 좌표 키. 노딩 후에도 부동소수 오차가 남아 mm로 반올림한다.""" + return (round(float(x), 3), round(float(y), 3)) + + +class ElevationSampler: + """등고선 구름에서 임의 지점 표고를 읽는다 — 상·하류 판정 전용. + + 최근접 등고선 정점만 쓰면 오차가 등고선 간격(주곡선 5m)만큼 나서, 계곡 교차점 표고가 + 실제보다 한 등고선 위로 잡히고 상류 조각이 통째로 하류로 오판된다. TIN 선형보간을 + 1차로 쓰고, TIN 밖(볼록껍질 외부)만 최근접 정점으로 메운다. + """ + + def __init__(self, cloud: ContourCloud) -> None: + self._z = cloud.z + if cloud.is_empty: + self._interpolator = None + self._tree = None + return + self._interpolator = LinearNDInterpolator(cloud.xy, cloud.z) + self._tree = cKDTree(cloud.xy) + + def at(self, xy: np.ndarray) -> np.ndarray: + """(N, 2) 좌표의 표고 (N,).""" + if self._interpolator is None or self._tree is None: + return np.zeros(xy.shape[0]) + values = np.asarray(self._interpolator(xy), dtype=np.float64) + missing = ~np.isfinite(values) + if missing.any(): + _, indices = self._tree.query(xy[missing]) + values[missing] = self._z[indices] + return values + + +def _point_elevation(point: Any, tree: cKDTree | None, cloud: ContourCloud) -> float: + """가장 가까운 등고선 정점의 표고. TIN이 없는 단계의 근사 표고다.""" + if tree is None: + return 0.0 + _, index = tree.query([[point.x, point.y]]) + return float(cloud.z[index[0]]) def _intersection_points(geometry: Any) -> list[Any]: @@ -287,46 +428,95 @@ def _substring(line: LineString, start: float, end: float) -> LineString | None: return piece -def _mean_elevation(line: LineString, tree: cKDTree | None, cloud: ContourCloud) -> float: - if tree is None: - return 0.0 +def _mean_elevation(line: LineString, sampler: ElevationSampler) -> float: + """선을 10m 간격으로 훑은 평균 표고.""" samples = max(2, int(line.length // 10.0) + 1) positions = np.linspace(0.0, line.length, samples) points = np.array([list(line.interpolate(position).coords)[0] for position in positions]) - _, indices = tree.query(points) - return float(np.mean(cloud.z[indices])) + return float(np.mean(sampler.at(points))) # ── ③ 격자 범위 ───────────────────────────────────────────────────────────── -def build_grid_spec( +@dataclass +class PrimaryRegion: + """1차 배수유역 — 격자 범위의 근거. 검증 화면이 이 내용을 그대로 그린다.""" + + split: StreamSplit + # 상류 세류망을 반경 버퍼해 합친 영역. 노선은 버퍼하지 않는다. + area: Polygon | MultiPolygon | None + spec: GridSpec + radius_m: float + # 1차 영역 밖으로 나간 노선 길이(m). 그 구간 사면은 해석에서 빠진다는 경고 지표. + road_outside_m: float = 0.0 + + +def build_primary_region( route_line: LineString, - streams: list[LineString], + stream_features: list[dict[str, Any]], + cloud: ContourCloud, radius_m: float, cell_m: float = DRAINAGE_GRID_SIZE_M, -) -> GridSpec: - """노선과 상류 세류선을 반경 버퍼한 범위의 bbox로 격자를 잡는다. +) -> PrimaryRegion: + """**상류 세류망만** 반경 버퍼한 범위 = 1차 배수유역, 그 bbox = 해석 격자. - 셀 수가 상한을 넘으면 셀 크기를 자동으로 키워 맞춘다(메모리 보호). 실제 유역 모양은 - 격자가 아니라 흐름 해석이 정한다 — 여기서는 넉넉한 사각 범위만 확보하면 된다. + 노선은 버퍼하지 않는다(2026-07-31 사용자 지시). 1차 영역의 기준은 도로 교차점 상류로 + 이어진 세류선 그 자체이며, 도로를 버퍼하면 도로 아래쪽(하류)까지 영역이 퍼져 의미가 없다. + + 노선이 이 영역 밖으로 나가는 길이는 따로 재서 남긴다 — 그 구간은 도로 셀이 격자에 + 없어 유역이 잡히지 않으므로 반경을 올릴지 판단하는 근거가 된다. """ - geometries = [route_line.buffer(radius_m)] - geometries.extend(line.buffer(radius_m) for line in streams) - x_min, y_min, x_max, y_max = unary_union(geometries).bounds - return _spec_from_bounds(x_min, y_min, x_max, y_max, cell_m) + split = split_streams_at_road(route_line, stream_features, cloud) + geometries = [line.buffer(radius_m) for line in split.upstream] + if not geometries: + logger.warning("배수유역: 상류 세류망이 없어 노선 버퍼로 대체합니다.") + geometries = [route_line.buffer(radius_m)] + area = unary_union(geometries) + x_min, y_min, x_max, y_max = area.bounds + spec = _spec_from_bounds(x_min, y_min, x_max, y_max, cell_m) + outside = route_line.difference(area) + road_outside_m = float(outside.length) if not outside.is_empty else 0.0 + logger.info( + "배수유역: 1차 영역 %.0f㎡, 범위 %.0fm × %.0fm, 격자 %d×%d (%.2fm), 노선 이탈 %.0fm/%.0fm", + area.area, + x_max - x_min, + y_max - y_min, + spec.n_rows, + spec.n_cols, + spec.cell_m, + road_outside_m, + route_line.length, + ) + return PrimaryRegion( + split=split, area=area, spec=spec, radius_m=radius_m, road_outside_m=road_outside_m + ) def _spec_from_bounds( x_min: float, y_min: float, x_max: float, y_max: float, cell_m: float ) -> GridSpec: + """격자 크기는 절대 자동으로 바꾸지 않는다 — config 값이 그대로 쓰인다(사용자 지시). + + 셀 수가 많으면 경고만 남기고 그대로 진행한다. 느리면 `DRAINAGE_GRID_SIZE_M`을 사용자가 + 직접 올린다. 자동 강등은 도로 굽기 두께·정밀도 전제를 조용히 깨뜨려서 금지한다. + """ width = max(x_max - x_min, cell_m) height = max(y_max - y_min, cell_m) - while (width / cell_m) * (height / cell_m) > DRAINAGE_MAX_GRID_CELLS: - cell_m *= 2.0 - logger.warning("배수유역: 셀 수 상한 초과 — 격자 크기를 %.1fm로 키웁니다.", cell_m) n_cols = int(math.ceil(width / cell_m)) n_rows = int(math.ceil(height / cell_m)) + if n_cols * n_rows > DRAINAGE_MAX_GRID_CELLS: + logger.warning( + "배수유역: 격자 %d×%d = %d셀 (%.0fm × %.0fm, 셀 %.1fm) — 권장 상한 %d셀 초과. " + "그대로 진행합니다. 느리면 DRAINAGE_GRID_SIZE_M을 올리세요.", + n_rows, + n_cols, + n_rows * n_cols, + width, + height, + cell_m, + DRAINAGE_MAX_GRID_CELLS, + ) return GridSpec( x_min=x_min, y_max=y_min + n_rows * cell_m, cell_m=cell_m, n_rows=n_rows, n_cols=n_cols ) @@ -345,11 +535,26 @@ def expand_grid_spec(spec: GridSpec, sides: dict[str, bool], step_m: float) -> G def interpolate_elevation(spec: GridSpec, cloud: ContourCloud) -> np.ndarray: - """등고선 정점 Delaunay TIN으로 셀 표고를 선형보간한다. 외부는 NaN.""" + """등고선 정점 Delaunay TIN으로 셀 표고를 선형보간한다. 외부는 NaN. + + 삼각망 비용은 정점 수에 비례한다. 격자 밖 정점으로 만든 삼각형은 어차피 쓰이지 않으므로 + 격자 범위 + 여유만큼만 남기고 잘라낸다 — 결과 표고는 그대로고 속도만 는다. + """ surface = np.full((spec.n_rows, spec.n_cols), np.nan, dtype=np.float32) if cloud.is_empty: return surface - interpolator = LinearNDInterpolator(cloud.xy, cloud.z) + margin = DRAINAGE_CONTOUR_CLIP_MARGIN_M + inside = ( + (cloud.xy[:, 0] >= spec.x_min - margin) + & (cloud.xy[:, 0] <= spec.x_min + spec.n_cols * spec.cell_m + margin) + & (cloud.xy[:, 1] >= spec.y_max - spec.n_rows * spec.cell_m - margin) + & (cloud.xy[:, 1] <= spec.y_max + margin) + ) + if inside.sum() < 3: + logger.warning("배수유역: 격자 범위 안에 등고선 정점이 없습니다.") + return surface + logger.info("배수유역: TIN 정점 %d개 사용 (전체 %d개)", int(inside.sum()), cloud.xy.shape[0]) + interpolator = LinearNDInterpolator(cloud.xy[inside], cloud.z[inside]) xs = spec.cell_centers_x() ys = spec.cell_centers_y() # 행 묶음 단위로 평가해 (행×열) 좌표 배열을 한 번에 들고 있지 않게 한다. diff --git a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py index 01b8b369..acb8ab9c 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py +++ b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py @@ -22,7 +22,10 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import ( build_route_vertices, propose_structure_stations, ) -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Basin import build_drainage_watershed +from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Basin import ( + build_drainage_watershed, + preview_primary_region, +) from B05_wf2_Route.B05_wf2_Route_Repository import ( get_latest_route, get_route_points, @@ -30,7 +33,11 @@ from B05_wf2_Route.B05_wf2_Route_Repository import ( ) from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool -from config.config_system import DRAINAGE_CACHE_DIRNAME, DRAINAGE_CACHE_FILENAME +from config.config_system import ( + DRAINAGE_CACHE_DIRNAME, + DRAINAGE_CACHE_FILENAME, + DRAINAGE_REGION_FILENAME, +) logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B05 Route Drainage"]) @@ -156,6 +163,7 @@ async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse: "vertices": vertices, "streams": streams, "contours": contour_features, + "stored_path": stored_path, "cache_path": _cache_path(stored_path), "to_lonlat": lambda x, y: to_lonlat_transformer.transform(x, y), } @@ -177,6 +185,126 @@ async def get_structure_candidates(project_id: UUID) -> dict[str, Any] | JSONRes } +@router.get("/{project_id}/drainage/primary-region", response_model=None) +async def get_primary_region(project_id: UUID) -> dict[str, Any] | JSONResponse: + """1차 배수유역 근거를 돌려준다 — 단계 검증용, TIN·흐름 계산은 하지 않는다. + + 도로 교차점 상류로 이어진 세류망, 제외된 하류망, 그 상류망을 반경 버퍼한 1차 영역, + 그 bbox로 잡은 격자 정보를 함께 준다. 같은 내용을 영구저장소에 GeoJSON으로도 남겨 + QGIS 등으로 직접 열어 대조할 수 있게 한다. + """ + prepared = await _prepare(project_id) + if isinstance(prepared, JSONResponse): + return prepared + region = await asyncio.to_thread( + preview_primary_region, + prepared["vertices"], + prepared["contours"], + prepared["streams"], + ) + if region is None: + return JSONResponse( + status_code=400, + content={"status": "error", "message": "1차 배수유역을 정할 등고선이 없습니다."}, + ) + to_lonlat = prepared["to_lonlat"] + spec = region.spec + payload = { + "status": "success", + "project_id": str(project_id), + "route_id": prepared["route_id"], + "radius_m": region.radius_m, + # 채택된 상류 세류망 = 1차 영역의 기준선. + "upstream_lines": [_line_lonlat(line, to_lonlat) for line in region.split.upstream], + # 도로 아래로 이어진 하류망 — 판정이 맞는지 눈으로 대조하기 위해 함께 준다. + "downstream_lines": [_line_lonlat(line, to_lonlat) for line in region.split.downstream], + "no_contact_count": region.split.no_contact, + # 노선이 1차 영역 밖으로 나간 길이(m). 크면 반경을 올려야 한다는 신호. + "road_outside_m": round(region.road_outside_m, 1), + # 1차 영역(버퍼 합집합) 외곽 링 목록. + "region_rings": _polygon_rings(region.area, to_lonlat), + "grid": { + "cell_m": spec.cell_m, + "rows": spec.n_rows, + "cols": spec.n_cols, + "cells": spec.size, + "width_m": round(spec.n_cols * spec.cell_m, 1), + "height_m": round(spec.n_rows * spec.cell_m, 1), + # 격자 bbox 링(닫힌 사각형). 프론트가 여기에 cell_m 간격으로 실제 셀을 그린다. + "bbox_lonlat": _grid_bbox_lonlat(spec, to_lonlat), + }, + } + payload["saved_to"] = _save_region_geojson(prepared["stored_path"], payload) + return payload + + +def _save_region_geojson(stored_path: str, payload: dict[str, Any]) -> str | None: + """1차 영역 검증 산출물을 영구저장소에 GeoJSON(WGS84)으로 남긴다.""" + features: list[dict[str, Any]] = [] + for index, ring in enumerate(payload["region_rings"]): + features.append(_geojson_feature("primary_region", index, "Polygon", [ring])) + for index, line in enumerate(payload["upstream_lines"]): + features.append(_geojson_feature("upstream", index, "LineString", line)) + for index, line in enumerate(payload["downstream_lines"]): + features.append(_geojson_feature("downstream", index, "LineString", line)) + features.append(_geojson_feature("grid_bbox", 0, "Polygon", [payload["grid"]["bbox_lonlat"]])) + document = { + "type": "FeatureCollection", + "crs": {"type": "name", "properties": {"name": "urn:ogc:def:crs:OGC:1.3:CRS84"}}, + "properties": { + "radius_m": payload["radius_m"], + "road_outside_m": payload["road_outside_m"], + "no_contact_count": payload["no_contact_count"], + "grid": {key: value for key, value in payload["grid"].items() if key != "bbox_lonlat"}, + }, + "features": features, + } + target = ( + Path(resolve_stored_project_path(stored_path)) + / "B05_wf2_Route" + / DRAINAGE_CACHE_DIRNAME + / DRAINAGE_REGION_FILENAME + ) + try: + target.parent.mkdir(parents=True, exist_ok=True) + with target.open("w", encoding="utf-8") as file: + json.dump(document, file, ensure_ascii=False) + except OSError: + logger.warning("배수유역: 1차 영역 GeoJSON을 저장하지 못했습니다 (%s).", target) + return None + logger.info("배수유역: 1차 영역 GeoJSON 저장 — %s (피처 %d개)", target, len(features)) + return str(target) + + +def _geojson_feature(kind: str, index: int, geom_type: str, coordinates: Any) -> dict[str, Any]: + return { + "type": "Feature", + "properties": {"kind": kind, "index": index}, + "geometry": {"type": geom_type, "coordinates": coordinates}, + } + + +def _line_lonlat(line: Any, to_lonlat: Any) -> list[list[float]]: + return [list(to_lonlat(x, y)) for x, y in line.coords] + + +def _polygon_rings(geometry: Any, to_lonlat: Any) -> list[list[list[float]]]: + """폴리곤/멀티폴리곤의 외곽 링만 뽑아 lonlat으로 바꾼다.""" + if geometry is None or geometry.is_empty: + return [] + parts = geometry.geoms if geometry.geom_type == "MultiPolygon" else [geometry] + return [[list(to_lonlat(x, y)) for x, y in part.exterior.coords] for part in parts] + + +def _grid_bbox_lonlat(spec: Any, to_lonlat: Any) -> list[list[float]]: + x_min = spec.x_min + x_max = spec.x_min + spec.n_cols * spec.cell_m + y_max = spec.y_max + y_min = spec.y_max - spec.n_rows * spec.cell_m + corners = ((x_min, y_min), (x_min, y_max), (x_max, y_max), (x_max, y_min), (x_min, y_min)) + return [list(to_lonlat(x, y)) for x, y in corners] + + @router.post("/{project_id}/drainage/basins", response_model=None) async def post_drainage_basins( project_id: UUID, diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts index eba48bd3..c127a8ed 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -21,7 +21,9 @@ import { } from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender"; import { fetchDrainageBasins, + fetchDrainagePrimaryRegion, type DrainageBasin, + type DrainagePrimaryRegion, type RoutePoint, } from "./B05_wf2_Route_Api_Fetch"; import { createPipeEditor } from "./B05_wf2_Route_UI_Drainage_Pipes"; @@ -107,7 +109,16 @@ export function createDrainagePanel(): DrainagePanel { autoButton.type = "button"; autoButton.className = "b05-drainage__analyze b05-drainage__tool"; autoButton.textContent = "자동 제안"; - header.append(analyzeButton, editButton, deleteButton, autoButton); + // 1차 영역 확인 — TIN·흐름 계산 없이 세류 상·하류 판정과 격자 범위만 그려 눈으로 검증한다. + const regionButton = document.createElement("button"); + regionButton.type = "button"; + regionButton.className = "b05-drainage__analyze b05-drainage__tool"; + regionButton.textContent = "1차 영역"; + regionButton.title = + "도로와 만나는 세류선의 상류측(파랑 굵은 선)·하류측(회색 파선)과 " + + "그 반경 버퍼로 잡은 1차 배수유역, 해석 격자 범위를 표시합니다."; + regionButton.setAttribute("aria-pressed", "false"); + header.append(analyzeButton, editButton, deleteButton, autoButton, regionButton); const viewport = document.createElement("div"); viewport.className = "b05-drainage__viewport"; @@ -145,6 +156,9 @@ export function createDrainagePanel(): DrainagePanel { // 2차 전체 배수유역 외곽선 = 분수령. 별도 "능선" 레이어를 두지 않고 이 선 하나로 표시한다 // (전체 유역 외곽선과 능선이 같은 선이므로 — 2026-07-31 사용자 지시). let mainBoundary: Array<[number, number]> = []; + // 1차 영역 검증 오버레이. null이면 표시하지 않는다. + let primaryRegion: DrainagePrimaryRegion | null = null; + let showRegion = false; let scale = 1; let offsetX = 0; let offsetY = 0; @@ -213,6 +227,8 @@ export function createDrainagePanel(): DrainagePanel { }); // 전체 유역 외곽선 = 분수령(능선). 세부유역 경계와 구분되게 파선 한 겹만 얹는다. if (mainBoundary.length > 2) drawRidgeRing(context, mainBoundary, normalizer, view); + // 1차 영역 검증 오버레이는 채움 위·등고선 아래에 깐다. + if (showRegion && primaryRegion) drawPrimaryRegion(context, normalizer, view); } // 등고선을 얇게 깔고 세류를 그 위에, 노선을 맨 위에 둔다. DRAINAGE_LAYERS.forEach((layer) => { @@ -234,6 +250,121 @@ export function createDrainagePanel(): DrainagePanel { updateImageTransform(); } + /** lon/lat 폴리라인을 화면 좌표로 옮겨 한 줄 그린다(1차 영역 오버레이 전용). */ + function strokeLonLat( + context: CanvasRenderingContext2D, + line: ReadonlyArray, + map: Normalizer, + view: ViewState, + ): void { + if (line.length < 2) return; + const ax = view.mapRect.width * view.scale; + const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX; + const ay = view.mapRect.height * view.scale; + const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY; + context.beginPath(); + line.forEach(([lon, lat], index) => { + const x = ((lon - map.lonMin) / map.lonRange) * ax + bx; + const y = (1 - (lat - map.latMin) / map.latRange) * ay + by; + if (index === 0) context.moveTo(x, y); + else context.lineTo(x, y); + }); + context.stroke(); + } + + /** 해석 격자를 실제 셀 눈금으로 그린다. + * + * 셀 간격이 화면에서 너무 촘촘하면(2px 미만) 눈금이 뭉개져 회색 덩어리가 되므로, + * 그때는 테두리만 남기고 "확대하면 셀이 보인다"는 상태를 유지한다. */ + function drawGridCells( + context: CanvasRenderingContext2D, + map: Normalizer, + view: ViewState, + region: DrainagePrimaryRegion, + ): void { + const ring = region.grid.bbox_lonlat; + if (ring.length < 4) return; + const lons = ring.map(([lon]) => lon); + const lats = ring.map(([, lat]) => lat); + const lonMin = Math.min(...lons); + const lonMax = Math.max(...lons); + const latMin = Math.min(...lats); + const latMax = Math.max(...lats); + const ax = view.mapRect.width * view.scale; + const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX; + const ay = view.mapRect.height * view.scale; + const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY; + const toX = (lon: number): number => ((lon - map.lonMin) / map.lonRange) * ax + bx; + const toY = (lat: number): number => (1 - (lat - map.latMin) / map.latRange) * ay + by; + + const left = toX(lonMin); + const right = toX(lonMax); + const top = toY(latMax); + const bottom = toY(latMin); + context.save(); + context.setLineDash([]); + // 셀 눈금 — 열/행 수로 나눠 실제 셀 경계를 그대로 찍는다. + const cellWidthPx = Math.abs(right - left) / Math.max(region.grid.cols, 1); + const cellHeightPx = Math.abs(bottom - top) / Math.max(region.grid.rows, 1); + if (Math.min(cellWidthPx, cellHeightPx) >= 2) { + context.lineWidth = 0.5; + context.strokeStyle = "rgba(120, 113, 108, 0.35)"; + context.beginPath(); + for (let col = 0; col <= region.grid.cols; col += 1) { + const x = left + (right - left) * (col / region.grid.cols); + if (x < -50 || x > view.width + 50) continue; + context.moveTo(x, top); + context.lineTo(x, bottom); + } + for (let row = 0; row <= region.grid.rows; row += 1) { + const y = top + (bottom - top) * (row / region.grid.rows); + if (y < -50 || y > view.height + 50) continue; + context.moveTo(left, y); + context.lineTo(right, y); + } + context.stroke(); + } + // 격자 전체 테두리는 항상 그린다. + context.setLineDash([10, 6]); + context.lineWidth = 1.5; + context.strokeStyle = "rgba(120, 113, 108, 0.9)"; + context.strokeRect(left, top, right - left, bottom - top); + context.restore(); + } + + /** 1차 배수유역 근거를 겹쳐 그린다 — 단계 검증용. */ + function drawPrimaryRegion( + context: CanvasRenderingContext2D, + map: Normalizer, + view: ViewState, + ): void { + const region = primaryRegion; + if (!region) return; + context.save(); + // ① 해석 격자 — bbox 테두리 + 실제 셀 눈금. + drawGridCells(context, map, view, region); + // ② 1차 배수유역 = 상류 세류망의 반경 버퍼 합집합. + context.setLineDash([]); + context.lineWidth = 2; + context.strokeStyle = "rgba(5, 150, 105, 0.95)"; + context.fillStyle = "rgba(16, 185, 129, 0.12)"; + region.region_rings.forEach((ring) => { + strokeLonLat(context, ring, map, view); + context.fill(); + }); + // ③ 도로 아래로 이어진 하류망 — 판정이 맞는지 대조하도록 회색 파선으로 남긴다. + context.setLineDash([6, 5]); + context.lineWidth = 2; + context.strokeStyle = "rgba(120, 113, 108, 0.85)"; + region.downstream_lines.forEach((line) => strokeLonLat(context, line, map, view)); + // ④ 채택된 상류망 = 1차 영역의 기준선. 가장 굵게, 맨 위에. + context.setLineDash([]); + context.lineWidth = 4; + context.strokeStyle = "rgba(29, 78, 216, 0.95)"; + region.upstream_lines.forEach((line) => strokeLonLat(context, line, map, view)); + context.restore(); + } + /** 현재 프레임 뷰 상태 (draw()와 동일 계산 — 포인터 히트 판정용). */ function currentView(): ViewState { const rect = viewport.getBoundingClientRect(); @@ -351,6 +482,53 @@ export function createDrainagePanel(): DrainagePanel { void analyze(true); }); + /** 1차 영역 근거를 불러와 겹쳐 그린다. + * + * 켤 때는 **항상 다시 요청한다** — config(반경·격자)를 바꾸고 서버를 재시작한 뒤 + * 눌렀는데 캐시된 예전 결과가 나오면 검증이 성립하지 않는다. 끌 때만 요청 없이 숨긴다. */ + async function toggleRegion(): Promise { + if (!projectId) return; + if (showRegion) { + showRegion = false; + regionButton.classList.remove("is-active"); + regionButton.setAttribute("aria-pressed", "false"); + status.hidden = true; + scheduleDraw(); + return; + } + regionButton.disabled = true; + status.hidden = false; + status.textContent = "1차 배수유역을 확인하는 중…"; + try { + primaryRegion = await fetchDrainagePrimaryRegion(projectId); + showRegion = true; + regionButton.classList.add("is-active"); + regionButton.setAttribute("aria-pressed", "true"); + status.textContent = regionSummary(primaryRegion); + } catch (error) { + status.textContent = + error instanceof Error ? error.message : "1차 배수유역을 확인하지 못했습니다."; + } finally { + regionButton.disabled = false; + scheduleDraw(); + } + } + + /** 상태줄에 띄울 1차 영역 요약 — 격자 셀 수를 보고 격자 크기를 조정할 근거가 된다. */ + function regionSummary(region: DrainagePrimaryRegion): string { + const cells = region.grid.cells.toLocaleString(); + const outside = + region.road_outside_m > 0 ? ` · 노선 이탈 ${Math.round(region.road_outside_m)}m` : ""; + return ( + `1차 영역(반경 ${region.radius_m}m): 상류망 ${region.upstream_lines.length}조각 채택 / ` + + `하류망 ${region.downstream_lines.length}조각·미연결 ${region.no_contact_count}개 제외 · ` + + `격자 ${region.grid.width_m}×${region.grid.height_m}m, ` + + `${region.grid.cell_m}m 셀 ${cells}개${outside}` + ); + } + + regionButton.addEventListener("click", () => void toggleRegion()); + /** 노선 전체가 보이도록 배율·중심을 맞춘다. 노선이 없으면 도엽 전체를 그대로 보여준다. */ function fitToRoute(): void { scale = 1; @@ -388,6 +566,11 @@ export function createDrainagePanel(): DrainagePanel { const sequence = ++loadSequence; meta = null; preparedLayers.clear(); + // 1차 영역은 프로젝트·노선에 종속이므로 새로 불러올 때 버린다. + primaryRegion = null; + showRegion = false; + regionButton.classList.remove("is-active"); + regionButton.setAttribute("aria-pressed", "false"); routeLayer = null; backgroundImage.removeAttribute("src"); status.hidden = false; diff --git a/config/config_system.py b/config/config_system.py index 8710eb23..29852717 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -242,13 +242,15 @@ SKELETON_NODE_SPACING_M = float(os.getenv("SKELETON_NODE_SPACING_M", "10.0")) # ───────────────────────────────────────────────────────────────────────── # 해석 격자 한 변(m). 작을수록 정밀하나 셀 수가 제곱으로 늘어난다. DRAINAGE_GRID_SIZE_M = float(os.getenv("DRAINAGE_GRID_SIZE_M", "1.0")) -# 1차 배수유역 반경(m). 정리된 세류선과 노선을 이 반경으로 버퍼해 초기 해석 범위를 잡는다. -DRAINAGE_INITIAL_RADIUS_M = float(os.getenv("DRAINAGE_INITIAL_RADIUS_M", "300.0")) +# 1차 배수유역 반경(m). 도로 교차점 상류로 이어진 세류망을 이 반경으로 버퍼한 범위가 +# 1차 영역이며 그 bbox가 해석 격자다. 노선은 버퍼하지 않는다(2026-07-31 사용자 지시). +DRAINAGE_INITIAL_RADIUS_M = float(os.getenv("DRAINAGE_INITIAL_RADIUS_M", "50.0")) # 활성 셀이 격자 최외곽에 닿았을 때 한 번에 넓히는 폭(m). DRAINAGE_EXPAND_STEP_M = float(os.getenv("DRAINAGE_EXPAND_STEP_M", "200.0")) # 확장 반복 상한. 경계 링이 전부 비활성이 되면 그 전에 스스로 멈춘다(안전핀). DRAINAGE_MAX_EXPAND_ROUNDS = int(os.getenv("DRAINAGE_MAX_EXPAND_ROUNDS", "6")) -# 격자 셀 수 상한. 초과하면 셀 크기를 자동으로 키워 맞춘다(메모리 보호). +# 격자 셀 수 권장 상한. 넘으면 **경고만** 남기고 그대로 계산한다 — 격자 크기 자동 조절은 +# 하지 않는다(2026-07-31 사용자 지시). 느리면 위 DRAINAGE_GRID_SIZE_M을 직접 올린다. DRAINAGE_MAX_GRID_CELLS = int(os.getenv("DRAINAGE_MAX_GRID_CELLS", "16000000")) # 도로 폭(m). 이 폭으로 노선을 격자에 구워 D8 흐름이 도로를 대각선으로 건너뛰지 못하게 한다. DRAINAGE_ROAD_WIDTH_M = float(os.getenv("DRAINAGE_ROAD_WIDTH_M", "4.0")) @@ -258,6 +260,9 @@ DRAINAGE_CONTOUR_MARGIN_M = float(os.getenv("DRAINAGE_CONTOUR_MARGIN_M", "10.0") DRAINAGE_CONTOUR_MIN_LENGTH_M = float(os.getenv("DRAINAGE_CONTOUR_MIN_LENGTH_M", "20.0")) # 등고선 정점 재샘플 간격(m). 조밀할수록 TIN이 정확하나 Delaunay 비용이 커진다. DRAINAGE_CONTOUR_RESAMPLE_M = float(os.getenv("DRAINAGE_CONTOUR_RESAMPLE_M", "5.0")) +# TIN 삼각망을 만들 때 격자 범위 밖으로 남길 여유(m). 도엽 전체 등고선을 다 물면 삼각망 +# 비용만 커지고 결과는 같다. 여유가 0이면 격자 가장자리가 TIN 밖으로 나가 NaN이 된다. +DRAINAGE_CONTOUR_CLIP_MARGIN_M = float(os.getenv("DRAINAGE_CONTOUR_CLIP_MARGIN_M", "100.0")) # 평탄면 해소용 미세 경사(m/셀). 채움 후 흐름 방향이 없는 셀에 출구 쪽 경사를 만들어 준다. DRAINAGE_FLAT_EPSILON_M = float(os.getenv("DRAINAGE_FLAT_EPSILON_M", "0.001")) # 관 매설 최대 간격(m). 이 간격을 넘으면 흐름 강도가 가장 큰 지점에 관을 보충한다. @@ -273,6 +278,8 @@ DRAINAGE_MIN_BASIN_AREA_M2 = float(os.getenv("DRAINAGE_MIN_BASIN_AREA_M2", "100. # 격자 해석 결과 캐시 파일명. 프로젝트 저장소의 B05_wf2_Route/drainage/ 아래에 놓인다. DRAINAGE_CACHE_DIRNAME = "drainage" DRAINAGE_CACHE_FILENAME = "watershed_grid.npz" +# 1차 영역 검증 산출물. 버튼을 누를 때마다 덮어써서 사람이 QGIS 등으로 직접 열어볼 수 있게 한다. +DRAINAGE_REGION_FILENAME = "primary_region.geojson" # ───────────────────────────────────────────────────────────────────────── From 578ada6d8493c9a4dd6683d42c68fa6938c7eb5e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 31 Jul 2026 17:53:43 +0900 Subject: [PATCH 29/61] =?UTF-8?q?feat(B05):=201=EC=B0=A8=20=EC=98=81?= =?UTF-8?q?=EC=97=AD=20=EB=B0=98=EA=B2=BD=20100m=20+=20=EB=85=B8=EC=84=A0?= =?UTF-8?q?=20=EB=B2=84=ED=8D=BC=20=EB=B3=B5=EA=B7=80,=20=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=EB=B3=84=20=EC=82=B0=EC=B6=9C=EB=AC=BC=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EB=AA=A8=EB=93=88=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DRAINAGE_INITIAL_RADIUS_M 50m -> 100m - 1차 영역 = 상류 세류망 buffer(100m) UNION 계획 노선 buffer(100m). 세류망 선정이 정확해져 노선 버퍼를 다시 넣어도 영역이 폭발하지 않는다. - 저장을 B05_wf2_Route_Engine_Watershed_Export.py 로 분리. STAGES 딕셔너리에 단계 이름을 추가하고 write_stage 를 부르면 drainage/{번호}_{단계}.geojson + manifest.json 이 함께 갱신된다. 앞으로 기능을 붙일 때마다 이 자리에 단계가 하나씩 쌓인다. - primary_region 단계에 route 레이어 추가(도로 대조용). - 라우터의 임시 GeoJSON 작성 코드 제거. Co-Authored-By: Claude Fable 5 --- .../B05_wf2_Route_Engine_Watershed_Export.py | 146 ++++++++++++++++++ .../B05_wf2_Route_Engine_Watershed_Grid.py | 14 +- .../B05_wf2_Route_Router_Drainage.py | 79 ++++------ config/config_system.py | 12 +- 4 files changed, 191 insertions(+), 60 deletions(-) create mode 100644 B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Export.py diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Export.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Export.py new file mode 100644 index 00000000..21768ec8 --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Export.py @@ -0,0 +1,146 @@ +"""배수유역 단계별 검증 산출물을 영구저장소에 남긴다. + +기능을 하나씩 붙일 때마다 그 단계의 결과를 파일로 남겨 사람이 QGIS 등으로 직접 열어 +대조할 수 있게 하는 것이 목적이다(2026-07-31 사용자 지시). 새 단계를 추가할 때는 +`STAGES`에 이름을 하나 더 넣고 `write_stage()`를 호출하면 된다 — 파일명 규칙과 매니페스트 +갱신은 여기서 일괄로 처리한다. + +저장 위치: `storage/{회사}/{사용자}/{프로젝트}/B05_wf2_Route/drainage/` + - `{단계번호}_{단계이름}.geojson` — WGS84 FeatureCollection, 피처마다 `kind` 속성 + - `manifest.json` — 지금까지 남긴 단계 목록과 요약값 +""" + +from __future__ import annotations + +import json +import logging +from collections.abc import Sequence +from datetime import datetime +from pathlib import Path +from typing import Any, Callable + +from shapely.geometry.base import BaseGeometry + +from common_util.common_util_storage import resolve_stored_project_path +from config.config_system import DRAINAGE_CACHE_DIRNAME + +logger = logging.getLogger(__name__) + +# 단계 이름 → 파일 접두 번호. 순서대로 읽으면 파이프라인 진행 순서가 된다. +STAGES: dict[str, str] = { + "primary_region": "01", +} + +_MANIFEST_FILENAME = "manifest.json" +LonLat = Callable[[float, float], tuple[float, float]] + + +def drainage_dir(stored_path: str) -> Path: + return Path(resolve_stored_project_path(stored_path)) / "B05_wf2_Route" / DRAINAGE_CACHE_DIRNAME + + +def write_stage( + stored_path: str, + stage: str, + layers: dict[str, Sequence[BaseGeometry]], + properties: dict[str, Any], + to_lonlat: LonLat, +) -> str | None: + """한 단계의 기하 산출물을 GeoJSON으로 저장하고 매니페스트를 갱신한다. + + `layers`는 {레이어이름: 사업지 CRS(m) 기하 목록}이며 피처 `kind` 속성이 된다. + 좌표는 여기서 WGS84로 바꾼다 — 저장 파일은 어떤 도구로 열어도 바로 보여야 한다. + """ + prefix = STAGES.get(stage) + if prefix is None: + logger.warning("배수유역: 등록되지 않은 저장 단계 '%s' — 저장을 건너뜁니다.", stage) + return None + + features: list[dict[str, Any]] = [] + counts: dict[str, int] = {} + for kind, geometries in layers.items(): + for index, geometry in enumerate(geometries): + feature = _to_feature(kind, index, geometry, to_lonlat) + if feature is not None: + features.append(feature) + counts[kind] = len(geometries) + + filename = f"{prefix}_{stage}.geojson" + directory = drainage_dir(stored_path) + target = directory / filename + document = { + "type": "FeatureCollection", + "crs": {"type": "name", "properties": {"name": "urn:ogc:def:crs:OGC:1.3:CRS84"}}, + "properties": {**properties, "counts": counts}, + "features": features, + } + try: + directory.mkdir(parents=True, exist_ok=True) + with target.open("w", encoding="utf-8") as file: + json.dump(document, file, ensure_ascii=False) + except OSError: + logger.warning("배수유역: %s 저장 실패 (%s)", stage, target) + return None + + _update_manifest(directory, stage, filename, {**properties, "counts": counts}) + logger.info("배수유역: %s 저장 — %s (피처 %d개)", stage, target, len(features)) + return str(target) + + +def _to_feature( + kind: str, index: int, geometry: BaseGeometry, to_lonlat: LonLat +) -> dict[str, Any] | None: + coordinates = _to_lonlat_coords(geometry, to_lonlat) + if coordinates is None: + return None + return { + "type": "Feature", + "properties": {"kind": kind, "index": index}, + "geometry": {"type": geometry.geom_type, "coordinates": coordinates}, + } + + +def _to_lonlat_coords(geometry: BaseGeometry, to_lonlat: LonLat) -> Any: + """shapely 기하를 WGS84 GeoJSON 좌표 배열로 바꾼다.""" + if geometry.is_empty: + return None + kind = geometry.geom_type + if kind == "Point": + return list(to_lonlat(geometry.x, geometry.y)) + if kind == "LineString": + return [list(to_lonlat(x, y)) for x, y in geometry.coords] + if kind == "Polygon": + return [ + [list(to_lonlat(x, y)) for x, y in ring.coords] + for ring in (geometry.exterior, *geometry.interiors) + ] + if kind in {"MultiPoint", "MultiLineString", "MultiPolygon", "GeometryCollection"}: + parts = [_to_lonlat_coords(part, to_lonlat) for part in geometry.geoms] + return [part for part in parts if part is not None] + return None + + +def _update_manifest( + directory: Path, stage: str, filename: str, properties: dict[str, Any] +) -> None: + """지금까지 남긴 단계 목록을 한 파일에 모아 둔다 — 무엇이 저장돼 있는지 한눈에 본다.""" + manifest_path = directory / _MANIFEST_FILENAME + manifest: dict[str, Any] = {} + if manifest_path.exists(): + try: + with manifest_path.open("r", encoding="utf-8") as file: + loaded = json.load(file) + if isinstance(loaded, dict): + manifest = loaded + except (OSError, json.JSONDecodeError): + logger.warning("배수유역: manifest를 읽지 못해 새로 만듭니다 (%s).", manifest_path) + manifest[stage] = { + "file": filename, + "saved_at": datetime.now().isoformat(timespec="seconds"), + "properties": properties, + } + try: + with manifest_path.open("w", encoding="utf-8") as file: + json.dump(manifest, file, ensure_ascii=False, indent=2) + except OSError: + logger.warning("배수유역: manifest 저장 실패 (%s).", manifest_path) diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py index b8c2c2e3..7f30333e 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py @@ -459,19 +459,19 @@ def build_primary_region( radius_m: float, cell_m: float = DRAINAGE_GRID_SIZE_M, ) -> PrimaryRegion: - """**상류 세류망만** 반경 버퍼한 범위 = 1차 배수유역, 그 bbox = 해석 격자. + """**상류 세류망 + 계획 노선**을 반경 버퍼해 합친 범위 = 1차 배수유역, bbox = 해석 격자. - 노선은 버퍼하지 않는다(2026-07-31 사용자 지시). 1차 영역의 기준은 도로 교차점 상류로 - 이어진 세류선 그 자체이며, 도로를 버퍼하면 도로 아래쪽(하류)까지 영역이 퍼져 의미가 없다. + 노선 버퍼는 세류 교차가 없는 구간의 도로도 격자 안에 들어오게 한다 — 그래야 그 구간 + 사면이 유역으로 잡힌다. 상류 세류망 선정이 정확해진 뒤 다시 포함했다(2026-07-31 사용자 지시). 노선이 이 영역 밖으로 나가는 길이는 따로 재서 남긴다 — 그 구간은 도로 셀이 격자에 없어 유역이 잡히지 않으므로 반경을 올릴지 판단하는 근거가 된다. """ split = split_streams_at_road(route_line, stream_features, cloud) - geometries = [line.buffer(radius_m) for line in split.upstream] - if not geometries: - logger.warning("배수유역: 상류 세류망이 없어 노선 버퍼로 대체합니다.") - geometries = [route_line.buffer(radius_m)] + geometries = [route_line.buffer(radius_m)] + geometries.extend(line.buffer(radius_m) for line in split.upstream) + if not split.upstream: + logger.warning("배수유역: 상류 세류망이 없어 노선 버퍼만으로 1차 영역을 잡습니다.") area = unary_union(geometries) x_min, y_min, x_max, y_max = area.bounds spec = _spec_from_bounds(x_min, y_min, x_max, y_max, cell_m) diff --git a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py index acb8ab9c..1f2569e5 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py +++ b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py @@ -15,6 +15,7 @@ from uuid import UUID from fastapi import APIRouter from fastapi.responses import JSONResponse from pyproj import Transformer +from shapely.geometry import LineString, Polygon, box from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import ( @@ -26,6 +27,7 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Basin import ( build_drainage_watershed, preview_primary_region, ) +from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Export import write_stage from B05_wf2_Route.B05_wf2_Route_Repository import ( get_latest_route, get_route_points, @@ -33,11 +35,7 @@ from B05_wf2_Route.B05_wf2_Route_Repository import ( ) from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool -from config.config_system import ( - DRAINAGE_CACHE_DIRNAME, - DRAINAGE_CACHE_FILENAME, - DRAINAGE_REGION_FILENAME, -) +from config.config_system import DRAINAGE_CACHE_DIRNAME, DRAINAGE_CACHE_FILENAME logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B05 Route Drainage"]) @@ -161,6 +159,7 @@ async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse: return { "route_id": int(route["id"]), "vertices": vertices, + "route_line": LineString([(vertex.x, vertex.y) for vertex in vertices]), "streams": streams, "contours": contour_features, "stored_path": stored_path, @@ -234,54 +233,38 @@ async def get_primary_region(project_id: UUID) -> dict[str, Any] | JSONResponse: "bbox_lonlat": _grid_bbox_lonlat(spec, to_lonlat), }, } - payload["saved_to"] = _save_region_geojson(prepared["stored_path"], payload) + # 단계 산출물을 영구저장소에 남긴다 — 기능을 붙일 때마다 여기에 단계가 하나씩 늘어난다. + payload["saved_to"] = write_stage( + prepared["stored_path"], + "primary_region", + { + "primary_region": _as_polygons(region.area), + "upstream": region.split.upstream, + "downstream": region.split.downstream, + "route": [prepared["route_line"]], + "grid_bbox": [_grid_bbox_polygon(spec)], + }, + { + "radius_m": region.radius_m, + "road_outside_m": payload["road_outside_m"], + "no_contact_count": region.split.no_contact, + "grid": {key: value for key, value in payload["grid"].items() if key != "bbox_lonlat"}, + }, + to_lonlat, + ) return payload -def _save_region_geojson(stored_path: str, payload: dict[str, Any]) -> str | None: - """1차 영역 검증 산출물을 영구저장소에 GeoJSON(WGS84)으로 남긴다.""" - features: list[dict[str, Any]] = [] - for index, ring in enumerate(payload["region_rings"]): - features.append(_geojson_feature("primary_region", index, "Polygon", [ring])) - for index, line in enumerate(payload["upstream_lines"]): - features.append(_geojson_feature("upstream", index, "LineString", line)) - for index, line in enumerate(payload["downstream_lines"]): - features.append(_geojson_feature("downstream", index, "LineString", line)) - features.append(_geojson_feature("grid_bbox", 0, "Polygon", [payload["grid"]["bbox_lonlat"]])) - document = { - "type": "FeatureCollection", - "crs": {"type": "name", "properties": {"name": "urn:ogc:def:crs:OGC:1.3:CRS84"}}, - "properties": { - "radius_m": payload["radius_m"], - "road_outside_m": payload["road_outside_m"], - "no_contact_count": payload["no_contact_count"], - "grid": {key: value for key, value in payload["grid"].items() if key != "bbox_lonlat"}, - }, - "features": features, - } - target = ( - Path(resolve_stored_project_path(stored_path)) - / "B05_wf2_Route" - / DRAINAGE_CACHE_DIRNAME - / DRAINAGE_REGION_FILENAME - ) - try: - target.parent.mkdir(parents=True, exist_ok=True) - with target.open("w", encoding="utf-8") as file: - json.dump(document, file, ensure_ascii=False) - except OSError: - logger.warning("배수유역: 1차 영역 GeoJSON을 저장하지 못했습니다 (%s).", target) - return None - logger.info("배수유역: 1차 영역 GeoJSON 저장 — %s (피처 %d개)", target, len(features)) - return str(target) +def _as_polygons(geometry: Any) -> list[Any]: + if geometry is None or geometry.is_empty: + return [] + return list(geometry.geoms) if geometry.geom_type == "MultiPolygon" else [geometry] -def _geojson_feature(kind: str, index: int, geom_type: str, coordinates: Any) -> dict[str, Any]: - return { - "type": "Feature", - "properties": {"kind": kind, "index": index}, - "geometry": {"type": geom_type, "coordinates": coordinates}, - } +def _grid_bbox_polygon(spec: Any) -> Polygon: + x_max = spec.x_min + spec.n_cols * spec.cell_m + y_min = spec.y_max - spec.n_rows * spec.cell_m + return box(spec.x_min, y_min, x_max, spec.y_max) def _line_lonlat(line: Any, to_lonlat: Any) -> list[list[float]]: diff --git a/config/config_system.py b/config/config_system.py index 29852717..53547279 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -242,9 +242,11 @@ SKELETON_NODE_SPACING_M = float(os.getenv("SKELETON_NODE_SPACING_M", "10.0")) # ───────────────────────────────────────────────────────────────────────── # 해석 격자 한 변(m). 작을수록 정밀하나 셀 수가 제곱으로 늘어난다. DRAINAGE_GRID_SIZE_M = float(os.getenv("DRAINAGE_GRID_SIZE_M", "1.0")) -# 1차 배수유역 반경(m). 도로 교차점 상류로 이어진 세류망을 이 반경으로 버퍼한 범위가 -# 1차 영역이며 그 bbox가 해석 격자다. 노선은 버퍼하지 않는다(2026-07-31 사용자 지시). -DRAINAGE_INITIAL_RADIUS_M = float(os.getenv("DRAINAGE_INITIAL_RADIUS_M", "50.0")) +# 1차 배수유역 반경(m). 도로 교차점 상류로 이어진 세류망과 계획 노선을 각각 이 반경으로 +# 버퍼해 합친 범위가 1차 영역이며, 그 bbox가 해석 격자다. +# 노선 버퍼가 필요한 이유: 세류 교차가 없는 구간의 도로도 격자 안에 있어야 그 구간 사면이 +# 유역으로 잡힌다. 세류망 선정이 정확해진 뒤 다시 포함했다(2026-07-31 사용자 지시). +DRAINAGE_INITIAL_RADIUS_M = float(os.getenv("DRAINAGE_INITIAL_RADIUS_M", "100.0")) # 활성 셀이 격자 최외곽에 닿았을 때 한 번에 넓히는 폭(m). DRAINAGE_EXPAND_STEP_M = float(os.getenv("DRAINAGE_EXPAND_STEP_M", "200.0")) # 확장 반복 상한. 경계 링이 전부 비활성이 되면 그 전에 스스로 멈춘다(안전핀). @@ -278,8 +280,8 @@ DRAINAGE_MIN_BASIN_AREA_M2 = float(os.getenv("DRAINAGE_MIN_BASIN_AREA_M2", "100. # 격자 해석 결과 캐시 파일명. 프로젝트 저장소의 B05_wf2_Route/drainage/ 아래에 놓인다. DRAINAGE_CACHE_DIRNAME = "drainage" DRAINAGE_CACHE_FILENAME = "watershed_grid.npz" -# 1차 영역 검증 산출물. 버튼을 누를 때마다 덮어써서 사람이 QGIS 등으로 직접 열어볼 수 있게 한다. -DRAINAGE_REGION_FILENAME = "primary_region.geojson" +# 단계별 검증 산출물은 같은 폴더에 `{번호}_{단계}.geojson` + `manifest.json`으로 쌓인다. +# 파일명 규칙은 B05_wf2_Route_Engine_Watershed_Export.STAGES가 유일한 정의처다. # ───────────────────────────────────────────────────────────────────────── From a77ee317a58afefd6c474145a9de1dd398d3ac2f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 31 Jul 2026 17:57:13 +0900 Subject: [PATCH 30/61] =?UTF-8?q?refactor(B05):=20=EC=84=B8=EB=A5=98?= =?UTF-8?q?=EB=A7=9D=C2=B71=EC=B0=A8=EC=98=81=EC=97=AD=EC=9D=84=20Watershe?= =?UTF-8?q?d=5FStream=20=EB=AA=A8=EB=93=88=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= =?UTF-8?q?=20(700=EC=A4=84=20=EC=A0=9C=ED=95=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grid.py 가 708줄로 제한을 넘어 관심사 단위로 갈랐다. - Watershed_Stream.py (292줄): 세류망 노딩·도로 절단·연결망 확산, ElevationSampler, PrimaryRegion, build_primary_region - Watershed_Grid.py (427줄): 등고선 구름, 격자 규격, TIN 보간, 웅덩이 채움, 평탄면 해소, D8 - _iter_linestrings -> iter_linestrings, _spec_from_bounds -> grid_spec_from_bounds 로 공개(모듈 간 재사용). 의존 방향은 Stream -> Grid 단방향. - 죽은 코드 제거: select_upstream_streams, _point_elevation 회귀 확인: 합성 유역 179,919㎡ 동일, 연결망 판정 동일. Co-Authored-By: Claude Fable 5 --- .../B05_wf2_Route_Engine_Watershed_Basin.py | 6 +- .../B05_wf2_Route_Engine_Watershed_Grid.py | 295 +----------------- .../B05_wf2_Route_Engine_Watershed_Stream.py | 292 +++++++++++++++++ 3 files changed, 303 insertions(+), 290 deletions(-) create mode 100644 B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Stream.py diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py index d98a9fea..452ac168 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py @@ -45,13 +45,15 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Flow import ( ) from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import ( GridSpec, - PrimaryRegion, build_contour_cloud, - build_primary_region, build_terrain_grid, expand_grid_spec, route_elevation_floor, ) +from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Stream import ( + PrimaryRegion, + build_primary_region, +) from config.config_system import ( DRAINAGE_DITCH_SAMPLE_M, DRAINAGE_EXPAND_STEP_M, diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py index 7f30333e..4d49bc2f 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py @@ -19,16 +19,14 @@ from __future__ import annotations import logging import math -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Any import numpy as np from scipy.interpolate import LinearNDInterpolator from scipy.ndimage import distance_transform_edt -from scipy.spatial import cKDTree from shapely import segmentize -from shapely.geometry import LineString, MultiPolygon, Polygon, shape -from shapely.ops import substring, unary_union +from shapely.geometry import LineString, shape from skimage.morphology import reconstruction from config.config_system import ( @@ -37,7 +35,6 @@ from config.config_system import ( DRAINAGE_CONTOUR_MIN_LENGTH_M, DRAINAGE_CONTOUR_RESAMPLE_M, DRAINAGE_FLAT_EPSILON_M, - DRAINAGE_GRID_SIZE_M, DRAINAGE_MAX_GRID_CELLS, ) @@ -131,13 +128,13 @@ def _feature_elevation(properties: dict[str, Any]) -> float | None: return None -def _iter_linestrings(geometry: Any) -> list[LineString]: +def iter_linestrings(geometry: Any) -> list[LineString]: if geometry.geom_type == "LineString": return [geometry] if geometry.geom_type in {"MultiLineString", "GeometryCollection"}: lines: list[LineString] = [] for part in geometry.geoms: - lines.extend(_iter_linestrings(part)) + lines.extend(iter_linestrings(part)) return lines return [] @@ -179,7 +176,7 @@ def build_contour_cloud( if clip_bounds is not None and _outside_bounds(parsed.bounds, clip_bounds): dropped_outside += 1 continue - for line in _iter_linestrings(parsed): + for line in iter_linestrings(parsed): if line.length < DRAINAGE_CONTOUR_MIN_LENGTH_M: dropped_short += 1 continue @@ -215,285 +212,7 @@ def _outside_bounds( return bounds[2] < clip[0] or bounds[0] > clip[2] or bounds[3] < clip[1] or bounds[1] > clip[3] -# ── ② 세류선 상류측만 남기기 ──────────────────────────────────────────────── - - -def select_upstream_streams( - route_line: LineString, - stream_features: list[dict[str, Any]], - cloud: ContourCloud, -) -> list[LineString]: - """도로 교차점 기준 상류측으로 이어진 세류 연결망 전체를 돌려준다.""" - return split_streams_at_road(route_line, stream_features, cloud).upstream - - -@dataclass -class StreamSplit: - """세류망을 도로 교차점에서 상·하류로 가른 결과. 검증 화면이 그대로 그린다.""" - - upstream: list[LineString] = field(default_factory=list) # 채택 — 1차 영역의 기준 - downstream: list[LineString] = field(default_factory=list) # 도로 아래로 이어진 망 - no_contact: int = 0 # 어느 쪽에도 이어지지 않아 제외한 조각 수 - - -def split_streams_at_road( - route_line: LineString, - stream_features: list[dict[str, Any]], - cloud: ContourCloud, -) -> StreamSplit: - """세류망을 도로에서 끊고, 교차점 상류측으로 **이어진 망 전체**를 채택한다. - - 도엽 하천중심선은 피처가 잘게 쪼개져 있고 지류가 본류 중간에 T자로 붙는다. 그래서 - 피처 단위로 보면 상류망이 통째로 빠진다. 순서를 이렇게 잡는다: - - ① 세류선끼리 `unary_union`으로 노딩 — 중간에서 만나는 지류도 연결로 인식된다 - ② 도로 교차점에서 한 번 더 잘라 상·하류 조각을 물리적으로 분리한다 - ③ 끝점 그래프를 만들고, **도로 교차 노드는 통과하지 못하게** 막는다 - ④ 도로에 접한 조각을 교차점 표고와 비교해 상·하류 씨앗으로 정한다 - ⑤ 씨앗에서 퍼뜨려 이어진 망 전체를 채택 — 도로를 넘어가지 못하므로 상·하류가 섞이지 않는다 - """ - lines: list[LineString] = [] - for feature in stream_features: - geometry = feature.get("geometry") - if not geometry: - continue - try: - parsed = shape(geometry) - except Exception: # noqa: BLE001 - continue - lines.extend(line for line in _iter_linestrings(parsed) if line.length > 0) - if not lines: - return StreamSplit() - - pieces, crossing_nodes = _cut_network_at_road(lines, route_line) - if not pieces: - return StreamSplit() - - node_edges: dict[tuple[float, float], list[int]] = {} - ends: list[tuple[tuple[float, float], tuple[float, float]]] = [] - for index, piece in enumerate(pieces): - head = _node_key(*piece.coords[0]) - tail = _node_key(*piece.coords[-1]) - ends.append((head, tail)) - node_edges.setdefault(head, []).append(index) - node_edges.setdefault(tail, []).append(index) - - sampler = ElevationSampler(cloud) - upper_seeds: set[int] = set() - lower_seeds: set[int] = set() - for index, piece in enumerate(pieces): - touching = [node for node in ends[index] if node in crossing_nodes] - if not touching: - continue - crossing_z = float(np.min(sampler.at(np.array(touching, dtype=np.float64)))) - if _mean_elevation(piece, sampler) > crossing_z: - upper_seeds.add(index) - else: - lower_seeds.add(index) - - upstream = _spread_network(upper_seeds, ends, node_edges, crossing_nodes) - downstream = _spread_network(lower_seeds, ends, node_edges, crossing_nodes) - upstream - logger.info( - "배수유역: 세류 조각 %d개 → 상류망 %d개 채택 / 하류망 %d개 · 미연결 %d개 제외", - len(pieces), - len(upstream), - len(downstream), - len(pieces) - len(upstream) - len(downstream), - ) - return StreamSplit( - upstream=[pieces[index] for index in sorted(upstream)], - downstream=[pieces[index] for index in sorted(downstream)], - no_contact=len(pieces) - len(upstream) - len(downstream), - ) - - -def _cut_network_at_road( - lines: list[LineString], route_line: LineString -) -> tuple[list[LineString], set[tuple[float, float]]]: - """세류망을 노딩한 뒤 도로 교차점에서 자르고, 그 교차 노드를 함께 돌려준다.""" - noded = unary_union(lines) - pieces: list[LineString] = [] - crossing_nodes: set[tuple[float, float]] = set() - for piece in _iter_linestrings(noded): - if not piece.intersects(route_line): - pieces.append(piece) - continue - hits = _intersection_points(piece.intersection(route_line)) - positions = sorted( - { - position - for position in (piece.project(point) for point in hits) - if 0.0 < position < piece.length - } - ) - for point in hits: - crossing_nodes.add(_node_key(point.x, point.y)) - if not positions: - # 끝점이 도로에 닿은 경우 — 자를 필요는 없고 그 끝점이 곧 교차 노드다. - pieces.append(piece) - continue - bounds = [0.0, *positions, piece.length] - for start, end in zip(bounds, bounds[1:]): - if end - start <= 0: - continue - cut = _substring(piece, start, end) - if cut is not None: - pieces.append(cut) - return pieces, crossing_nodes - - -def _spread_network( - seeds: set[int], - ends: list[tuple[tuple[float, float], tuple[float, float]]], - node_edges: dict[tuple[float, float], list[int]], - blocked: set[tuple[float, float]], -) -> set[int]: - """씨앗 조각에서 끝점을 타고 퍼진다. 도로 교차 노드는 통과하지 않는다.""" - reached = set(seeds) - queue = list(seeds) - while queue: - index = queue.pop() - for node in ends[index]: - if node in blocked: - continue - for neighbour in node_edges.get(node, ()): - if neighbour not in reached: - reached.add(neighbour) - queue.append(neighbour) - return reached - - -def _node_key(x: float, y: float) -> tuple[float, float]: - """끝점 일치 판정용 좌표 키. 노딩 후에도 부동소수 오차가 남아 mm로 반올림한다.""" - return (round(float(x), 3), round(float(y), 3)) - - -class ElevationSampler: - """등고선 구름에서 임의 지점 표고를 읽는다 — 상·하류 판정 전용. - - 최근접 등고선 정점만 쓰면 오차가 등고선 간격(주곡선 5m)만큼 나서, 계곡 교차점 표고가 - 실제보다 한 등고선 위로 잡히고 상류 조각이 통째로 하류로 오판된다. TIN 선형보간을 - 1차로 쓰고, TIN 밖(볼록껍질 외부)만 최근접 정점으로 메운다. - """ - - def __init__(self, cloud: ContourCloud) -> None: - self._z = cloud.z - if cloud.is_empty: - self._interpolator = None - self._tree = None - return - self._interpolator = LinearNDInterpolator(cloud.xy, cloud.z) - self._tree = cKDTree(cloud.xy) - - def at(self, xy: np.ndarray) -> np.ndarray: - """(N, 2) 좌표의 표고 (N,).""" - if self._interpolator is None or self._tree is None: - return np.zeros(xy.shape[0]) - values = np.asarray(self._interpolator(xy), dtype=np.float64) - missing = ~np.isfinite(values) - if missing.any(): - _, indices = self._tree.query(xy[missing]) - values[missing] = self._z[indices] - return values - - -def _point_elevation(point: Any, tree: cKDTree | None, cloud: ContourCloud) -> float: - """가장 가까운 등고선 정점의 표고. TIN이 없는 단계의 근사 표고다.""" - if tree is None: - return 0.0 - _, index = tree.query([[point.x, point.y]]) - return float(cloud.z[index[0]]) - - -def _intersection_points(geometry: Any) -> list[Any]: - if geometry.is_empty: - return [] - if geometry.geom_type == "Point": - return [geometry] - if geometry.geom_type in {"MultiPoint", "GeometryCollection", "MultiLineString"}: - points: list[Any] = [] - for part in geometry.geoms: - points.extend(_intersection_points(part)) - return points - if geometry.geom_type == "LineString": - return [geometry.interpolate(0.5, normalized=True)] - return [] - - -def _substring(line: LineString, start: float, end: float) -> LineString | None: - """선형 위 [start, end] 구간을 잘라낸다.""" - piece = substring(line, start, end) - if piece.is_empty or piece.geom_type != "LineString" or piece.length <= 0: - return None - return piece - - -def _mean_elevation(line: LineString, sampler: ElevationSampler) -> float: - """선을 10m 간격으로 훑은 평균 표고.""" - samples = max(2, int(line.length // 10.0) + 1) - positions = np.linspace(0.0, line.length, samples) - points = np.array([list(line.interpolate(position).coords)[0] for position in positions]) - return float(np.mean(sampler.at(points))) - - -# ── ③ 격자 범위 ───────────────────────────────────────────────────────────── - - -@dataclass -class PrimaryRegion: - """1차 배수유역 — 격자 범위의 근거. 검증 화면이 이 내용을 그대로 그린다.""" - - split: StreamSplit - # 상류 세류망을 반경 버퍼해 합친 영역. 노선은 버퍼하지 않는다. - area: Polygon | MultiPolygon | None - spec: GridSpec - radius_m: float - # 1차 영역 밖으로 나간 노선 길이(m). 그 구간 사면은 해석에서 빠진다는 경고 지표. - road_outside_m: float = 0.0 - - -def build_primary_region( - route_line: LineString, - stream_features: list[dict[str, Any]], - cloud: ContourCloud, - radius_m: float, - cell_m: float = DRAINAGE_GRID_SIZE_M, -) -> PrimaryRegion: - """**상류 세류망 + 계획 노선**을 반경 버퍼해 합친 범위 = 1차 배수유역, bbox = 해석 격자. - - 노선 버퍼는 세류 교차가 없는 구간의 도로도 격자 안에 들어오게 한다 — 그래야 그 구간 - 사면이 유역으로 잡힌다. 상류 세류망 선정이 정확해진 뒤 다시 포함했다(2026-07-31 사용자 지시). - - 노선이 이 영역 밖으로 나가는 길이는 따로 재서 남긴다 — 그 구간은 도로 셀이 격자에 - 없어 유역이 잡히지 않으므로 반경을 올릴지 판단하는 근거가 된다. - """ - split = split_streams_at_road(route_line, stream_features, cloud) - geometries = [route_line.buffer(radius_m)] - geometries.extend(line.buffer(radius_m) for line in split.upstream) - if not split.upstream: - logger.warning("배수유역: 상류 세류망이 없어 노선 버퍼만으로 1차 영역을 잡습니다.") - area = unary_union(geometries) - x_min, y_min, x_max, y_max = area.bounds - spec = _spec_from_bounds(x_min, y_min, x_max, y_max, cell_m) - outside = route_line.difference(area) - road_outside_m = float(outside.length) if not outside.is_empty else 0.0 - logger.info( - "배수유역: 1차 영역 %.0f㎡, 범위 %.0fm × %.0fm, 격자 %d×%d (%.2fm), 노선 이탈 %.0fm/%.0fm", - area.area, - x_max - x_min, - y_max - y_min, - spec.n_rows, - spec.n_cols, - spec.cell_m, - road_outside_m, - route_line.length, - ) - return PrimaryRegion( - split=split, area=area, spec=spec, radius_m=radius_m, road_outside_m=road_outside_m - ) - - -def _spec_from_bounds( +def grid_spec_from_bounds( x_min: float, y_min: float, x_max: float, y_max: float, cell_m: float ) -> GridSpec: """격자 크기는 절대 자동으로 바꾸지 않는다 — config 값이 그대로 쓰인다(사용자 지시). @@ -528,7 +247,7 @@ def expand_grid_spec(spec: GridSpec, sides: dict[str, bool], step_m: float) -> G x_max = spec.x_min + spec.n_cols * spec.cell_m + (step_m if sides.get("east") else 0.0) y_max = spec.y_max + (step_m if sides.get("north") else 0.0) y_min = spec.y_max - spec.n_rows * spec.cell_m - (step_m if sides.get("south") else 0.0) - return _spec_from_bounds(x_min, y_min, x_max, y_max, spec.cell_m) + return grid_spec_from_bounds(x_min, y_min, x_max, y_max, spec.cell_m) # ── ④ TIN 보간 ────────────────────────────────────────────────────────────── diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Stream.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Stream.py new file mode 100644 index 00000000..cb629dea --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Stream.py @@ -0,0 +1,292 @@ +"""세류망 상·하류 분리와 1차 배수유역 산정. + +도엽 하천중심선은 피처가 잘게 쪼개져 있고 지류가 본류 중간에 T자로 붙는다. 피처 단위로 +자르면 상류망이 통째로 빠지므로, 노딩 → 도로 절단 → 끝점 그래프 확산으로 **이어진 망 +전체**를 잡는다(2026-07-31 사용자 지시). + +여기서 정해진 1차 배수유역의 bbox가 곧 격자 해석 범위가 된다. +표고 해석·격자 생성은 `B05_wf2_Route_Engine_Watershed_Grid.py`가 맡는다. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Any + +import numpy as np +from scipy.interpolate import LinearNDInterpolator +from scipy.spatial import cKDTree +from shapely.geometry import LineString, MultiPolygon, Polygon, shape +from shapely.ops import substring, unary_union + +from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import ( + ContourCloud, + GridSpec, + grid_spec_from_bounds, + iter_linestrings, +) +from config.config_system import DRAINAGE_GRID_SIZE_M + +logger = logging.getLogger(__name__) + + +# ── 세류망 상·하류 분리 ───────────────────────────────────────────────────── + + +@dataclass +class StreamSplit: + """세류망을 도로 교차점에서 상·하류로 가른 결과. 검증 화면이 그대로 그린다.""" + + upstream: list[LineString] = field(default_factory=list) # 채택 — 1차 영역의 기준 + downstream: list[LineString] = field(default_factory=list) # 도로 아래로 이어진 망 + no_contact: int = 0 # 어느 쪽에도 이어지지 않아 제외한 조각 수 + + +def split_streams_at_road( + route_line: LineString, + stream_features: list[dict[str, Any]], + cloud: ContourCloud, +) -> StreamSplit: + """세류망을 도로에서 끊고, 교차점 상류측으로 **이어진 망 전체**를 채택한다. + + 도엽 하천중심선은 피처가 잘게 쪼개져 있고 지류가 본류 중간에 T자로 붙는다. 그래서 + 피처 단위로 보면 상류망이 통째로 빠진다. 순서를 이렇게 잡는다: + + ① 세류선끼리 `unary_union`으로 노딩 — 중간에서 만나는 지류도 연결로 인식된다 + ② 도로 교차점에서 한 번 더 잘라 상·하류 조각을 물리적으로 분리한다 + ③ 끝점 그래프를 만들고, **도로 교차 노드는 통과하지 못하게** 막는다 + ④ 도로에 접한 조각을 교차점 표고와 비교해 상·하류 씨앗으로 정한다 + ⑤ 씨앗에서 퍼뜨려 이어진 망 전체를 채택 — 도로를 넘어가지 못하므로 상·하류가 섞이지 않는다 + """ + lines: list[LineString] = [] + for feature in stream_features: + geometry = feature.get("geometry") + if not geometry: + continue + try: + parsed = shape(geometry) + except Exception: # noqa: BLE001 + continue + lines.extend(line for line in iter_linestrings(parsed) if line.length > 0) + if not lines: + return StreamSplit() + + pieces, crossing_nodes = _cut_network_at_road(lines, route_line) + if not pieces: + return StreamSplit() + + node_edges: dict[tuple[float, float], list[int]] = {} + ends: list[tuple[tuple[float, float], tuple[float, float]]] = [] + for index, piece in enumerate(pieces): + head = _node_key(*piece.coords[0]) + tail = _node_key(*piece.coords[-1]) + ends.append((head, tail)) + node_edges.setdefault(head, []).append(index) + node_edges.setdefault(tail, []).append(index) + + sampler = ElevationSampler(cloud) + upper_seeds: set[int] = set() + lower_seeds: set[int] = set() + for index, piece in enumerate(pieces): + touching = [node for node in ends[index] if node in crossing_nodes] + if not touching: + continue + crossing_z = float(np.min(sampler.at(np.array(touching, dtype=np.float64)))) + if _mean_elevation(piece, sampler) > crossing_z: + upper_seeds.add(index) + else: + lower_seeds.add(index) + + upstream = _spread_network(upper_seeds, ends, node_edges, crossing_nodes) + downstream = _spread_network(lower_seeds, ends, node_edges, crossing_nodes) - upstream + logger.info( + "배수유역: 세류 조각 %d개 → 상류망 %d개 채택 / 하류망 %d개 · 미연결 %d개 제외", + len(pieces), + len(upstream), + len(downstream), + len(pieces) - len(upstream) - len(downstream), + ) + return StreamSplit( + upstream=[pieces[index] for index in sorted(upstream)], + downstream=[pieces[index] for index in sorted(downstream)], + no_contact=len(pieces) - len(upstream) - len(downstream), + ) + + +def _cut_network_at_road( + lines: list[LineString], route_line: LineString +) -> tuple[list[LineString], set[tuple[float, float]]]: + """세류망을 노딩한 뒤 도로 교차점에서 자르고, 그 교차 노드를 함께 돌려준다.""" + noded = unary_union(lines) + pieces: list[LineString] = [] + crossing_nodes: set[tuple[float, float]] = set() + for piece in iter_linestrings(noded): + if not piece.intersects(route_line): + pieces.append(piece) + continue + hits = _intersection_points(piece.intersection(route_line)) + positions = sorted( + { + position + for position in (piece.project(point) for point in hits) + if 0.0 < position < piece.length + } + ) + for point in hits: + crossing_nodes.add(_node_key(point.x, point.y)) + if not positions: + # 끝점이 도로에 닿은 경우 — 자를 필요는 없고 그 끝점이 곧 교차 노드다. + pieces.append(piece) + continue + bounds = [0.0, *positions, piece.length] + for start, end in zip(bounds, bounds[1:]): + if end - start <= 0: + continue + cut = _substring(piece, start, end) + if cut is not None: + pieces.append(cut) + return pieces, crossing_nodes + + +def _spread_network( + seeds: set[int], + ends: list[tuple[tuple[float, float], tuple[float, float]]], + node_edges: dict[tuple[float, float], list[int]], + blocked: set[tuple[float, float]], +) -> set[int]: + """씨앗 조각에서 끝점을 타고 퍼진다. 도로 교차 노드는 통과하지 않는다.""" + reached = set(seeds) + queue = list(seeds) + while queue: + index = queue.pop() + for node in ends[index]: + if node in blocked: + continue + for neighbour in node_edges.get(node, ()): + if neighbour not in reached: + reached.add(neighbour) + queue.append(neighbour) + return reached + + +def _node_key(x: float, y: float) -> tuple[float, float]: + """끝점 일치 판정용 좌표 키. 노딩 후에도 부동소수 오차가 남아 mm로 반올림한다.""" + return (round(float(x), 3), round(float(y), 3)) + + +class ElevationSampler: + """등고선 구름에서 임의 지점 표고를 읽는다 — 상·하류 판정 전용. + + 최근접 등고선 정점만 쓰면 오차가 등고선 간격(주곡선 5m)만큼 나서, 계곡 교차점 표고가 + 실제보다 한 등고선 위로 잡히고 상류 조각이 통째로 하류로 오판된다. TIN 선형보간을 + 1차로 쓰고, TIN 밖(볼록껍질 외부)만 최근접 정점으로 메운다. + """ + + def __init__(self, cloud: ContourCloud) -> None: + self._z = cloud.z + if cloud.is_empty: + self._interpolator = None + self._tree = None + return + self._interpolator = LinearNDInterpolator(cloud.xy, cloud.z) + self._tree = cKDTree(cloud.xy) + + def at(self, xy: np.ndarray) -> np.ndarray: + """(N, 2) 좌표의 표고 (N,).""" + if self._interpolator is None or self._tree is None: + return np.zeros(xy.shape[0]) + values = np.asarray(self._interpolator(xy), dtype=np.float64) + missing = ~np.isfinite(values) + if missing.any(): + _, indices = self._tree.query(xy[missing]) + values[missing] = self._z[indices] + return values + + +def _intersection_points(geometry: Any) -> list[Any]: + if geometry.is_empty: + return [] + if geometry.geom_type == "Point": + return [geometry] + if geometry.geom_type in {"MultiPoint", "GeometryCollection", "MultiLineString"}: + points: list[Any] = [] + for part in geometry.geoms: + points.extend(_intersection_points(part)) + return points + if geometry.geom_type == "LineString": + return [geometry.interpolate(0.5, normalized=True)] + return [] + + +def _substring(line: LineString, start: float, end: float) -> LineString | None: + """선형 위 [start, end] 구간을 잘라낸다.""" + piece = substring(line, start, end) + if piece.is_empty or piece.geom_type != "LineString" or piece.length <= 0: + return None + return piece + + +def _mean_elevation(line: LineString, sampler: ElevationSampler) -> float: + """선을 10m 간격으로 훑은 평균 표고.""" + samples = max(2, int(line.length // 10.0) + 1) + positions = np.linspace(0.0, line.length, samples) + points = np.array([list(line.interpolate(position).coords)[0] for position in positions]) + return float(np.mean(sampler.at(points))) + + +# ── 1차 배수유역 ──────────────────────────────────────────────────────────── + + +@dataclass +class PrimaryRegion: + """1차 배수유역 — 격자 범위의 근거. 검증 화면이 이 내용을 그대로 그린다.""" + + split: StreamSplit + # 상류 세류망을 반경 버퍼해 합친 영역. 노선은 버퍼하지 않는다. + area: Polygon | MultiPolygon | None + spec: GridSpec + radius_m: float + # 1차 영역 밖으로 나간 노선 길이(m). 그 구간 사면은 해석에서 빠진다는 경고 지표. + road_outside_m: float = 0.0 + + +def build_primary_region( + route_line: LineString, + stream_features: list[dict[str, Any]], + cloud: ContourCloud, + radius_m: float, + cell_m: float = DRAINAGE_GRID_SIZE_M, +) -> PrimaryRegion: + """**상류 세류망 + 계획 노선**을 반경 버퍼해 합친 범위 = 1차 배수유역, bbox = 해석 격자. + + 노선 버퍼는 세류 교차가 없는 구간의 도로도 격자 안에 들어오게 한다 — 그래야 그 구간 + 사면이 유역으로 잡힌다. 상류 세류망 선정이 정확해진 뒤 다시 포함했다(2026-07-31 사용자 지시). + + 노선이 이 영역 밖으로 나가는 길이는 따로 재서 남긴다 — 그 구간은 도로 셀이 격자에 + 없어 유역이 잡히지 않으므로 반경을 올릴지 판단하는 근거가 된다. + """ + split = split_streams_at_road(route_line, stream_features, cloud) + geometries = [route_line.buffer(radius_m)] + geometries.extend(line.buffer(radius_m) for line in split.upstream) + if not split.upstream: + logger.warning("배수유역: 상류 세류망이 없어 노선 버퍼만으로 1차 영역을 잡습니다.") + area = unary_union(geometries) + x_min, y_min, x_max, y_max = area.bounds + spec = grid_spec_from_bounds(x_min, y_min, x_max, y_max, cell_m) + outside = route_line.difference(area) + road_outside_m = float(outside.length) if not outside.is_empty else 0.0 + logger.info( + "배수유역: 1차 영역 %.0f㎡, 범위 %.0fm × %.0fm, 격자 %d×%d (%.2fm), 노선 이탈 %.0fm/%.0fm", + area.area, + x_max - x_min, + y_max - y_min, + spec.n_rows, + spec.n_cols, + spec.cell_m, + road_outside_m, + route_line.length, + ) + return PrimaryRegion( + split=split, area=area, spec=spec, radius_m=radius_m, road_outside_m=road_outside_m + ) From 683a5c91987a1856157771d50658e94d9b96a8ad Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 31 Jul 2026 18:05:58 +0900 Subject: [PATCH 31/61] =?UTF-8?q?feat(B05):=20=EA=B2=A9=EC=9E=90=EB=A5=BC?= =?UTF-8?q?=201=EC=B0=A8=20=EC=98=81=EC=97=AD=20=EC=95=88=EC=97=90?= =?UTF-8?q?=EB=A7=8C=20=EC=83=9D=EC=84=B1,=20=EC=9B=90=EC=A0=90=EC=9D=84?= =?UTF-8?q?=20=EB=8F=84=EB=A1=9C=20=EC=8B=9C=EC=9E=91=EC=A0=90=EC=97=90=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bbox 사각형만 그리던 것을 실제 셀 생성으로 바꾼다. - 격자 원점을 도로 시작점에 맞춘다(grid_spec_from_bounds anchor_xy). bbox 좌상단 기준이면 1차 영역이 조금만 달라져도 격자가 통째로 밀려 이전 결과와 셀이 대응되지 않는다. - build_cell_mask: 1차 영역에 조금이라도 걸치는 셀만 생성(all_touched). 합성 검증에서 bbox 360,000셀 중 197,623셀(55%)만 생성. - mask_row_spans: 셀을 행별 연속 구간으로 압축해 응답에 싣는다. 낱개 대비 0.23% 크기(451구간 vs 197,623셀). - expand_grid_spec 을 셀 정수배 확장으로 바꿔 확장해도 격자점이 유지된다. - grid_transform 을 Flow -> Grid 로 옮겨 중복 제거. - 프론트: bbox 전체 눈금 대신 생성된 셀만 흐린 선으로. 셀이 2px 미만이면 구간을 통짜로 흐리게 칠하고 확대하면 셀 하나하나가 보인다. - 저장: 01_primary_region_cells.npz 에 격자 규격 + 셀 마스크 기록 (셀 수십만 개라 GeoJSON 폴리곤으로는 못 남긴다) Co-Authored-By: Claude Fable 5 --- B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts | 7 +- .../B05_wf2_Route_Engine_Watershed_Export.py | 30 +++++ .../B05_wf2_Route_Engine_Watershed_Flow.py | 12 +- .../B05_wf2_Route_Engine_Watershed_Grid.py | 105 ++++++++++++++---- .../B05_wf2_Route_Engine_Watershed_Stream.py | 40 +++++-- .../B05_wf2_Route_Router_Drainage.py | 15 ++- .../B05_wf2_Route_UI_Drainage_Panel.ts | 73 ++++++------ 7 files changed, 204 insertions(+), 78 deletions(-) diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index 75e6841a..c9811d5c 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -316,11 +316,16 @@ export interface DrainagePrimaryRegion { cell_m: number; rows: number; cols: number; + /** bbox 전체 셀 수(참고값). */ + bbox_cells: number; + /** 1차 영역에 걸쳐 실제로 생성된 셀 수. */ cells: number; width_m: number; height_m: number; - /** 격자 bbox 링. 화면은 여기에 rows×cols 간격으로 실제 셀을 그린다. */ + /** 격자 bbox 링. 화면은 이 사각형을 rows×cols로 나눠 셀 좌표를 얻는다. */ bbox_lonlat: Array<[number, number]>; + /** 실제 생성된 셀 구간 [행, 시작열, 끝열(포함)]. 낱개 셀 대신 구간으로 온다. */ + row_spans: Array<[number, number, number]>; }; /** 영구저장소에 남긴 검증용 GeoJSON 경로. */ saved_to: string | null; diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Export.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Export.py index 21768ec8..871ddc41 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Export.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Export.py @@ -19,6 +19,7 @@ from datetime import datetime from pathlib import Path from typing import Any, Callable +import numpy as np from shapely.geometry.base import BaseGeometry from common_util.common_util_storage import resolve_stored_project_path @@ -120,6 +121,35 @@ def _to_lonlat_coords(geometry: BaseGeometry, to_lonlat: LonLat) -> Any: return None +def write_cell_mask(stored_path: str, stage: str, spec: Any, mask: Any) -> str | None: + """격자 셀 마스크를 `.npz`로 남긴다. + + 셀이 수십만 개라 GeoJSON 폴리곤으로는 못 남긴다. 격자 원점·셀 크기와 bool 마스크만 + 저장하면 어느 셀이 생성됐는지 그대로 복원된다. + """ + prefix = STAGES.get(stage) + if prefix is None or mask is None: + return None + directory = drainage_dir(stored_path) + target = directory / f"{prefix}_{stage}_cells.npz" + try: + directory.mkdir(parents=True, exist_ok=True) + np.savez_compressed( + target, + x_min=spec.x_min, + y_max=spec.y_max, + cell_m=spec.cell_m, + n_rows=spec.n_rows, + n_cols=spec.n_cols, + mask=mask, + ) + except OSError: + logger.warning("배수유역: 셀 마스크 저장 실패 (%s)", target) + return None + logger.info("배수유역: 셀 마스크 저장 — %s (%d셀)", target, int(mask.sum())) + return str(target) + + def _update_manifest( directory: Path, stage: str, filename: str, properties: dict[str, Any] ) -> None: diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Flow.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Flow.py index bc567ca5..015aa261 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Flow.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Flow.py @@ -19,12 +19,15 @@ from dataclasses import dataclass import numpy as np from rasterio.features import rasterize, shapes -from rasterio.transform import from_origin from scipy.spatial import cKDTree from shapely.geometry import LineString, MultiPolygon, Polygon, shape from shapely.ops import unary_union -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import GridSpec, TerrainGrid +from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import ( + GridSpec, + TerrainGrid, + grid_transform, +) from config.config_system import ( DRAINAGE_MIN_BASIN_AREA_M2, DRAINAGE_POLYGON_SIMPLIFY_M, @@ -62,11 +65,6 @@ class FlowResult: strength: np.ndarray # (K,) int64 — 도로 셀별 상류 셀 수(흐름 강도) -def grid_transform(spec: GridSpec): - """rasterio 아핀 변환. 행 0이 북쪽(y_max)이다.""" - return from_origin(spec.x_min, spec.y_max, spec.cell_m, spec.cell_m) - - # ── 도로 굽기 ─────────────────────────────────────────────────────────────── diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py index 4d49bc2f..e384ae65 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py @@ -23,10 +23,14 @@ from dataclasses import dataclass from typing import Any import numpy as np +from affine import Affine +from rasterio.features import rasterize +from rasterio.transform import from_origin from scipy.interpolate import LinearNDInterpolator from scipy.ndimage import distance_transform_edt from shapely import segmentize from shapely.geometry import LineString, shape +from shapely.geometry.base import BaseGeometry from skimage.morphology import reconstruction from config.config_system import ( @@ -213,41 +217,102 @@ def _outside_bounds( def grid_spec_from_bounds( - x_min: float, y_min: float, x_max: float, y_max: float, cell_m: float + x_min: float, + y_min: float, + x_max: float, + y_max: float, + cell_m: float, + anchor_xy: tuple[float, float] | None = None, ) -> GridSpec: - """격자 크기는 절대 자동으로 바꾸지 않는다 — config 값이 그대로 쓰인다(사용자 지시). + """범위를 덮는 격자를 만든다. `anchor_xy`를 주면 그 점에 셀 모서리를 맞춘다. - 셀 수가 많으면 경고만 남기고 그대로 진행한다. 느리면 `DRAINAGE_GRID_SIZE_M`을 사용자가 - 직접 올린다. 자동 강등은 도로 굽기 두께·정밀도 전제를 조용히 깨뜨려서 금지한다. + 격자 원점은 **도로 시작점**에 고정한다(2026-07-31 사용자 지시). bbox 좌상단에 맞추면 + 1차 영역이 조금만 달라져도 격자가 통째로 밀려 이전 결과와 셀이 대응되지 않는다. + 도로 시작점에 맞추면 반경·영역을 바꿔도 같은 자리의 셀은 같은 자리에 남는다. + + 격자 크기는 절대 자동으로 바꾸지 않는다 — config 값이 그대로 쓰인다(사용자 지시). + 셀 수가 많으면 경고만 남기고 그대로 진행한다. """ - width = max(x_max - x_min, cell_m) - height = max(y_max - y_min, cell_m) - n_cols = int(math.ceil(width / cell_m)) - n_rows = int(math.ceil(height / cell_m)) + if anchor_xy is not None: + anchor_x, anchor_y = anchor_xy + # 앵커에서 셀 정수배만큼 밖으로 나가 범위를 덮는다(넓어질 뿐 좁아지지 않는다). + x_min = anchor_x - math.ceil((anchor_x - x_min) / cell_m) * cell_m + y_max = anchor_y + math.ceil((y_max - anchor_y) / cell_m) * cell_m + n_cols = max(1, int(math.ceil((x_max - x_min) / cell_m))) + n_rows = max(1, int(math.ceil((y_max - y_min) / cell_m))) if n_cols * n_rows > DRAINAGE_MAX_GRID_CELLS: logger.warning( - "배수유역: 격자 %d×%d = %d셀 (%.0fm × %.0fm, 셀 %.1fm) — 권장 상한 %d셀 초과. " + "배수유역: 격자 %d×%d = %d셀 (%.0fm × %.0fm, 셀 %.2fm) — 권장 상한 %d셀 초과. " "그대로 진행합니다. 느리면 DRAINAGE_GRID_SIZE_M을 올리세요.", n_rows, n_cols, n_rows * n_cols, - width, - height, + n_cols * cell_m, + n_rows * cell_m, cell_m, DRAINAGE_MAX_GRID_CELLS, ) - return GridSpec( - x_min=x_min, y_max=y_min + n_rows * cell_m, cell_m=cell_m, n_rows=n_rows, n_cols=n_cols - ) + return GridSpec(x_min=x_min, y_max=y_max, cell_m=cell_m, n_rows=n_rows, n_cols=n_cols) def expand_grid_spec(spec: GridSpec, sides: dict[str, bool], step_m: float) -> GridSpec: - """활성 셀이 닿은 방향으로만 격자를 넓힌다.""" - x_min = spec.x_min - (step_m if sides.get("west") else 0.0) - x_max = spec.x_min + spec.n_cols * spec.cell_m + (step_m if sides.get("east") else 0.0) - y_max = spec.y_max + (step_m if sides.get("north") else 0.0) - y_min = spec.y_max - spec.n_rows * spec.cell_m - (step_m if sides.get("south") else 0.0) - return grid_spec_from_bounds(x_min, y_min, x_max, y_max, spec.cell_m) + """활성 셀이 닿은 방향으로만 격자를 넓힌다. + + 셀 정수배로만 넓혀 격자 격자점(도로 시작점 기준)이 그대로 유지되게 한다. + """ + steps = max(1, int(math.ceil(step_m / spec.cell_m))) + west = steps if sides.get("west") else 0 + east = steps if sides.get("east") else 0 + north = steps if sides.get("north") else 0 + south = steps if sides.get("south") else 0 + return GridSpec( + x_min=spec.x_min - west * spec.cell_m, + y_max=spec.y_max + north * spec.cell_m, + cell_m=spec.cell_m, + n_rows=spec.n_rows + north + south, + n_cols=spec.n_cols + west + east, + ) + + +def grid_transform(spec: GridSpec) -> Affine: + """rasterio 아핀 변환. 행 0이 북쪽(y_max)이다.""" + return from_origin(spec.x_min, spec.y_max, spec.cell_m, spec.cell_m) + + +def build_cell_mask(spec: GridSpec, geometry: BaseGeometry) -> np.ndarray: + """영역에 **조금이라도 걸치는** 셀만 True인 (rows, cols) 마스크. + + `all_touched=True`라서 셀이 영역과 한 점만 스쳐도 생성 대상이 된다(사용자 지시). + """ + if geometry is None or geometry.is_empty: + return np.zeros((spec.n_rows, spec.n_cols), dtype=bool) + burned = rasterize( + [(geometry, 1)], + out_shape=(spec.n_rows, spec.n_cols), + transform=grid_transform(spec), + fill=0, + dtype="uint8", + all_touched=True, + ) + return burned.astype(bool) + + +def mask_row_spans(mask: np.ndarray) -> list[tuple[int, int, int]]: + """마스크를 행별 연속 구간 [행, 시작열, 끝열(포함)]으로 압축한다. + + 셀 수십만 개를 그대로 내보낼 수 없으니 구간으로 줄인다 — 프론트는 이 구간만 받아 + 실제 셀 사각형을 그린다. + """ + spans: list[tuple[int, int, int]] = [] + for row in range(mask.shape[0]): + line = mask[row] + if not line.any(): + continue + padded = np.concatenate(([False], line, [False])) + edges = np.flatnonzero(padded[1:] != padded[:-1]) + for start, stop in zip(edges[0::2], edges[1::2]): + spans.append((row, int(start), int(stop) - 1)) + return spans # ── ④ TIN 보간 ────────────────────────────────────────────────────────────── diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Stream.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Stream.py index cb629dea..cdbedbb6 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Stream.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Stream.py @@ -23,6 +23,7 @@ from shapely.ops import substring, unary_union from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import ( ContourCloud, GridSpec, + build_cell_mask, grid_spec_from_bounds, iter_linestrings, ) @@ -240,16 +241,22 @@ def _mean_elevation(line: LineString, sampler: ElevationSampler) -> float: @dataclass class PrimaryRegion: - """1차 배수유역 — 격자 범위의 근거. 검증 화면이 이 내용을 그대로 그린다.""" + """1차 배수유역과 그 안에 생성된 격자. 검증 화면이 이 내용을 그대로 그린다.""" split: StreamSplit - # 상류 세류망을 반경 버퍼해 합친 영역. 노선은 버퍼하지 않는다. + # 상류 세류망 + 노선을 반경 버퍼해 합친 영역. area: Polygon | MultiPolygon | None spec: GridSpec radius_m: float + # 1차 영역에 조금이라도 걸쳐 실제로 생성된 셀 (rows, cols) bool 마스크. + cell_mask: np.ndarray | None = None # 1차 영역 밖으로 나간 노선 길이(m). 그 구간 사면은 해석에서 빠진다는 경고 지표. road_outside_m: float = 0.0 + @property + def active_cells(self) -> int: + return 0 if self.cell_mask is None else int(self.cell_mask.sum()) + def build_primary_region( route_line: LineString, @@ -258,11 +265,16 @@ def build_primary_region( radius_m: float, cell_m: float = DRAINAGE_GRID_SIZE_M, ) -> PrimaryRegion: - """**상류 세류망 + 계획 노선**을 반경 버퍼해 합친 범위 = 1차 배수유역, bbox = 해석 격자. + """**상류 세류망 + 계획 노선**을 반경 버퍼한 범위 = 1차 배수유역, 그 안에 격자를 생성한다. 노선 버퍼는 세류 교차가 없는 구간의 도로도 격자 안에 들어오게 한다 — 그래야 그 구간 사면이 유역으로 잡힌다. 상류 세류망 선정이 정확해진 뒤 다시 포함했다(2026-07-31 사용자 지시). + 격자는 bbox를 통째로 채우지 않는다. **도로 시작점에 셀 모서리를 맞춘 뒤, 1차 영역에 + 조금이라도 걸치는 셀만** 생성한다(2026-07-31 사용자 지시). bbox 전체를 쓰면 영역 밖 + 빈 셀이 대부분이라 의미가 없고, 원점을 bbox 좌상단에 두면 영역이 조금만 변해도 격자가 + 통째로 밀려 이전 결과와 셀이 대응되지 않는다. + 노선이 이 영역 밖으로 나가는 길이는 따로 재서 남긴다 — 그 구간은 도로 셀이 격자에 없어 유역이 잡히지 않으므로 반경을 올릴지 판단하는 근거가 된다. """ @@ -273,20 +285,32 @@ def build_primary_region( logger.warning("배수유역: 상류 세류망이 없어 노선 버퍼만으로 1차 영역을 잡습니다.") area = unary_union(geometries) x_min, y_min, x_max, y_max = area.bounds - spec = grid_spec_from_bounds(x_min, y_min, x_max, y_max, cell_m) + road_start = route_line.coords[0] + spec = grid_spec_from_bounds( + x_min, y_min, x_max, y_max, cell_m, anchor_xy=(float(road_start[0]), float(road_start[1])) + ) + cell_mask = build_cell_mask(spec, area) outside = route_line.difference(area) road_outside_m = float(outside.length) if not outside.is_empty else 0.0 + active = int(cell_mask.sum()) logger.info( - "배수유역: 1차 영역 %.0f㎡, 범위 %.0fm × %.0fm, 격자 %d×%d (%.2fm), 노선 이탈 %.0fm/%.0fm", + "배수유역: 1차 영역 %.0f㎡ → 격자 %d×%d (%.2fm, 도로 시점 기준) 중 %d셀 생성 " + "(bbox %d셀의 %.0f%%), 노선 이탈 %.0fm/%.0fm", area.area, - x_max - x_min, - y_max - y_min, spec.n_rows, spec.n_cols, spec.cell_m, + active, + spec.size, + 100.0 * active / max(spec.size, 1), road_outside_m, route_line.length, ) return PrimaryRegion( - split=split, area=area, spec=spec, radius_m=radius_m, road_outside_m=road_outside_m + split=split, + area=area, + spec=spec, + radius_m=radius_m, + cell_mask=cell_mask, + road_outside_m=road_outside_m, ) diff --git a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py index 1f2569e5..13675fa3 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py +++ b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py @@ -27,7 +27,8 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Basin import ( build_drainage_watershed, preview_primary_region, ) -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Export import write_stage +from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Export import write_cell_mask, write_stage +from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import mask_row_spans from B05_wf2_Route.B05_wf2_Route_Repository import ( get_latest_route, get_route_points, @@ -226,11 +227,18 @@ async def get_primary_region(project_id: UUID) -> dict[str, Any] | JSONResponse: "cell_m": spec.cell_m, "rows": spec.n_rows, "cols": spec.n_cols, - "cells": spec.size, + # bbox 전체 셀 수와, 1차 영역에 걸쳐 실제로 생성된 셀 수. + "bbox_cells": spec.size, + "cells": region.active_cells, "width_m": round(spec.n_cols * spec.cell_m, 1), "height_m": round(spec.n_rows * spec.cell_m, 1), - # 격자 bbox 링(닫힌 사각형). 프론트가 여기에 cell_m 간격으로 실제 셀을 그린다. + # 격자 bbox 링. 프론트는 이 사각형을 rows×cols로 나눠 행·열 좌표를 얻는다. "bbox_lonlat": _grid_bbox_lonlat(spec, to_lonlat), + # 실제 생성된 셀을 행별 연속 구간 [행, 시작열, 끝열]으로 압축해 보낸다. + # 셀을 낱개로 보내면 수십만 건이라 응답이 감당되지 않는다. + "row_spans": [list(span) for span in mask_row_spans(region.cell_mask)] + if region.cell_mask is not None + else [], }, } # 단계 산출물을 영구저장소에 남긴다 — 기능을 붙일 때마다 여기에 단계가 하나씩 늘어난다. @@ -252,6 +260,7 @@ async def get_primary_region(project_id: UUID) -> dict[str, Any] | JSONResponse: }, to_lonlat, ) + write_cell_mask(prepared["stored_path"], "primary_region", spec, region.cell_mask) return payload diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts index c127a8ed..62466fa4 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -272,10 +272,11 @@ export function createDrainagePanel(): DrainagePanel { context.stroke(); } - /** 해석 격자를 실제 셀 눈금으로 그린다. + /** 1차 영역에 걸쳐 실제로 생성된 셀만 사각형으로 그린다. * - * 셀 간격이 화면에서 너무 촘촘하면(2px 미만) 눈금이 뭉개져 회색 덩어리가 되므로, - * 그때는 테두리만 남기고 "확대하면 셀이 보인다"는 상태를 유지한다. */ + * bbox 전체를 채우지 않는다 — 백엔드가 준 행별 구간(row_spans)만 그린다. + * 셀이 화면에서 2px 미만이면 선이 뭉개져 회색 덩어리가 되므로, 그때는 구간을 통짜 + * 사각형으로 채워 격자가 덮은 범위만 흐리게 보여 준다(확대하면 셀 하나하나가 보인다). */ function drawGridCells( context: CanvasRenderingContext2D, map: Normalizer, @@ -286,49 +287,42 @@ export function createDrainagePanel(): DrainagePanel { if (ring.length < 4) return; const lons = ring.map(([lon]) => lon); const lats = ring.map(([, lat]) => lat); - const lonMin = Math.min(...lons); - const lonMax = Math.max(...lons); - const latMin = Math.min(...lats); - const latMax = Math.max(...lats); const ax = view.mapRect.width * view.scale; const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX; const ay = view.mapRect.height * view.scale; const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY; - const toX = (lon: number): number => ((lon - map.lonMin) / map.lonRange) * ax + bx; - const toY = (lat: number): number => (1 - (lat - map.latMin) / map.latRange) * ay + by; + const left = ((Math.min(...lons) - map.lonMin) / map.lonRange) * ax + bx; + const right = ((Math.max(...lons) - map.lonMin) / map.lonRange) * ax + bx; + const top = (1 - (Math.max(...lats) - map.latMin) / map.latRange) * ay + by; + const bottom = (1 - (Math.min(...lats) - map.latMin) / map.latRange) * ay + by; + + const { rows, cols, row_spans: spans } = region.grid; + const cellW = (right - left) / Math.max(cols, 1); + const cellH = (bottom - top) / Math.max(rows, 1); + const fine = Math.min(Math.abs(cellW), Math.abs(cellH)) >= 2; - const left = toX(lonMin); - const right = toX(lonMax); - const top = toY(latMax); - const bottom = toY(latMin); context.save(); context.setLineDash([]); - // 셀 눈금 — 열/행 수로 나눠 실제 셀 경계를 그대로 찍는다. - const cellWidthPx = Math.abs(right - left) / Math.max(region.grid.cols, 1); - const cellHeightPx = Math.abs(bottom - top) / Math.max(region.grid.rows, 1); - if (Math.min(cellWidthPx, cellHeightPx) >= 2) { - context.lineWidth = 0.5; - context.strokeStyle = "rgba(120, 113, 108, 0.35)"; - context.beginPath(); - for (let col = 0; col <= region.grid.cols; col += 1) { - const x = left + (right - left) * (col / region.grid.cols); - if (x < -50 || x > view.width + 50) continue; - context.moveTo(x, top); - context.lineTo(x, bottom); + context.strokeStyle = "rgba(120, 113, 108, 0.3)"; + context.fillStyle = "rgba(120, 113, 108, 0.12)"; + context.lineWidth = 0.5; + if (fine) context.beginPath(); + spans.forEach(([row, colStart, colEnd]) => { + const y = top + cellH * row; + if (y + cellH < -50 || y > view.height + 50) return; + const x = left + cellW * colStart; + const width = cellW * (colEnd - colStart + 1); + if (x + width < -50 || x > view.width + 50) return; + if (!fine) { + // 축소 상태 — 구간을 통짜로 칠해 격자가 덮은 범위만 흐리게 보여 준다. + context.fillRect(x, y, width, cellH); + return; } - for (let row = 0; row <= region.grid.rows; row += 1) { - const y = top + (bottom - top) * (row / region.grid.rows); - if (y < -50 || y > view.height + 50) continue; - context.moveTo(left, y); - context.lineTo(right, y); + for (let col = colStart; col <= colEnd; col += 1) { + context.rect(left + cellW * col, y, cellW, cellH); } - context.stroke(); - } - // 격자 전체 테두리는 항상 그린다. - context.setLineDash([10, 6]); - context.lineWidth = 1.5; - context.strokeStyle = "rgba(120, 113, 108, 0.9)"; - context.strokeRect(left, top, right - left, bottom - top); + }); + if (fine) context.stroke(); context.restore(); } @@ -522,8 +516,9 @@ export function createDrainagePanel(): DrainagePanel { return ( `1차 영역(반경 ${region.radius_m}m): 상류망 ${region.upstream_lines.length}조각 채택 / ` + `하류망 ${region.downstream_lines.length}조각·미연결 ${region.no_contact_count}개 제외 · ` + - `격자 ${region.grid.width_m}×${region.grid.height_m}m, ` + - `${region.grid.cell_m}m 셀 ${cells}개${outside}` + `격자 ${region.grid.cell_m}m(도로 시점 기준) 셀 ${cells}개 ` + + `/ bbox ${region.grid.width_m}×${region.grid.height_m}m ` + + `${region.grid.bbox_cells.toLocaleString()}셀${outside}` ); } From 06f2328b55300dfe6176892bdc91b4275b035199 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 31 Jul 2026 18:23:35 +0900 Subject: [PATCH 32/61] =?UTF-8?q?feat(B05):=20=EC=85=80=EB=B3=84=20?= =?UTF-8?q?=ED=9D=90=EB=A6=84=20=EB=B0=A9=ED=96=A5=20=ED=8C=90=EC=A0=95=20?= =?UTF-8?q?+=20=ED=99=94=EC=82=B4=ED=91=9C/=EC=A0=81=EC=B2=AD=20=ED=91=9C?= =?UTF-8?q?=EA=B8=B0=20(=ED=99=95=EC=9E=A5=20=EB=AF=B8=ED=8F=AC=ED=95=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 최외곽 셀부터 물길을 따라가며 도로 도달 여부를 판정하고 화면에 셀 단위로 표기한다. 격자 확장은 다음 검증 단계라 넣지 않았다. - classify_flow: 최외곽 셀에서 출발해 D8 수신 셀을 한 칸씩 따라가고, 경로가 끝나면 결과를 경로 전체에 되돌려 적는다. 한 번 판정한 셀은 재분석하지 않고 다른 경로가 만나면 즉시 결론을 가져온다. 최외곽으로 안 닿은 내부 셀은 그다음에 따로 출발시킨다. 합성 검증 165,874셀 0.5s. - outermost_cells: 해석 영역 밖에 8이웃이 닿는 셀 = 최외곽. - build_terrain_grid(domain=): 1차 영역 셀 마스크 안쪽만 해석 대상으로 삼는다. - direction_codes: 수신 셀 인덱스를 3x3 방향 코드로. 화살표 렌더용. - 응답은 셀당 1바이트 base64 (하위4비트=방향, 15=표고없음, 0x80=도로도달). 197,623셀 -> 257KB. - 프론트: 셀마다 화살표. 도로 도달 적색, 미도달 파랑, 표고없음 회색. 셀이 7px 미만이면 채움색만 남긴다. 격자선을 흰색으로 변경. - 저장: 02_flow_direction.npz (direction/reaches_road/analyzed) + manifest 합성 검증(능선 y=200, 도로 y=500): 도달 셀 y 200~502, 미도달 y 150~600. 능선 남쪽과 도로 아래가 정확히 제외됨. Co-Authored-By: Claude Fable 5 --- B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts | 14 ++ .../B05_wf2_Route_Engine_Watershed_Basin.py | 56 +++++++ .../B05_wf2_Route_Engine_Watershed_Export.py | 22 ++- .../B05_wf2_Route_Engine_Watershed_Flow.py | 122 ++++++++++++++ .../B05_wf2_Route_Engine_Watershed_Grid.py | 37 ++++- .../B05_wf2_Route_Router_Drainage.py | 79 ++++++++- .../B05_wf2_Route_UI_Drainage_Panel.ts | 157 +++++++++++++++--- 7 files changed, 444 insertions(+), 43 deletions(-) diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index c9811d5c..fd71c833 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -327,6 +327,20 @@ export interface DrainagePrimaryRegion { /** 실제 생성된 셀 구간 [행, 시작열, 끝열(포함)]. 낱개 셀 대신 구간으로 온다. */ row_spans: Array<[number, number, number]>; }; + /** 셀별 흐름 방향과 도로 도달 여부. 등고선이 없어 판정을 못하면 null. */ + flow: { + encoding: "base64-uint8"; + cells: number; + reaches_road: number; + no_road: number; + /** 격자에는 있으나 등고선 TIN 밖이라 표고가 없어 판정 못한 셀. */ + unanalyzed: number; + outer_seeds: number; + interior_seeds: number; + /** 셀당 1바이트. 하위 4비트=3×3 방향코드(4=제자리, 15=무효), 0x80=도로 도달. + * 순서는 grid.row_spans를 행 → 구간 → 열 오름차순으로 훑은 순서와 같다. */ + data: string; + } | null; /** 영구저장소에 남긴 검증용 GeoJSON 경로. */ saved_to: string | null; } diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py index 452ac168..c15e9db1 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py @@ -36,7 +36,10 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import ( is_uphill_at, ) from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Flow import ( + FlowClassification, + RoadRaster, border_contact, + classify_flow, largest_ring, outer_boundary, polygonize_labels, @@ -45,6 +48,7 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Flow import ( ) from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import ( GridSpec, + TerrainGrid, build_contour_cloud, build_terrain_grid, expand_grid_spec, @@ -55,6 +59,7 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Stream import ( build_primary_region, ) from config.config_system import ( + DRAINAGE_CONTOUR_CLIP_MARGIN_M, DRAINAGE_DITCH_SAMPLE_M, DRAINAGE_EXPAND_STEP_M, DRAINAGE_GRID_SIZE_M, @@ -203,6 +208,57 @@ def preview_primary_region( return resolve_primary_region(vertices, route_line, contour_features, stream_features) +@dataclass +class StagePreview: + """단계 검증 산출물 묶음. 기능을 붙일 때마다 여기에 항목이 하나씩 늘어난다.""" + + region: PrimaryRegion + terrain: TerrainGrid | None = None + road: RoadRaster | None = None + flow: FlowClassification | None = None + + +def preview_stages( + vertices: list[RouteVertex], + contour_features: list[dict[str, Any]], + stream_features: list[dict[str, Any]], +) -> StagePreview | None: + """지금까지 구현·검증된 단계를 순서대로 돌려 결과를 모은다. + + 현재 포함: ① 1차 배수유역 ② 격자 생성 ③ 표고·D8 ④ 흐름 방향/도로 도달 판정. + **격자 확장은 넣지 않는다** — 다음 검증 단계다(2026-07-31 사용자 지시). + """ + if len(vertices) < 2: + return None + route_line = LineString([(vertex.x, vertex.y) for vertex in vertices]) + region = resolve_primary_region(vertices, route_line, contour_features, stream_features) + if region is None: + return None + + # TIN은 격자 범위 + 여유만큼만 읽는다. 확장이 없으므로 여유는 클리핑 마진이면 충분하다. + spec = region.spec + cloud = build_contour_cloud( + contour_features, + route_elevation_floor([vertex.z for vertex in vertices]), + ( + spec.x_min - DRAINAGE_CONTOUR_CLIP_MARGIN_M, + spec.y_max - spec.n_rows * spec.cell_m - DRAINAGE_CONTOUR_CLIP_MARGIN_M, + spec.x_min + spec.n_cols * spec.cell_m + DRAINAGE_CONTOUR_CLIP_MARGIN_M, + spec.y_max + DRAINAGE_CONTOUR_CLIP_MARGIN_M, + ), + ) + if cloud.is_empty: + logger.warning("배수유역: 격자 범위 안에 등고선이 없어 흐름 판정을 건너뜁니다.") + return StagePreview(region=region) + + started = time.perf_counter() + terrain = build_terrain_grid(spec, cloud, region.cell_mask) + road = rasterize_road(spec, route_line, DRAINAGE_ROAD_WIDTH_M) + flow = classify_flow(terrain, road) + logger.info("배수유역: 흐름 판정 %.1fs (셀 %d개)", time.perf_counter() - started, spec.size) + return StagePreview(region=region, terrain=terrain, road=road, flow=flow) + + # ── ③~④ 격자 해석 (캐시 대상) ─────────────────────────────────────────────── diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Export.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Export.py index 871ddc41..1b8ac236 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Export.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Export.py @@ -30,6 +30,7 @@ logger = logging.getLogger(__name__) # 단계 이름 → 파일 접두 번호. 순서대로 읽으면 파이프라인 진행 순서가 된다. STAGES: dict[str, str] = { "primary_region": "01", + "flow_direction": "02", } _MANIFEST_FILENAME = "manifest.json" @@ -121,17 +122,19 @@ def _to_lonlat_coords(geometry: BaseGeometry, to_lonlat: LonLat) -> Any: return None -def write_cell_mask(stored_path: str, stage: str, spec: Any, mask: Any) -> str | None: - """격자 셀 마스크를 `.npz`로 남긴다. +def write_grid_arrays( + stored_path: str, stage: str, spec: Any, arrays: dict[str, Any], summary: dict[str, Any] +) -> str | None: + """격자 크기의 배열들을 `.npz`로 남긴다(셀 마스크·흐름 방향·도달 여부 등). - 셀이 수십만 개라 GeoJSON 폴리곤으로는 못 남긴다. 격자 원점·셀 크기와 bool 마스크만 - 저장하면 어느 셀이 생성됐는지 그대로 복원된다. + 셀이 수십만 개라 GeoJSON 폴리곤으로는 못 남긴다. 격자 원점·셀 크기와 배열만 저장하면 + 어느 셀이 어떤 값이었는지 그대로 복원된다. 요약값은 manifest에도 기록한다. """ prefix = STAGES.get(stage) - if prefix is None or mask is None: + if prefix is None or not arrays: return None directory = drainage_dir(stored_path) - target = directory / f"{prefix}_{stage}_cells.npz" + target = directory / f"{prefix}_{stage}.npz" try: directory.mkdir(parents=True, exist_ok=True) np.savez_compressed( @@ -141,12 +144,13 @@ def write_cell_mask(stored_path: str, stage: str, spec: Any, mask: Any) -> str | cell_m=spec.cell_m, n_rows=spec.n_rows, n_cols=spec.n_cols, - mask=mask, + **arrays, ) except OSError: - logger.warning("배수유역: 셀 마스크 저장 실패 (%s)", target) + logger.warning("배수유역: %s 배열 저장 실패 (%s)", stage, target) return None - logger.info("배수유역: 셀 마스크 저장 — %s (%d셀)", target, int(mask.sum())) + _update_manifest(directory, f"{stage}_arrays", target.name, summary) + logger.info("배수유역: %s 배열 저장 — %s (%s)", stage, target, ", ".join(arrays)) return str(target) diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Flow.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Flow.py index 015aa261..7edd0366 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Flow.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Flow.py @@ -26,6 +26,7 @@ from shapely.ops import unary_union from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import ( GridSpec, TerrainGrid, + direction_codes, grid_transform, ) from config.config_system import ( @@ -39,6 +40,18 @@ logger = logging.getLogger(__name__) # 포인터 더블링 반복 상한. 한 번에 경로 길이가 2배가 되므로 2^40 스텝이면 어떤 격자도 덮는다. _MAX_DOUBLING_ROUNDS = 40 +# 8이웃 (행 증분, 열 증분) — 최외곽 판정용. 거리는 쓰지 않는다. +_NEIGHBOR_SHIFTS = ( + (-1, 0, 1.0), + (1, 0, 1.0), + (0, -1, 1.0), + (0, 1, 1.0), + (-1, -1, 1.0), + (-1, 1, 1.0), + (1, -1, 1.0), + (1, 1, 1.0), +) + @dataclass class RoadRaster: @@ -158,6 +171,115 @@ def trace_flow(terrain: TerrainGrid, road: RoadRaster) -> FlowResult: ) +@dataclass +class FlowClassification: + """셀별 흐름 방향과 도로 도달 여부 — 확장 없이 현재 격자만 본 결과.""" + + direction: np.ndarray # (R*C,) int8 — 3×3 방향 코드(4=제자리), 무효 셀은 −1 + reaches_road: np.ndarray # (R*C,) bool — 물길을 따라가면 도로에 닿는가 + analyzed: np.ndarray # (R*C,) bool — 실제로 판정한 셀 + outer_seeds: int # 최외곽에서 출발해 판정한 셀 수 + interior_seeds: int # 최외곽 추적에 안 걸려 따로 출발시킨 내부 셀 수 + + +def outermost_cells(domain: np.ndarray) -> np.ndarray: + """해석 영역의 최외곽 셀 — 영역 밖(또는 격자 밖)에 8이웃이 하나라도 닿는 셀.""" + padded = np.zeros((domain.shape[0] + 2, domain.shape[1] + 2), dtype=bool) + padded[1:-1, 1:-1] = domain + exposed = np.zeros_like(domain) + rows, cols = domain.shape + for row_shift, col_shift, _ in _NEIGHBOR_SHIFTS: + neighbour = padded[ + 1 + row_shift : 1 + row_shift + rows, 1 + col_shift : 1 + col_shift + cols + ] + exposed |= ~neighbour + return exposed & domain + + +def classify_flow(terrain: TerrainGrid, road: RoadRaster) -> FlowClassification: + """최외곽 셀부터 물길을 따라가며 도로 도달 여부를 판정한다. + + 최외곽 셀에서 출발해 D8 수신 셀을 한 칸씩 따라가고, 경로가 끝나면 그 결과를 경로 전체에 + 되돌려 적는다. **한 번 판정한 셀은 다시 분석하지 않는다** — 다른 경로가 그 셀을 만나면 + 거기서 즉시 결론을 가져온다. 최외곽 추적으로 안 닿은 내부 셀은 그다음에 따로 출발시킨다 + (2026-07-31 사용자 지시). + + 경로가 도로 셀에 닿으면 경로 전체가 도로 도달(적색), 싱크에서 멈추거나 해석 영역 밖으로 + 나가면 미도달(파랑)이다. 격자 확장은 하지 않는다 — 다음 단계다. + """ + spec = terrain.spec + valid = terrain.valid.reshape(-1) + receiver = terrain.receiver + is_road = road.mask.reshape(-1) & valid + + direction = np.where(valid, direction_codes(spec, receiver), -1).astype(np.int8) + reaches = np.zeros(spec.size, dtype=bool) + # 0=미방문, 1=경로에 올라 있음, 2=판정 완료 + state = np.zeros(spec.size, dtype=np.int8) + + outer = np.flatnonzero(outermost_cells(terrain.valid)) + remaining = np.flatnonzero(valid) + outer_done = _walk_from(outer, receiver, valid, is_road, state, reaches) + interior_done = _walk_from(remaining, receiver, valid, is_road, state, reaches) + + analyzed = state == 2 + logger.info( + "배수유역: 흐름 판정 %d셀 (최외곽 출발 %d / 내부 보충 %d) — 도로 도달 %d, 미도달 %d", + int(analyzed.sum()), + outer_done, + interior_done, + int((reaches & analyzed).sum()), + int((~reaches & analyzed).sum()), + ) + return FlowClassification( + direction=direction, + reaches_road=reaches, + analyzed=analyzed, + outer_seeds=outer_done, + interior_seeds=interior_done, + ) + + +def _walk_from( + starts: np.ndarray, + receiver: np.ndarray, + valid: np.ndarray, + is_road: np.ndarray, + state: np.ndarray, + reaches: np.ndarray, +) -> int: + """출발 셀 목록에서 물길을 따라가며 판정한다. 새로 판정한 셀 수를 돌려준다.""" + resolved = 0 + path: list[int] = [] + for start in starts.tolist(): + if state[start] == 2: + continue + path.clear() + node = start + while True: + if state[node] == 2: + verdict = bool(reaches[node]) + break + if state[node] == 1: # 방어: 채움·평탄해소 후에는 순환이 없어야 한다 + verdict = False + break + state[node] = 1 + path.append(node) + if is_road[node]: + verdict = True + break + following = int(receiver[node]) + if following == node or not valid[following]: + verdict = False # 싱크에 갇히거나 해석 영역 밖으로 빠짐 + break + node = following + for visited in path: + reaches[visited] = verdict + state[visited] = 2 + resolved += len(path) + return resolved + + def border_contact(active: np.ndarray) -> dict[str, bool]: """활성 셀이 격자 최외곽에 닿은 방향. 전부 False면 유역이 능선 안에서 닫힌 것이다.""" return { diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py index e384ae65..b4e72984 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py @@ -353,16 +353,22 @@ def interpolate_elevation(spec: GridSpec, cloud: ContourCloud) -> np.ndarray: # ── ⑤ 웅덩이 채움 + 평탄면 해소 ───────────────────────────────────────────── -def condition_surface(surface: np.ndarray) -> tuple[np.ndarray, np.ndarray]: +def condition_surface( + surface: np.ndarray, domain: np.ndarray | None = None +) -> tuple[np.ndarray, np.ndarray]: """가짜 웅덩이를 채우고 평탄면에 미세 경사를 준다. 등고선 TIN은 같은 표고 정점 3개로 이루어진 평탄 삼각형과 계단형 가짜 웅덩이를 필연적으로 만든다. 그대로 D8을 돌리면 흐름이 거기서 끊겨 상류 추적이 멈춘다. - 채움은 형태학적 재구성(erosion)으로 한다. 배출구는 격자 최외곽과 TIN 경계(무효 셀에 - 맞닿은 유효 셀)로 둔다 — 그래야 유효 영역 전체가 하나의 평탄면으로 잠기지 않는다. + 채움은 형태학적 재구성(erosion)으로 한다. 배출구는 격자 최외곽과 유효 영역 경계(무효 + 셀에 맞닿은 유효 셀)로 둔다 — 그래야 유효 영역 전체가 하나의 평탄면으로 잠기지 않는다. + + `domain`을 주면 그 안쪽만 해석 대상으로 삼는다(1차 영역에 걸쳐 실제 생성된 셀 마스크). """ valid = np.isfinite(surface) + if domain is not None: + valid &= domain if not valid.any(): return surface, valid ceiling = float(np.nanmax(surface)) + 1000.0 @@ -467,13 +473,18 @@ def compute_receivers( # ── 오케스트레이션 ────────────────────────────────────────────────────────── -def build_terrain_grid(spec: GridSpec, cloud: ContourCloud) -> TerrainGrid: - """격자 범위와 등고선 구름으로 지형 해석 격자를 만든다.""" +def build_terrain_grid( + spec: GridSpec, cloud: ContourCloud, domain: np.ndarray | None = None +) -> TerrainGrid: + """격자 범위와 등고선 구름으로 지형 해석 격자를 만든다. + + `domain`은 실제 해석할 셀 마스크(1차 영역에 걸친 셀). 주면 그 밖은 무효로 둔다. + """ surface = interpolate_elevation(spec, cloud) - conditioned, valid = condition_surface(surface) + conditioned, valid = condition_surface(surface, domain) receiver, step = compute_receivers(spec, conditioned, valid) logger.info( - "배수유역: 격자 %d×%d (%.1fm), 유효 셀 %d개", + "배수유역: 격자 %d×%d (%.2fm), 해석 대상 셀 %d개", spec.n_rows, spec.n_cols, spec.cell_m, @@ -484,6 +495,18 @@ def build_terrain_grid(spec: GridSpec, cloud: ContourCloud) -> TerrainGrid: ) +def direction_codes(spec: GridSpec, receiver: np.ndarray) -> np.ndarray: + """수신 셀 인덱스를 3×3 방향 코드로 바꾼다. + + 코드 = (행증분 + 1) * 3 + (열증분 + 1) → 0~8. 4는 제자리(싱크)를 뜻한다. + 화면이 셀마다 화살표를 그릴 수 있도록 방향만 뽑아낸 표현이다. + """ + index = np.arange(receiver.size, dtype=np.int64) + row_delta = receiver // spec.n_cols - index // spec.n_cols + col_delta = receiver % spec.n_cols - index % spec.n_cols + return ((row_delta + 1) * 3 + (col_delta + 1)).astype(np.int8) + + def route_elevation_floor(route_z_values: list[float]) -> float | None: """계획선 최저점에서 여유를 뺀 등고선 하한. 값이 없으면 None(필터 미적용).""" finite = [value for value in route_z_values if math.isfinite(value) and value != 0.0] diff --git a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py index 13675fa3..fb316b9c 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py +++ b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py @@ -6,12 +6,14 @@ """ import asyncio +import base64 import json import logging from pathlib import Path from typing import Any from uuid import UUID +import numpy as np from fastapi import APIRouter from fastapi.responses import JSONResponse from pyproj import Transformer @@ -25,9 +27,9 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import ( ) from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Basin import ( build_drainage_watershed, - preview_primary_region, + preview_stages, ) -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Export import write_cell_mask, write_stage +from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Export import write_grid_arrays, write_stage from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import mask_row_spans from B05_wf2_Route.B05_wf2_Route_Repository import ( get_latest_route, @@ -196,17 +198,18 @@ async def get_primary_region(project_id: UUID) -> dict[str, Any] | JSONResponse: prepared = await _prepare(project_id) if isinstance(prepared, JSONResponse): return prepared - region = await asyncio.to_thread( - preview_primary_region, + preview = await asyncio.to_thread( + preview_stages, prepared["vertices"], prepared["contours"], prepared["streams"], ) - if region is None: + if preview is None: return JSONResponse( status_code=400, content={"status": "error", "message": "1차 배수유역을 정할 등고선이 없습니다."}, ) + region = preview.region to_lonlat = prepared["to_lonlat"] spec = region.spec payload = { @@ -240,6 +243,8 @@ async def get_primary_region(project_id: UUID) -> dict[str, Any] | JSONResponse: if region.cell_mask is not None else [], }, + # 셀별 흐름 방향과 도로 도달 여부. row_spans 순서(행 → 구간 → 열 오름차순)로 1바이트씩. + "flow": _flow_payload(preview, region), } # 단계 산출물을 영구저장소에 남긴다 — 기능을 붙일 때마다 여기에 단계가 하나씩 늘어난다. payload["saved_to"] = write_stage( @@ -260,10 +265,72 @@ async def get_primary_region(project_id: UUID) -> dict[str, Any] | JSONResponse: }, to_lonlat, ) - write_cell_mask(prepared["stored_path"], "primary_region", spec, region.cell_mask) + _write_stage_arrays(prepared["stored_path"], preview, region, spec) return payload +def _write_stage_arrays(stored_path: str, preview: Any, region: Any, spec: Any) -> None: + """격자 규모 배열(셀 마스크·흐름 방향·도달 여부)을 단계별 `.npz`로 남긴다.""" + if region.cell_mask is not None: + write_grid_arrays( + stored_path, + "primary_region", + spec, + {"mask": region.cell_mask}, + {"cells": region.active_cells, "bbox_cells": spec.size}, + ) + flow = preview.flow + if flow is None: + return + write_grid_arrays( + stored_path, + "flow_direction", + spec, + { + "direction": flow.direction.reshape(spec.n_rows, spec.n_cols), + "reaches_road": flow.reaches_road.reshape(spec.n_rows, spec.n_cols), + "analyzed": flow.analyzed.reshape(spec.n_rows, spec.n_cols), + }, + { + "analyzed": int(flow.analyzed.sum()), + "reaches_road": int((flow.reaches_road & flow.analyzed).sum()), + "no_road": int((~flow.reaches_road & flow.analyzed).sum()), + "outer_seeds": flow.outer_seeds, + "interior_seeds": flow.interior_seeds, + }, + ) + + +def _flow_payload(preview: Any, region: Any) -> dict[str, Any] | None: + """셀별 흐름 방향·도로 도달 여부를 바이트 배열로 압축한다. + + 셀이 수십만 개라 JSON 객체로는 못 보낸다. 셀 하나당 1바이트로 줄이고 base64로 싣는다: + 하위 4비트 = 3×3 방향 코드(0~8, 4=제자리), 15 = 무효(표고 없음) + 최상위 비트(0x80) = 도로 도달(적색). 꺼져 있으면 미도달(파랑). + 바이트 순서는 `grid.row_spans`를 행 → 구간 → 열 오름차순으로 훑은 순서와 같다. + """ + flow = preview.flow + if flow is None or region.cell_mask is None: + return None + order = np.flatnonzero(region.cell_mask.reshape(-1)) + codes = flow.direction[order] + analyzed = flow.analyzed[order] + reaches = flow.reaches_road[order] + packed = np.where(codes < 0, 15, codes).astype(np.uint8) + packed |= np.where(reaches, 0x80, 0).astype(np.uint8) + return { + "encoding": "base64-uint8", + "cells": int(order.size), + "reaches_road": int((reaches & analyzed).sum()), + "no_road": int((~reaches & analyzed).sum()), + # 격자에는 있으나 등고선 TIN 밖이라 표고가 없어 판정 못한 셀(방향 코드 15). + "unanalyzed": int((~analyzed).sum()), + "outer_seeds": flow.outer_seeds, + "interior_seeds": flow.interior_seeds, + "data": base64.b64encode(packed.tobytes()).decode("ascii"), + } + + def _as_polygons(geometry: Any) -> list[Any]: if geometry is None or geometry.is_empty: return [] diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts index 62466fa4..b5afcb29 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -51,6 +51,18 @@ const LAYER_LABELS: Record = { const ROUTE_COLOR = "#f97316"; const COLLAPSED_KEY = "b05-route-drainage-collapsed"; +// 해석 격자 셀 선 — 등고선·세류 위에 얹으므로 흰색으로 둔다(2026-07-31 사용자 지시). +const GRID_LINE_COLOR = "rgba(255, 255, 255, 0.55)"; +// 흐름 판정 색 — 도로로 물이 오는 셀은 적색, 오지 않는 셀은 파랑. +const FLOW_TO_ROAD_FILL = "rgba(220, 38, 38, 0.28)"; +const FLOW_TO_ROAD_LINE = "rgba(153, 27, 27, 0.95)"; +const FLOW_AWAY_FILL = "rgba(37, 99, 235, 0.22)"; +const FLOW_AWAY_LINE = "rgba(30, 64, 175, 0.9)"; +/** 등고선 TIN 밖이라 표고가 없어 판정하지 못한 셀 — 미도달(파랑)과 구분한다. */ +const FLOW_UNKNOWN_FILL = "rgba(120, 113, 108, 0.18)"; +/** 셀이 이보다 작으면 화살표가 뭉개져 읽히지 않으므로 채움색만 남긴다(px). */ +const ARROW_MIN_PX = 7; + /** 유역 오버레이 파스텔 색상. 번호 순으로 돌려쓴다(사용자 지시: 파스텔톤). */ const BASIN_COLORS = [ "rgba(167, 216, 199, 0.45)", @@ -159,6 +171,8 @@ export function createDrainagePanel(): DrainagePanel { // 1차 영역 검증 오버레이. null이면 표시하지 않는다. let primaryRegion: DrainagePrimaryRegion | null = null; let showRegion = false; + // 흐름 방향 바이트 디코드 캐시 — 매 프레임 base64를 다시 풀지 않는다. + let flowCache: { source: string; bytes: Uint8Array } | null = null; let scale = 1; let offsetX = 0; let offsetY = 0; @@ -272,11 +286,22 @@ export function createDrainagePanel(): DrainagePanel { context.stroke(); } - /** 1차 영역에 걸쳐 실제로 생성된 셀만 사각형으로 그린다. + /** 흐름 방향 바이트를 셀 순서대로 디코드한다(캐시 — 매 프레임 다시 풀지 않는다). */ + function flowBytes(region: DrainagePrimaryRegion): Uint8Array | null { + if (!region.flow) return null; + if (flowCache?.source === region.flow.data) return flowCache.bytes; + const binary = atob(region.flow.data); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); + flowCache = { source: region.flow.data, bytes }; + return bytes; + } + + /** 1차 영역에 걸쳐 실제로 생성된 셀만 그린다. * - * bbox 전체를 채우지 않는다 — 백엔드가 준 행별 구간(row_spans)만 그린다. - * 셀이 화면에서 2px 미만이면 선이 뭉개져 회색 덩어리가 되므로, 그때는 구간을 통짜 - * 사각형으로 채워 격자가 덮은 범위만 흐리게 보여 준다(확대하면 셀 하나하나가 보인다). */ + * bbox 전체를 채우지 않는다 — 백엔드가 준 행별 구간(row_spans)만 그린다. 흐름 판정이 + * 있으면 셀마다 방향 화살표를 얹고, 도로에 물이 닿는 셀은 적색·닿지 않으면 파랑으로 + * 칠한다. 셀이 화면에서 작아지면 화살표가 안 보이므로 채움색만 남긴다. */ function drawGridCells( context: CanvasRenderingContext2D, map: Normalizer, @@ -299,33 +324,115 @@ export function createDrainagePanel(): DrainagePanel { const { rows, cols, row_spans: spans } = region.grid; const cellW = (right - left) / Math.max(cols, 1); const cellH = (bottom - top) / Math.max(rows, 1); - const fine = Math.min(Math.abs(cellW), Math.abs(cellH)) >= 2; + const cellPx = Math.min(Math.abs(cellW), Math.abs(cellH)); + const bytes = flowBytes(region); context.save(); context.setLineDash([]); - context.strokeStyle = "rgba(120, 113, 108, 0.3)"; - context.fillStyle = "rgba(120, 113, 108, 0.12)"; - context.lineWidth = 0.5; - if (fine) context.beginPath(); + context.lineCap = "round"; + let cursor = 0; // row_spans를 훑은 순서 = 흐름 바이트 순서 spans.forEach(([row, colStart, colEnd]) => { + const count = colEnd - colStart + 1; + const base = cursor; + cursor += count; const y = top + cellH * row; - if (y + cellH < -50 || y > view.height + 50) return; + if (y + cellH < -40 || y > view.height + 40) return; const x = left + cellW * colStart; - const width = cellW * (colEnd - colStart + 1); - if (x + width < -50 || x > view.width + 50) return; - if (!fine) { - // 축소 상태 — 구간을 통짜로 칠해 격자가 덮은 범위만 흐리게 보여 준다. - context.fillRect(x, y, width, cellH); + const width = cellW * count; + if (x + width < -40 || x > view.width + 40) return; + + if (!bytes) { + // 흐름 판정 전 — 격자만 흰 선으로 보여 준다. + if (cellPx >= 2) { + context.strokeStyle = GRID_LINE_COLOR; + context.lineWidth = 0.5; + context.beginPath(); + for (let col = colStart; col <= colEnd; col += 1) { + context.rect(left + cellW * col, y, cellW, cellH); + } + context.stroke(); + } else { + context.fillStyle = "rgba(255, 255, 255, 0.2)"; + context.fillRect(x, y, width, cellH); + } return; } - for (let col = colStart; col <= colEnd; col += 1) { - context.rect(left + cellW * col, y, cellW, cellH); + for (let offset = 0; offset < count; offset += 1) { + drawFlowCell( + context, + bytes[base + offset], + left + cellW * (colStart + offset), + y, + cellW, + cellH, + cellPx, + ); } }); - if (fine) context.stroke(); context.restore(); } + /** 셀 하나 — 도달 여부로 칠하고, 충분히 크면 흐름 방향 화살표를 얹는다. */ + function drawFlowCell( + context: CanvasRenderingContext2D, + code: number, + x: number, + y: number, + cellW: number, + cellH: number, + cellPx: number, + ): void { + const direction = code & 0x0f; + const reaches = (code & 0x80) !== 0; + // 코드 15 = 등고선 TIN 밖이라 표고가 없어 판정 못한 셀. 파랑(미도달)과 구분한다. + const unanalyzed = direction === 15; + context.fillStyle = unanalyzed + ? FLOW_UNKNOWN_FILL + : reaches + ? FLOW_TO_ROAD_FILL + : FLOW_AWAY_FILL; + context.fillRect(x, y, cellW, cellH); + if (cellPx >= 2) { + context.strokeStyle = GRID_LINE_COLOR; + context.lineWidth = 0.5; + context.strokeRect(x, y, cellW, cellH); + } + if (cellPx < ARROW_MIN_PX || direction === 15) return; + const stroke = reaches ? FLOW_TO_ROAD_LINE : FLOW_AWAY_LINE; + const midX = x + cellW / 2; + const midY = y + cellH / 2; + if (direction === 4) { + // 제자리(싱크) — 방향이 없으므로 점으로 표시한다. + context.fillStyle = stroke; + context.beginPath(); + context.arc(midX, midY, Math.max(1, cellPx * 0.12), 0, Math.PI * 2); + context.fill(); + return; + } + const colDelta = (direction % 3) - 1; + const rowDelta = Math.floor(direction / 3) - 1; + const length = Math.hypot(colDelta, rowDelta) || 1; + const reach = (cellPx * 0.38) / length; + const tipX = midX + colDelta * reach; + const tipY = midY + rowDelta * reach; + context.strokeStyle = stroke; + context.lineWidth = Math.max(0.6, cellPx * 0.09); + context.beginPath(); + context.moveTo(midX - colDelta * reach, midY - rowDelta * reach); + context.lineTo(tipX, tipY); + context.stroke(); + // 촉 — 진행 방향 기준 좌우로 짧게 접는다. + const head = cellPx * 0.16; + const unitX = (colDelta / length) * head; + const unitY = (rowDelta / length) * head; + context.beginPath(); + context.moveTo(tipX, tipY); + context.lineTo(tipX - unitX - unitY * 0.7, tipY - unitY + unitX * 0.7); + context.moveTo(tipX, tipY); + context.lineTo(tipX - unitX + unitY * 0.7, tipY - unitY - unitX * 0.7); + context.stroke(); + } + /** 1차 배수유역 근거를 겹쳐 그린다 — 단계 검증용. */ function drawPrimaryRegion( context: CanvasRenderingContext2D, @@ -513,12 +620,20 @@ export function createDrainagePanel(): DrainagePanel { const cells = region.grid.cells.toLocaleString(); const outside = region.road_outside_m > 0 ? ` · 노선 이탈 ${Math.round(region.road_outside_m)}m` : ""; + const unknown = + region.flow && region.flow.unanalyzed > 0 + ? ` / 표고없음 ${region.flow.unanalyzed.toLocaleString()}(회)` + : ""; + const flow = region.flow + ? ` · 흐름 도로도달 ${region.flow.reaches_road.toLocaleString()}(적) / ` + + `미도달 ${region.flow.no_road.toLocaleString()}(청)${unknown}, ` + + `최외곽 출발 ${region.flow.outer_seeds.toLocaleString()} + ` + + `내부 보충 ${region.flow.interior_seeds.toLocaleString()}` + : " · 흐름 판정 없음"; return ( `1차 영역(반경 ${region.radius_m}m): 상류망 ${region.upstream_lines.length}조각 채택 / ` + `하류망 ${region.downstream_lines.length}조각·미연결 ${region.no_contact_count}개 제외 · ` + - `격자 ${region.grid.cell_m}m(도로 시점 기준) 셀 ${cells}개 ` + - `/ bbox ${region.grid.width_m}×${region.grid.height_m}m ` + - `${region.grid.bbox_cells.toLocaleString()}셀${outside}` + `격자 ${region.grid.cell_m}m(도로 시점 기준) 셀 ${cells}개${outside}${flow}` ); } From 638af3ecc6b5502c5385eba4350e2b15e5aa1c0b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 31 Jul 2026 18:50:21 +0900 Subject: [PATCH 33/61] =?UTF-8?q?feat(B05):=20=EC=84=B8=EB=A5=98=EB=A7=9D?= =?UTF-8?q?=20=ED=9D=90=EB=A6=84=20=EC=83=88=EA=B9=80=20+=2032=EB=B0=A9?= =?UTF-8?q?=EC=9C=84=20=ED=99=94=EC=82=B4=ED=91=9C=20+=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5=20=EB=A3=A8=ED=94=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 세류선 위 셀이 파랑으로 나오던 문제 원인: 등고선 TIN 보간면은 실제 물골(thalweg)을 재현하지 못해, 세류선 위 셀인데도 D8이 옆 사면으로 흘려보내 도로에 닿지 못했다. 조치 (2가지 함께): - burn_stream_flow: 확정된 상류 세류망을 따라 격자 흐름 방향을 강제로 새긴다. 세류망은 이미 도로를 건너 하류로 빠지는 물길로 확정된 자료다. - 사슬 추적의 종결 조건에 세류망 셀을 추가. 물이 세류에 합류한 시점에 도로 도달이 결정된다(사슬 끝이 도로 셀에 정확히 닿지 않아도 된다). - StreamSplit.upstream 을 물 흐름 방향(상류->하류)으로 정렬. 방향은 _spread_network 확산 시 진입 끝점을 기록해 한 번에 정한다. 2. 화살표 8방위 -> 32방위 descent_azimuth: 지표면 기울기에서 연속 최급강하 방위를 뽑아 32단계로 양자화. D8은 연결(도로 도달 판정)에만 쓰고 표시는 실제 지형 방위를 따른다. 세류망 새김 셀은 확정된 물길 방향을 그대로 쓴다. 응답 바이트: 하위 6비트=32방위(32=제자리, 33=표고없음), 0x80=도로 도달. 3. 확장 루프 분리 Basin._solve_grid 안에 있던 루프를 Flow.expand_until_closed 로 분리. 단계 검증 미리보기는 이 경로를 타지 않는다. 4. 도로 미도달 셀 화살표를 백색으로 변경(파랑 채움 + 백색 화살표). 합성 검증: 세류 새김 389셀 전부 도로 도달, 32방위 전부 등장, 지류가 본류 중간에 합류하는 갈래를 미연결로 오탐하던 경고 제거. Co-Authored-By: Claude Fable 5 --- B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts | 10 +- .../B05_wf2_Route_Engine_Watershed_Basin.py | 56 ++--- .../B05_wf2_Route_Engine_Watershed_Flow.py | 226 ++++++++++++++++-- .../B05_wf2_Route_Engine_Watershed_Grid.py | 56 ++++- .../B05_wf2_Route_Engine_Watershed_Stream.py | 64 +++-- .../B05_wf2_Route_Router_Drainage.py | 39 ++- .../B05_wf2_Route_UI_Drainage_Panel.ts | 50 ++-- 7 files changed, 388 insertions(+), 113 deletions(-) diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index fd71c833..2c36b24d 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -330,14 +330,22 @@ export interface DrainagePrimaryRegion { /** 셀별 흐름 방향과 도로 도달 여부. 등고선이 없어 판정을 못하면 null. */ flow: { encoding: "base64-uint8"; + /** 방위 분해능(32). 코드 0 = 화면 오른쪽, 시계방향 증가. */ + azimuth_steps: number; + /** 제자리(더 낮은 이웃 없음)를 뜻하는 코드. */ + sink_code: number; + /** 표고가 없어 판정 못한 셀 코드. */ + invalid_code: number; cells: number; reaches_road: number; no_road: number; /** 격자에는 있으나 등고선 TIN 밖이라 표고가 없어 판정 못한 셀. */ unanalyzed: number; + /** 확정된 상류 세류망을 따라 흐름을 강제로 새긴 셀 수. */ + burned: number; outer_seeds: number; interior_seeds: number; - /** 셀당 1바이트. 하위 4비트=3×3 방향코드(4=제자리, 15=무효), 0x80=도로 도달. + /** 셀당 1바이트. 하위 6비트=32방위 코드(32=제자리, 33=무효), 0x80=도로 도달. * 순서는 grid.row_spans를 행 → 구간 → 열 오름차순으로 훑은 순서와 같다. */ data: string; } | null; diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py index c15e9db1..3a03ff5b 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py @@ -38,20 +38,19 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import ( from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Flow import ( FlowClassification, RoadRaster, - border_contact, + burn_stream_flow, classify_flow, + expand_until_closed, largest_ring, outer_boundary, polygonize_labels, rasterize_road, - trace_flow, ) from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import ( GridSpec, TerrainGrid, build_contour_cloud, build_terrain_grid, - expand_grid_spec, route_elevation_floor, ) from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Stream import ( @@ -254,7 +253,10 @@ def preview_stages( started = time.perf_counter() terrain = build_terrain_grid(spec, cloud, region.cell_mask) road = rasterize_road(spec, route_line, DRAINAGE_ROAD_WIDTH_M) - flow = classify_flow(terrain, road) + # 확정된 상류 세류망을 따라 흐름을 새긴다 — 세류선 위 셀과 그리로 흘러드는 셀은 + # 반드시 도로에 도달해야 한다(TIN 보간면은 실제 물골을 재현하지 못한다). + terrain, burned = burn_stream_flow(terrain, road, region.split.upstream) + flow = classify_flow(terrain, road, burned) logger.info("배수유역: 흐름 판정 %.1fs (셀 %d개)", time.perf_counter() - started, spec.size) return StagePreview(region=region, terrain=terrain, road=road, flow=flow) @@ -291,40 +293,18 @@ def _solve_grid( logger.warning("배수유역: 1차 영역 안에 등고선이 없어 격자 해석을 건너뜁니다.") return None - terrain = road = flow = None - for round_index in range(DRAINAGE_MAX_EXPAND_ROUNDS + 1): - started = time.perf_counter() - terrain = build_terrain_grid(spec, cloud) - road = rasterize_road(spec, route_line, DRAINAGE_ROAD_WIDTH_M) - flow = trace_flow(terrain, road) - # 격자 크기(config DRAINAGE_GRID_SIZE_M)를 조정할 근거가 되도록 회차별 소요를 남긴다. - logger.info( - "배수유역: %d회차 해석 %.1fs (셀 %d개)", - round_index + 1, - time.perf_counter() - started, - spec.size, - ) - contact = border_contact(flow.active) - if not any(contact.values()): - break - if round_index == DRAINAGE_MAX_EXPAND_ROUNDS: - logger.warning( - "배수유역: 확장 상한(%d회)에 도달 — 경계 %s가 아직 활성입니다.", - DRAINAGE_MAX_EXPAND_ROUNDS, - [side for side, touched in contact.items() if touched], - ) - break - widened = expand_grid_spec(spec, contact, DRAINAGE_EXPAND_STEP_M) - if widened == spec: - break - logger.info( - "배수유역: 경계 %s 활성 — %.0fm 확장", - [side for side, touched in contact.items() if touched], - DRAINAGE_EXPAND_STEP_M, - ) - spec = widened - - assert terrain is not None and road is not None and flow is not None + # 확장 루프는 `Watershed_Flow.expand_until_closed()`로 분리했다(2026-07-31 사용자 지시). + # 단계 검증 미리보기(`preview_stages`)는 이 경로를 타지 않는다 — 확장 자체가 아직 검증 대상. + started = time.perf_counter() + expansion = expand_until_closed(spec, cloud, route_line) + spec, terrain, road, flow = expansion.spec, expansion.terrain, expansion.road, expansion.flow + logger.info( + "배수유역: 격자 해석 %.1fs (확장 %d회, %s, 셀 %d개)", + time.perf_counter() - started, + expansion.rounds, + "닫힘" if expansion.closed else "미닫힘", + spec.size, + ) solution = _GridSolution( spec=spec, elevation=terrain.elevation.reshape(-1), diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Flow.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Flow.py index 7edd0366..dbd3340a 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Flow.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Flow.py @@ -16,6 +16,7 @@ from __future__ import annotations import logging from dataclasses import dataclass +from typing import Any import numpy as np from rasterio.features import rasterize, shapes @@ -24,12 +25,18 @@ from shapely.geometry import LineString, MultiPolygon, Polygon, shape from shapely.ops import unary_union from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import ( + ContourCloud, GridSpec, TerrainGrid, - direction_codes, + build_cell_mask, + build_terrain_grid, + descent_azimuth, + expand_grid_spec, grid_transform, ) from config.config_system import ( + DRAINAGE_EXPAND_STEP_M, + DRAINAGE_MAX_EXPAND_ROUNDS, DRAINAGE_MIN_BASIN_AREA_M2, DRAINAGE_POLYGON_SIMPLIFY_M, DRAINAGE_ROAD_WIDTH_M, @@ -171,15 +178,103 @@ def trace_flow(terrain: TerrainGrid, road: RoadRaster) -> FlowResult: ) +def burn_stream_flow( + terrain: TerrainGrid, road: RoadRaster, streams: list[LineString] +) -> tuple[TerrainGrid, np.ndarray]: + """확정된 상류 세류망을 따라 격자 흐름 방향을 강제로 새긴다. + + 등고선 TIN 보간면은 실제 물골(thalweg)을 그대로 재현하지 못한다. 그래서 세류선 위 + 셀인데도 D8이 옆 사면으로 흘려보내 도로에 닿지 못하는 일이 생긴다. 세류선은 이미 + "도로를 건너 하류로 빠지는 물길"로 확정된 자료이므로, 그 위 셀의 흐름 방향은 추정할 + 것이 아니라 **그대로 따라야 한다**(2026-07-31 사용자 지시). + + 세류선 위 셀은 물길 방향의 다음 셀을 수신 셀로 삼는다. 그러면 세류선 셀은 물론, + 세류선으로 흘러드는 사면 셀까지 전부 도로에 도달한다. 도로 셀은 흡수점이므로 건드리지 않는다. + + 돌려주는 값: (흐름이 새겨진 지형, 새긴 셀 마스크). + """ + spec = terrain.spec + receiver = terrain.receiver.copy() + step_length = terrain.step_length.copy() + valid = terrain.valid.reshape(-1) + road_cells = road.mask.reshape(-1) + burned = np.zeros(spec.size, dtype=bool) + + tails: list[int] = [] + for line in streams: + chain = _line_cell_chain(spec, line) + if len(chain) < 2: + continue + for current, following in zip(chain, chain[1:]): + if not valid[current] or not valid[following] or road_cells[current]: + continue + if not _is_neighbour(spec, current, following): + continue + receiver[current] = following + step_length[current] = _cell_distance(spec, current, following) + burned[current] = True + tails.append(chain[-1]) + + # 하류 끝이 도로에도, 다른 세류 갈래에도 닿지 않은 갈래만 진짜 문제다. + # 지류가 본류 중간에 합류하는 경우는 끝점이 본류 셀이므로 정상이다. + detached = sum(1 for tail in tails if not road_cells[tail] and not burned[tail]) + if detached: + logger.warning( + "배수유역: 세류망 %d갈래의 하류 끝이 도로·다른 세류 어디에도 닿지 않습니다.", + detached, + ) + logger.info("배수유역: 세류망 흐름 새김 %d셀 (세류 %d갈래)", int(burned.sum()), len(streams)) + return ( + TerrainGrid( + spec=spec, + elevation=terrain.elevation, + valid=terrain.valid, + receiver=receiver, + step_length=step_length, + ), + burned, + ) + + +def _line_cell_chain(spec: GridSpec, line: LineString) -> list[int]: + """선을 따라 지나가는 셀을 순서대로 뽑는다(연속 중복 제거).""" + step = max(spec.cell_m / 2.0, 0.1) + positions = np.arange(0.0, line.length + step, step) + chain: list[int] = [] + for position in positions: + point = line.interpolate(float(position)) + col = int((point.x - spec.x_min) // spec.cell_m) + row = int((spec.y_max - point.y) // spec.cell_m) + if not (0 <= row < spec.n_rows and 0 <= col < spec.n_cols): + continue + index = row * spec.n_cols + col + if not chain or chain[-1] != index: + chain.append(index) + return chain + + +def _is_neighbour(spec: GridSpec, first: int, second: int) -> bool: + row_delta = abs(first // spec.n_cols - second // spec.n_cols) + col_delta = abs(first % spec.n_cols - second % spec.n_cols) + return max(row_delta, col_delta) == 1 + + +def _cell_distance(spec: GridSpec, first: int, second: int) -> float: + row_delta = abs(first // spec.n_cols - second // spec.n_cols) + col_delta = abs(first % spec.n_cols - second % spec.n_cols) + return spec.cell_m * float(np.hypot(row_delta, col_delta)) + + @dataclass class FlowClassification: """셀별 흐름 방향과 도로 도달 여부 — 확장 없이 현재 격자만 본 결과.""" - direction: np.ndarray # (R*C,) int8 — 3×3 방향 코드(4=제자리), 무효 셀은 −1 + direction: np.ndarray # (R*C,) int16 — 32방위 코드(32=제자리, 33=무효) reaches_road: np.ndarray # (R*C,) bool — 물길을 따라가면 도로에 닿는가 analyzed: np.ndarray # (R*C,) bool — 실제로 판정한 셀 outer_seeds: int # 최외곽에서 출발해 판정한 셀 수 interior_seeds: int # 최외곽 추적에 안 걸려 따로 출발시킨 내부 셀 수 + burned: np.ndarray | None = None # (R*C,) bool — 세류망을 따라 흐름을 새긴 셀 def outermost_cells(domain: np.ndarray) -> np.ndarray: @@ -196,33 +291,54 @@ def outermost_cells(domain: np.ndarray) -> np.ndarray: return exposed & domain -def classify_flow(terrain: TerrainGrid, road: RoadRaster) -> FlowClassification: +def classify_flow( + terrain: TerrainGrid, road: RoadRaster, burned: np.ndarray | None = None +) -> FlowClassification: """최외곽 셀부터 물길을 따라가며 도로 도달 여부를 판정한다. - 최외곽 셀에서 출발해 D8 수신 셀을 한 칸씩 따라가고, 경로가 끝나면 그 결과를 경로 전체에 - 되돌려 적는다. **한 번 판정한 셀은 다시 분석하지 않는다** — 다른 경로가 그 셀을 만나면 - 거기서 즉시 결론을 가져온다. 최외곽 추적으로 안 닿은 내부 셀은 그다음에 따로 출발시킨다 + 물이 흐르는 순서 그대로 따라간다 — 셀이 각자 도로를 바라보는 게 아니라, 한 셀에서 + 출발해 화살표가 가리키는 다음 셀, 그 셀이 가리키는 그다음 셀로 사슬처럼 이어 간다 (2026-07-31 사용자 지시). - 경로가 도로 셀에 닿으면 경로 전체가 도로 도달(적색), 싱크에서 멈추거나 해석 영역 밖으로 - 나가면 미도달(파랑)이다. 격자 확장은 하지 않는다 — 다음 단계다. + 사슬이 **도로 셀 또는 확정된 세류망 셀**에 닿으면 그 사슬 전체가 적색이다. 세류망은 + 이미 "도로를 건너 하류로 빠지는 물길"로 확정된 자료이므로, 물이 세류에 합류한 시점에 + 도로 도달이 결정된다. 사슬이 싱크에서 멈추거나 해석 영역 밖으로 나가면 전체가 미도달 + (파랑 채움 + 백색 화살표)이다. + + **한 번 판정한 셀은 다시 분석하지 않는다** — 사슬이 이미 색이 정해진 셀을 만나면 그 + 셀의 색을 그대로 물려받고 끝낸다. 최외곽 추적으로 안 닿은 내부 셀은 그다음에 따로 + 출발시킨다. + + 화살표 방위는 D8(8방위)이 아니라 지형 최급강하 32방위를 쓴다. `burned`(세류망을 따라 + 흐름을 새긴 셀)는 확정된 물길 방향을 그대로 쓴다. """ spec = terrain.spec valid = terrain.valid.reshape(-1) receiver = terrain.receiver - is_road = road.mask.reshape(-1) & valid + # 도로 셀과 세류망 셀 둘 다 "여기 닿으면 적색"인 종결점이다. + absorbing = road.mask.reshape(-1) & valid + if burned is not None: + absorbing = absorbing | (burned & valid) - direction = np.where(valid, direction_codes(spec, receiver), -1).astype(np.int8) + direction = descent_azimuth(spec, terrain.elevation, terrain.valid, receiver, burned) reaches = np.zeros(spec.size, dtype=bool) # 0=미방문, 1=경로에 올라 있음, 2=판정 완료 state = np.zeros(spec.size, dtype=np.int8) outer = np.flatnonzero(outermost_cells(terrain.valid)) remaining = np.flatnonzero(valid) - outer_done = _walk_from(outer, receiver, valid, is_road, state, reaches) - interior_done = _walk_from(remaining, receiver, valid, is_road, state, reaches) + outer_done = _walk_from(outer, receiver, valid, absorbing, state, reaches) + interior_done = _walk_from(remaining, receiver, valid, absorbing, state, reaches) analyzed = state == 2 + if burned is not None: + stranded = int((burned & analyzed & ~reaches).sum()) + if stranded: + logger.warning( + "배수유역: 세류망 새김 셀 %d개가 도로에 닿지 않습니다 — 하류 끝이 도로 셀과 " + "이어지지 않았는지 확인이 필요합니다.", + stranded, + ) logger.info( "배수유역: 흐름 판정 %d셀 (최외곽 출발 %d / 내부 보충 %d) — 도로 도달 %d, 미도달 %d", int(analyzed.sum()), @@ -237,6 +353,7 @@ def classify_flow(terrain: TerrainGrid, road: RoadRaster) -> FlowClassification: analyzed=analyzed, outer_seeds=outer_done, interior_seeds=interior_done, + burned=burned, ) @@ -244,11 +361,15 @@ def _walk_from( starts: np.ndarray, receiver: np.ndarray, valid: np.ndarray, - is_road: np.ndarray, + absorbing: np.ndarray, state: np.ndarray, reaches: np.ndarray, ) -> int: - """출발 셀 목록에서 물길을 따라가며 판정한다. 새로 판정한 셀 수를 돌려준다.""" + """출발 셀에서 물길 사슬을 따라가며 판정하고, 사슬 전체에 같은 색을 적는다. + + `absorbing`은 도로 셀과 확정된 세류망 셀 — 여기 닿으면 사슬 전체가 도로 도달이다. + 새로 판정한 셀 수를 돌려준다. + """ resolved = 0 path: list[int] = [] for start in starts.tolist(): @@ -258,15 +379,15 @@ def _walk_from( node = start while True: if state[node] == 2: - verdict = bool(reaches[node]) + verdict = bool(reaches[node]) # 이미 색이 정해진 셀 — 그 색을 물려받는다 break if state[node] == 1: # 방어: 채움·평탄해소 후에는 순환이 없어야 한다 verdict = False break state[node] = 1 path.append(node) - if is_road[node]: - verdict = True + if absorbing[node]: + verdict = True # 도로 또는 세류망에 합류 — 여기서 하류로 빠진다 break following = int(receiver[node]) if following == node or not valid[following]: @@ -280,6 +401,77 @@ def _walk_from( return resolved +# ── 격자 확장 (별도 단계) ─────────────────────────────────────────────────── + + +@dataclass +class ExpansionResult: + """확장 루프 결과. 확장을 쓰지 않는 경로에서는 이 모듈을 부르지 않는다.""" + + spec: GridSpec + terrain: TerrainGrid + road: RoadRaster + flow: FlowResult + rounds: int # 실제로 넓힌 횟수 (0 = 처음부터 닫혀 있었음) + closed: bool # 경계 링이 전부 비활성이 되어 스스로 멈췄는가 + + +def expand_until_closed( + spec: GridSpec, + cloud: ContourCloud, + route_line: LineString, + region_area: Any = None, + road_width_m: float = DRAINAGE_ROAD_WIDTH_M, + max_rounds: int = DRAINAGE_MAX_EXPAND_ROUNDS, + step_m: float = DRAINAGE_EXPAND_STEP_M, +) -> ExpansionResult: + """활성 셀이 격자 최외곽에 닿은 방향으로만 넓히며 유역이 닫힐 때까지 반복한다. + + **본 계산 경로에서만 쓰는 별도 단계다**(2026-07-31 사용자 지시로 분리). 단계 검증 + 미리보기는 확장 없이 현재 격자만 본다 — 확장 로직 자체가 아직 검증 대상이기 때문이다. + + 종료 조건은 반경 상한이 아니라 **경계 링 전체가 비활성**이 되는 것이다. 비활성 셀이 + 최외곽에 띠로 완성되면 그 바깥은 볼 필요가 없다. `max_rounds`는 무한 반복 방지용이다. + + `region_area`를 주면 확장된 격자에서도 그 영역에 걸치는 셀만 해석 대상으로 삼는다. + """ + terrain = road = flow = None + rounds = 0 + closed = False + for attempt in range(max_rounds + 1): + domain = build_cell_mask(spec, region_area) if region_area is not None else None + terrain = build_terrain_grid(spec, cloud, domain) + road = rasterize_road(spec, route_line, road_width_m) + flow = trace_flow(terrain, road) + contact = border_contact(flow.active) + if not any(contact.values()): + closed = True + break + if attempt == max_rounds: + logger.warning( + "배수유역: 확장 상한(%d회) 도달 — 경계 %s가 아직 활성입니다.", + max_rounds, + [side for side, touched in contact.items() if touched], + ) + break + widened = expand_grid_spec(spec, contact, step_m) + if widened == spec: + break + logger.info( + "배수유역: 경계 %s 활성 — %.0fm 확장 (%d회차)", + [side for side, touched in contact.items() if touched], + step_m, + attempt + 1, + ) + spec = widened + rounds += 1 + + assert terrain is not None and road is not None and flow is not None + return ExpansionResult( + spec=spec, terrain=terrain, road=road, flow=flow, rounds=rounds, closed=closed + ) + + def border_contact(active: np.ndarray) -> dict[str, bool]: """활성 셀이 격자 최외곽에 닿은 방향. 전부 False면 유역이 능선 안에서 닫힌 것이다.""" return { diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py index b4e72984..b6498d83 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py @@ -495,16 +495,58 @@ def build_terrain_grid( ) -def direction_codes(spec: GridSpec, receiver: np.ndarray) -> np.ndarray: - """수신 셀 인덱스를 3×3 방향 코드로 바꾼다. +# 화살표 방위 분해능. 0 = 화면상 오른쪽(+열), 시계방향으로 증가(행이 아래로 증가하므로). +AZIMUTH_STEPS = 32 +# 방위 코드 특수값. +AZIMUTH_SINK = AZIMUTH_STEPS # 32 = 제자리(더 낮은 이웃 없음) +AZIMUTH_INVALID = AZIMUTH_STEPS + 1 # 33 = 표고 없음(해석 불가) - 코드 = (행증분 + 1) * 3 + (열증분 + 1) → 0~8. 4는 제자리(싱크)를 뜻한다. - 화면이 셀마다 화살표를 그릴 수 있도록 방향만 뽑아낸 표현이다. + +def descent_azimuth( + spec: GridSpec, + surface: np.ndarray, + valid: np.ndarray, + receiver: np.ndarray, + forced: np.ndarray | None = None, +) -> np.ndarray: + """셀별 물 흐름 방위를 32방위 코드로 낸다. + + D8은 연결(도로 도달 판정)에는 충분하지만 화면에 8방위밖에 못 그린다. 실제 지표수는 + 지형 최급강하 방향으로 흐르고 그 방향은 연속값이므로, **표시는 지표면 기울기에서 뽑은 + 연속 방위를 32단계로 양자화**해 보여 준다(2026-07-31 사용자 지시). + + `forced`(세류망을 따라 흐름을 새긴 셀)는 기울기 대신 실제 수신 셀 방향을 쓴다 — 그 + 셀들은 지형 추정이 아니라 확정된 물길을 따르기 때문이다. 기울기가 0에 가까운 셀도 + 수신 셀 방향으로 대체한다. """ + rows, cols = spec.n_rows, spec.n_cols + filled = np.where(valid, surface, np.nan) + # np.gradient는 NaN이 번지므로 무효 셀을 주변 유효값으로 임시 대체한 뒤 기울기를 잡는다. + working = np.where(np.isfinite(filled), filled, np.nanmean(filled) if valid.any() else 0.0) + grad_row, grad_col = np.gradient(working.astype(np.float64), spec.cell_m) + # 내리막 방향 = 기울기 반대. 행은 아래로 증가하므로 화면 좌표와 부호가 같다. + move_row = -grad_row + move_col = -grad_col + magnitude = np.hypot(move_row, move_col) + index = np.arange(receiver.size, dtype=np.int64) - row_delta = receiver // spec.n_cols - index // spec.n_cols - col_delta = receiver % spec.n_cols - index % spec.n_cols - return ((row_delta + 1) * 3 + (col_delta + 1)).astype(np.int8) + receiver_row = (receiver // cols - index // cols).reshape(rows, cols).astype(np.float64) + receiver_col = (receiver % cols - index % cols).reshape(rows, cols).astype(np.float64) + use_receiver = magnitude < 1e-9 + if forced is not None: + use_receiver |= forced.reshape(rows, cols) + move_row = np.where(use_receiver, receiver_row, move_row) + move_col = np.where(use_receiver, receiver_col, move_col) + + angle = np.arctan2(move_row, move_col) + code = np.rint(angle / (2.0 * math.pi / AZIMUTH_STEPS)).astype(np.int16) % AZIMUTH_STEPS + # 수신 셀이 자기 자신이거나 이동량이 없는 셀은 방향이 없다. + is_sink = (receiver.reshape(rows, cols) == index.reshape(rows, cols)) | ( + (np.abs(move_row) < 1e-12) & (np.abs(move_col) < 1e-12) + ) + code = np.where(is_sink, AZIMUTH_SINK, code) + code = np.where(valid, code, AZIMUTH_INVALID) + return code.reshape(-1).astype(np.int16) def route_elevation_floor(route_z_values: list[float]) -> float | None: diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Stream.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Stream.py index cdbedbb6..0d337bd0 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Stream.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Stream.py @@ -37,7 +37,11 @@ logger = logging.getLogger(__name__) @dataclass class StreamSplit: - """세류망을 도로 교차점에서 상·하류로 가른 결과. 검증 화면이 그대로 그린다.""" + """세류망을 도로 교차점에서 상·하류로 가른 결과. 검증 화면이 그대로 그린다. + + `upstream`은 **물이 흐르는 방향(상류 → 하류)으로 정렬**돼 있다. 마지막 좌표가 도로에 + 가까운 끝이다. 격자 흐름에 세류 방향을 새겨 넣을 때 이 순서를 그대로 쓴다. + """ upstream: list[LineString] = field(default_factory=list) # 채택 — 1차 영역의 기준 downstream: list[LineString] = field(default_factory=list) # 도로 아래로 이어진 망 @@ -87,20 +91,24 @@ def split_streams_at_road( node_edges.setdefault(tail, []).append(index) sampler = ElevationSampler(cloud) - upper_seeds: set[int] = set() - lower_seeds: set[int] = set() + # 씨앗 조각 → 그 조각의 하류쪽 끝점(= 도로 교차 노드). 이 값이 물 흐름 방향의 기준이 된다. + upper_seeds: dict[int, tuple[float, float]] = {} + lower_seeds: dict[int, tuple[float, float]] = {} for index, piece in enumerate(pieces): touching = [node for node in ends[index] if node in crossing_nodes] if not touching: continue - crossing_z = float(np.min(sampler.at(np.array(touching, dtype=np.float64)))) - if _mean_elevation(piece, sampler) > crossing_z: - upper_seeds.add(index) + heights = sampler.at(np.array(touching, dtype=np.float64)) + crossing_node = touching[int(np.argmin(heights))] + if _mean_elevation(piece, sampler) > float(np.min(heights)): + upper_seeds[index] = crossing_node else: - lower_seeds.add(index) + lower_seeds[index] = crossing_node - upstream = _spread_network(upper_seeds, ends, node_edges, crossing_nodes) - downstream = _spread_network(lower_seeds, ends, node_edges, crossing_nodes) - upstream + upstream_flow = _spread_network(upper_seeds, ends, node_edges, crossing_nodes) + downstream_flow = _spread_network(lower_seeds, ends, node_edges, crossing_nodes) + upstream = set(upstream_flow) + downstream = set(downstream_flow) - upstream logger.info( "배수유역: 세류 조각 %d개 → 상류망 %d개 채택 / 하류망 %d개 · 미연결 %d개 제외", len(pieces), @@ -109,12 +117,26 @@ def split_streams_at_road( len(pieces) - len(upstream) - len(downstream), ) return StreamSplit( - upstream=[pieces[index] for index in sorted(upstream)], + # 상류망은 물 흐름 방향(상류 → 하류)으로 뒤집어 둔다 — 격자 흐름 새김에 그대로 쓴다. + upstream=[ + _oriented(pieces[index], upstream_flow[index], ends[index]) + for index in sorted(upstream) + ], downstream=[pieces[index] for index in sorted(downstream)], no_contact=len(pieces) - len(upstream) - len(downstream), ) +def _oriented( + piece: LineString, + downstream_node: tuple[float, float], + piece_ends: tuple[tuple[float, float], tuple[float, float]], +) -> LineString: + """조각을 하류쪽 끝이 마지막 좌표가 되도록 정렬한다.""" + head, _tail = piece_ends + return LineString(list(piece.coords)[::-1]) if head == downstream_node else piece + + def _cut_network_at_road( lines: list[LineString], route_line: LineString ) -> tuple[list[LineString], set[tuple[float, float]]]: @@ -151,13 +173,18 @@ def _cut_network_at_road( def _spread_network( - seeds: set[int], + seeds: dict[int, tuple[float, float]], ends: list[tuple[tuple[float, float], tuple[float, float]]], node_edges: dict[tuple[float, float], list[int]], blocked: set[tuple[float, float]], -) -> set[int]: - """씨앗 조각에서 끝점을 타고 퍼진다. 도로 교차 노드는 통과하지 않는다.""" - reached = set(seeds) +) -> dict[int, tuple[float, float]]: + """씨앗 조각에서 끝점을 타고 퍼진다. 도로 교차 노드는 통과하지 않는다. + + 조각마다 **어느 끝점을 통해 도달했는지**를 함께 기록한다. 그 끝점이 도로에 더 가까운 + 쪽이므로 곧 그 조각의 하류 방향이다 — 세류망 전체의 물 흐름 방향이 이 한 번의 확산으로 + 같이 정해진다. + """ + downstream = dict(seeds) queue = list(seeds) while queue: index = queue.pop() @@ -165,10 +192,11 @@ def _spread_network( if node in blocked: continue for neighbour in node_edges.get(node, ()): - if neighbour not in reached: - reached.add(neighbour) - queue.append(neighbour) - return reached + if neighbour in downstream: + continue + downstream[neighbour] = node + queue.append(neighbour) + return downstream def _node_key(x: float, y: float) -> tuple[float, float]: diff --git a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py index fb316b9c..d3862598 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py +++ b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py @@ -30,7 +30,12 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Basin import ( preview_stages, ) from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Export import write_grid_arrays, write_stage -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import mask_row_spans +from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import ( + AZIMUTH_INVALID, + AZIMUTH_SINK, + AZIMUTH_STEPS, + mask_row_spans, +) from B05_wf2_Route.B05_wf2_Route_Repository import ( get_latest_route, get_route_points, @@ -282,19 +287,26 @@ def _write_stage_arrays(stored_path: str, preview: Any, region: Any, spec: Any) flow = preview.flow if flow is None: return + arrays = { + "direction": flow.direction.reshape(spec.n_rows, spec.n_cols), + "reaches_road": flow.reaches_road.reshape(spec.n_rows, spec.n_cols), + "analyzed": flow.analyzed.reshape(spec.n_rows, spec.n_cols), + } + if flow.burned is not None: + arrays["burned"] = flow.burned.reshape(spec.n_rows, spec.n_cols) write_grid_arrays( stored_path, "flow_direction", spec, + arrays, { - "direction": flow.direction.reshape(spec.n_rows, spec.n_cols), - "reaches_road": flow.reaches_road.reshape(spec.n_rows, spec.n_cols), - "analyzed": flow.analyzed.reshape(spec.n_rows, spec.n_cols), - }, - { + "azimuth_steps": AZIMUTH_STEPS, + "sink_code": AZIMUTH_SINK, + "invalid_code": AZIMUTH_INVALID, "analyzed": int(flow.analyzed.sum()), "reaches_road": int((flow.reaches_road & flow.analyzed).sum()), "no_road": int((~flow.reaches_road & flow.analyzed).sum()), + "burned": 0 if flow.burned is None else int(flow.burned.sum()), "outer_seeds": flow.outer_seeds, "interior_seeds": flow.interior_seeds, }, @@ -305,26 +317,31 @@ def _flow_payload(preview: Any, region: Any) -> dict[str, Any] | None: """셀별 흐름 방향·도로 도달 여부를 바이트 배열로 압축한다. 셀이 수십만 개라 JSON 객체로는 못 보낸다. 셀 하나당 1바이트로 줄이고 base64로 싣는다: - 하위 4비트 = 3×3 방향 코드(0~8, 4=제자리), 15 = 무효(표고 없음) - 최상위 비트(0x80) = 도로 도달(적색). 꺼져 있으면 미도달(파랑). + 하위 6비트(0x3F) = 32방위 코드(0~31, 0=화면 오른쪽·시계방향), 32=제자리, 33=표고 없음 + 최상위 비트(0x80) = 도로 도달(적색). 꺼져 있으면 미도달(백색 화살표). 바이트 순서는 `grid.row_spans`를 행 → 구간 → 열 오름차순으로 훑은 순서와 같다. """ flow = preview.flow if flow is None or region.cell_mask is None: return None order = np.flatnonzero(region.cell_mask.reshape(-1)) - codes = flow.direction[order] analyzed = flow.analyzed[order] reaches = flow.reaches_road[order] - packed = np.where(codes < 0, 15, codes).astype(np.uint8) + packed = np.clip(flow.direction[order], 0, AZIMUTH_INVALID).astype(np.uint8) packed |= np.where(reaches, 0x80, 0).astype(np.uint8) + burned = flow.burned return { "encoding": "base64-uint8", + "azimuth_steps": AZIMUTH_STEPS, + "sink_code": AZIMUTH_SINK, + "invalid_code": AZIMUTH_INVALID, "cells": int(order.size), "reaches_road": int((reaches & analyzed).sum()), "no_road": int((~reaches & analyzed).sum()), - # 격자에는 있으나 등고선 TIN 밖이라 표고가 없어 판정 못한 셀(방향 코드 15). + # 격자에는 있으나 등고선 TIN 밖이라 표고가 없어 판정 못한 셀. "unanalyzed": int((~analyzed).sum()), + # 확정된 상류 세류망을 따라 흐름을 강제로 새긴 셀 수. + "burned": 0 if burned is None else int(burned[order].sum()), "outer_seeds": flow.outer_seeds, "interior_seeds": flow.interior_seeds, "data": base64.b64encode(packed.tobytes()).decode("ascii"), diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts index b5afcb29..ff267960 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -53,11 +53,11 @@ const COLLAPSED_KEY = "b05-route-drainage-collapsed"; // 해석 격자 셀 선 — 등고선·세류 위에 얹으므로 흰색으로 둔다(2026-07-31 사용자 지시). const GRID_LINE_COLOR = "rgba(255, 255, 255, 0.55)"; -// 흐름 판정 색 — 도로로 물이 오는 셀은 적색, 오지 않는 셀은 파랑. +// 흐름 판정 색 — 도로로 물이 오는 셀은 적색, 오지 않는 셀은 파랑 채움 + 백색 화살표. const FLOW_TO_ROAD_FILL = "rgba(220, 38, 38, 0.28)"; const FLOW_TO_ROAD_LINE = "rgba(153, 27, 27, 0.95)"; const FLOW_AWAY_FILL = "rgba(37, 99, 235, 0.22)"; -const FLOW_AWAY_LINE = "rgba(30, 64, 175, 0.9)"; +const FLOW_AWAY_LINE = "rgba(255, 255, 255, 0.95)"; /** 등고선 TIN 밖이라 표고가 없어 판정하지 못한 셀 — 미도달(파랑)과 구분한다. */ const FLOW_UNKNOWN_FILL = "rgba(120, 113, 108, 0.18)"; /** 셀이 이보다 작으면 화살표가 뭉개져 읽히지 않으므로 채움색만 남긴다(px). */ @@ -357,6 +357,9 @@ export function createDrainagePanel(): DrainagePanel { } return; } + const sink = region.flow?.sink_code ?? 32; + const invalid = region.flow?.invalid_code ?? 33; + const steps = region.flow?.azimuth_steps ?? 32; for (let offset = 0; offset < count; offset += 1) { drawFlowCell( context, @@ -366,13 +369,14 @@ export function createDrainagePanel(): DrainagePanel { cellW, cellH, cellPx, + { sink, invalid, steps }, ); } }); context.restore(); } - /** 셀 하나 — 도달 여부로 칠하고, 충분히 크면 흐름 방향 화살표를 얹는다. */ + /** 셀 하나 — 도달 여부로 칠하고, 충분히 크면 32방위 흐름 화살표를 얹는다. */ function drawFlowCell( context: CanvasRenderingContext2D, code: number, @@ -381,11 +385,12 @@ export function createDrainagePanel(): DrainagePanel { cellW: number, cellH: number, cellPx: number, + codes: { sink: number; invalid: number; steps: number }, ): void { - const direction = code & 0x0f; + const azimuth = code & 0x3f; const reaches = (code & 0x80) !== 0; - // 코드 15 = 등고선 TIN 밖이라 표고가 없어 판정 못한 셀. 파랑(미도달)과 구분한다. - const unanalyzed = direction === 15; + // 표고가 없어 판정 못한 셀 — 미도달(파랑 채움)과 구분해야 오독이 없다. + const unanalyzed = azimuth === codes.invalid; context.fillStyle = unanalyzed ? FLOW_UNKNOWN_FILL : reaches @@ -397,11 +402,11 @@ export function createDrainagePanel(): DrainagePanel { context.lineWidth = 0.5; context.strokeRect(x, y, cellW, cellH); } - if (cellPx < ARROW_MIN_PX || direction === 15) return; + if (cellPx < ARROW_MIN_PX || unanalyzed) return; const stroke = reaches ? FLOW_TO_ROAD_LINE : FLOW_AWAY_LINE; const midX = x + cellW / 2; const midY = y + cellH / 2; - if (direction === 4) { + if (azimuth === codes.sink) { // 제자리(싱크) — 방향이 없으므로 점으로 표시한다. context.fillStyle = stroke; context.beginPath(); @@ -409,27 +414,26 @@ export function createDrainagePanel(): DrainagePanel { context.fill(); return; } - const colDelta = (direction % 3) - 1; - const rowDelta = Math.floor(direction / 3) - 1; - const length = Math.hypot(colDelta, rowDelta) || 1; - const reach = (cellPx * 0.38) / length; - const tipX = midX + colDelta * reach; - const tipY = midY + rowDelta * reach; + // 코드 0 = 화면 오른쪽(+x), 시계방향(캔버스 y는 아래가 +). + const angle = (azimuth * 2 * Math.PI) / codes.steps; + const unitX = Math.cos(angle); + const unitY = Math.sin(angle); + const reach = cellPx * 0.38; + const tipX = midX + unitX * reach; + const tipY = midY + unitY * reach; context.strokeStyle = stroke; context.lineWidth = Math.max(0.6, cellPx * 0.09); context.beginPath(); - context.moveTo(midX - colDelta * reach, midY - rowDelta * reach); + context.moveTo(midX - unitX * reach, midY - unitY * reach); context.lineTo(tipX, tipY); context.stroke(); // 촉 — 진행 방향 기준 좌우로 짧게 접는다. - const head = cellPx * 0.16; - const unitX = (colDelta / length) * head; - const unitY = (rowDelta / length) * head; + const head = cellPx * 0.18; context.beginPath(); context.moveTo(tipX, tipY); - context.lineTo(tipX - unitX - unitY * 0.7, tipY - unitY + unitX * 0.7); + context.lineTo(tipX - (unitX + unitY * 0.7) * head, tipY - (unitY - unitX * 0.7) * head); context.moveTo(tipX, tipY); - context.lineTo(tipX - unitX + unitY * 0.7, tipY - unitY - unitX * 0.7); + context.lineTo(tipX - (unitX - unitY * 0.7) * head, tipY - (unitY + unitX * 0.7) * head); context.stroke(); } @@ -624,11 +628,15 @@ export function createDrainagePanel(): DrainagePanel { region.flow && region.flow.unanalyzed > 0 ? ` / 표고없음 ${region.flow.unanalyzed.toLocaleString()}(회)` : ""; + const burned = + region.flow && region.flow.burned > 0 + ? ` · 세류망 새김 ${region.flow.burned.toLocaleString()}셀` + : ""; const flow = region.flow ? ` · 흐름 도로도달 ${region.flow.reaches_road.toLocaleString()}(적) / ` + `미도달 ${region.flow.no_road.toLocaleString()}(청)${unknown}, ` + `최외곽 출발 ${region.flow.outer_seeds.toLocaleString()} + ` + - `내부 보충 ${region.flow.interior_seeds.toLocaleString()}` + `내부 보충 ${region.flow.interior_seeds.toLocaleString()}${burned}` : " · 흐름 판정 없음"; return ( `1차 영역(반경 ${region.radius_m}m): 상류망 ${region.upstream_lines.length}조각 채택 / ` + From 12bee4bc823cd4943f5eb790a2f7c8cfbe32ebb4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 31 Jul 2026 18:57:16 +0900 Subject: [PATCH 34/61] =?UTF-8?q?fix(B05):=20=EC=84=B8=EB=A5=98=EC=84=A0?= =?UTF-8?q?=20=EA=B2=B9=EC=B9=A8=20=EC=85=80=EC=9D=84=20=ED=8C=90=EC=A0=95?= =?UTF-8?q?=20=EB=A7=A8=20=EC=95=9E=EC=97=90=EC=84=9C=20=EC=A0=81=EC=83=89?= =?UTF-8?q?=20=ED=99=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 세류선 위인데 빨강이 아닌 셀이 남던 문제. 원인 3가지를 함께 고쳤다. 1. 사슬의 마지막 셀 누락 — zip(chain, chain[1:]) 이라 각 갈래의 끝 셀이 세류 셀로 표시되지 않았다. burned[chain] 전체로 바꿨다. 2. 표고 없는 셀 제외 — valid(등고선 TIN 껍질 안) 조건 때문에 껍질 밖 세류 셀이 새김에서 빠져 회색으로 남았다. 세류선은 확정 자료이므로 표고 유무를 따지지 않는다. 3. 사슬이 그런 셀로 흘러들면 not valid[following] 에서 파랑으로 판정됐다. 이미 색이 정해진 셀이면 따라가도록 조건을 풀었다. 판정 순서를 지시대로 바꿨다: 0. 세류선과 겹치는 셀을 먼저 적색으로 못박는다 1. 최외곽 셀에서 사슬 추적 2. 도로/세류 셀에 닿으면 사슬 전체 적색, 못 닿으면 전체 미도달 3. 이미 색이 정해진 셀을 만나면 그 색을 물려받고 끝 4. 최외곽에 안 걸린 내부 셀을 따로 출발 표고 없는 세류 셀도 화살표가 나오도록 descent_azimuth 에서 forced 셀을 유효로 인정. 합류 지점 끝 셀을 미연결로 오탐하던 경고도 갈래 공유 횟수로 판정하게 수정. 합성 검증(등고선 범위 밖으로 나가는 지류 포함): 세류 셀 806개 전부 적색·전부 판정·화살표 무효 0. Co-Authored-By: Claude Fable 5 --- .../B05_wf2_Route_Engine_Watershed_Flow.py | 84 +++++++++++-------- .../B05_wf2_Route_Engine_Watershed_Grid.py | 4 +- 2 files changed, 50 insertions(+), 38 deletions(-) diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Flow.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Flow.py index dbd3340a..3fcf332f 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Flow.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Flow.py @@ -189,41 +189,46 @@ def burn_stream_flow( 것이 아니라 **그대로 따라야 한다**(2026-07-31 사용자 지시). 세류선 위 셀은 물길 방향의 다음 셀을 수신 셀로 삼는다. 그러면 세류선 셀은 물론, - 세류선으로 흘러드는 사면 셀까지 전부 도로에 도달한다. 도로 셀은 흡수점이므로 건드리지 않는다. + 세류선으로 흘러드는 사면 셀까지 전부 도로에 도달한다. 도로 셀은 흡수점이므로 수신 셀은 + 건드리지 않되, 세류 셀 목록에는 포함한다. - 돌려주는 값: (흐름이 새겨진 지형, 새긴 셀 마스크). + **표고 유무를 따지지 않는다.** 세류선은 확정된 자료이므로 등고선 TIN 껍질 밖이라 + 표고가 없는 셀이라도 물이 지나간다는 사실은 변하지 않는다. 표고를 조건으로 걸면 그런 + 셀이 새김에서 빠져 파랑·회색으로 남는다. + + 돌려주는 값: (흐름이 새겨진 지형, 세류선이 지나는 셀 마스크). """ spec = terrain.spec receiver = terrain.receiver.copy() step_length = terrain.step_length.copy() - valid = terrain.valid.reshape(-1) road_cells = road.mask.reshape(-1) burned = np.zeros(spec.size, dtype=bool) tails: list[int] = [] + # 셀이 몇 갈래에 속하는지 — 하류 끝이 합류 지점인지 판단하는 근거. + visits = np.zeros(spec.size, dtype=np.int32) for line in streams: chain = _line_cell_chain(spec, line) - if len(chain) < 2: + if not chain: continue + # 사슬의 **모든** 셀을 세류 셀로 표시한다 — 마지막 셀도 물길 위다. + burned[chain] = True + np.add.at(visits, np.unique(chain), 1) for current, following in zip(chain, chain[1:]): - if not valid[current] or not valid[following] or road_cells[current]: - continue - if not _is_neighbour(spec, current, following): + if road_cells[current] or not _is_neighbour(spec, current, following): continue receiver[current] = following step_length[current] = _cell_distance(spec, current, following) - burned[current] = True tails.append(chain[-1]) - # 하류 끝이 도로에도, 다른 세류 갈래에도 닿지 않은 갈래만 진짜 문제다. - # 지류가 본류 중간에 합류하는 경우는 끝점이 본류 셀이므로 정상이다. - detached = sum(1 for tail in tails if not road_cells[tail] and not burned[tail]) + # 하류 끝이 도로에도 닿지 않고 다른 갈래와도 겹치지 않으면 그 갈래는 떠 있는 것이다. + # 지류가 본류 중간에 합류하는 경우는 끝 셀을 두 갈래가 공유하므로 정상이다. + detached = sum(1 for tail in tails if not road_cells[tail] and visits[tail] < 2) if detached: logger.warning( - "배수유역: 세류망 %d갈래의 하류 끝이 도로·다른 세류 어디에도 닿지 않습니다.", - detached, + "배수유역: 세류망 %d갈래의 하류 끝이 도로·다른 세류 어디에도 닿지 않습니다.", detached ) - logger.info("배수유역: 세류망 흐름 새김 %d셀 (세류 %d갈래)", int(burned.sum()), len(streams)) + logger.info("배수유역: 세류망 셀 %d개 표시 (세류 %d갈래)", int(burned.sum()), len(streams)) return ( TerrainGrid( spec=spec, @@ -300,48 +305,49 @@ def classify_flow( 출발해 화살표가 가리키는 다음 셀, 그 셀이 가리키는 그다음 셀로 사슬처럼 이어 간다 (2026-07-31 사용자 지시). - 사슬이 **도로 셀 또는 확정된 세류망 셀**에 닿으면 그 사슬 전체가 적색이다. 세류망은 - 이미 "도로를 건너 하류로 빠지는 물길"로 확정된 자료이므로, 물이 세류에 합류한 시점에 - 도로 도달이 결정된다. 사슬이 싱크에서 멈추거나 해석 영역 밖으로 나가면 전체가 미도달 - (파랑 채움 + 백색 화살표)이다. + 판정 순서는 다음과 같다(2026-07-31 사용자 지시): - **한 번 판정한 셀은 다시 분석하지 않는다** — 사슬이 이미 색이 정해진 셀을 만나면 그 - 셀의 색을 그대로 물려받고 끝낸다. 최외곽 추적으로 안 닿은 내부 셀은 그다음에 따로 - 출발시킨다. + ⓪ **세류선과 겹치는 셀을 먼저 적색으로 확정한다.** 세류망은 이미 "도로를 건너 하류로 + 빠지는 물길"로 확정된 자료다. 표고가 있든 없든 물이 지나간다는 사실은 변하지 않으므로 + 추적 결과를 기다릴 이유가 없다. + ① 최외곽 셀에서 출발해 사슬을 따라간다. + ② 사슬이 도로 셀이나 세류 셀에 닿으면 사슬 전체가 적색, 싱크에서 멈추거나 해석 영역 + 밖으로 나가면 전체가 미도달(파랑 채움 + 백색 화살표)이다. + ③ 이미 색이 정해진 셀을 만나면 **그 셀의 색을 그대로 물려받고** 끝낸다. 판정된 셀은 + 다시 분석하지 않는다. + ④ 최외곽 추적에 안 걸린 내부 셀을 그다음에 따로 출발시킨다. - 화살표 방위는 D8(8방위)이 아니라 지형 최급강하 32방위를 쓴다. `burned`(세류망을 따라 - 흐름을 새긴 셀)는 확정된 물길 방향을 그대로 쓴다. + 화살표 방위는 D8(8방위)이 아니라 지형 최급강하 32방위를 쓴다. 세류 셀은 확정된 물길 + 방향을 그대로 쓴다. """ spec = terrain.spec valid = terrain.valid.reshape(-1) receiver = terrain.receiver # 도로 셀과 세류망 셀 둘 다 "여기 닿으면 적색"인 종결점이다. - absorbing = road.mask.reshape(-1) & valid - if burned is not None: - absorbing = absorbing | (burned & valid) + stream_cells = np.zeros(spec.size, dtype=bool) if burned is None else burned + absorbing = (road.mask.reshape(-1) & valid) | stream_cells direction = descent_azimuth(spec, terrain.elevation, terrain.valid, receiver, burned) reaches = np.zeros(spec.size, dtype=bool) # 0=미방문, 1=경로에 올라 있음, 2=판정 완료 state = np.zeros(spec.size, dtype=np.int8) + # ⓪ 세류선과 겹치는 셀을 먼저 적색으로 못박는다. + reaches[stream_cells] = True + state[stream_cells] = 2 + stream_done = int(stream_cells.sum()) + outer = np.flatnonzero(outermost_cells(terrain.valid)) remaining = np.flatnonzero(valid) outer_done = _walk_from(outer, receiver, valid, absorbing, state, reaches) interior_done = _walk_from(remaining, receiver, valid, absorbing, state, reaches) analyzed = state == 2 - if burned is not None: - stranded = int((burned & analyzed & ~reaches).sum()) - if stranded: - logger.warning( - "배수유역: 세류망 새김 셀 %d개가 도로에 닿지 않습니다 — 하류 끝이 도로 셀과 " - "이어지지 않았는지 확인이 필요합니다.", - stranded, - ) logger.info( - "배수유역: 흐름 판정 %d셀 (최외곽 출발 %d / 내부 보충 %d) — 도로 도달 %d, 미도달 %d", + "배수유역: 흐름 판정 %d셀 (세류 선확정 %d / 최외곽 출발 %d / 내부 보충 %d) — " + "도로 도달 %d, 미도달 %d", int(analyzed.sum()), + stream_done, outer_done, interior_done, int((reaches & analyzed).sum()), @@ -390,8 +396,12 @@ def _walk_from( verdict = True # 도로 또는 세류망에 합류 — 여기서 하류로 빠진다 break following = int(receiver[node]) - if following == node or not valid[following]: - verdict = False # 싱크에 갇히거나 해석 영역 밖으로 빠짐 + if following == node: + verdict = False # 싱크에 갇힘 + break + if not valid[following] and state[following] != 2: + # 해석 영역 밖으로 빠짐. 단, 이미 색이 정해진 셀(세류 선확정 등)이면 따라간다. + verdict = False break node = following for visited in path: diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py index b6498d83..1bad541d 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py @@ -545,7 +545,9 @@ def descent_azimuth( (np.abs(move_row) < 1e-12) & (np.abs(move_col) < 1e-12) ) code = np.where(is_sink, AZIMUTH_SINK, code) - code = np.where(valid, code, AZIMUTH_INVALID) + # 세류망을 따라 흐름을 새긴 셀은 표고가 없어도 방향이 확정돼 있다. + known = valid if forced is None else (valid | forced.reshape(rows, cols)) + code = np.where(known, code, AZIMUTH_INVALID) return code.reshape(-1).astype(np.int16) From 445f51bbcbb63628bd8976771e1f3d634d1fd6cb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 31 Jul 2026 19:23:33 +0900 Subject: [PATCH 35/61] =?UTF-8?q?feat(B05):=20=ED=9D=90=EB=A6=84=20?= =?UTF-8?q?=EB=B0=A9=ED=96=A5=EC=9D=84=20=EB=93=B1=EA=B3=A0=EC=84=A0=20?= =?UTF-8?q?=ED=95=98=EA=B0=95=20=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EA=B5=90=EC=B2=B4=20(TIN=20=EB=B3=B4=EA=B0=84=20=ED=8F=90?= =?UTF-8?q?=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 영구저장소 데이터를 오프라인 재현해 원인을 특정한 뒤 방식을 바꿨다. 진단 (실데이터 536,708셀, 저장본과 정확히 일치 재현) - 파랑 셀 365,303개의 사슬 종료 사유가 100% 싱크. 영역 이탈 0%. - 싱크 3,974개의 표고가 전부 5의 배수(600.0/765.0/585.0) = 등고선 값. - 원인: 등고선 TIN 보간이 만든 평탄면을 EDT 로 해소할 때 가장 가까운 비평탄 셀을 출구로 삼는데, 그게 오르막이면 물이 나갈 수 없어 싱크로 남는다. 싱크 하나가 상류 유역 전체를 삼켰다(최대 10,171셀). - 화살표(기울기 32방위)와 추적(D8)이 서로 달라 2,055셀에서 어긋났다. 교체 방식 — Watershed_Descent.py 신설 1. 등고선을 격자에 직접 굽는다(보간면을 만들지 않는다) 2. 셀마다 가장 가까운 등고 라인의 표고를 밴드로 삼는다 3. 높은 밴드부터 내려오며 한 단 낮은 등고 라인까지 거리를 잰다 4. 위치에너지 = 밴드 순위 x 큰 수 + 그 거리 5. 수신 셀 = 위치에너지가 더 낮은 8이웃 중 화살표 방향에 가장 가까운 셀 위치에너지가 흐름을 따라 반드시 감소하므로 웅덩이도 순환도 원리적으로 생기지 않는다 — 채움/평탄해소 자체가 불필요해졌다. 수신 셀을 화살표에서 고르므로 화면 화살표와 실제 경로가 항상 일치한다. 실측 (같은 데이터) 적색 파랑 싱크 화살표=경로 TIN 방식 171,405 (32%) 365,303 (68%) 3,974 불일치 2,055셀 등고선 하강 424,193 (79%) 112,515 (21%) 15,911 일치 100%, 불일치 0셀 부수 수정 - 최하단 밴드 셀을 무효가 아닌 정지 셀로 남겨 도로/세류면 적색으로 잡히게 함 - 저장에 receiver / band_elevation 추가 (사후에 사슬을 다시 따라갈 수 있게) - manifest 에 row_spans 가 통째로 들어가 110KB 가 되던 것 정리 Co-Authored-By: Claude Fable 5 --- .../B05_wf2_Route_Engine_Watershed_Basin.py | 49 ++-- .../B05_wf2_Route_Engine_Watershed_Descent.py | 265 ++++++++++++++++++ .../B05_wf2_Route_Engine_Watershed_Flow.py | 36 ++- .../B05_wf2_Route_Router_Drainage.py | 11 +- 4 files changed, 334 insertions(+), 27 deletions(-) create mode 100644 B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Descent.py diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py index 3a03ff5b..57841d48 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py @@ -35,6 +35,10 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import ( find_stream_crossings, is_uphill_at, ) +from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Descent import ( + ContourDescent, + build_contour_descent, +) from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Flow import ( FlowClassification, RoadRaster, @@ -50,7 +54,6 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import ( GridSpec, TerrainGrid, build_contour_cloud, - build_terrain_grid, route_elevation_floor, ) from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Stream import ( @@ -58,7 +61,6 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Stream import ( build_primary_region, ) from config.config_system import ( - DRAINAGE_CONTOUR_CLIP_MARGIN_M, DRAINAGE_DITCH_SAMPLE_M, DRAINAGE_EXPAND_STEP_M, DRAINAGE_GRID_SIZE_M, @@ -215,6 +217,7 @@ class StagePreview: terrain: TerrainGrid | None = None road: RoadRaster | None = None flow: FlowClassification | None = None + descent: ContourDescent | None = None def preview_stages( @@ -224,8 +227,12 @@ def preview_stages( ) -> StagePreview | None: """지금까지 구현·검증된 단계를 순서대로 돌려 결과를 모은다. - 현재 포함: ① 1차 배수유역 ② 격자 생성 ③ 표고·D8 ④ 흐름 방향/도로 도달 판정. - **격자 확장은 넣지 않는다** — 다음 검증 단계다(2026-07-31 사용자 지시). + 현재 포함: ① 1차 배수유역 ② 격자 생성 ③ **등고선 하강 방향** ④ 도로 도달 판정. + + ③은 보간면(TIN)을 쓰지 않는다. 높은 등고 라인에서 낮은 등고 라인으로 내려가며 방향을 + 세우므로 가짜 웅덩이·평탄면이 원리적으로 생기지 않는다(2026-07-31 사용자 지시로 방식 교체). + + **격자 확장은 넣지 않는다** — 다음 검증 단계다. """ if len(vertices) < 2: return None @@ -234,31 +241,29 @@ def preview_stages( if region is None: return None - # TIN은 격자 범위 + 여유만큼만 읽는다. 확장이 없으므로 여유는 클리핑 마진이면 충분하다. spec = region.spec - cloud = build_contour_cloud( - contour_features, - route_elevation_floor([vertex.z for vertex in vertices]), - ( - spec.x_min - DRAINAGE_CONTOUR_CLIP_MARGIN_M, - spec.y_max - spec.n_rows * spec.cell_m - DRAINAGE_CONTOUR_CLIP_MARGIN_M, - spec.x_min + spec.n_cols * spec.cell_m + DRAINAGE_CONTOUR_CLIP_MARGIN_M, - spec.y_max + DRAINAGE_CONTOUR_CLIP_MARGIN_M, - ), - ) - if cloud.is_empty: - logger.warning("배수유역: 격자 범위 안에 등고선이 없어 흐름 판정을 건너뜁니다.") + started = time.perf_counter() + floor = route_elevation_floor([vertex.z for vertex in vertices]) + descent = build_contour_descent(spec, contour_features, region.cell_mask, floor) + if not descent.valid.any(): + logger.warning("배수유역: 등고선 하강 방향을 세우지 못해 흐름 판정을 건너뜁니다.") return StagePreview(region=region) - started = time.perf_counter() - terrain = build_terrain_grid(spec, cloud, region.cell_mask) + # 이후 단계(유역 제원)가 표고를 쓰므로 밴드 표고를 지형 격자로 함께 들고 간다. + terrain = TerrainGrid( + spec=spec, + elevation=descent.band_elevation, + valid=descent.valid, + receiver=descent.receiver, + step_length=descent.step_length, + ) road = rasterize_road(spec, route_line, DRAINAGE_ROAD_WIDTH_M) # 확정된 상류 세류망을 따라 흐름을 새긴다 — 세류선 위 셀과 그리로 흘러드는 셀은 - # 반드시 도로에 도달해야 한다(TIN 보간면은 실제 물골을 재현하지 못한다). + # 반드시 도로에 도달해야 한다. terrain, burned = burn_stream_flow(terrain, road, region.split.upstream) - flow = classify_flow(terrain, road, burned) + flow = classify_flow(terrain, road, burned, azimuth=descent.azimuth) logger.info("배수유역: 흐름 판정 %.1fs (셀 %d개)", time.perf_counter() - started, spec.size) - return StagePreview(region=region, terrain=terrain, road=road, flow=flow) + return StagePreview(region=region, terrain=terrain, road=road, flow=flow, descent=descent) # ── ③~④ 격자 해석 (캐시 대상) ─────────────────────────────────────────────── diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Descent.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Descent.py new file mode 100644 index 00000000..faa429f4 --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Descent.py @@ -0,0 +1,265 @@ +"""등고선 기반 흐름 방향 — 높은 등고 라인에서 낮은 등고 라인으로 내려가며 방향을 세운다. + +기존 방식(등고선 → TIN 보간 → 지표면 기울기 → D8)은 보간면이 만든 가짜 웅덩이와 평탄 +삼각형 때문에 흐름이 중간에서 끊겼다. 실데이터에서 채움·평탄해소를 거치고도 싱크가 수천 개 +남았고, 그 싱크 하나가 상류 유역 전체를 통째로 삼켰다. + +여기서는 **보간면을 거치지 않는다.** 등고선을 격자에 직접 굽고, 셀마다 "내가 속한 등고 +라인보다 한 단 낮은 등고 라인"이 어디인지를 찾아 그쪽으로 방향을 준다(2026-07-31 사용자 지시). + + ① 등고선을 격자에 굽는다 — 셀이 어느 표고의 라인 위인지 기록 + ② 셀마다 가장 가까운 등고 라인을 찾아 그 표고를 '밴드'로 삼는다 + ③ 표고가 높은 밴드부터 내려오며, 각 밴드에서 **한 단 낮은 등고 라인까지의 거리**를 잰다 + ④ 위치에너지 = 밴드 순위 × 큰 수 + 그 거리 + ⑤ 수신 셀 = 위치에너지가 더 낮은 8이웃 중 화살표 방향에 가장 가까운 셀 + +④의 위치에너지는 흐름을 따라 반드시 감소한다. 그래서 **순환도 웅덩이도 원리적으로 생기지 +않는다** — 채움이나 평탄면 해소가 아예 필요 없다. + +⑤ 덕분에 화면 화살표(32방위)와 실제 추적 경로가 항상 같은 방향을 가리킨다. 예전에는 +화살표는 기울기, 추적은 D8이라 서로 어긋나 눈으로 검증할 수가 없었다. +""" + +from __future__ import annotations + +import logging +import math +from dataclasses import dataclass +from typing import Any + +import numpy as np +from rasterio.features import rasterize +from scipy.ndimage import distance_transform_edt +from shapely.geometry import shape + +from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import ( + AZIMUTH_INVALID, + AZIMUTH_SINK, + AZIMUTH_STEPS, + GridSpec, + _feature_elevation, + grid_transform, + iter_linestrings, +) +from config.config_system import DRAINAGE_CONTOUR_MIN_LENGTH_M + +logger = logging.getLogger(__name__) + +# 8이웃 (행 증분, 열 증분). +_NEIGHBOURS = ( + (-1, -1), + (-1, 0), + (-1, 1), + (0, -1), + (0, 1), + (1, -1), + (1, 0), + (1, 1), +) + + +@dataclass +class ContourDescent: + """등고선에서 직접 세운 흐름 방향 격자.""" + + spec: GridSpec + band_elevation: np.ndarray # (R, C) float32 — 셀이 속한 등고 라인 표고, 무효는 NaN + valid: np.ndarray # (R, C) bool — 방향을 세운 셀 + receiver: np.ndarray # (R*C,) int32 — 다음 셀, 최하단 밴드는 자기 자신 + step_length: np.ndarray # (R*C,) float32 + azimuth: np.ndarray # (R*C,) int16 — 32방위 코드(32=제자리, 33=무효) + levels: list[float] # 사용된 등고 표고(내림차순) + + +def rasterize_contours( + spec: GridSpec, + contour_features: list[dict[str, Any]], + elevation_floor_m: float | None = None, +) -> tuple[np.ndarray, list[float]]: + """등고선을 격자에 굽는다. 셀마다 그 위를 지나는 등고 라인의 표고(없으면 NaN).""" + by_level: dict[float, list[Any]] = {} + for feature in contour_features: + geometry = feature.get("geometry") + if not geometry: + continue + elevation = _feature_elevation(feature.get("properties") or {}) + if elevation is None: + continue + if elevation_floor_m is not None and elevation < elevation_floor_m: + continue + try: + parsed = shape(geometry) + except Exception: # noqa: BLE001 + continue + for line in iter_linestrings(parsed): + if line.length < DRAINAGE_CONTOUR_MIN_LENGTH_M: + continue + by_level.setdefault(float(elevation), []).append(line) + + burned = np.full((spec.n_rows, spec.n_cols), np.nan, dtype=np.float32) + levels = sorted(by_level, reverse=True) + transform = grid_transform(spec) + for elevation in levels: + stamp = rasterize( + [(line, 1) for line in by_level[elevation]], + out_shape=(spec.n_rows, spec.n_cols), + transform=transform, + fill=0, + dtype="uint8", + all_touched=True, + ).astype(bool) + # 낮은 표고부터 덮어써야 겹치는 셀이 낮은 라인으로 남는다 — 물은 낮은 쪽으로 간다. + burned[stamp] = elevation + logger.info( + "배수유역: 등고 라인 %d단(%.0f~%.0fm)을 격자에 굽어 %d셀", + len(levels), + levels[-1] if levels else 0.0, + levels[0] if levels else 0.0, + int(np.isfinite(burned).sum()), + ) + return burned, levels + + +def build_contour_descent( + spec: GridSpec, + contour_features: list[dict[str, Any]], + domain: np.ndarray | None = None, + elevation_floor_m: float | None = None, +) -> ContourDescent: + """등고선만으로 셀별 흐름 방향을 세운다. 보간면을 만들지 않는다.""" + rows, cols = spec.n_rows, spec.n_cols + burned, levels = rasterize_contours(spec, contour_features, elevation_floor_m) + empty = ContourDescent( + spec=spec, + band_elevation=np.full((rows, cols), np.nan, dtype=np.float32), + valid=np.zeros((rows, cols), dtype=bool), + receiver=np.arange(spec.size, dtype=np.int32), + step_length=np.zeros(spec.size, dtype=np.float32), + azimuth=np.full(spec.size, AZIMUTH_INVALID, dtype=np.int16), + levels=levels, + ) + if len(levels) < 2: + logger.warning("배수유역: 등고 라인이 2단 미만이라 방향을 세울 수 없습니다.") + return empty + + on_contour = np.isfinite(burned) + # ② 셀마다 가장 가까운 등고 라인의 표고 = 그 셀의 밴드. + _, (near_row, near_col) = distance_transform_edt(~on_contour, return_indices=True) + band_elevation = burned[near_row, near_col].astype(np.float32) + inside = domain if domain is not None else np.ones((rows, cols), dtype=bool) + band_elevation = np.where(inside, band_elevation, np.nan) + + # ③④ 높은 밴드부터 내려오며 한 단 낮은 등고 라인까지의 거리와 목표 셀을 구한다. + distance = np.full((rows, cols), np.inf, dtype=np.float32) + target_row = np.zeros((rows, cols), dtype=np.int32) + target_col = np.zeros((rows, cols), dtype=np.int32) + band_rank = np.full((rows, cols), -1, dtype=np.int32) + for rank, elevation in enumerate(levels[:-1]): + members = inside & (band_elevation == elevation) + if not members.any(): + continue + lower = on_contour & (burned < elevation) + if not lower.any(): + continue + step_distance, (step_row, step_col) = distance_transform_edt(~lower, return_indices=True) + distance[members] = step_distance[members].astype(np.float32) + target_row[members] = step_row[members] + target_col[members] = step_col[members] + band_rank[members] = len(levels) - 1 - rank # 높을수록 큰 값 + + # 최하단 밴드는 더 내려갈 등고 라인이 없다. 무효로 빼지 않고 **정지 셀**로 남긴다 — + # 그래야 그 자리가 도로·세류선이면 적색으로 잡히고, 아니면 물이 고이는 지점으로 보인다. + lowest = inside & (band_elevation == levels[-1]) & (band_rank < 0) + if lowest.any(): + distance[lowest] = 0.0 + band_rank[lowest] = 0 + + valid = band_rank >= 0 + if not valid.any(): + logger.warning("배수유역: 하강 방향을 세운 셀이 없습니다.") + return empty + + # 밴드가 하나 낮아지면 위치에너지가 반드시 떨어지도록 거리 최대치보다 큰 간격을 준다. + finite = distance[valid & np.isfinite(distance)] + span = (float(finite.max()) if finite.size else 1.0) + 2.0 + distance[valid & ~np.isfinite(distance)] = 0.0 + potential = np.where(valid, band_rank.astype(np.float64) * span + distance, np.inf) + + receiver, step_length, azimuth = _route_by_potential( + spec, potential, valid, target_row, target_col + ) + logger.info( + "배수유역: 등고선 하강 방향 %d셀 (밴드 %d단), 최하단 정지 %d셀", + int(valid.sum()), + int(band_rank[valid].max() - band_rank[valid].min() + 1), + int((azimuth == AZIMUTH_SINK).sum()), + ) + return ContourDescent( + spec=spec, + band_elevation=np.where(valid, band_elevation, np.nan).astype(np.float32), + valid=valid, + receiver=receiver, + step_length=step_length, + azimuth=azimuth, + levels=levels, + ) + + +def _route_by_potential( + spec: GridSpec, + potential: np.ndarray, + valid: np.ndarray, + target_row: np.ndarray, + target_col: np.ndarray, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """위치에너지가 낮은 8이웃 중 **화살표 방향에 가장 가까운** 셀을 수신 셀로 고른다. + + 화살표는 "한 단 낮은 등고 라인 쪽"을 가리키는 연속 방위이고, 수신 셀은 그 방위에 가장 + 가까운 이웃이다. 그래서 화면 화살표와 실제 추적 경로가 어긋나지 않는다. + 위치에너지가 더 낮은 이웃만 후보로 두므로 순환이 생기지 않는다. + """ + rows, cols = spec.n_rows, spec.n_cols + grid_row, grid_col = np.meshgrid(np.arange(rows), np.arange(cols), indexing="ij") + # 목표(한 단 낮은 등고 라인 위의 셀)를 향하는 연속 방위. + aim_row = (target_row - grid_row).astype(np.float64) + aim_col = (target_col - grid_col).astype(np.float64) + aim_norm = np.hypot(aim_row, aim_col) + aim_norm[aim_norm == 0.0] = 1.0 + aim_row /= aim_norm + aim_col /= aim_norm + + padded = np.full((rows + 2, cols + 2), np.inf) + padded[1:-1, 1:-1] = potential + flat_index = np.arange(spec.size, dtype=np.int32).reshape(rows, cols) + padded_index = np.full((rows + 2, cols + 2), -1, dtype=np.int32) + padded_index[1:-1, 1:-1] = flat_index + + best_score = np.full((rows, cols), -np.inf) + receiver = flat_index.copy() + step = np.zeros((rows, cols), dtype=np.float32) + centre = potential + for row_shift, col_shift in _NEIGHBOURS: + neighbour = padded[ + 1 + row_shift : 1 + row_shift + rows, 1 + col_shift : 1 + col_shift + cols + ] + length = math.hypot(row_shift, col_shift) + # 방위 일치도(코사인 유사도)가 클수록 좋은 후보다. + score = (aim_row * row_shift + aim_col * col_shift) / length + better = valid & np.isfinite(neighbour) & (neighbour < centre) & (score > best_score) + if not better.any(): + continue + best_score = np.where(better, score, best_score) + neighbour_index = padded_index[ + 1 + row_shift : 1 + row_shift + rows, 1 + col_shift : 1 + col_shift + cols + ] + receiver = np.where(better, neighbour_index, receiver) + step = np.where(better, np.float32(length * spec.cell_m), step) + + moved = receiver != flat_index + delta_row = (receiver // cols - flat_index // cols).astype(np.float64) + delta_col = (receiver % cols - flat_index % cols).astype(np.float64) + angle = np.arctan2(delta_row, delta_col) + code = np.rint(angle / (2.0 * math.pi / AZIMUTH_STEPS)).astype(np.int16) % AZIMUTH_STEPS + azimuth = np.where(moved, code, AZIMUTH_SINK) + azimuth = np.where(valid, azimuth, AZIMUTH_INVALID) + return receiver.reshape(-1), step.reshape(-1), azimuth.reshape(-1).astype(np.int16) diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Flow.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Flow.py index 3fcf332f..ad6d141d 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Flow.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Flow.py @@ -25,6 +25,7 @@ from shapely.geometry import LineString, MultiPolygon, Polygon, shape from shapely.ops import unary_union from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import ( + AZIMUTH_STEPS, ContourCloud, GridSpec, TerrainGrid, @@ -297,7 +298,10 @@ def outermost_cells(domain: np.ndarray) -> np.ndarray: def classify_flow( - terrain: TerrainGrid, road: RoadRaster, burned: np.ndarray | None = None + terrain: TerrainGrid, + road: RoadRaster, + burned: np.ndarray | None = None, + azimuth: np.ndarray | None = None, ) -> FlowClassification: """최외곽 셀부터 물길을 따라가며 도로 도달 여부를 판정한다. @@ -317,8 +321,9 @@ def classify_flow( 다시 분석하지 않는다. ④ 최외곽 추적에 안 걸린 내부 셀을 그다음에 따로 출발시킨다. - 화살표 방위는 D8(8방위)이 아니라 지형 최급강하 32방위를 쓴다. 세류 셀은 확정된 물길 - 방향을 그대로 쓴다. + `azimuth`를 주면 그 32방위 코드를 그대로 화살표로 쓴다(등고선 하강 방향). 주지 않으면 + 지표면 기울기에서 뽑는다. **화살표는 실제 수신 셀과 같은 방향이어야 한다** — 어긋나면 + 화살표로 사슬을 따라가는 눈 검증이 성립하지 않는다. """ spec = terrain.spec valid = terrain.valid.reshape(-1) @@ -327,7 +332,10 @@ def classify_flow( stream_cells = np.zeros(spec.size, dtype=bool) if burned is None else burned absorbing = (road.mask.reshape(-1) & valid) | stream_cells - direction = descent_azimuth(spec, terrain.elevation, terrain.valid, receiver, burned) + if azimuth is None: + direction = descent_azimuth(spec, terrain.elevation, terrain.valid, receiver, burned) + else: + direction = _azimuth_with_burn(spec, azimuth, receiver, burned) reaches = np.zeros(spec.size, dtype=bool) # 0=미방문, 1=경로에 올라 있음, 2=판정 완료 state = np.zeros(spec.size, dtype=np.int8) @@ -363,6 +371,26 @@ def classify_flow( ) +def _azimuth_with_burn( + spec: GridSpec, azimuth: np.ndarray, receiver: np.ndarray, burned: np.ndarray | None +) -> np.ndarray: + """세류망을 따라 흐름을 새긴 셀은 그 수신 셀 방향으로 화살표를 덮어쓴다.""" + direction = azimuth.astype(np.int16, copy=True) + if burned is None or not burned.any(): + return direction + index = np.arange(spec.size, dtype=np.int64) + moved = burned & (receiver != index) + if not moved.any(): + return direction + row_delta = (receiver[moved] // spec.n_cols - index[moved] // spec.n_cols).astype(np.float64) + col_delta = (receiver[moved] % spec.n_cols - index[moved] % spec.n_cols).astype(np.float64) + angle = np.arctan2(row_delta, col_delta) + direction[moved] = ( + np.rint(angle / (2.0 * np.pi / AZIMUTH_STEPS)).astype(np.int16) % AZIMUTH_STEPS + ) + return direction + + def _walk_from( starts: np.ndarray, receiver: np.ndarray, diff --git a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py index d3862598..789d8fda 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py +++ b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py @@ -266,7 +266,12 @@ async def get_primary_region(project_id: UUID) -> dict[str, Any] | JSONResponse: "radius_m": region.radius_m, "road_outside_m": payload["road_outside_m"], "no_contact_count": region.split.no_contact, - "grid": {key: value for key, value in payload["grid"].items() if key != "bbox_lonlat"}, + # 기하(bbox·셀 구간)는 파일 본문에 있으므로 요약 수치만 남긴다. + "grid": { + key: value + for key, value in payload["grid"].items() + if key not in {"bbox_lonlat", "row_spans"} + }, }, to_lonlat, ) @@ -291,9 +296,13 @@ def _write_stage_arrays(stored_path: str, preview: Any, region: Any, spec: Any) "direction": flow.direction.reshape(spec.n_rows, spec.n_cols), "reaches_road": flow.reaches_road.reshape(spec.n_rows, spec.n_cols), "analyzed": flow.analyzed.reshape(spec.n_rows, spec.n_cols), + # 사후 진단용 — 수신 셀이 있어야 사슬을 다시 따라가 볼 수 있다. + "receiver": preview.terrain.receiver.reshape(spec.n_rows, spec.n_cols), } if flow.burned is not None: arrays["burned"] = flow.burned.reshape(spec.n_rows, spec.n_cols) + if preview.descent is not None: + arrays["band_elevation"] = preview.descent.band_elevation write_grid_arrays( stored_path, "flow_direction", From 44eb4ad2affd5c5d12309823a9206f1f06a4d2cb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 31 Jul 2026 19:36:38 +0900 Subject: [PATCH 36/61] =?UTF-8?q?feat(B05):=20=EC=B5=9C=EC=99=B8=EA=B3=BD?= =?UTF-8?q?=20=EC=A0=81=EC=83=89=20=EC=85=80=20=EC=A3=BC=EB=B3=80=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5=20=EB=A3=A8=ED=94=84=20(Watershed=5FExpand?= =?UTF-8?q?=20=EC=8B=A0=EC=84=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 적색 셀이 해석 영역 최외곽에 있다는 것은 그 바깥에서 물이 더 들어온다는 뜻이다. 거기서 멈추면 유역이 잘린다. 반대로 최외곽이 전부 파랑이면 바깥 물은 도로로 오지 않으므로 더 볼 필요가 없다. 1. 해석 영역 최외곽 셀 중 적색인 것을 찾는다 2. 그 주변으로 한 겹(기본 50m) 넓힌다 3. 넓힌 영역으로 흐름 방향/색을 다시 분석한다 4. 새로 추가한 셀에 적색이 없으면 종료 (사용자 지시) 최외곽에 적색이 아예 없어도 종료 - 둘 다 같은 판단이다 격자 bbox 에 닿으면 격자도 셀 정수배로 넓힌다. 도로 시작점 기준 격자점은 그대로 유지되므로 확장 전후 같은 자리 셀이 같은 자리에 남는다. 실데이터 결과 (33.3s) 최외곽 적색 1,184 -> 400 -> 16 -> 0 (3회차에 닫힘) 해석 셀 536,708 -> 640,872 (+104,164) 적색 424,193 -> 457,404 화살표=경로 일치 100%, 파랑인데 적색 가리킴 0개 (유지) 구조 변경 - Watershed_Expand.py 신설: analyze_domain(1회 분석) + expand_by_red_boundary(루프) - StagePreview 에 spec/domain/expand_* 추가. 확장하면 격자가 1차 영역보다 커지므로 화면/저장은 region.spec 이 아니라 이쪽을 봐야 한다. - 라우터 응답에 expansion(회차/닫힘/추가 셀 수) 추가 - config: DRAINAGE_RED_EXPAND_BAND_M=50, DRAINAGE_RED_EXPAND_MAX_ROUNDS=20 Co-Authored-By: Claude Fable 5 --- B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts | 9 + .../B05_wf2_Route_Engine_Watershed_Basin.py | 70 +++--- .../B05_wf2_Route_Engine_Watershed_Expand.py | 221 ++++++++++++++++++ .../B05_wf2_Route_Router_Drainage.py | 42 ++-- .../B05_wf2_Route_UI_Drainage_Panel.ts | 7 +- config/config_system.py | 4 + 6 files changed, 311 insertions(+), 42 deletions(-) create mode 100644 B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Expand.py diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index 2c36b24d..579e79bc 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -327,6 +327,15 @@ export interface DrainagePrimaryRegion { /** 실제 생성된 셀 구간 [행, 시작열, 끝열(포함)]. 낱개 셀 대신 구간으로 온다. */ row_spans: Array<[number, number, number]>; }; + /** 최외곽 적색 셀 주변 확장 결과. */ + expansion: { + rounds: number; + /** 새로 추가한 셀에 적색이 없어 스스로 멈췄는가. */ + closed: boolean; + added_cells: number; + /** 확장 전(1차 영역) 셀 수. */ + initial_cells: number; + }; /** 셀별 흐름 방향과 도로 도달 여부. 등고선이 없어 판정을 못하면 null. */ flow: { encoding: "base64-uint8"; diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py index 57841d48..27382d51 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py @@ -35,20 +35,15 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import ( find_stream_crossings, is_uphill_at, ) -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Descent import ( - ContourDescent, - build_contour_descent, -) +from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Descent import ContourDescent +from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Expand import expand_by_red_boundary from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Flow import ( FlowClassification, RoadRaster, - burn_stream_flow, - classify_flow, expand_until_closed, largest_ring, outer_boundary, polygonize_labels, - rasterize_road, ) from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import ( GridSpec, @@ -211,13 +206,22 @@ def preview_primary_region( @dataclass class StagePreview: - """단계 검증 산출물 묶음. 기능을 붙일 때마다 여기에 항목이 하나씩 늘어난다.""" + """단계 검증 산출물 묶음. 기능을 붙일 때마다 여기에 항목이 하나씩 늘어난다. + + 확장을 거치면 격자와 해석 영역이 1차 영역보다 커진다. 화면·저장은 `region.spec`이 + 아니라 여기 `spec`/`domain`을 봐야 한다. + """ region: PrimaryRegion + spec: GridSpec | None = None + domain: np.ndarray | None = None terrain: TerrainGrid | None = None road: RoadRaster | None = None flow: FlowClassification | None = None descent: ContourDescent | None = None + expand_rounds: int = 0 + expand_closed: bool = False + expand_added_cells: int = 0 def preview_stages( @@ -227,12 +231,14 @@ def preview_stages( ) -> StagePreview | None: """지금까지 구현·검증된 단계를 순서대로 돌려 결과를 모은다. - 현재 포함: ① 1차 배수유역 ② 격자 생성 ③ **등고선 하강 방향** ④ 도로 도달 판정. + 현재 포함: ① 1차 배수유역 ② 격자 생성 ③ **등고선 하강 방향** ④ 도로 도달 판정 + ⑤ **최외곽 적색 셀 주변 확장**. ③은 보간면(TIN)을 쓰지 않는다. 높은 등고 라인에서 낮은 등고 라인으로 내려가며 방향을 세우므로 가짜 웅덩이·평탄면이 원리적으로 생기지 않는다(2026-07-31 사용자 지시로 방식 교체). - **격자 확장은 넣지 않는다** — 다음 검증 단계다. + ⑤는 최외곽에 적색이 남아 있으면 그 주변으로 넓혀 다시 분석하고, **새로 추가한 셀에 + 적색이 없으면** 멈춘다. """ if len(vertices) < 2: return None @@ -241,29 +247,39 @@ def preview_stages( if region is None: return None - spec = region.spec started = time.perf_counter() floor = route_elevation_floor([vertex.z for vertex in vertices]) - descent = build_contour_descent(spec, contour_features, region.cell_mask, floor) - if not descent.valid.any(): + expansion = expand_by_red_boundary( + region.spec, + region.cell_mask, + contour_features, + route_line, + region.split.upstream, + floor, + ) + if expansion is None: logger.warning("배수유역: 등고선 하강 방향을 세우지 못해 흐름 판정을 건너뜁니다.") return StagePreview(region=region) - # 이후 단계(유역 제원)가 표고를 쓰므로 밴드 표고를 지형 격자로 함께 들고 간다. - terrain = TerrainGrid( - spec=spec, - elevation=descent.band_elevation, - valid=descent.valid, - receiver=descent.receiver, - step_length=descent.step_length, + analysis = expansion.analysis + logger.info( + "배수유역: 단계 분석 %.1fs (확장 %d회, 최종 셀 %d개)", + time.perf_counter() - started, + expansion.rounds, + analysis.spec.size, + ) + return StagePreview( + region=region, + spec=analysis.spec, + domain=analysis.domain, + terrain=analysis.terrain, + road=analysis.road, + flow=analysis.flow, + descent=analysis.descent, + expand_rounds=expansion.rounds, + expand_closed=expansion.closed, + expand_added_cells=expansion.added_cells, ) - road = rasterize_road(spec, route_line, DRAINAGE_ROAD_WIDTH_M) - # 확정된 상류 세류망을 따라 흐름을 새긴다 — 세류선 위 셀과 그리로 흘러드는 셀은 - # 반드시 도로에 도달해야 한다. - terrain, burned = burn_stream_flow(terrain, road, region.split.upstream) - flow = classify_flow(terrain, road, burned, azimuth=descent.azimuth) - logger.info("배수유역: 흐름 판정 %.1fs (셀 %d개)", time.perf_counter() - started, spec.size) - return StagePreview(region=region, terrain=terrain, road=road, flow=flow, descent=descent) # ── ③~④ 격자 해석 (캐시 대상) ─────────────────────────────────────────────── diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Expand.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Expand.py new file mode 100644 index 00000000..a4de669f --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Expand.py @@ -0,0 +1,221 @@ +"""해석 영역 확장 — 최외곽의 **적색 셀** 주변으로 넓히며 다시 분석한다. + +적색 셀이 해석 영역 최외곽에 있다는 것은 그 바깥에서 물이 더 흘러 들어온다는 뜻이다. +거기서 멈추면 유역이 잘린다. 반대로 최외곽이 전부 파랑이면 그 바깥 물은 도로로 오지 +않으므로 더 볼 필요가 없다. + + ① 현재 해석 영역의 최외곽 셀 중 **적색**인 것을 찾는다 + ② 그 주변으로 한 겹(설정 폭) 넓힌다 + ③ 넓힌 영역으로 흐름 방향·색을 다시 분석한다 + ④ **새로 추가된 셀에 적색이 하나도 없으면 종료** (2026-07-31 사용자 지시) + +격자 bbox에 닿으면 격자 자체도 셀 정수배로 넓힌다 — 도로 시작점 기준 격자점은 유지된다. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any + +import numpy as np +from shapely.geometry import LineString + +from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Descent import ( + ContourDescent, + build_contour_descent, +) +from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Flow import ( + FlowClassification, + RoadRaster, + burn_stream_flow, + classify_flow, + outermost_cells, + rasterize_road, +) +from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import GridSpec, TerrainGrid +from config.config_system import ( + DRAINAGE_RED_EXPAND_BAND_M, + DRAINAGE_RED_EXPAND_MAX_ROUNDS, + DRAINAGE_ROAD_WIDTH_M, +) + +logger = logging.getLogger(__name__) + + +@dataclass +class GridAnalysis: + """한 회차 분석 결과 — 격자·해석 영역·방향·색까지 한 묶음.""" + + spec: GridSpec + domain: np.ndarray # (R, C) bool — 해석 대상 셀 + descent: ContourDescent + terrain: TerrainGrid + road: RoadRaster + flow: FlowClassification + + +@dataclass +class RedExpansion: + """확장 루프 결과.""" + + analysis: GridAnalysis + rounds: int # 실제로 넓힌 횟수 (0 = 처음부터 최외곽에 적색이 없었음) + closed: bool # 새로 추가한 셀에 적색이 없어 스스로 멈췄는가 + added_cells: int # 확장으로 늘어난 셀 수 + + +def analyze_domain( + spec: GridSpec, + domain: np.ndarray, + contour_features: list[dict[str, Any]], + route_line: LineString, + upstream_streams: list[LineString], + elevation_floor_m: float | None = None, +) -> GridAnalysis | None: + """주어진 격자·해석 영역에 대해 등고선 하강 방향과 도로 도달 색을 한 번 계산한다.""" + descent = build_contour_descent(spec, contour_features, domain, elevation_floor_m) + if not descent.valid.any(): + return None + terrain = TerrainGrid( + spec=spec, + elevation=descent.band_elevation, + valid=descent.valid, + receiver=descent.receiver, + step_length=descent.step_length, + ) + road = rasterize_road(spec, route_line, DRAINAGE_ROAD_WIDTH_M) + terrain, burned = burn_stream_flow(terrain, road, upstream_streams) + flow = classify_flow(terrain, road, burned, azimuth=descent.azimuth) + return GridAnalysis( + spec=spec, domain=domain, descent=descent, terrain=terrain, road=road, flow=flow + ) + + +def expand_by_red_boundary( + spec: GridSpec, + domain: np.ndarray, + contour_features: list[dict[str, Any]], + route_line: LineString, + upstream_streams: list[LineString], + elevation_floor_m: float | None = None, + band_m: float = DRAINAGE_RED_EXPAND_BAND_M, + max_rounds: int = DRAINAGE_RED_EXPAND_MAX_ROUNDS, +) -> RedExpansion | None: + """최외곽 적색 셀 주변으로 넓히며, 새로 추가한 셀에 적색이 없을 때까지 반복한다.""" + analysis = analyze_domain( + spec, domain, contour_features, route_line, upstream_streams, elevation_floor_m + ) + if analysis is None: + return None + + band_cells = max(1, int(round(band_m / spec.cell_m))) + started_cells = int(domain.sum()) + rounds = 0 + closed = False + for attempt in range(max_rounds): + current = analysis.spec + reaches = analysis.flow.reaches_road.reshape(current.n_rows, current.n_cols) + rim_red = outermost_cells(analysis.domain) & reaches + if not rim_red.any(): + closed = True # 최외곽이 전부 파랑 — 바깥 물은 도로로 오지 않는다 + break + + grown_spec, grown_domain, grown_rim = _grow_for_rim( + current, analysis.domain, rim_red, band_cells + ) + widened = grown_domain | _dilate_by(grown_rim, band_cells) + added_mask = widened & ~grown_domain + if not added_mask.any(): + closed = True + break + + logger.info( + "배수유역: %d회차 확장 — 최외곽 적색 %d셀 주변 %.0fm, 셀 %d개 추가", + attempt + 1, + int(rim_red.sum()), + band_m, + int(added_mask.sum()), + ) + widened_analysis = analyze_domain( + grown_spec, widened, contour_features, route_line, upstream_streams, elevation_floor_m + ) + if widened_analysis is None: + break + analysis = widened_analysis + rounds += 1 + + added_reaches = widened_analysis.flow.reaches_road.reshape( + grown_spec.n_rows, grown_spec.n_cols + ) + if not (added_mask & added_reaches).any(): + closed = True # 새로 추가한 셀에 적색이 없다 — 여기까지가 유역이다 + logger.info("배수유역: 새로 추가한 셀에 적색이 없어 확장을 멈춥니다.") + break + else: + logger.warning("배수유역: 확장 상한(%d회)에 도달했습니다.", max_rounds) + + added = int(analysis.domain.sum()) - started_cells + logger.info( + "배수유역: 확장 %d회, 셀 %d → %d (+%d), %s", + rounds, + started_cells, + int(analysis.domain.sum()), + added, + "닫힘" if closed else "미닫힘", + ) + return RedExpansion(analysis=analysis, rounds=rounds, closed=closed, added_cells=added) + + +def _dilate_by(mask: np.ndarray, steps: int) -> np.ndarray: + """8이웃 팽창을 `steps`회 반복한다(정사각 커널이라 반경 = steps 셀).""" + rows, cols = mask.shape + result = mask + for _ in range(steps): + padded = np.zeros((rows + 2, cols + 2), dtype=bool) + padded[1:-1, 1:-1] = result + grown = np.zeros_like(result) + for row_shift in (0, 1, 2): + for col_shift in (0, 1, 2): + grown |= padded[row_shift : row_shift + rows, col_shift : col_shift + cols] + result = grown + return result + + +def _grow_for_rim( + spec: GridSpec, domain: np.ndarray, rim: np.ndarray, band_cells: int +) -> tuple[GridSpec, np.ndarray, np.ndarray]: + """적색 최외곽이 격자 bbox에 닿았으면 그 방향으로 격자를 넓히고 마스크를 옮겨 담는다. + + 격자는 셀 정수배로만 넓히므로 도로 시작점 기준 격자점이 그대로 유지된다. + """ + north = band_cells if rim[0, :].any() else 0 + south = band_cells if rim[-1, :].any() else 0 + west = band_cells if rim[:, 0].any() else 0 + east = band_cells if rim[:, -1].any() else 0 + if not (north or south or west or east): + return spec, domain, rim + + grown = GridSpec( + x_min=spec.x_min - west * spec.cell_m, + y_max=spec.y_max + north * spec.cell_m, + cell_m=spec.cell_m, + n_rows=spec.n_rows + north + south, + n_cols=spec.n_cols + west + east, + ) + new_domain = np.zeros((grown.n_rows, grown.n_cols), dtype=bool) + new_rim = np.zeros_like(new_domain) + new_domain[north : north + spec.n_rows, west : west + spec.n_cols] = domain + new_rim[north : north + spec.n_rows, west : west + spec.n_cols] = rim + logger.info( + "배수유역: 격자 확대 %d×%d → %d×%d (북%d 남%d 서%d 동%d 셀)", + spec.n_rows, + spec.n_cols, + grown.n_rows, + grown.n_cols, + north, + south, + west, + east, + ) + return grown, new_domain, new_rim diff --git a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py index 789d8fda..c15f44b7 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py +++ b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py @@ -216,7 +216,9 @@ async def get_primary_region(project_id: UUID) -> dict[str, Any] | JSONResponse: ) region = preview.region to_lonlat = prepared["to_lonlat"] - spec = region.spec + # 확장을 거치면 격자·해석 영역이 1차 영역보다 커진다. 최종본을 써야 화면과 어긋나지 않는다. + spec = preview.spec or region.spec + domain = preview.domain if preview.domain is not None else region.cell_mask payload = { "status": "success", "project_id": str(project_id), @@ -235,21 +237,28 @@ async def get_primary_region(project_id: UUID) -> dict[str, Any] | JSONResponse: "cell_m": spec.cell_m, "rows": spec.n_rows, "cols": spec.n_cols, - # bbox 전체 셀 수와, 1차 영역에 걸쳐 실제로 생성된 셀 수. + # bbox 전체 셀 수와, 해석 영역에 실제로 생성된 셀 수(확장 반영). "bbox_cells": spec.size, - "cells": region.active_cells, + "cells": int(domain.sum()) if domain is not None else 0, "width_m": round(spec.n_cols * spec.cell_m, 1), "height_m": round(spec.n_rows * spec.cell_m, 1), # 격자 bbox 링. 프론트는 이 사각형을 rows×cols로 나눠 행·열 좌표를 얻는다. "bbox_lonlat": _grid_bbox_lonlat(spec, to_lonlat), # 실제 생성된 셀을 행별 연속 구간 [행, 시작열, 끝열]으로 압축해 보낸다. # 셀을 낱개로 보내면 수십만 건이라 응답이 감당되지 않는다. - "row_spans": [list(span) for span in mask_row_spans(region.cell_mask)] - if region.cell_mask is not None + "row_spans": [list(span) for span in mask_row_spans(domain)] + if domain is not None else [], }, + # 최외곽 적색 셀 주변 확장 결과. + "expansion": { + "rounds": preview.expand_rounds, + "closed": preview.expand_closed, + "added_cells": preview.expand_added_cells, + "initial_cells": region.active_cells, + }, # 셀별 흐름 방향과 도로 도달 여부. row_spans 순서(행 → 구간 → 열 오름차순)로 1바이트씩. - "flow": _flow_payload(preview, region), + "flow": _flow_payload(preview, domain), } # 단계 산출물을 영구저장소에 남긴다 — 기능을 붙일 때마다 여기에 단계가 하나씩 늘어난다. payload["saved_to"] = write_stage( @@ -275,19 +284,24 @@ async def get_primary_region(project_id: UUID) -> dict[str, Any] | JSONResponse: }, to_lonlat, ) - _write_stage_arrays(prepared["stored_path"], preview, region, spec) + _write_stage_arrays(prepared["stored_path"], preview, domain, spec) return payload -def _write_stage_arrays(stored_path: str, preview: Any, region: Any, spec: Any) -> None: +def _write_stage_arrays(stored_path: str, preview: Any, domain: Any, spec: Any) -> None: """격자 규모 배열(셀 마스크·흐름 방향·도달 여부)을 단계별 `.npz`로 남긴다.""" - if region.cell_mask is not None: + if domain is not None: write_grid_arrays( stored_path, "primary_region", spec, - {"mask": region.cell_mask}, - {"cells": region.active_cells, "bbox_cells": spec.size}, + {"mask": domain}, + { + "cells": int(domain.sum()), + "bbox_cells": spec.size, + "expand_rounds": preview.expand_rounds, + "expand_closed": preview.expand_closed, + }, ) flow = preview.flow if flow is None: @@ -322,7 +336,7 @@ def _write_stage_arrays(stored_path: str, preview: Any, region: Any, spec: Any) ) -def _flow_payload(preview: Any, region: Any) -> dict[str, Any] | None: +def _flow_payload(preview: Any, domain: Any) -> dict[str, Any] | None: """셀별 흐름 방향·도로 도달 여부를 바이트 배열로 압축한다. 셀이 수십만 개라 JSON 객체로는 못 보낸다. 셀 하나당 1바이트로 줄이고 base64로 싣는다: @@ -331,9 +345,9 @@ def _flow_payload(preview: Any, region: Any) -> dict[str, Any] | None: 바이트 순서는 `grid.row_spans`를 행 → 구간 → 열 오름차순으로 훑은 순서와 같다. """ flow = preview.flow - if flow is None or region.cell_mask is None: + if flow is None or domain is None: return None - order = np.flatnonzero(region.cell_mask.reshape(-1)) + order = np.flatnonzero(domain.reshape(-1)) analyzed = flow.analyzed[order] reaches = flow.reaches_road[order] packed = np.clip(flow.direction[order], 0, AZIMUTH_INVALID).astype(np.uint8) diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts index ff267960..9e6f72a4 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -638,10 +638,15 @@ export function createDrainagePanel(): DrainagePanel { `최외곽 출발 ${region.flow.outer_seeds.toLocaleString()} + ` + `내부 보충 ${region.flow.interior_seeds.toLocaleString()}${burned}` : " · 흐름 판정 없음"; + const expansion = region.expansion + ? ` · 확장 ${region.expansion.rounds}회` + + `(${region.expansion.initial_cells.toLocaleString()}→${cells}셀, ` + + `${region.expansion.closed ? "닫힘" : "상한 도달"})` + : ""; return ( `1차 영역(반경 ${region.radius_m}m): 상류망 ${region.upstream_lines.length}조각 채택 / ` + `하류망 ${region.downstream_lines.length}조각·미연결 ${region.no_contact_count}개 제외 · ` + - `격자 ${region.grid.cell_m}m(도로 시점 기준) 셀 ${cells}개${outside}${flow}` + `격자 ${region.grid.cell_m}m(도로 시점 기준) 셀 ${cells}개${outside}${expansion}${flow}` ); } diff --git a/config/config_system.py b/config/config_system.py index 53547279..51c5e2fc 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -251,6 +251,10 @@ DRAINAGE_INITIAL_RADIUS_M = float(os.getenv("DRAINAGE_INITIAL_RADIUS_M", "100.0" DRAINAGE_EXPAND_STEP_M = float(os.getenv("DRAINAGE_EXPAND_STEP_M", "200.0")) # 확장 반복 상한. 경계 링이 전부 비활성이 되면 그 전에 스스로 멈춘다(안전핀). DRAINAGE_MAX_EXPAND_ROUNDS = int(os.getenv("DRAINAGE_MAX_EXPAND_ROUNDS", "6")) +# 최외곽 적색 셀 주변을 한 회차에 넓히는 폭(m). 좁을수록 유역 경계가 정밀하나 회차가 늘어난다. +DRAINAGE_RED_EXPAND_BAND_M = float(os.getenv("DRAINAGE_RED_EXPAND_BAND_M", "50.0")) +# 적색 확장 반복 상한. 새로 추가한 셀에 적색이 없으면 그 전에 스스로 멈춘다(안전핀). +DRAINAGE_RED_EXPAND_MAX_ROUNDS = int(os.getenv("DRAINAGE_RED_EXPAND_MAX_ROUNDS", "20")) # 격자 셀 수 권장 상한. 넘으면 **경고만** 남기고 그대로 계산한다 — 격자 크기 자동 조절은 # 하지 않는다(2026-07-31 사용자 지시). 느리면 위 DRAINAGE_GRID_SIZE_M을 직접 올린다. DRAINAGE_MAX_GRID_CELLS = int(os.getenv("DRAINAGE_MAX_GRID_CELLS", "16000000")) From 2dcafcd28643f1d506458eefcc77a73c7777f102 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 31 Jul 2026 19:47:13 +0900 Subject: [PATCH 37/61] =?UTF-8?q?fix(B05):=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=ED=83=80=EC=9E=84=EC=95=84=EC=9B=83=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20+=20=ED=99=95=EC=9E=A5=20=ED=9A=8C?= =?UTF-8?q?=EC=B0=A8=EB=A7=88=EB=8B=A4=20=EB=B0=A9=ED=96=A5=EC=9E=A5=20?= =?UTF-8?q?=EC=9E=AC=EA=B3=84=EC=82=B0=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "signal is aborted without reason" 오류. 프론트 공통 타임아웃이 30초인데 등고선 하강 + 확장 루프가 33초 걸려 계산 도중 abort 됐다. - config_frontend 에 API_ANALYSIS_TIMEOUT_MS(60초) 추가. 공통 30초는 그대로 두고 분석 엔드포인트(primary-region, basins)에만 적용한다. 일반 요청까지 늘리면 장애 시 화면이 오래 멈춘다. - requestJson 에 timeoutMs 인자 추가. AbortError 원문은 원인을 알 수 없으므로 "요청이 N초 안에 끝나지 않았습니다"로 바꿔 던진다. 같이 속도도 줄였다 (33.3s -> 26.2s, 결과 동일) - 하강 방향장은 해석 영역과 무관하다 — 등고선 기하만으로 정해진다. 확장 회차마다 다시 만들 이유가 없어, 격자가 커졌을 때만 새로 만들고 아니면 재사용한다. 해석 영역은 마지막에 마스크로만 씌운다. - 1차 영역 bbox 는 영역에 딱 붙어 있어 첫 회차부터 격자를 넓혀야 했다. 시작할 때 사방에 확장폭 2배 여유를 둬 몇 회차는 격자를 안 넓히고 돈다. 검증: 적색 457,404 로 이전과 동일. 확장 3회 닫힘, 방향장 계산 4회 -> 2회. Co-Authored-By: Claude Fable 5 --- B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts | 40 +++++++++++---- .../B05_wf2_Route_Engine_Watershed_Expand.py | 50 ++++++++++++++++--- config/config_frontend.ts | 4 ++ 3 files changed, 75 insertions(+), 19 deletions(-) diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index 579e79bc..e66b6c25 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -11,7 +11,7 @@ * - 오류 응답 형식 {status:"error", message:"..."}을 Error로 변환. * ========================================================================== */ -import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; +import { API_ANALYSIS_TIMEOUT_MS, API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; /** 경로 제어점 (BP/EP/CP) */ export interface RoutePoint { @@ -141,10 +141,17 @@ export interface RouteLatestResponse { } | null; } -/** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. */ -async function requestJson(path: string, init: RequestInit): Promise { +/** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. + * + * `timeoutMs`를 주면 그 값으로 끊는다. 격자 해석처럼 오래 걸리는 요청은 + * `API_ANALYSIS_TIMEOUT_MS`를 넘긴다 — 기본값으로 두면 계산 도중 abort 된다. */ +async function requestJson( + path: string, + init: RequestInit, + timeoutMs: number = API_TIMEOUT_MS, +): Promise { const controller = new AbortController(); - const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS); + const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(`${API_BASE_URL}${path}`, { ...init, @@ -160,6 +167,12 @@ async function requestJson(path: string, init: RequestInit): Promise { throw new Error(payload.message ?? `HTTP ${response.status}`); } return payload; + } catch (error) { + // AbortError 원문("signal is aborted without reason")은 원인을 알 수 없으니 바꿔 준다. + if (error instanceof DOMException && error.name === "AbortError") { + throw new Error(`요청이 ${Math.round(timeoutMs / 1000)}초 안에 끝나지 않았습니다.`); + } + throw error; } finally { window.clearTimeout(timeoutId); } @@ -365,9 +378,12 @@ export interface DrainagePrimaryRegion { export async function fetchDrainagePrimaryRegion( projectId: string, ): Promise { - return requestJson(`/projects/${projectId}/drainage/primary-region`, { - method: "GET", - }); + // 등고선 하강 방향 + 적색 확장 루프까지 도는 요청이라 수십 초가 걸린다. + return requestJson( + `/projects/${projectId}/drainage/primary-region`, + { method: "GET" }, + API_ANALYSIS_TIMEOUT_MS, + ); } export async function fetchDrainageCandidates( @@ -383,8 +399,10 @@ export async function fetchDrainageBasins( projectId: string, chainages?: number[], ): Promise { - return requestJson(`/projects/${projectId}/drainage/basins`, { - method: "POST", - body: JSON.stringify({ chainages: chainages ?? [] }), - }); + // 격자 해석이 포함된 요청이라 캐시가 없으면 수십 초가 걸린다. + return requestJson( + `/projects/${projectId}/drainage/basins`, + { method: "POST", body: JSON.stringify({ chainages: chainages ?? [] }) }, + API_ANALYSIS_TIMEOUT_MS, + ); } diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Expand.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Expand.py index a4de669f..54017008 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Expand.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Expand.py @@ -72,15 +72,23 @@ def analyze_domain( route_line: LineString, upstream_streams: list[LineString], elevation_floor_m: float | None = None, + descent: ContourDescent | None = None, ) -> GridAnalysis | None: - """주어진 격자·해석 영역에 대해 등고선 하강 방향과 도로 도달 색을 한 번 계산한다.""" - descent = build_contour_descent(spec, contour_features, domain, elevation_floor_m) - if not descent.valid.any(): + """주어진 격자·해석 영역에 대해 등고선 하강 방향과 도로 도달 색을 한 번 계산한다. + + **하강 방향장은 해석 영역과 무관하다** — 등고선 기하만으로 정해진다. 그래서 확장 + 회차마다 다시 계산하지 않고, 격자가 커졌을 때만 새로 만들어 넘겨받는다(`descent`). + 해석 영역은 마지막에 마스크로만 씌운다. + """ + if descent is None or descent.spec != spec: + descent = build_contour_descent(spec, contour_features, None, elevation_floor_m) + valid = descent.valid & domain + if not valid.any(): return None terrain = TerrainGrid( spec=spec, - elevation=descent.band_elevation, - valid=descent.valid, + elevation=np.where(valid, descent.band_elevation, np.nan).astype(np.float32), + valid=valid, receiver=descent.receiver, step_length=descent.step_length, ) @@ -103,14 +111,17 @@ def expand_by_red_boundary( max_rounds: int = DRAINAGE_RED_EXPAND_MAX_ROUNDS, ) -> RedExpansion | None: """최외곽 적색 셀 주변으로 넓히며, 새로 추가한 셀에 적색이 없을 때까지 반복한다.""" + band_cells = max(1, int(round(band_m / spec.cell_m))) + # 1차 영역의 bbox는 영역에 딱 붙어 있어 첫 회차부터 격자를 넓혀야 한다. 미리 여유를 + # 두면 방향장을 다시 만들지 않고 해석 영역만 넓히며 몇 회차를 돌 수 있다. + spec, domain = _pad_spec(spec, domain, band_cells * 2) + started_cells = int(domain.sum()) analysis = analyze_domain( spec, domain, contour_features, route_line, upstream_streams, elevation_floor_m ) if analysis is None: return None - band_cells = max(1, int(round(band_m / spec.cell_m))) - started_cells = int(domain.sum()) rounds = 0 closed = False for attempt in range(max_rounds): @@ -138,7 +149,14 @@ def expand_by_red_boundary( int(added_mask.sum()), ) widened_analysis = analyze_domain( - grown_spec, widened, contour_features, route_line, upstream_streams, elevation_floor_m + grown_spec, + widened, + contour_features, + route_line, + upstream_streams, + elevation_floor_m, + # 격자가 그대로면 방향장을 재사용한다 — 등고선 기하가 안 바뀌었으므로 결과는 같다. + descent=analysis.descent if grown_spec == current else None, ) if widened_analysis is None: break @@ -182,6 +200,22 @@ def _dilate_by(mask: np.ndarray, steps: int) -> np.ndarray: return result +def _pad_spec(spec: GridSpec, domain: np.ndarray, cells: int) -> tuple[GridSpec, np.ndarray]: + """격자에 사방 여유를 두고 해석 영역 마스크를 그 안으로 옮겨 담는다.""" + if cells <= 0: + return spec, domain + padded_spec = GridSpec( + x_min=spec.x_min - cells * spec.cell_m, + y_max=spec.y_max + cells * spec.cell_m, + cell_m=spec.cell_m, + n_rows=spec.n_rows + 2 * cells, + n_cols=spec.n_cols + 2 * cells, + ) + padded = np.zeros((padded_spec.n_rows, padded_spec.n_cols), dtype=bool) + padded[cells : cells + spec.n_rows, cells : cells + spec.n_cols] = domain + return padded_spec, padded + + def _grow_for_rim( spec: GridSpec, domain: np.ndarray, rim: np.ndarray, band_cells: int ) -> tuple[GridSpec, np.ndarray, np.ndarray]: diff --git a/config/config_frontend.ts b/config/config_frontend.ts index bd7bd651..3e0f6e56 100644 --- a/config/config_frontend.ts +++ b/config/config_frontend.ts @@ -16,6 +16,10 @@ export const API_BASE_URL = "/api"; /** API 요청 타임아웃 (ms) */ export const API_TIMEOUT_MS = 30_000; +/** 격자 해석처럼 수십 초가 걸리는 분석 요청용 타임아웃 (ms). + * 일반 요청에 이 값을 쓰면 장애 시 화면이 오래 멈추므로 분석 엔드포인트에만 쓴다. */ +export const API_ANALYSIS_TIMEOUT_MS = 60_000; + /** B03~B09 워크플로우에서 사용할 현재 프로젝트 UUID 저장 키 */ export const CURRENT_PROJECT_ID_KEY = "frd_current_project_id"; From baef88dd50697e7b52eef05595929ae46388ae39 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 31 Jul 2026 19:58:54 +0900 Subject: [PATCH 38/61] =?UTF-8?q?feat(B05):=20=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=EC=97=90=20=ED=9D=90=EB=A6=84=20=EA=B0=95?= =?UTF-8?q?=EB=8F=84=C2=B72=EC=B0=A8=20=EC=9C=A0=EC=97=AD=20=EC=99=B8?= =?UTF-8?q?=EA=B3=BD=EC=84=A0=C2=B7=EA=B8=B0=EB=B3=B8=20=EA=B4=80=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 본 계산(POST /basins)은 손대지 않았다. 검증 경로에만 6/7/8 단계를 얹는다. 6. 흐름 강도 색 판정은 세류 셀에서 멈춘다(거기서 도로 도달이 확정되므로). 강도는 그 물이 세류를 타고 최종적으로 어느 도로 셀로 들어가는지를 봐야 하므로, 도로만 흡수점으로 두고 한 번 더 따라가 도로 셀별로 센다. -> 누가거리 5m 구간별 유입 면적 곡선 7. 2차 전체 배수유역 외곽선 적색 셀 전체를 폴리곤화한 외곽 링 + 면적 8. 기본 관 매설 위치 도로 x 세류선 교차점. 20m 이내는 하나로 병합. 300m 보충 배치는 다음 단계. 프론트는 기존 렌더 경로를 그대로 쓴다 — 2차 유역선은 갈색 파선, 강도는 계획선 위 파란 띠, 관은 번호 마커. 버튼을 끄면 이 셋도 같이 걷는다. 실데이터 검증 (27.8s) 적색 457,404셀 = 457,404m2 7. 폴리곤 면적 457,374m2 (셀 면적의 100.0%) 6. 강도 합계 457,404m2 (적색 면적의 100.0% - 귀속 누락 0) 최대 지점 150m 에 409,244m2(89.5%) 집중 8. 기본 관 1개 chainage 149.7m -> 강도 최대 지점과 정확히 일치. 세류가 도로를 건너는 그 자리다. 교차 노드는 2개였으나 13.3m 간격이라 20m 병합 규칙으로 1개가 됐다. Co-Authored-By: Claude Fable 5 --- B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts | 7 +++ .../B05_wf2_Route_Engine_Watershed_Basin.py | 63 ++++++++++++++++++- .../B05_wf2_Route_Router_Drainage.py | 21 ++++++- .../B05_wf2_Route_UI_Drainage_Panel.ts | 18 +++++- 4 files changed, 104 insertions(+), 5 deletions(-) diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index e66b6c25..c22140e9 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -371,6 +371,13 @@ export interface DrainagePrimaryRegion { * 순서는 grid.row_spans를 행 → 구간 → 열 오름차순으로 훑은 순서와 같다. */ data: string; } | null; + /** 2차 전체 배수유역 외곽선(= 분수령). 적색 셀 전체의 외곽. */ + basin_polygon_lonlat: Array<[number, number]>; + basin_area_m2: number; + /** 도로 위 흐름 강도 — [누가거리 m, 그 구간으로 모이는 상류 면적 ㎡]. */ + strength_profile: Array<[number, number]>; + /** 기본 관 매설 위치 — 도로 × 세류선 교차점. */ + pipes: DrainageCandidate[]; /** 영구저장소에 남긴 검증용 GeoJSON 경로. */ saved_to: string | null; } diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py index 27382d51..128ca2d4 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py @@ -44,6 +44,7 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Flow import ( largest_ring, outer_boundary, polygonize_labels, + trace_flow, ) from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import ( GridSpec, @@ -222,6 +223,13 @@ class StagePreview: expand_rounds: int = 0 expand_closed: bool = False expand_added_cells: int = 0 + # ⑥ 도로 위 흐름 강도 — (누가거리 m, 그 구간으로 모이는 상류 면적 ㎡). + strength_profile: list[tuple[float, float]] = field(default_factory=list) + # ⑦ 2차 전체 배수유역 외곽선(= 분수령). 적색 셀 전체를 폴리곤화한 것. + basin_boundary_xy: list[tuple[float, float]] = field(default_factory=list) + basin_area_m2: float = 0.0 + # ⑧ 기본 관 매설 위치 — 도로 × 세류선 교차점. + pipes: list[StructureCandidate] = field(default_factory=list) def preview_stages( @@ -262,15 +270,34 @@ def preview_stages( return StagePreview(region=region) analysis = expansion.analysis + spec = analysis.spec + red = analysis.flow.reaches_road & analysis.flow.analyzed + + # ⑥ 흐름 강도 — 셀마다 물이 실제로 들어가는 도로 셀을 구해 도로 셀별로 센다. + # 색 판정은 세류 셀에서 멈추지만(거기서 도달이 확정되므로), 강도는 그 물이 세류를 타고 + # 최종적으로 어느 도로 셀로 들어가는지를 봐야 하므로 도로만 흡수점으로 두고 다시 따라간다. + strength_curve = _preview_strength(analysis, red, route_line.length) + + # ⑦ 2차 전체 배수유역 외곽선 = 적색 셀 전체의 외곽. + boundary = outer_boundary(spec, red.reshape(spec.n_rows, spec.n_cols)) + basin_ring = largest_ring(boundary) if boundary is not None else [] + + # ⑧ 기본 관 매설 위치 = 도로 × 세류선 교차점(너무 가까운 것은 하나로 합침). + pipes = _base_pipes(vertices, stream_features) + logger.info( - "배수유역: 단계 분석 %.1fs (확장 %d회, 최종 셀 %d개)", + "배수유역: 단계 분석 %.1fs (확장 %d회, 최종 셀 %d개) — " + "2차 유역 %.0f㎡, 기본 관 %d개, 강도 곡선 %d점", time.perf_counter() - started, expansion.rounds, - analysis.spec.size, + spec.size, + int(red.sum()) * spec.cell_area_m2, + len(pipes), + int((strength_curve > 0).sum()), ) return StagePreview( region=region, - spec=analysis.spec, + spec=spec, domain=analysis.domain, terrain=analysis.terrain, road=analysis.road, @@ -279,9 +306,39 @@ def preview_stages( expand_rounds=expansion.rounds, expand_closed=expansion.closed, expand_added_cells=expansion.added_cells, + strength_profile=_downsample_strength(strength_curve), + basin_boundary_xy=basin_ring, + basin_area_m2=int(red.sum()) * spec.cell_area_m2, + pipes=pipes, ) +def _preview_strength(analysis: Any, red: np.ndarray, route_length_m: float) -> np.ndarray: + """적색 셀이 실제로 들어가는 도로 셀을 세어 누가거리별 유입 면적 곡선을 만든다.""" + road = analysis.road + if road.count == 0: + return np.zeros(1) + routed = trace_flow(analysis.terrain, road) + slots = routed.road_slot + counted = red & (slots >= 0) + strength = np.bincount(slots[counted], minlength=road.count).astype(np.float64) + return _strength_by_chainage( + road.chainage, strength * analysis.spec.cell_area_m2, route_length_m + ) + + +def _base_pipes( + vertices: list[RouteVertex], stream_features: list[dict[str, Any]] +) -> list[StructureCandidate]: + """도로 × 세류선 교차점을 기본 관 위치로 삼는다. 300m 보충 배치는 다음 단계다.""" + 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: + continue + pipes.append(candidate) + return pipes + + # ── ③~④ 격자 해석 (캐시 대상) ─────────────────────────────────────────────── diff --git a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py index c15f44b7..7a5c0345 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py +++ b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py @@ -17,7 +17,7 @@ import numpy as np from fastapi import APIRouter from fastapi.responses import JSONResponse from pyproj import Transformer -from shapely.geometry import LineString, Polygon, box +from shapely.geometry import LineString, Point, Polygon, box from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import ( @@ -259,6 +259,15 @@ async def get_primary_region(project_id: UUID) -> dict[str, Any] | JSONResponse: }, # 셀별 흐름 방향과 도로 도달 여부. row_spans 순서(행 → 구간 → 열 오름차순)로 1바이트씩. "flow": _flow_payload(preview, domain), + # ⑦ 2차 전체 배수유역 외곽선(= 분수령)과 면적. + "basin_polygon_lonlat": [list(to_lonlat(x, y)) for x, y in preview.basin_boundary_xy], + "basin_area_m2": round(preview.basin_area_m2, 1), + # ⑥ 도로 위 흐름 강도 — [누가거리 m, 그 구간으로 모이는 상류 면적 ㎡]. + "strength_profile": [ + [round(chainage, 1), round(area, 1)] for chainage, area in preview.strength_profile + ], + # ⑧ 기본 관 매설 위치 — 도로 × 세류선 교차점. + "pipes": [_candidate_payload(pipe, to_lonlat) for pipe in preview.pipes], } # 단계 산출물을 영구저장소에 남긴다 — 기능을 붙일 때마다 여기에 단계가 하나씩 늘어난다. payload["saved_to"] = write_stage( @@ -270,11 +279,16 @@ async def get_primary_region(project_id: UUID) -> dict[str, Any] | JSONResponse: "downstream": region.split.downstream, "route": [prepared["route_line"]], "grid_bbox": [_grid_bbox_polygon(spec)], + # ⑦ 2차 전체 배수유역 외곽선, ⑧ 기본 관 위치. + "basin_boundary": _boundary_geometry(preview.basin_boundary_xy), + "pipe": [Point(pipe.x, pipe.y) for pipe in preview.pipes], }, { "radius_m": region.radius_m, "road_outside_m": payload["road_outside_m"], "no_contact_count": region.split.no_contact, + "basin_area_m2": payload["basin_area_m2"], + "pipe_count": len(preview.pipes), # 기하(bbox·셀 구간)는 파일 본문에 있으므로 요약 수치만 남긴다. "grid": { key: value @@ -371,6 +385,11 @@ def _flow_payload(preview: Any, domain: Any) -> dict[str, Any] | None: } +def _boundary_geometry(ring: list[tuple[float, float]]) -> list[Any]: + """2차 유역 외곽 링을 저장용 폴리곤으로 만든다(정점 3개 미만이면 비운다).""" + return [Polygon(ring)] if len(ring) >= 4 else [] + + def _as_polygons(geometry: Any) -> list[Any]: if geometry is None or geometry.is_empty: return [] diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts index 9e6f72a4..a26e9c40 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -598,6 +598,10 @@ export function createDrainagePanel(): DrainagePanel { regionButton.classList.remove("is-active"); regionButton.setAttribute("aria-pressed", "false"); status.hidden = true; + // 이 버튼이 얹은 2차 유역선·강도 띠·관 마커도 함께 걷는다. + mainBoundary = []; + pipeEditor.setPipes([]); + pipeEditor.setStrength([]); scheduleDraw(); return; } @@ -606,6 +610,15 @@ export function createDrainagePanel(): DrainagePanel { status.textContent = "1차 배수유역을 확인하는 중…"; try { primaryRegion = await fetchDrainagePrimaryRegion(projectId); + // 2차 유역 외곽선·흐름 강도·기본 관 위치를 기존 렌더 경로에 그대로 태운다. + mainBoundary = primaryRegion.basin_polygon_lonlat ?? []; + pipeEditor.setPipes( + (primaryRegion.pipes ?? []).map((pipe) => ({ + chainage_m: pipe.chainage_m, + reason: pipe.reason, + })), + ); + pipeEditor.setStrength(primaryRegion.strength_profile ?? []); showRegion = true; regionButton.classList.add("is-active"); regionButton.setAttribute("aria-pressed", "true"); @@ -643,10 +656,13 @@ export function createDrainagePanel(): DrainagePanel { `(${region.expansion.initial_cells.toLocaleString()}→${cells}셀, ` + `${region.expansion.closed ? "닫힘" : "상한 도달"})` : ""; + const basin = region.basin_area_m2 + ? ` · 2차 유역 ${formatArea(region.basin_area_m2)}, 기본 관 ${region.pipes.length}개` + : ""; return ( `1차 영역(반경 ${region.radius_m}m): 상류망 ${region.upstream_lines.length}조각 채택 / ` + `하류망 ${region.downstream_lines.length}조각·미연결 ${region.no_contact_count}개 제외 · ` + - `격자 ${region.grid.cell_m}m(도로 시점 기준) 셀 ${cells}개${outside}${expansion}${flow}` + `격자 ${region.grid.cell_m}m(도로 시점 기준) 셀 ${cells}개${outside}${expansion}${basin}${flow}` ); } From b0747e74b667d8d97af1d45a698203ce82410ef3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 31 Jul 2026 20:04:26 +0900 Subject: [PATCH 39/61] =?UTF-8?q?fix(B05):=206/7/8=20=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=EB=AC=BC=EB=8F=84=20=EC=98=81=EA=B5=AC?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EC=86=8C=EC=97=90=20=EB=82=A8=EA=B8=B4?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 확인해 보니 7(2차 유역 외곽선)과 8(기본 관)은 저장되고 있었으나 6(흐름 강도 곡선)이 빠져 있었고, 관에 누가거리가 안 붙었다. - write_stage 의 레이어 항목이 (기하, 속성dict) 짝도 받도록 확장. 관 피처에 chainage_m / reason / stream_name 이 실린다. - 흐름 강도 곡선은 기하가 아니라 수치 곡선이라 GeoJSON 대신 02_flow_direction.npz 에 strength_chainage_m / strength_area_m2 로 담는다. - manifest 에 strength_points / strength_total_m2 요약 추가. 저장 확인 (실데이터) 01_primary_region.geojson 688KB kind: primary_region 1 / upstream 16 / downstream 860 / route 1 / grid_bbox 1 / basin_boundary 1 / pipe 1 pipe 속성 = chainage_m 149.73, reason stream 01_primary_region.npz 11KB 셀 마스크 + 확장 회차 02_flow_direction.npz 2353KB direction/reaches_road/analyzed/receiver/ burned/band_elevation + 강도 곡선 71점(합계 457,404m2, 최대 150m) manifest.json 3단계 요약 전부 Co-Authored-By: Claude Fable 5 --- .../B05_wf2_Route_Engine_Watershed_Export.py | 23 ++++++++++++------- .../B05_wf2_Route_Router_Drainage.py | 21 +++++++++++++++-- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Export.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Export.py index 1b8ac236..babe06a2 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Export.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Export.py @@ -44,13 +44,14 @@ def drainage_dir(stored_path: str) -> Path: def write_stage( stored_path: str, stage: str, - layers: dict[str, Sequence[BaseGeometry]], + layers: dict[str, Sequence[BaseGeometry | tuple[BaseGeometry, dict[str, Any]]]], properties: dict[str, Any], to_lonlat: LonLat, ) -> str | None: """한 단계의 기하 산출물을 GeoJSON으로 저장하고 매니페스트를 갱신한다. - `layers`는 {레이어이름: 사업지 CRS(m) 기하 목록}이며 피처 `kind` 속성이 된다. + `layers`는 {레이어이름: 사업지 CRS(m) 기하 목록}이며 레이어 이름이 피처 `kind` 속성이 + 된다. 기하 대신 `(기하, 속성dict)` 짝을 넣으면 그 속성이 피처에 함께 실린다. 좌표는 여기서 WGS84로 바꾼다 — 저장 파일은 어떤 도구로 열어도 바로 보여야 한다. """ prefix = STAGES.get(stage) @@ -60,12 +61,14 @@ def write_stage( features: list[dict[str, Any]] = [] counts: dict[str, int] = {} - for kind, geometries in layers.items(): - for index, geometry in enumerate(geometries): - feature = _to_feature(kind, index, geometry, to_lonlat) + for kind, entries in layers.items(): + for index, entry in enumerate(entries): + # 항목은 기하 하나이거나 (기하, 속성) 짝이다 — 관 누가거리처럼 붙일 값이 있을 때 쓴다. + geometry, extra = entry if isinstance(entry, tuple) else (entry, None) + feature = _to_feature(kind, index, geometry, to_lonlat, extra) if feature is not None: features.append(feature) - counts[kind] = len(geometries) + counts[kind] = len(entries) filename = f"{prefix}_{stage}.geojson" directory = drainage_dir(stored_path) @@ -90,14 +93,18 @@ def write_stage( def _to_feature( - kind: str, index: int, geometry: BaseGeometry, to_lonlat: LonLat + kind: str, + index: int, + geometry: BaseGeometry, + to_lonlat: LonLat, + extra: dict[str, Any] | None = None, ) -> dict[str, Any] | None: coordinates = _to_lonlat_coords(geometry, to_lonlat) if coordinates is None: return None return { "type": "Feature", - "properties": {"kind": kind, "index": index}, + "properties": {"kind": kind, "index": index, **(extra or {})}, "geometry": {"type": geometry.geom_type, "coordinates": coordinates}, } diff --git a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py index 7a5c0345..6c5334d4 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py +++ b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py @@ -279,9 +279,19 @@ async def get_primary_region(project_id: UUID) -> dict[str, Any] | JSONResponse: "downstream": region.split.downstream, "route": [prepared["route_line"]], "grid_bbox": [_grid_bbox_polygon(spec)], - # ⑦ 2차 전체 배수유역 외곽선, ⑧ 기본 관 위치. + # ⑦ 2차 전체 배수유역 외곽선, ⑧ 기본 관 위치(누가거리·근거 포함). "basin_boundary": _boundary_geometry(preview.basin_boundary_xy), - "pipe": [Point(pipe.x, pipe.y) for pipe in preview.pipes], + "pipe": [ + ( + Point(pipe.x, pipe.y), + { + "chainage_m": round(pipe.chainage_m, 2), + "reason": pipe.reason, + "stream_name": pipe.stream_name, + }, + ) + for pipe in preview.pipes + ], }, { "radius_m": region.radius_m, @@ -331,6 +341,11 @@ def _write_stage_arrays(stored_path: str, preview: Any, domain: Any, spec: Any) arrays["burned"] = flow.burned.reshape(spec.n_rows, spec.n_cols) if preview.descent is not None: arrays["band_elevation"] = preview.descent.band_elevation + # ⑥ 흐름 강도 곡선 — 기하가 아니라 수치 곡선이라 GeoJSON이 아닌 여기에 함께 담는다. + if preview.strength_profile: + curve = np.asarray(preview.strength_profile, dtype=np.float64) + arrays["strength_chainage_m"] = curve[:, 0] + arrays["strength_area_m2"] = curve[:, 1] write_grid_arrays( stored_path, "flow_direction", @@ -346,6 +361,8 @@ def _write_stage_arrays(stored_path: str, preview: Any, domain: Any, spec: Any) "burned": 0 if flow.burned is None else int(flow.burned.sum()), "outer_seeds": flow.outer_seeds, "interior_seeds": flow.interior_seeds, + "strength_points": len(preview.strength_profile), + "strength_total_m2": round(sum(area for _, area in preview.strength_profile), 1), }, ) From 8a0d640e0e4312a0957da6f93aeddbb672f61041 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 31 Jul 2026 20:57:09 +0900 Subject: [PATCH 40/61] =?UTF-8?q?refactor(B04/B05):=20=EB=B0=B0=EC=88=98?= =?UTF-8?q?=EC=9C=A0=EC=97=AD=20=EB=B6=84=EC=84=9D=EC=9D=84=20B04=EB=A1=9C?= =?UTF-8?q?=20=EC=9D=B4=EA=B4=80,=20B05=EB=8A=94=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EB=B6=84=20=EC=86=8C=EB=B9=84=EB=A7=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 분석이 30초 걸리는데 B05는 일반 사용자 화면이다. 관리자 확인용 B04에서 한 번 돌려 저장하고, B05는 그 결과를 읽어 관 보충과 세부유역만 처리한다 (2026-07-31 사용자 지시). 노선 원천 변경 - B05 확정 경로 -> B03 업로드 계획 노선 파일(CSV). 분석이 노선 설계보다 먼저 끝나 있어야 하기 때문. 샘플 planned_route_sample_epsg5187.csv 로 검증. - common_util_route_geometry.py 신설 — RouteVertex/StructureCandidate/누가거리 보간/세류 교차점/계획 노선 CSV 리더. B04와 B05가 같은 표현을 쓰도록 공용화. 열 이름은 대소문자·한글 표기를 함께 받는다(B03이 여러 형식 수용 예정). B04 (관리자 확인용, 신규) - Engine_Watershed_{Grid,Stream,Descent,Flow,Expand,Export} — B05에서 git mv - Engine_Watershed_Analyze.py — 1~8단계 오케스트레이션 - Router_Watershed.py — GET /drainage/primary-region - UI_Watershed.ts — 2D 지도 GIS 레이어 그룹에 "배수유역" 토글 추가. 격자/화살표/세류망/1차영역/2차유역/기본관을 겹쳐 그린다. - 저장 위치 B05_wf2_Route/drainage -> B04_wf1_Surface/drainage - 03_road_routing 단계 추가: B05가 세부유역을 나눌 최소 배열(셀->도로셀 귀속, 유하장, 강도, 도로셀 제원, 셀 표고) + 계획도로선/기본배관/2차유역 기하 B05 (일반 사용자용, 축소) - Engine_Drainage_Basin.py — B04 산출물 로더 + 관 보충(9) + 측구 라우팅/세부유역(10,11) - Engine_Drainage.py 는 관경 산정만 남기고 322 -> 27줄 - Router_Drainage.py 509 -> 142줄. POST /drainage/basins 만 남김 - 화살표·격자·강도 띠 렌더 제거. 계획도로선/기본배관/2차유역만 받는다 삭제 - _legacy_watershed/ 4파일 (능선 행진 방식 원본 보관본) - Engine_Watershed_Basin.py (B04 Analyze + B05 Drainage_Basin 으로 분할) - GET /drainage/candidates 와 propose_structure_stations (구방식 후보 제안) E2E 검증 (실데이터) B04 분석 28.2s -> 저장(geojson 11KB + npz 2.6MB) B05 로드 + 세부 설계 0.11s <-- 30초가 0.1초로 면적 457,404m2 로 B04 2차 유역과 정확히 일치 관 편집 재산정 0.12s, 관 3개 -> 세부유역 3개, 면적 보존 Co-Authored-By: Claude Fable 5 --- B03_FileInput/B03_FileInput_Api_Fetch.ts | 7 +- B03_FileInput/B03_FileInput_Engine_Analyze.py | 112 +++ B03_FileInput/B03_FileInput_Repository.py | 25 + B03_FileInput/B03_FileInput_Router.py | 107 ++- B03_FileInput/B03_FileInput_Schema.py | 1 + B03_FileInput/B03_FileInput_UI_Page.ts | 16 +- B03_FileInput/B03_FileInput_UI_Style.css | 9 + B03_FileInput/B03_FileInput_UI_Support.ts | 9 +- .../test_B03_FileInput_Engine_Analyze.py | 58 ++ B03_FileInput/test_B03_FileInput_Router.py | 60 ++ B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts | 120 ++- ...04_wf1_Surface_Engine_Watershed_Analyze.py | 270 +++++++ ...04_wf1_Surface_Engine_Watershed_Descent.py | 2 +- ...B04_wf1_Surface_Engine_Watershed_Expand.py | 6 +- ...B04_wf1_Surface_Engine_Watershed_Export.py | 8 +- .../B04_wf1_Surface_Engine_Watershed_Flow.py | 2 +- .../B04_wf1_Surface_Engine_Watershed_Grid.py | 0 ...B04_wf1_Surface_Engine_Watershed_Stream.py | 4 +- .../B04_wf1_Surface_Router_Watershed.py | 482 ++++++++++++ .../B04_wf1_Surface_UI_MapViewer.ts | 18 +- .../B04_wf1_Surface_UI_Watershed.ts | 397 ++++++++++ B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts | 100 +-- .../B05_wf2_Route_Engine_Drainage.py | 305 +------- .../B05_wf2_Route_Engine_Drainage_Basin.py | 441 +++++++++++ .../B05_wf2_Route_Engine_Watershed_Basin.py | 723 ------------------ .../B05_wf2_Route_Router_Drainage.py | 437 +---------- .../B05_wf2_Route_UI_Drainage_Panel.ts | 342 +-------- .../B05_wf2_Route_UI_Drainage_Pipes.ts | 40 - ...B05_wf2_Route_Engine_Drainage_Watershed.py | 572 -------------- ...B05_wf2_Route_Engine_Watershed_Assemble.py | 205 ----- ...05_wf2_Route_Engine_Watershed_Subdivide.py | 171 ----- .../B05_wf2_Route_Engine_Watershed_Trace.py | 657 ---------------- B05_wf2_Route/_legacy_watershed/README.md | 16 - common_util/common_util_route_geometry.py | 255 ++++++ config/config_frontend.ts | 4 +- config/config_system.py | 2 +- main.py | 2 + ui_template/ui_template_locale.ts | 15 +- 38 files changed, 2440 insertions(+), 3560 deletions(-) create mode 100644 B03_FileInput/test_B03_FileInput_Engine_Analyze.py create mode 100644 B03_FileInput/test_B03_FileInput_Router.py create mode 100644 B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Analyze.py rename B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Descent.py => B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Descent.py (99%) rename B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Expand.py => B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Expand.py (97%) rename B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Export.py => B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Export.py (95%) rename B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Flow.py => B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Flow.py (99%) rename B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py => B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Grid.py (100%) rename B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Stream.py => B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Stream.py (98%) create mode 100644 B04_wf1_Surface/B04_wf1_Surface_Router_Watershed.py create mode 100644 B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts create mode 100644 B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py delete mode 100644 B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py delete mode 100644 B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Drainage_Watershed.py delete mode 100644 B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Watershed_Assemble.py delete mode 100644 B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Watershed_Subdivide.py delete mode 100644 B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Watershed_Trace.py delete mode 100644 B05_wf2_Route/_legacy_watershed/README.md create mode 100644 common_util/common_util_route_geometry.py diff --git a/B03_FileInput/B03_FileInput_Api_Fetch.ts b/B03_FileInput/B03_FileInput_Api_Fetch.ts index 62bab955..e9664935 100644 --- a/B03_FileInput/B03_FileInput_Api_Fetch.ts +++ b/B03_FileInput/B03_FileInput_Api_Fetch.ts @@ -120,12 +120,17 @@ export async function finalizeUploadSession( projectId: string, sessionId: string, totalChunks: number, + completeUpload: boolean, ): Promise { const response = await fetch(`${API_BASE_URL}/projects/${projectId}/finalize`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ session_id: sessionId, total_chunks: totalChunks }), + body: JSON.stringify({ + session_id: sessionId, + total_chunks: totalChunks, + complete_upload: completeUpload, + }), }); return await readJsonOrThrow(response); } diff --git a/B03_FileInput/B03_FileInput_Engine_Analyze.py b/B03_FileInput/B03_FileInput_Engine_Analyze.py index 682b711b..3bed2c34 100644 --- a/B03_FileInput/B03_FileInput_Engine_Analyze.py +++ b/B03_FileInput/B03_FileInput_Engine_Analyze.py @@ -1,5 +1,6 @@ """B03 원본 입력 파일 메타데이터 분석.""" +import csv import logging import math import re @@ -269,10 +270,121 @@ def analyze_tif_metadata(path: str | Path) -> dict[str, Any]: rasterio_logger.removeFilter(warning_filter) +_PLANNED_ROUTE_COLUMNS = ("route_name", "sequence", "x", "y", "z", "crs_epsg") + + +def _parse_route_integer(value: str, *, field: str, row_number: int) -> int: + normalized = value.strip() + if not re.fullmatch(r"[0-9]+", normalized): + raise ValueError(f"CSV {row_number}행의 {field} 값은 양의 정수여야 합니다.") + parsed = int(normalized) + if parsed <= 0: + raise ValueError(f"CSV {row_number}행의 {field} 값은 양의 정수여야 합니다.") + return parsed + + +def _parse_route_coordinate(value: str, *, field: str, row_number: int) -> float: + try: + parsed = float(value.strip()) + except (AttributeError, ValueError) as exc: + raise ValueError(f"CSV {row_number}행의 {field} 값은 숫자여야 합니다.") from exc + if not math.isfinite(parsed): + raise ValueError(f"CSV {row_number}행의 {field} 값은 유한한 숫자여야 합니다.") + return parsed + + +def analyze_planned_route_csv(path: str | Path) -> dict[str, Any]: + """원청 계획노선 CSV를 검증하고 경로 메타데이터를 반환한다.""" + source = Path(path) + with source.open("r", encoding="utf-8-sig", newline="") as csv_file: + reader = csv.DictReader(csv_file) + if reader.fieldnames is None: + raise ValueError("계획노선 CSV 헤더를 찾을 수 없습니다.") + + normalized_headers = [header.strip() for header in reader.fieldnames] + if len(set(normalized_headers)) != len(normalized_headers): + raise ValueError("계획노선 CSV 헤더에 중복된 열이 있습니다.") + header_map = dict(zip(normalized_headers, reader.fieldnames, strict=True)) + missing = [column for column in _PLANNED_ROUTE_COLUMNS if column not in header_map] + if missing: + raise ValueError(f"계획노선 CSV 필수 열이 없습니다: {', '.join(missing)}") + + route_name: str | None = None + crs_epsg: int | None = None + points: list[tuple[float, float, float]] = [] + for expected_sequence, row in enumerate(reader, start=1): + row_number = expected_sequence + 1 + current_name = (row.get(header_map["route_name"]) or "").strip() + if not current_name: + raise ValueError(f"CSV {row_number}행의 route_name 값이 비어 있습니다.") + if route_name is None: + route_name = current_name + elif current_name != route_name: + raise ValueError("계획노선 CSV에는 하나의 route_name만 사용할 수 있습니다.") + + sequence = _parse_route_integer( + row.get(header_map["sequence"]) or "", + field="sequence", + row_number=row_number, + ) + if sequence != expected_sequence: + raise ValueError( + f"CSV {row_number}행의 sequence는 {expected_sequence}이어야 합니다." + ) + + current_epsg = _parse_route_integer( + row.get(header_map["crs_epsg"]) or "", + field="crs_epsg", + row_number=row_number, + ) + if crs_epsg is None: + crs_epsg = current_epsg + elif current_epsg != crs_epsg: + raise ValueError("계획노선 CSV의 crs_epsg는 모든 행에서 같아야 합니다.") + + points.append( + tuple( + _parse_route_coordinate( + row.get(header_map[field]) or "", + field=field, + row_number=row_number, + ) + for field in ("x", "y", "z") + ) + ) + + if len(points) < 2: + raise ValueError("계획노선 CSV에는 좌표가 2개 이상 있어야 합니다.") + + xs, ys, zs = zip(*points, strict=True) + return { + "file": source.name, + "extension": "csv", + "size_bytes": source.stat().st_size, + "purpose": "planned_route", + "route_name": route_name, + "point_count": len(points), + "epsg": crs_epsg, + "columns": list(_PLANNED_ROUTE_COLUMNS), + "bounds": { + "x_min": min(xs), + "x_max": max(xs), + "y_min": min(ys), + "y_max": max(ys), + "z_min": min(zs), + "z_max": max(zs), + }, + "start_point": list(points[0]), + "end_point": list(points[-1]), + } + + def analyze_input_metadata(path: str | Path) -> dict[str, Any]: """입력 파일 확장자에 맞는 B03 메타데이터 분석 함수를 호출한다.""" source = Path(path) extension = source.suffix.lower() + if extension == ".csv": + return analyze_planned_route_csv(source) if extension in {".las", ".laz"}: return analyze_las_metadata(source) if extension == ".prj": diff --git a/B03_FileInput/B03_FileInput_Repository.py b/B03_FileInput/B03_FileInput_Repository.py index 752b1d78..f9ed89a7 100644 --- a/B03_FileInput/B03_FileInput_Repository.py +++ b/B03_FileInput/B03_FileInput_Repository.py @@ -61,6 +61,31 @@ async def create_input_file( return int(input_file_id) +async def get_project_input_readiness( + connection: aiomysql.Connection, + project_id: UUID, +) -> tuple[set[str], int | None]: + """현재 업로드 파일 유형과 최신 포인트클라우드 입력 ID를 반환한다.""" + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """ + SELECT id, LOWER(file_type) AS file_type + FROM input_files + WHERE project_id = %s AND status IN ('UPLOADED', 'PROCESSED') + ORDER BY id DESC + """, + (str(project_id),), + ) + rows = await cursor.fetchall() + + file_types = {str(row["file_type"]) for row in rows if row.get("file_type")} + point_cloud_id = next( + (int(row["id"]) for row in rows if str(row.get("file_type") or "") in {"las", "laz"}), + None, + ) + return file_types, point_cloud_id + + async def get_project_storage_relative_path( connection: aiomysql.Connection, project_id: UUID, diff --git a/B03_FileInput/B03_FileInput_Router.py b/B03_FileInput/B03_FileInput_Router.py index f69d088b..afcbef4b 100644 --- a/B03_FileInput/B03_FileInput_Router.py +++ b/B03_FileInput/B03_FileInput_Router.py @@ -1,6 +1,7 @@ """B03 파일 입력 FastAPI 라우터.""" import asyncio +import json import logging from pathlib import Path from typing import Any @@ -25,6 +26,7 @@ from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_input_metadata from B03_FileInput.B03_FileInput_Repository import ( create_input_file, create_upload_session, + get_project_input_readiness, get_project_storage_relative_path, get_upload_session, list_completed_chunk_indexes, @@ -60,13 +62,69 @@ from config.config_system import ( logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B03 File Input"]) +_REQUIRED_FILE_TYPES = frozenset({"csv", "prj", "tfw"}) +_POINT_CLOUD_FILE_TYPES = frozenset({"las", "laz"}) + def _total_chunks(size_bytes: int, chunk_size_bytes: int) -> int: return max(1, (size_bytes + chunk_size_bytes - 1) // chunk_size_bytes) def _is_point_cloud_result(result: UploadedFileResult) -> bool: - return result.file_type.lower() in {"las", "laz"} + return result.file_type.lower() in _POINT_CLOUD_FILE_TYPES + + +def _missing_required_file_types(file_types: set[str]) -> list[str]: + missing = sorted(_REQUIRED_FILE_TYPES - file_types) + if not file_types.intersection(_POINT_CLOUD_FILE_TYPES): + missing.append("las/laz") + return missing + + +def _require_complete_file_set(file_types: set[str]) -> None: + missing = _missing_required_file_types(file_types) + if missing: + raise ValueError(f"B03 필수 입력 파일이 없습니다: {', '.join(missing)}") + + +async def _complete_file_input_if_ready( + connection: aiomysql.Connection, + project_id: UUID, +) -> int: + file_types, point_cloud_input_id = await get_project_input_readiness(connection, project_id) + _require_complete_file_set(file_types) + if point_cloud_input_id is None: + raise ValueError("LAS 또는 LAZ 입력 파일을 찾을 수 없습니다.") + async with connection.cursor() as cursor: + await complete_stage(cursor, str(project_id), 0) + return point_cloud_input_id + + +def _write_stage_metadata( + stage_root: Path, + project_id: UUID, + results: list[UploadedFileResult], +) -> None: + metadata_path = stage_root / "metadata.json" + existing_files: list[dict[str, Any]] = [] + if metadata_path.exists(): + try: + payload = json.loads(metadata_path.read_text(encoding="utf-8")) + existing_files = list(payload.get("files") or []) + except (OSError, TypeError, ValueError): + logger.warning("B03 metadata.json을 읽지 못해 새로 작성합니다: %s", metadata_path) + + merged = { + str(item.get("relative_path") or item.get("original_filename")): item + for item in existing_files + } + for result in results: + dumped = result.model_dump() + merged[result.relative_path] = dumped + atomic_write_json( + metadata_path, + {"project_id": str(project_id), "files": list(merged.values())}, + ) def _schedule_background_task(coro: Any, *, task_name: str) -> None: @@ -170,11 +228,31 @@ async def upload_project_files( "message": "LAS 또는 LAZ 파일을 정확히 1개 포함해야 합니다.", }, ) + csv_count = sum(Path(filename).suffix.lower() == ".csv" for filename in filenames) + if csv_count != 1: + return JSONResponse( + status_code=400, + content={ + "status": "error", + "message": "계획노선 CSV 파일을 정확히 1개 포함해야 합니다.", + }, + ) + request_file_types = {Path(filename).suffix.lower().lstrip(".") for filename in filenames} + missing_required = _missing_required_file_types(request_file_types) + if missing_required: + return JSONResponse( + status_code=400, + content={ + "status": "error", + "message": f"B03 필수 입력 파일이 없습니다: {', '.join(missing_required)}", + }, + ) pool = get_db_pool() saved_paths: list[Path] = [] try: results: list[UploadedFileResult] = [] + point_cloud_input_id: int | None = None async with pool.acquire() as connection: stored_path = await get_project_storage_relative_path(connection, project_id) @@ -219,18 +297,14 @@ async def upload_project_files( metadata=metadata, ) ) - async with connection.cursor() as cursor: - await complete_stage(cursor, str(project_id), 0) + point_cloud_input_id = await _complete_file_input_if_ready(connection, project_id) await connection.commit() except Exception: await connection.rollback() raise stage_root = project_root / "B03_FileInput" - atomic_write_json( - stage_root / "metadata.json", - {"project_id": str(project_id), "files": [result.model_dump() for result in results]}, - ) + _write_stage_metadata(stage_root, project_id, results) workflow_path = project_root / "workflow.json" if not workflow_path.exists(): atomic_write_json(workflow_path, load_project_workflow(project_root)) @@ -246,10 +320,11 @@ async def upload_project_files( ), task_name=f"b03-upload-email-{project_id}", ) + if point_cloud_input_id is not None: _schedule_background_task( trigger_wf1_analysis_and_email( project_id=project_id, - input_file_id=point_cloud_result.input_file_id, + input_file_id=point_cloud_input_id, user_role=str(session["role"]), ), task_name=f"b04-wf1-auto-{project_id}", @@ -390,6 +465,7 @@ async def finalize_project_upload( """청크 업로드를 최종 병합하고 input_files 메타데이터를 기록한다.""" pool = get_db_pool() final_path: Path | None = None + point_cloud_input_id: int | None = None try: async with pool.acquire() as connection: session = await get_upload_session( @@ -444,8 +520,11 @@ async def finalize_project_upload( metadata=metadata, ) await mark_upload_session_completed(connection, session_id=payload.session_id) - async with connection.cursor() as cursor: - await complete_stage(cursor, str(project_id), 0) + if payload.complete_upload: + point_cloud_input_id = await _complete_file_input_if_ready( + connection, + project_id, + ) await connection.commit() except Exception: await connection.rollback() @@ -462,10 +541,7 @@ async def finalize_project_upload( metadata=metadata, ) stage_root = project_root / "B03_FileInput" - atomic_write_json( - stage_root / "metadata.json", - {"project_id": str(project_id), "files": [result.model_dump()]}, - ) + _write_stage_metadata(stage_root, project_id, [result]) if _is_point_cloud_result(result): _schedule_background_task( _send_upload_complete_notification( @@ -474,10 +550,11 @@ async def finalize_project_upload( ), task_name=f"b03-upload-email-{project_id}", ) + if point_cloud_input_id is not None: _schedule_background_task( trigger_wf1_analysis_and_email( project_id=project_id, - input_file_id=result.input_file_id, + input_file_id=point_cloud_input_id, user_role=str(session["role"]), ), task_name=f"b04-wf1-auto-{project_id}", diff --git a/B03_FileInput/B03_FileInput_Schema.py b/B03_FileInput/B03_FileInput_Schema.py index 32247939..e1338472 100644 --- a/B03_FileInput/B03_FileInput_Schema.py +++ b/B03_FileInput/B03_FileInput_Schema.py @@ -89,6 +89,7 @@ class UploadFinalizeRequest(BaseModel): session_id: str = Field(min_length=1, max_length=36) total_chunks: int = Field(gt=0) + complete_upload: bool = True class UploadStatusResponse(BaseModel): diff --git a/B03_FileInput/B03_FileInput_UI_Page.ts b/B03_FileInput/B03_FileInput_UI_Page.ts index 7b618c41..140360eb 100644 --- a/B03_FileInput/B03_FileInput_UI_Page.ts +++ b/B03_FileInput/B03_FileInput_UI_Page.ts @@ -368,6 +368,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { async function uploadOneFile( projectId: string, state: FileSlotState, + completeUpload: boolean, ): Promise { const file = state.file; if (!file) return []; @@ -421,7 +422,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { } } - const response = await finalizeUploadSession(projectId, session, totalChunks); + const response = await finalizeUploadSession(projectId, session, totalChunks, completeUpload); localStorage.removeItem(storageKey); saveB03UploadedFile(projectId, { slot: state.slot, @@ -470,8 +471,11 @@ export async function renderB03FileInput(root: HTMLElement): Promise { pageError.textContent = ""; const uploaded: UploadedFileResult[] = []; try { - for (const state of targetStates) { - uploaded.push(...(await uploadOneFile(activeProjectId, state))); + for (let index = 0; index < targetStates.length; index += 1) { + const state = targetStates[index]; + uploaded.push( + ...(await uploadOneFile(activeProjectId, state, index === targetStates.length - 1)), + ); } renderUploadResults(uploaded); showToast(L("B03_File_Upload_Success"), "success"); @@ -544,11 +548,13 @@ export async function renderB03FileInput(root: HTMLElement): Promise { uploadControlPanel.className = "b03-file__control-panel"; uploadControlPanel.append(subtitle, dropzone, resumeBanner, pageError, uploadButton, resultList); - const filesGroup = createCardGroup("", ["las_laz", "prj", "tfw", "tif"]); // 타이틀 공백으로 전달 + const routeGroup = createCardGroup(L("B03_File_Group_Route"), ["csv"]); + routeGroup.classList.add("b03-file__group--route"); + const filesGroup = createCardGroup(L("B03_File_Group_Terrain"), ["las_laz", "prj", "tfw", "tif"]); const cardsContainer = document.createElement("div"); cardsContainer.className = "b03-file__control-panel b03-file__cards-container-panel"; - cardsContainer.append(filesGroup); + cardsContainer.append(routeGroup, filesGroup); const workflowState = activeProjectId ? await fetchWorkflowState(activeProjectId).catch(() => undefined) diff --git a/B03_FileInput/B03_FileInput_UI_Style.css b/B03_FileInput/B03_FileInput_UI_Style.css index 2726521f..9ac5af96 100644 --- a/B03_FileInput/B03_FileInput_UI_Style.css +++ b/B03_FileInput/B03_FileInput_UI_Style.css @@ -136,6 +136,15 @@ gap: var(--spacing-24); } +.b03-file__group--route .b03-file__group-content { + grid-template-columns: 1fr; +} + +.b03-file__group--route .b03-file__card { + border-color: var(--color-royal-amethyst, #3e0079); + background: var(--color-mist-violet, #edecff); +} + /* Wiza 8px radius 카드 */ .b03-file__card { min-height: 220px; diff --git a/B03_FileInput/B03_FileInput_UI_Support.ts b/B03_FileInput/B03_FileInput_UI_Support.ts index 40daa173..ad2a509c 100644 --- a/B03_FileInput/B03_FileInput_UI_Support.ts +++ b/B03_FileInput/B03_FileInput_UI_Support.ts @@ -1,6 +1,6 @@ import { ui_locales } from "@ui/ui_template_locale"; -export type FileSlot = "las_laz" | "prj" | "tfw" | "tif" | "dxf"; +export type FileSlot = "csv" | "las_laz" | "prj" | "tfw" | "tif" | "dxf"; export type UploadStatus = "pending" | "uploading" | "completed" | "failed"; export interface SlotConfig { @@ -35,6 +35,13 @@ export interface StoredUploadSession { } const SLOT_CONFIGS: readonly SlotConfig[] = [ + { + slot: "csv", + labelKey: "B03_File_Slot_PlannedRoute", + icon: "⌁", + extensions: [".csv"], + isRequired: true, + }, { slot: "las_laz", labelKey: "B03_File_Slot_PointCloud", diff --git a/B03_FileInput/test_B03_FileInput_Engine_Analyze.py b/B03_FileInput/test_B03_FileInput_Engine_Analyze.py new file mode 100644 index 00000000..39dfa9d3 --- /dev/null +++ b/B03_FileInput/test_B03_FileInput_Engine_Analyze.py @@ -0,0 +1,58 @@ +import tempfile +import unittest +from pathlib import Path + +from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_planned_route_csv + + +class PlannedRouteCsvTest(unittest.TestCase): + def analyze(self, content: str) -> dict: + with tempfile.TemporaryDirectory() as temporary_dir: + path = Path(temporary_dir) / "planned_route.csv" + path.write_text(content, encoding="utf-8") + return analyze_planned_route_csv(path) + + def test_valid_route_returns_metadata(self) -> None: + metadata = self.analyze( + "route_name,sequence,x,y,z,crs_epsg\n" + "sample,1,183493.5,489290.335,544.659,5187\n" + "sample,2,183500.0,489300.0,545.0,5187\n" + ) + + self.assertEqual(metadata["purpose"], "planned_route") + self.assertEqual(metadata["route_name"], "sample") + self.assertEqual(metadata["point_count"], 2) + self.assertEqual(metadata["epsg"], 5187) + self.assertEqual(metadata["start_point"], [183493.5, 489290.335, 544.659]) + + def test_missing_header_is_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "필수 열"): + self.analyze("route_name,sequence,x,y,crs_epsg\nsample,1,183493.5,489290.335,5187\n") + + def test_non_numeric_coordinate_is_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "x 값은 숫자"): + self.analyze( + "route_name,sequence,x,y,z,crs_epsg\n" + "sample,1,not-a-number,489290.335,544.659,5187\n" + "sample,2,183500.0,489300.0,545.0,5187\n" + ) + + def test_non_contiguous_sequence_is_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "sequence는 2"): + self.analyze( + "route_name,sequence,x,y,z,crs_epsg\n" + "sample,1,183493.5,489290.335,544.659,5187\n" + "sample,3,183500.0,489300.0,545.0,5187\n" + ) + + def test_mixed_epsg_is_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "모든 행에서 같아야"): + self.analyze( + "route_name,sequence,x,y,z,crs_epsg\n" + "sample,1,183493.5,489290.335,544.659,5187\n" + "sample,2,183500.0,489300.0,545.0,5186\n" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/B03_FileInput/test_B03_FileInput_Router.py b/B03_FileInput/test_B03_FileInput_Router.py new file mode 100644 index 00000000..84f7671b --- /dev/null +++ b/B03_FileInput/test_B03_FileInput_Router.py @@ -0,0 +1,60 @@ +import json +import tempfile +import unittest +from pathlib import Path +from uuid import UUID + +from B03_FileInput.B03_FileInput_Router import ( + _missing_required_file_types, + _write_stage_metadata, +) +from B03_FileInput.B03_FileInput_Schema import UploadedFileResult + + +class B03RouterHelperTest(unittest.TestCase): + def test_required_file_types_include_planned_route(self) -> None: + self.assertEqual( + _missing_required_file_types({"las", "prj", "tfw"}), + ["csv"], + ) + self.assertEqual( + _missing_required_file_types({"csv", "laz", "prj", "tfw"}), + [], + ) + + def test_stage_metadata_preserves_existing_files(self) -> None: + project_id = UUID("acb9170b-9ac8-49b3-82a0-51cfa32bb42d") + with tempfile.TemporaryDirectory() as temporary_dir: + stage_root = Path(temporary_dir) + (stage_root / "metadata.json").write_text( + json.dumps( + { + "project_id": str(project_id), + "files": [ + { + "original_filename": "terrain.las", + "relative_path": "B03_FileInput/input/las/terrain.las", + } + ], + } + ), + encoding="utf-8", + ) + route = UploadedFileResult( + input_file_id=100, + original_filename="planned_route.csv", + file_type="csv", + relative_path="B03_FileInput/input/csv/planned_route.csv", + size_bytes=1000, + metadata={"purpose": "planned_route", "epsg": 5187}, + ) + + _write_stage_metadata(stage_root, project_id, [route]) + + payload = json.loads((stage_root / "metadata.json").read_text(encoding="utf-8")) + self.assertEqual(len(payload["files"]), 2) + self.assertEqual(payload["files"][1]["metadata"]["purpose"], "planned_route") + + +if __name__ == "__main__": + unittest.main() diff --git a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts index edcd77f8..7650e13f 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts @@ -11,7 +11,7 @@ * - 오류 응답 형식 {status:"error", message:"..."}을 Error로 변환. * ========================================================================== */ -import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; +import { API_ANALYSIS_TIMEOUT_MS, API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; /** 지표면 분석 실행 요청 (SurfaceAnalyzeRequest) */ export interface SurfaceAnalyzeRequest { @@ -111,10 +111,17 @@ export interface SurfaceModelListResponse { models: SurfaceModelSummary[]; } -/** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. */ -async function requestJson(path: string, init: RequestInit): Promise { +/** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. + * + * `timeoutMs`를 주면 그 값으로 끊는다. 배수유역 격자 해석처럼 수십 초가 걸리는 요청은 + * `API_ANALYSIS_TIMEOUT_MS`를 넘긴다 — 기본값으로 두면 계산 도중 abort 된다. */ +async function requestJson( + path: string, + init: RequestInit, + timeoutMs: number = API_TIMEOUT_MS, +): Promise { const controller = new AbortController(); - const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS); + const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(`${API_BASE_URL}${path}`, { ...init, @@ -130,6 +137,12 @@ async function requestJson(path: string, init: RequestInit): Promise { throw new Error(payload.message ?? `HTTP ${response.status}`); } return payload; + } catch (error) { + // AbortError 원문("signal is aborted without reason")은 원인을 알 수 없으니 바꿔 준다. + if (error instanceof DOMException && error.name === "AbortError") { + throw new Error(`요청이 ${Math.round(timeoutMs / 1000)}초 안에 끝나지 않았습니다.`); + } + throw error; } finally { window.clearTimeout(timeoutId); } @@ -230,3 +243,102 @@ export async function fetchGisGeoJson(projectId: string, layer: string): Promise method: "GET", }); } + +/* ── 배수유역 분석 (B04_wf1_Surface_Router_Watershed.py) ──────────────────── + * 관리자 확인용. 계획 노선(B03 CSV) + 도엽 등고선·세류선으로 유역을 끝까지 분석하고 + * 결과를 영구저장소에 남긴다. 30초 안팎이 걸리므로 여기서 한 번만 돌린다. + * ------------------------------------------------------------------------ */ + +/** 관 매설 지점 1개. reason: stream=세류 교차, spacing=간격 보충, confirmed=사용자 확정. */ +export interface WatershedPipe { + chainage_m: number; + x: number; + y: number; + lon: number; + lat: number; + reason: string; + stream_name: string | null; +} + +/** 1차 배수유역 근거(단계 검증용). TIN·흐름 계산 없이 세류 상·하류 판정과 격자 범위만 준다. */ +export interface WatershedAnalysis { + status: string; + project_id: string; + /** 분석에 쓴 계획 노선 파일명(B03 업로드). */ + route_source: string; + radius_m: number; + /** 도로와 만난 세류선의 상류측 = 1차 영역의 기준선. */ + upstream_lines: Array>; + /** 교차했으나 하류로 판정해 제외한 조각. 판정이 맞는지 눈으로 대조하는 용도. */ + downstream_lines: Array>; + /** 상·하류 어느 망에도 이어지지 않아 제외한 세류 조각 수. */ + no_contact_count: number; + /** 노선이 1차 영역 밖으로 나간 길이(m). 크면 반경을 올려야 한다는 신호. */ + road_outside_m: number; + /** 1차 영역(상류 세류망 버퍼 합집합)의 외곽 링 목록. */ + region_rings: Array>; + grid: { + cell_m: number; + rows: number; + cols: number; + /** bbox 전체 셀 수(참고값). */ + bbox_cells: number; + /** 1차 영역에 걸쳐 실제로 생성된 셀 수. */ + cells: number; + width_m: number; + height_m: number; + /** 격자 bbox 링. 화면은 이 사각형을 rows×cols로 나눠 셀 좌표를 얻는다. */ + bbox_lonlat: Array<[number, number]>; + /** 실제 생성된 셀 구간 [행, 시작열, 끝열(포함)]. 낱개 셀 대신 구간으로 온다. */ + row_spans: Array<[number, number, number]>; + }; + /** 최외곽 적색 셀 주변 확장 결과. */ + expansion: { + rounds: number; + /** 새로 추가한 셀에 적색이 없어 스스로 멈췄는가. */ + closed: boolean; + added_cells: number; + /** 확장 전(1차 영역) 셀 수. */ + initial_cells: number; + }; + /** 셀별 흐름 방향과 도로 도달 여부. 등고선이 없어 판정을 못하면 null. */ + flow: { + encoding: "base64-uint8"; + /** 방위 분해능(32). 코드 0 = 화면 오른쪽, 시계방향 증가. */ + azimuth_steps: number; + /** 제자리(더 낮은 이웃 없음)를 뜻하는 코드. */ + sink_code: number; + /** 표고가 없어 판정 못한 셀 코드. */ + invalid_code: number; + cells: number; + reaches_road: number; + no_road: number; + /** 격자에는 있으나 등고선 TIN 밖이라 표고가 없어 판정 못한 셀. */ + unanalyzed: number; + /** 확정된 상류 세류망을 따라 흐름을 강제로 새긴 셀 수. */ + burned: number; + outer_seeds: number; + interior_seeds: number; + /** 셀당 1바이트. 하위 6비트=32방위 코드(32=제자리, 33=무효), 0x80=도로 도달. + * 순서는 grid.row_spans를 행 → 구간 → 열 오름차순으로 훑은 순서와 같다. */ + data: string; + } | null; + /** 2차 전체 배수유역 외곽선(= 분수령). 적색 셀 전체의 외곽. */ + basin_polygon_lonlat: Array<[number, number]>; + basin_area_m2: number; + /** 도로 위 흐름 강도 — [누가거리 m, 그 구간으로 모이는 상류 면적 ㎡]. */ + strength_profile: Array<[number, number]>; + /** 기본 관 매설 위치 — 도로 × 세류선 교차점. */ + pipes: WatershedPipe[]; + /** 영구저장소에 남긴 검증용 GeoJSON 경로. */ + saved_to: string | null; +} + +export async function fetchWatershedAnalysis(projectId: string): Promise { + // 등고선 하강 방향 + 적색 확장 루프까지 도는 요청이라 수십 초가 걸린다. + return requestJson( + `/projects/${projectId}/drainage/primary-region`, + { method: "GET" }, + API_ANALYSIS_TIMEOUT_MS, + ); +} diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Analyze.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Analyze.py new file mode 100644 index 00000000..7fcc071d --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Analyze.py @@ -0,0 +1,270 @@ +"""배수유역 분석 오케스트레이터 (B04 — 관리자 확인용 전처리). + +계획 노선(B03 업로드 파일)과 도엽 등고선·세류선만으로 배수유역을 끝까지 분석해 +영구저장소에 남긴다. 30초 안팎이 걸리는 무거운 작업이라 여기서 한 번만 돌리고, +일반 사용자가 쓰는 B05는 그 결과를 읽어 쓰기만 한다(2026-07-31 사용자 지시). + + ① 도로 교차 세류망 상류측 추출 → 반경 버퍼 = 1차 배수유역 + ② 도로 시작점 기준 격자 생성 (1차 영역에 걸치는 셀만) + ③ 등고선 하강 방향 — 높은 등고 라인에서 낮은 등고 라인으로. 보간면을 쓰지 않으므로 + 가짜 웅덩이·평탄면이 원리적으로 생기지 않는다 + ④ 세류망 흐름 새김 → 최외곽부터 사슬 추적 → 도로 도달 여부(적/청) 판정 + ⑤ 최외곽 적색 셀 주변 확장 — 새로 추가한 셀에 적색이 없을 때까지 + ⑥ 도로 셀별 흐름 강도 + ⑦ 2차 전체 배수유역 외곽선 + ⑧ 기본 관 매설 위치 (도로 × 세류선 교차점) + +⑨ 이후(관 최소 개수 보충, 세부유역 분할)는 사용자가 관을 옮길 수 있어야 하므로 +B05에 남긴다. +""" + +from __future__ import annotations + +import logging +import time +from dataclasses import dataclass, field +from typing import Any + +import numpy as np +from shapely.geometry import LineString + +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Descent import ContourDescent +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Expand import expand_by_red_boundary +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Flow import ( + FlowClassification, + RoadRaster, + largest_ring, + outer_boundary, + trace_flow, +) +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import ( + GridSpec, + TerrainGrid, + build_contour_cloud, + route_elevation_floor, +) +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Stream import ( + PrimaryRegion, + build_primary_region, +) +from common_util.common_util_route_geometry import ( + RouteVertex, + StructureCandidate, + find_stream_crossings, +) +from config.config_system import ( + DRAINAGE_GRID_SIZE_M, + DRAINAGE_INITIAL_RADIUS_M, + DRAINAGE_PIPE_MIN_SPACING_M, +) + +logger = logging.getLogger(__name__) + +# 강도 곡선 응답 간격(m). 도로 위 흐름 강도 표기는 이 간격으로 내보낸다. +_STRENGTH_OUTPUT_STEP_M = 5.0 + + +# ── ①~② 1차 배수유역 ──────────────────────────────────────────────────────── + + +def resolve_primary_region( + vertices: list[RouteVertex], + route_line: LineString, + contour_features: list[dict[str, Any]], + stream_features: list[dict[str, Any]], +) -> PrimaryRegion | None: + """도로 교차 세류선(상류측)과 노선을 반경 버퍼한 1차 배수유역과 격자 범위를 정한다. + + 상·하류 판정에 쓸 등고선은 노선 주변만 있으면 된다(교차점이 전부 노선 위이므로). + 도엽 전체를 읽으면 이 단계에서만 수십 초가 날아간다. + """ + floor = route_elevation_floor([vertex.z for vertex in vertices]) + near_bounds = route_line.buffer(DRAINAGE_INITIAL_RADIUS_M * 2.0).bounds + cloud = build_contour_cloud(contour_features, floor, near_bounds) + if cloud.is_empty: + logger.warning("배수유역: 노선 주변에 등고선이 없어 1차 영역을 정할 수 없습니다.") + return None + return build_primary_region( + route_line, stream_features, cloud, DRAINAGE_INITIAL_RADIUS_M, DRAINAGE_GRID_SIZE_M + ) + + +def preview_primary_region( + vertices: list[RouteVertex], + contour_features: list[dict[str, Any]], + stream_features: list[dict[str, Any]], +) -> PrimaryRegion | None: + """단계 검증용 — TIN·흐름 계산 없이 1차 배수유역 근거만 뽑는다.""" + if len(vertices) < 2: + return None + route_line = LineString([(vertex.x, vertex.y) for vertex in vertices]) + return resolve_primary_region(vertices, route_line, contour_features, stream_features) + + +@dataclass +class StagePreview: + """단계 검증 산출물 묶음. 기능을 붙일 때마다 여기에 항목이 하나씩 늘어난다. + + 확장을 거치면 격자와 해석 영역이 1차 영역보다 커진다. 화면·저장은 `region.spec`이 + 아니라 여기 `spec`/`domain`을 봐야 한다. + """ + + region: PrimaryRegion + spec: GridSpec | None = None + domain: np.ndarray | None = None + terrain: TerrainGrid | None = None + road: RoadRaster | None = None + flow: FlowClassification | None = None + descent: ContourDescent | None = None + expand_rounds: int = 0 + expand_closed: bool = False + expand_added_cells: int = 0 + # ⑥ 도로 위 흐름 강도 — (누가거리 m, 그 구간으로 모이는 상류 면적 ㎡). + strength_profile: list[tuple[float, float]] = field(default_factory=list) + # ⑦ 2차 전체 배수유역 외곽선(= 분수령). 적색 셀 전체를 폴리곤화한 것. + basin_boundary_xy: list[tuple[float, float]] = field(default_factory=list) + basin_area_m2: float = 0.0 + # 셀 → 도로 셀 귀속. B05가 세부유역을 나눌 때 이 배열이 있어야 한다. + routing: Any = None + # ⑧ 기본 관 매설 위치 — 도로 × 세류선 교차점. + pipes: list[StructureCandidate] = field(default_factory=list) + + +def preview_stages( + vertices: list[RouteVertex], + contour_features: list[dict[str, Any]], + stream_features: list[dict[str, Any]], +) -> StagePreview | None: + """지금까지 구현·검증된 단계를 순서대로 돌려 결과를 모은다. + + 현재 포함: ① 1차 배수유역 ② 격자 생성 ③ **등고선 하강 방향** ④ 도로 도달 판정 + ⑤ **최외곽 적색 셀 주변 확장**. + + ③은 보간면(TIN)을 쓰지 않는다. 높은 등고 라인에서 낮은 등고 라인으로 내려가며 방향을 + 세우므로 가짜 웅덩이·평탄면이 원리적으로 생기지 않는다(2026-07-31 사용자 지시로 방식 교체). + + ⑤는 최외곽에 적색이 남아 있으면 그 주변으로 넓혀 다시 분석하고, **새로 추가한 셀에 + 적색이 없으면** 멈춘다. + """ + if len(vertices) < 2: + return None + route_line = LineString([(vertex.x, vertex.y) for vertex in vertices]) + region = resolve_primary_region(vertices, route_line, contour_features, stream_features) + if region is None: + return None + + started = time.perf_counter() + floor = route_elevation_floor([vertex.z for vertex in vertices]) + expansion = expand_by_red_boundary( + region.spec, + region.cell_mask, + contour_features, + route_line, + region.split.upstream, + floor, + ) + if expansion is None: + logger.warning("배수유역: 등고선 하강 방향을 세우지 못해 흐름 판정을 건너뜁니다.") + return StagePreview(region=region) + + analysis = expansion.analysis + spec = analysis.spec + red = analysis.flow.reaches_road & analysis.flow.analyzed + + # ⑥ 흐름 강도 — 셀마다 물이 실제로 들어가는 도로 셀을 구해 도로 셀별로 센다. + # 색 판정은 세류 셀에서 멈추지만(거기서 도달이 확정되므로), 강도는 그 물이 세류를 타고 + # 최종적으로 어느 도로 셀로 들어가는지를 봐야 하므로 도로만 흡수점으로 두고 다시 따라간다. + routing = trace_flow(analysis.terrain, analysis.road) if analysis.road.count else None + strength_curve = _preview_strength(analysis, routing, red, route_line.length) + + # ⑦ 2차 전체 배수유역 외곽선 = 적색 셀 전체의 외곽. + boundary = outer_boundary(spec, red.reshape(spec.n_rows, spec.n_cols)) + basin_ring = largest_ring(boundary) if boundary is not None else [] + + # ⑧ 기본 관 매설 위치 = 도로 × 세류선 교차점(너무 가까운 것은 하나로 합침). + pipes = _base_pipes(vertices, stream_features) + + logger.info( + "배수유역: 단계 분석 %.1fs (확장 %d회, 최종 셀 %d개) — " + "2차 유역 %.0f㎡, 기본 관 %d개, 강도 곡선 %d점", + time.perf_counter() - started, + expansion.rounds, + spec.size, + int(red.sum()) * spec.cell_area_m2, + len(pipes), + int((strength_curve > 0).sum()), + ) + return StagePreview( + region=region, + spec=spec, + domain=analysis.domain, + terrain=analysis.terrain, + road=analysis.road, + flow=analysis.flow, + descent=analysis.descent, + expand_rounds=expansion.rounds, + expand_closed=expansion.closed, + expand_added_cells=expansion.added_cells, + strength_profile=_downsample_strength(strength_curve), + basin_boundary_xy=basin_ring, + basin_area_m2=int(red.sum()) * spec.cell_area_m2, + routing=routing, + pipes=pipes, + ) + + +def _preview_strength( + analysis: Any, routing: Any, red: np.ndarray, route_length_m: float +) -> np.ndarray: + """적색 셀이 실제로 들어가는 도로 셀을 세어 누가거리별 유입 면적 곡선을 만든다.""" + road = analysis.road + if routing is None or road.count == 0: + return np.zeros(1) + slots = routing.road_slot + counted = red & (slots >= 0) + strength = np.bincount(slots[counted], minlength=road.count).astype(np.float64) + return _strength_by_chainage( + road.chainage, strength * analysis.spec.cell_area_m2, route_length_m + ) + + +def _base_pipes( + vertices: list[RouteVertex], stream_features: list[dict[str, Any]] +) -> list[StructureCandidate]: + """도로 × 세류선 교차점을 기본 관 위치로 삼는다. 300m 보충 배치는 다음 단계다.""" + 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: + continue + pipes.append(candidate) + return pipes + + +# ── 흐름 강도 곡선 ────────────────────────────────────────────────────────── + + +def _strength_by_chainage( + road_chainage: np.ndarray, strength_area: np.ndarray, total_length: float +) -> np.ndarray: + """도로 셀 강도를 1m 누가거리 구간으로 합산한 곡선(㎡/m 구간 합).""" + bins = max(1, int(np.ceil(total_length)) + 1) + if road_chainage.size == 0: + return np.zeros(bins) + index = np.clip(np.round(road_chainage).astype(np.int64), 0, bins - 1) + return np.bincount(index, weights=strength_area, minlength=bins) + + +def _downsample_strength(curve: np.ndarray) -> list[tuple[float, float]]: + """응답용으로 강도 곡선을 일정 간격으로 줄인다(구간 합 유지). + + 끝자락을 잘라내면 종점 부근 유입 면적이 통째로 사라지므로 0으로 채워 맞춘다. + """ + step = max(1, int(_STRENGTH_OUTPUT_STEP_M)) + if curve.size == 0: + return [] + padding = (-curve.size) % step + padded = np.append(curve, np.zeros(padding)) if padding else curve + summed = padded.reshape(-1, step).sum(axis=1) + return [ + (float(position * step), float(value)) for position, value in enumerate(summed) if value > 0 + ] diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Descent.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Descent.py similarity index 99% rename from B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Descent.py rename to B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Descent.py index faa429f4..98e87368 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Descent.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Descent.py @@ -32,7 +32,7 @@ from rasterio.features import rasterize from scipy.ndimage import distance_transform_edt from shapely.geometry import shape -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import ( +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import ( AZIMUTH_INVALID, AZIMUTH_SINK, AZIMUTH_STEPS, diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Expand.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Expand.py similarity index 97% rename from B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Expand.py rename to B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Expand.py index 54017008..c24d5982 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Expand.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Expand.py @@ -21,11 +21,11 @@ from typing import Any import numpy as np from shapely.geometry import LineString -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Descent import ( +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Descent import ( ContourDescent, build_contour_descent, ) -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Flow import ( +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Flow import ( FlowClassification, RoadRaster, burn_stream_flow, @@ -33,7 +33,7 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Flow import ( outermost_cells, rasterize_road, ) -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import GridSpec, TerrainGrid +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import GridSpec, TerrainGrid from config.config_system import ( DRAINAGE_RED_EXPAND_BAND_M, DRAINAGE_RED_EXPAND_MAX_ROUNDS, diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Export.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Export.py similarity index 95% rename from B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Export.py rename to B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Export.py index babe06a2..9e4c5e17 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Export.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Export.py @@ -5,7 +5,7 @@ `STAGES`에 이름을 하나 더 넣고 `write_stage()`를 호출하면 된다 — 파일명 규칙과 매니페스트 갱신은 여기서 일괄로 처리한다. -저장 위치: `storage/{회사}/{사용자}/{프로젝트}/B05_wf2_Route/drainage/` +저장 위치: `storage/{회사}/{사용자}/{프로젝트}/B04_wf1_Surface/drainage/` - `{단계번호}_{단계이름}.geojson` — WGS84 FeatureCollection, 피처마다 `kind` 속성 - `manifest.json` — 지금까지 남긴 단계 목록과 요약값 """ @@ -31,6 +31,8 @@ logger = logging.getLogger(__name__) STAGES: dict[str, str] = { "primary_region": "01", "flow_direction": "02", + # B05가 읽어 세부유역을 나누는 데 필요한 최소 배열·기하. 화살표·표고는 넣지 않는다. + "road_routing": "03", } _MANIFEST_FILENAME = "manifest.json" @@ -38,7 +40,9 @@ LonLat = Callable[[float, float], tuple[float, float]] def drainage_dir(stored_path: str) -> Path: - return Path(resolve_stored_project_path(stored_path)) / "B05_wf2_Route" / DRAINAGE_CACHE_DIRNAME + return ( + Path(resolve_stored_project_path(stored_path)) / "B04_wf1_Surface" / DRAINAGE_CACHE_DIRNAME + ) def write_stage( diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Flow.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Flow.py similarity index 99% rename from B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Flow.py rename to B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Flow.py index ad6d141d..91ec8f0b 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Flow.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Flow.py @@ -24,7 +24,7 @@ from scipy.spatial import cKDTree from shapely.geometry import LineString, MultiPolygon, Polygon, shape from shapely.ops import unary_union -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import ( +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import ( AZIMUTH_STEPS, ContourCloud, GridSpec, diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Grid.py similarity index 100% rename from B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Grid.py rename to B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Grid.py diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Stream.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Stream.py similarity index 98% rename from B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Stream.py rename to B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Stream.py index 0d337bd0..cd376ee8 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Stream.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Stream.py @@ -5,7 +5,7 @@ 전체**를 잡는다(2026-07-31 사용자 지시). 여기서 정해진 1차 배수유역의 bbox가 곧 격자 해석 범위가 된다. -표고 해석·격자 생성은 `B05_wf2_Route_Engine_Watershed_Grid.py`가 맡는다. +표고 해석·격자 생성은 `B04_wf1_Surface_Engine_Watershed_Grid.py`가 맡는다. """ from __future__ import annotations @@ -20,7 +20,7 @@ from scipy.spatial import cKDTree from shapely.geometry import LineString, MultiPolygon, Polygon, shape from shapely.ops import substring, unary_union -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import ( +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import ( ContourCloud, GridSpec, build_cell_mask, diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router_Watershed.py b/B04_wf1_Surface/B04_wf1_Surface_Router_Watershed.py new file mode 100644 index 00000000..248a69f1 --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Router_Watershed.py @@ -0,0 +1,482 @@ +"""배수유역 분석 API 라우터 (B04 — 관리자 확인용). + +계획 노선(B03 업로드 CSV)과 도엽 등고선·세류선으로 배수유역을 끝까지 분석하고, 결과를 +`storage/{프로젝트}/B04_wf1_Surface/drainage/`에 남긴다. 30초 안팎이 걸리므로 여기서 한 번만 +돌리고, 일반 사용자가 쓰는 B05는 저장분을 읽어 쓴다(2026-07-31 사용자 지시). + +좌표는 사업지 CRS(m)에서 계산하고 응답만 WGS84로 바꿔 내보낸다. +""" + +import asyncio +import base64 +import json +import logging +from pathlib import Path +from typing import Any +from uuid import UUID + +import numpy as np +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from pyproj import Transformer +from shapely.geometry import Point, Polygon, box + +from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Analyze import preview_stages +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Export import write_grid_arrays, write_stage +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import ( + AZIMUTH_INVALID, + AZIMUTH_SINK, + AZIMUTH_STEPS, + mask_row_spans, +) +from B05_wf2_Route.B05_wf2_Route_Repository import get_surface_crs_epsg +from common_util.common_util_route_geometry import ( + StructureCandidate, + find_planned_route_file, + read_planned_route_csv, +) +from common_util.common_util_storage import resolve_stored_project_path +from config.config_db import get_db_pool + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B04 Surface Watershed"]) + +# 도엽 레이어 파일명 (B04 전처리 산출물과 같은 위치) +_CONTOUR_FILE = "도엽_등고선.geojson" +_STREAM_FILE = "도엽_하천중심선.geojson" + + +def _sheet_dir(stored_path: str) -> Path: + return Path(resolve_stored_project_path(stored_path)) / "B04_wf1_Surface" / "processed" + + +def _route_input_dir(stored_path: str) -> Path: + """B03 업로드 폴더 — 계획 노선 파일이 여기 들어온다.""" + return Path(resolve_stored_project_path(stored_path)) / "B03_FileInput" / "input" + + +def _load_features(directory: Path, filename: str) -> list[dict[str, Any]]: + """도엽 GeoJSON을 읽어 피처 목록만 돌려준다. 없으면 빈 목록.""" + path = directory / filename + if not path.exists(): + return [] + try: + with path.open("r", encoding="utf-8") as file: + data = json.load(file) + except (OSError, json.JSONDecodeError): + logger.warning("도엽 GeoJSON을 읽지 못했습니다: %s", path) + return [] + features = data.get("features") + return features if isinstance(features, list) else [] + + +def _reproject_features( + features: list[dict[str, Any]], + transformer: Transformer | None, +) -> list[dict[str, Any]]: + """WGS84 도엽 좌표를 사업지 CRS(m)로 바꾼다. 거리·면적을 미터로 계산하기 위함.""" + if transformer is None: + return features + converted: list[dict[str, Any]] = [] + for feature in features: + geometry = feature.get("geometry") + if not geometry: + continue + coordinates = _map_coordinates(geometry.get("coordinates"), transformer) + if coordinates is None: + continue + converted.append( + { + "type": "Feature", + "properties": feature.get("properties") or {}, + "geometry": {"type": geometry.get("type"), "coordinates": coordinates}, + } + ) + return converted + + +def _map_coordinates(coordinates: Any, transformer: Transformer) -> Any: + """중첩 좌표 배열을 재귀적으로 변환한다.""" + if not isinstance(coordinates, list) or not coordinates: + return None + first = coordinates[0] + if isinstance(first, (int, float)): + x, y = transformer.transform(float(coordinates[0]), float(coordinates[1])) + return [x, y] + mapped = [_map_coordinates(item, transformer) for item in coordinates] + return [item for item in mapped if item is not None] + + +def _candidate_payload( + candidate: StructureCandidate, + to_lonlat: Any, +) -> dict[str, Any]: + lon, lat = to_lonlat(candidate.x, candidate.y) + return { + "chainage_m": round(candidate.chainage_m, 2), + "x": candidate.x, + "y": candidate.y, + "lon": lon, + "lat": lat, + "reason": candidate.reason, + "stream_name": candidate.stream_name, + } + + +async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse: + """계획 노선 파일과 도엽 피처, 좌표 변환기를 준비한다. + + 노선은 **B03에 업로드된 계획 노선 파일**에서 읽는다 — B05의 확정 경로가 아니다. + 배수유역 분석은 노선 설계보다 먼저 끝나 있어야 하기 때문이다(2026-07-31 사용자 지시). + """ + pool = get_db_pool() + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + epsg = await get_surface_crs_epsg(connection, project_id, 0) + + route_file = find_planned_route_file(_route_input_dir(stored_path)) + if route_file is None: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "계획 노선 파일이 업로드되지 않았습니다."}, + ) + planned = read_planned_route_csv(route_file) + if planned is None or len(planned.vertices) < 2: + return JSONResponse( + status_code=400, + content={ + "status": "error", + "message": f"계획 노선 파일을 읽지 못했습니다: {route_file.name}", + }, + ) + + # 노선 파일이 CRS를 명시하면 그 값을 따른다. 도엽 재투영도 같은 좌표계로 맞춘다. + source_crs = f"EPSG:{planned.epsg or epsg or 5186}" + to_lonlat_transformer = Transformer.from_crs(source_crs, "EPSG:4326", always_xy=True) + to_metric_transformer = Transformer.from_crs("EPSG:4326", source_crs, always_xy=True) + + directory = _sheet_dir(stored_path) + streams = _reproject_features(_load_features(directory, _STREAM_FILE), to_metric_transformer) + contour_features = _reproject_features( + _load_features(directory, _CONTOUR_FILE), to_metric_transformer + ) + return { + "route_source": route_file.name, + "vertices": planned.vertices, + "route_line": planned.line, + "streams": streams, + "contours": contour_features, + "stored_path": stored_path, + "to_lonlat": lambda x, y: to_lonlat_transformer.transform(x, y), + } + + +@router.get("/{project_id}/drainage/primary-region", response_model=None) +async def get_primary_region(project_id: UUID) -> dict[str, Any] | JSONResponse: + """1차 배수유역 근거를 돌려준다 — 단계 검증용, TIN·흐름 계산은 하지 않는다. + + 도로 교차점 상류로 이어진 세류망, 제외된 하류망, 그 상류망을 반경 버퍼한 1차 영역, + 그 bbox로 잡은 격자 정보를 함께 준다. 같은 내용을 영구저장소에 GeoJSON으로도 남겨 + QGIS 등으로 직접 열어 대조할 수 있게 한다. + """ + prepared = await _prepare(project_id) + if isinstance(prepared, JSONResponse): + return prepared + preview = await asyncio.to_thread( + preview_stages, + prepared["vertices"], + prepared["contours"], + prepared["streams"], + ) + if preview is None: + return JSONResponse( + status_code=400, + content={"status": "error", "message": "1차 배수유역을 정할 등고선이 없습니다."}, + ) + region = preview.region + to_lonlat = prepared["to_lonlat"] + # 확장을 거치면 격자·해석 영역이 1차 영역보다 커진다. 최종본을 써야 화면과 어긋나지 않는다. + spec = preview.spec or region.spec + domain = preview.domain if preview.domain is not None else region.cell_mask + payload = { + "status": "success", + "project_id": str(project_id), + "route_source": prepared["route_source"], + "radius_m": region.radius_m, + # 채택된 상류 세류망 = 1차 영역의 기준선. + "upstream_lines": [_line_lonlat(line, to_lonlat) for line in region.split.upstream], + # 도로 아래로 이어진 하류망 — 판정이 맞는지 눈으로 대조하기 위해 함께 준다. + "downstream_lines": [_line_lonlat(line, to_lonlat) for line in region.split.downstream], + "no_contact_count": region.split.no_contact, + # 노선이 1차 영역 밖으로 나간 길이(m). 크면 반경을 올려야 한다는 신호. + "road_outside_m": round(region.road_outside_m, 1), + # 1차 영역(버퍼 합집합) 외곽 링 목록. + "region_rings": _polygon_rings(region.area, to_lonlat), + "grid": { + "cell_m": spec.cell_m, + "rows": spec.n_rows, + "cols": spec.n_cols, + # bbox 전체 셀 수와, 해석 영역에 실제로 생성된 셀 수(확장 반영). + "bbox_cells": spec.size, + "cells": int(domain.sum()) if domain is not None else 0, + "width_m": round(spec.n_cols * spec.cell_m, 1), + "height_m": round(spec.n_rows * spec.cell_m, 1), + # 격자 bbox 링. 프론트는 이 사각형을 rows×cols로 나눠 행·열 좌표를 얻는다. + "bbox_lonlat": _grid_bbox_lonlat(spec, to_lonlat), + # 실제 생성된 셀을 행별 연속 구간 [행, 시작열, 끝열]으로 압축해 보낸다. + # 셀을 낱개로 보내면 수십만 건이라 응답이 감당되지 않는다. + "row_spans": [list(span) for span in mask_row_spans(domain)] + if domain is not None + else [], + }, + # 최외곽 적색 셀 주변 확장 결과. + "expansion": { + "rounds": preview.expand_rounds, + "closed": preview.expand_closed, + "added_cells": preview.expand_added_cells, + "initial_cells": region.active_cells, + }, + # 셀별 흐름 방향과 도로 도달 여부. row_spans 순서(행 → 구간 → 열 오름차순)로 1바이트씩. + "flow": _flow_payload(preview, domain), + # ⑦ 2차 전체 배수유역 외곽선(= 분수령)과 면적. + "basin_polygon_lonlat": [list(to_lonlat(x, y)) for x, y in preview.basin_boundary_xy], + "basin_area_m2": round(preview.basin_area_m2, 1), + # ⑥ 도로 위 흐름 강도 — [누가거리 m, 그 구간으로 모이는 상류 면적 ㎡]. + "strength_profile": [ + [round(chainage, 1), round(area, 1)] for chainage, area in preview.strength_profile + ], + # ⑧ 기본 관 매설 위치 — 도로 × 세류선 교차점. + "pipes": [_candidate_payload(pipe, to_lonlat) for pipe in preview.pipes], + } + # 단계 산출물을 영구저장소에 남긴다 — 기능을 붙일 때마다 여기에 단계가 하나씩 늘어난다. + payload["saved_to"] = write_stage( + prepared["stored_path"], + "primary_region", + { + "primary_region": _as_polygons(region.area), + "upstream": region.split.upstream, + "downstream": region.split.downstream, + "route": [prepared["route_line"]], + "grid_bbox": [_grid_bbox_polygon(spec)], + # ⑦ 2차 전체 배수유역 외곽선, ⑧ 기본 관 위치(누가거리·근거 포함). + "basin_boundary": _boundary_geometry(preview.basin_boundary_xy), + "pipe": [ + ( + Point(pipe.x, pipe.y), + { + "chainage_m": round(pipe.chainage_m, 2), + "reason": pipe.reason, + "stream_name": pipe.stream_name, + }, + ) + for pipe in preview.pipes + ], + }, + { + "radius_m": region.radius_m, + "road_outside_m": payload["road_outside_m"], + "no_contact_count": region.split.no_contact, + "basin_area_m2": payload["basin_area_m2"], + "pipe_count": len(preview.pipes), + # 기하(bbox·셀 구간)는 파일 본문에 있으므로 요약 수치만 남긴다. + "grid": { + key: value + for key, value in payload["grid"].items() + if key not in {"bbox_lonlat", "row_spans"} + }, + }, + to_lonlat, + ) + _write_stage_arrays(prepared["stored_path"], preview, domain, spec) + _write_road_routing(prepared["stored_path"], preview, spec, prepared["route_line"], to_lonlat) + return payload + + +def _write_road_routing( + stored_path: str, preview: Any, spec: Any, route_line: Any, to_lonlat: Any +) -> None: + """B05가 세부유역을 나눌 때 쓸 최소 산출물을 남긴다. + + B05는 일반 사용자용이라 가벼워야 한다. 화살표(방향 코드)·밴드 표고 같은 확인용 배열은 + 빼고, **셀 → 도로 셀 귀속**과 도로 셀 제원만 담는다. 여기에 표고를 함께 넣는 이유는 + 유역 낙차를 내려면 셀 표고가 필요해서다(2026-07-31 사용자 지시). + """ + routing = preview.routing + road = preview.road + if routing is None or road is None or road.count == 0: + return + write_grid_arrays( + stored_path, + "road_routing", + spec, + { + "road_slot": routing.road_slot, + "path_length": routing.path_length, + "strength": routing.strength, + "road_cell_index": road.cell_index, + "road_chainage": road.chainage, + "elevation": preview.terrain.elevation.reshape(-1), + }, + { + "road_cells": road.count, + "reached_cells": int((routing.road_slot >= 0).sum()), + "basin_area_m2": round(preview.basin_area_m2, 1), + "pipe_count": len(preview.pipes), + }, + ) + # B05가 그대로 그릴 기하 — 계획도로선 · 기본 배관 · 2차 전체 배수유역, 이 셋뿐이다. + write_stage( + stored_path, + "road_routing", + { + "route": [route_line], + "basin_boundary": _boundary_geometry(preview.basin_boundary_xy), + "pipe": [ + ( + Point(pipe.x, pipe.y), + {"chainage_m": round(pipe.chainage_m, 2), "reason": pipe.reason}, + ) + for pipe in preview.pipes + ], + }, + { + "basin_area_m2": round(preview.basin_area_m2, 1), + "pipe_count": len(preview.pipes), + "route_length_m": round(route_line.length, 1), + }, + to_lonlat, + ) + + +def _write_stage_arrays(stored_path: str, preview: Any, domain: Any, spec: Any) -> None: + """격자 규모 배열(셀 마스크·흐름 방향·도달 여부)을 단계별 `.npz`로 남긴다.""" + if domain is not None: + write_grid_arrays( + stored_path, + "primary_region", + spec, + {"mask": domain}, + { + "cells": int(domain.sum()), + "bbox_cells": spec.size, + "expand_rounds": preview.expand_rounds, + "expand_closed": preview.expand_closed, + }, + ) + flow = preview.flow + if flow is None: + return + arrays = { + "direction": flow.direction.reshape(spec.n_rows, spec.n_cols), + "reaches_road": flow.reaches_road.reshape(spec.n_rows, spec.n_cols), + "analyzed": flow.analyzed.reshape(spec.n_rows, spec.n_cols), + # 사후 진단용 — 수신 셀이 있어야 사슬을 다시 따라가 볼 수 있다. + "receiver": preview.terrain.receiver.reshape(spec.n_rows, spec.n_cols), + } + if flow.burned is not None: + arrays["burned"] = flow.burned.reshape(spec.n_rows, spec.n_cols) + if preview.descent is not None: + arrays["band_elevation"] = preview.descent.band_elevation + # ⑥ 흐름 강도 곡선 — 기하가 아니라 수치 곡선이라 GeoJSON이 아닌 여기에 함께 담는다. + if preview.strength_profile: + curve = np.asarray(preview.strength_profile, dtype=np.float64) + arrays["strength_chainage_m"] = curve[:, 0] + arrays["strength_area_m2"] = curve[:, 1] + write_grid_arrays( + stored_path, + "flow_direction", + spec, + arrays, + { + "azimuth_steps": AZIMUTH_STEPS, + "sink_code": AZIMUTH_SINK, + "invalid_code": AZIMUTH_INVALID, + "analyzed": int(flow.analyzed.sum()), + "reaches_road": int((flow.reaches_road & flow.analyzed).sum()), + "no_road": int((~flow.reaches_road & flow.analyzed).sum()), + "burned": 0 if flow.burned is None else int(flow.burned.sum()), + "outer_seeds": flow.outer_seeds, + "interior_seeds": flow.interior_seeds, + "strength_points": len(preview.strength_profile), + "strength_total_m2": round(sum(area for _, area in preview.strength_profile), 1), + }, + ) + + +def _flow_payload(preview: Any, domain: Any) -> dict[str, Any] | None: + """셀별 흐름 방향·도로 도달 여부를 바이트 배열로 압축한다. + + 셀이 수십만 개라 JSON 객체로는 못 보낸다. 셀 하나당 1바이트로 줄이고 base64로 싣는다: + 하위 6비트(0x3F) = 32방위 코드(0~31, 0=화면 오른쪽·시계방향), 32=제자리, 33=표고 없음 + 최상위 비트(0x80) = 도로 도달(적색). 꺼져 있으면 미도달(백색 화살표). + 바이트 순서는 `grid.row_spans`를 행 → 구간 → 열 오름차순으로 훑은 순서와 같다. + """ + flow = preview.flow + if flow is None or domain is None: + return None + order = np.flatnonzero(domain.reshape(-1)) + analyzed = flow.analyzed[order] + reaches = flow.reaches_road[order] + packed = np.clip(flow.direction[order], 0, AZIMUTH_INVALID).astype(np.uint8) + packed |= np.where(reaches, 0x80, 0).astype(np.uint8) + burned = flow.burned + return { + "encoding": "base64-uint8", + "azimuth_steps": AZIMUTH_STEPS, + "sink_code": AZIMUTH_SINK, + "invalid_code": AZIMUTH_INVALID, + "cells": int(order.size), + "reaches_road": int((reaches & analyzed).sum()), + "no_road": int((~reaches & analyzed).sum()), + # 격자에는 있으나 등고선 TIN 밖이라 표고가 없어 판정 못한 셀. + "unanalyzed": int((~analyzed).sum()), + # 확정된 상류 세류망을 따라 흐름을 강제로 새긴 셀 수. + "burned": 0 if burned is None else int(burned[order].sum()), + "outer_seeds": flow.outer_seeds, + "interior_seeds": flow.interior_seeds, + "data": base64.b64encode(packed.tobytes()).decode("ascii"), + } + + +def _boundary_geometry(ring: list[tuple[float, float]]) -> list[Any]: + """2차 유역 외곽 링을 저장용 폴리곤으로 만든다(정점 3개 미만이면 비운다).""" + return [Polygon(ring)] if len(ring) >= 4 else [] + + +def _boundary_geometry(ring: list[tuple[float, float]]) -> list[Any]: + """2차 유역 외곽 링을 저장용 폴리곤으로 만든다(정점 3개 미만이면 비운다).""" + return [Polygon(ring)] if len(ring) >= 4 else [] + + +def _as_polygons(geometry: Any) -> list[Any]: + if geometry is None or geometry.is_empty: + return [] + return list(geometry.geoms) if geometry.geom_type == "MultiPolygon" else [geometry] + + +def _grid_bbox_polygon(spec: Any) -> Polygon: + x_max = spec.x_min + spec.n_cols * spec.cell_m + y_min = spec.y_max - spec.n_rows * spec.cell_m + return box(spec.x_min, y_min, x_max, spec.y_max) + + +def _line_lonlat(line: Any, to_lonlat: Any) -> list[list[float]]: + return [list(to_lonlat(x, y)) for x, y in line.coords] + + +def _polygon_rings(geometry: Any, to_lonlat: Any) -> list[list[list[float]]]: + """폴리곤/멀티폴리곤의 외곽 링만 뽑아 lonlat으로 바꾼다.""" + if geometry is None or geometry.is_empty: + return [] + parts = geometry.geoms if geometry.geom_type == "MultiPolygon" else [geometry] + return [[list(to_lonlat(x, y)) for x, y in part.exterior.coords] for part in parts] + + +def _grid_bbox_lonlat(spec: Any, to_lonlat: Any) -> list[list[float]]: + x_min = spec.x_min + x_max = spec.x_min + spec.n_cols * spec.cell_m + y_max = spec.y_max + y_min = spec.y_max - spec.n_rows * spec.cell_m + corners = ((x_min, y_min), (x_min, y_max), (x_max, y_max), (x_max, y_min), (x_min, y_min)) + return [list(to_lonlat(x, y)) for x, y in corners] diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index 42f3a300..b65f302a 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -7,6 +7,7 @@ import { type VWorldMeta, } from "./B04_wf1_Surface_Api_Fetch"; import { niceScaleDistance } from "./B04_wf1_Surface_UI_Camera"; +import { createWatershedOverlay } from "./B04_wf1_Surface_UI_Watershed"; import { computeMapRect, createNormalizer, @@ -15,6 +16,7 @@ import { prepareLayer, type GeoJsonCollection, type MapRect, + type Normalizer, type PreparedLayer, type ViewState, } from "./B04_wf1_Surface_UI_MapRender"; @@ -130,6 +132,8 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { let currentProjectId: string | null = null; let referenceBounds: SurfaceBounds | null = null; let meta: VWorldMeta | null = null; + // 배수유역 오버레이가 lon/lat을 화면 좌표로 옮길 때 쓴다. 레이어 로드 시 1회 만든다. + let normalizer: Normalizer | null = null; // 사전 투영된 렌더용 레이어. 원본 GeoJSON은 변형하지 않으며 투영 후에는 참조를 잡아두지 않는다. const preparedLayers = new Map(); const activeBackgrounds = new Set(BACKGROUND_LAYERS); @@ -217,6 +221,14 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { }); gisButtons.append(contourLabelButton); + // 배수유역 분석 오버레이 — 계산은 백엔드가 하고 여기서는 겹쳐 그리기만 한다. + const watershed = createWatershedOverlay(() => { + const text = watershed.status(); + if (text) status.textContent = text; + scheduleDraw(); + }); + gisButtons.append(watershed.button); + function updateImageTransform(): void { backgroundImages.forEach((image) => { image.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`; @@ -354,12 +366,12 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { if (sequence !== loadSequence) return; meta = nextMeta; // 좌표 변환은 여기서 1회만 수행하고, 이후 프레임은 사전 투영 결과만 사용한다. - const normalizer = createNormalizer(nextMeta); + normalizer = createNormalizer(nextMeta); let featureCount = 0; loadedLayers.forEach(([layer, data]) => { if (!data) return; featureCount += data.features?.length ?? 0; - preparedLayers.set(layer, prepareLayer(data, normalizer, CONTOUR_LABEL_KEYS[layer])); + preparedLayers.set(layer, prepareLayer(data, normalizer!, CONTOUR_LABEL_KEYS[layer])); }); BACKGROUND_LAYERS.forEach((layer) => { backgroundImages.get(layer)!.src = `${getVWorldMapUrl(projectId, layer)}&_t=${Date.now()}`; @@ -422,6 +434,8 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { root, render(projectId, nextReferenceBounds) { currentProjectId = projectId; + watershed.reset(); + watershed.setProject(projectId); referenceBounds = nextReferenceBounds ?? null; void loadLayers(); }, diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts new file mode 100644 index 00000000..0f55463e --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts @@ -0,0 +1,397 @@ +import { fetchWatershedAnalysis, type WatershedAnalysis } from "./B04_wf1_Surface_Api_Fetch"; +import type { Normalizer, ViewState } from "./B04_wf1_Surface_UI_MapRender"; + +/* ============================================================================= + * 배수유역 분석 오버레이 (B04 — 관리자 확인용) + * + * 2D 배경지도 위에 배수유역 분석 결과를 겹쳐 그린다. 계산은 백엔드가 하고 여기서는 + * 그리기만 한다. 30초 안팎이 걸리는 요청이라 버튼을 눌렀을 때만 돈다. + * + * 겹쳐 그리는 것 + * · 해석 격자 — 1차 영역에 걸치는 셀만, 흰 선 + * · 셀별 흐름 — 도로 도달 적색 / 미도달 파랑 채움 + 백색 화살표 / 표고없음 회색 + * · 상류 세류망(굵은 파랑) · 하류망(회색 파선) · 1차 영역(초록 채움) + * · 2차 전체 배수유역 외곽선(갈색 파선) · 기본 관 위치 + * ========================================================================== */ + +// 해석 격자 셀 선 — 등고선·세류 위에 얹으므로 흰색으로 둔다. +const GRID_LINE_COLOR = "rgba(255, 255, 255, 0.55)"; +// 흐름 판정 색 — 도로로 물이 오는 셀은 적색, 오지 않는 셀은 파랑 채움 + 백색 화살표. +const FLOW_TO_ROAD_FILL = "rgba(220, 38, 38, 0.28)"; +const FLOW_TO_ROAD_LINE = "rgba(153, 27, 27, 0.95)"; +const FLOW_AWAY_FILL = "rgba(37, 99, 235, 0.22)"; +const FLOW_AWAY_LINE = "rgba(255, 255, 255, 0.95)"; +/** 등고선 TIN 밖이라 표고가 없어 판정하지 못한 셀 — 미도달(파랑)과 구분한다. */ +const FLOW_UNKNOWN_FILL = "rgba(120, 113, 108, 0.18)"; +/** 셀이 이보다 작으면 화살표가 뭉개져 읽히지 않으므로 채움색만 남긴다(px). */ +const ARROW_MIN_PX = 7; +/** 2차 전체 배수유역 외곽선 = 분수령. */ +const BASIN_RING_COLOR = "rgba(146, 64, 14, 0.95)"; +/** 기본 관 마커. */ +const PIPE_COLOR = "rgba(249, 115, 22, 0.95)"; + +export interface WatershedOverlay { + /** 레이어 토글 버튼. 지도 헤더의 GIS 버튼 줄에 넣는다. */ + button: HTMLButtonElement; + /** 켜져 있는지. draw() 호출 전에 확인한다. */ + visible: () => boolean; + /** 상태 문구(분석 요약 또는 오류). 없으면 빈 문자열. */ + status: () => string; + /** 프로젝트가 바뀌면 받아 둔 분석 결과를 버린다. */ + reset: () => void; + /** 현재 프로젝트를 알려 준다. 지정 전에는 버튼이 아무 일도 하지 않는다. */ + setProject: (projectId: string) => void; + draw: (context: CanvasRenderingContext2D, map: Normalizer, view: ViewState) => void; +} + +export function createWatershedOverlay(onChange: () => void): WatershedOverlay { + let analysis: WatershedAnalysis | null = null; + let shown = false; + let statusText = ""; + let flowCache: { source: string; bytes: Uint8Array } | null = null; + + const button = document.createElement("button"); + button.type = "button"; + button.className = "b04-map__layer-button b04-map__layer-button--gis"; + button.textContent = "배수유역"; + button.style.setProperty("--b04-layer-color", "#dc2626"); + button.setAttribute("aria-pressed", "false"); + button.title = + "계획 노선(B03 업로드)과 도엽 등고선·세류선으로 배수유역을 분석합니다. " + + "30초 안팎이 걸리며 결과는 영구저장소에 남습니다."; + + let projectId: string | null = null; + + function strokeLonLat( + context: CanvasRenderingContext2D, + line: ReadonlyArray, + map: Normalizer, + view: ViewState, + ): void { + if (line.length < 2) return; + const ax = view.mapRect.width * view.scale; + const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX; + const ay = view.mapRect.height * view.scale; + const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY; + context.beginPath(); + line.forEach(([lon, lat], index) => { + const x = ((lon - map.lonMin) / map.lonRange) * ax + bx; + const y = (1 - (lat - map.latMin) / map.latRange) * ay + by; + if (index === 0) context.moveTo(x, y); + else context.lineTo(x, y); + }); + context.stroke(); + } + + /** 흐름 방향 바이트를 셀 순서대로 디코드한다(캐시 — 매 프레임 다시 풀지 않는다). */ + function flowBytes(region: WatershedAnalysis): Uint8Array | null { + if (!region.flow) return null; + if (flowCache?.source === region.flow.data) return flowCache.bytes; + const binary = atob(region.flow.data); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); + flowCache = { source: region.flow.data, bytes }; + return bytes; + } + + /** 1차 영역에 걸쳐 실제로 생성된 셀만 그린다. + * + * bbox 전체를 채우지 않는다 — 백엔드가 준 행별 구간(row_spans)만 그린다. 흐름 판정이 + * 있으면 셀마다 방향 화살표를 얹고, 도로에 물이 닿는 셀은 적색·닿지 않으면 파랑으로 + * 칠한다. 셀이 화면에서 작아지면 화살표가 안 보이므로 채움색만 남긴다. */ + function drawGridCells( + context: CanvasRenderingContext2D, + map: Normalizer, + view: ViewState, + region: WatershedAnalysis, + ): void { + const ring = region.grid.bbox_lonlat; + if (ring.length < 4) return; + const lons = ring.map(([lon]) => lon); + const lats = ring.map(([, lat]) => lat); + const ax = view.mapRect.width * view.scale; + const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX; + const ay = view.mapRect.height * view.scale; + const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY; + const left = ((Math.min(...lons) - map.lonMin) / map.lonRange) * ax + bx; + const right = ((Math.max(...lons) - map.lonMin) / map.lonRange) * ax + bx; + const top = (1 - (Math.max(...lats) - map.latMin) / map.latRange) * ay + by; + const bottom = (1 - (Math.min(...lats) - map.latMin) / map.latRange) * ay + by; + + const { rows, cols, row_spans: spans } = region.grid; + const cellW = (right - left) / Math.max(cols, 1); + const cellH = (bottom - top) / Math.max(rows, 1); + const cellPx = Math.min(Math.abs(cellW), Math.abs(cellH)); + const bytes = flowBytes(region); + + context.save(); + context.setLineDash([]); + context.lineCap = "round"; + let cursor = 0; // row_spans를 훑은 순서 = 흐름 바이트 순서 + spans.forEach(([row, colStart, colEnd]) => { + const count = colEnd - colStart + 1; + const base = cursor; + cursor += count; + const y = top + cellH * row; + if (y + cellH < -40 || y > view.height + 40) return; + const x = left + cellW * colStart; + const width = cellW * count; + if (x + width < -40 || x > view.width + 40) return; + + if (!bytes) { + // 흐름 판정 전 — 격자만 흰 선으로 보여 준다. + if (cellPx >= 2) { + context.strokeStyle = GRID_LINE_COLOR; + context.lineWidth = 0.5; + context.beginPath(); + for (let col = colStart; col <= colEnd; col += 1) { + context.rect(left + cellW * col, y, cellW, cellH); + } + context.stroke(); + } else { + context.fillStyle = "rgba(255, 255, 255, 0.2)"; + context.fillRect(x, y, width, cellH); + } + return; + } + const sink = region.flow?.sink_code ?? 32; + const invalid = region.flow?.invalid_code ?? 33; + const steps = region.flow?.azimuth_steps ?? 32; + for (let offset = 0; offset < count; offset += 1) { + drawFlowCell( + context, + bytes[base + offset], + left + cellW * (colStart + offset), + y, + cellW, + cellH, + cellPx, + { sink, invalid, steps }, + ); + } + }); + context.restore(); + } + + /** 셀 하나 — 도달 여부로 칠하고, 충분히 크면 32방위 흐름 화살표를 얹는다. */ + function drawFlowCell( + context: CanvasRenderingContext2D, + code: number, + x: number, + y: number, + cellW: number, + cellH: number, + cellPx: number, + codes: { sink: number; invalid: number; steps: number }, + ): void { + const azimuth = code & 0x3f; + const reaches = (code & 0x80) !== 0; + // 표고가 없어 판정 못한 셀 — 미도달(파랑 채움)과 구분해야 오독이 없다. + const unanalyzed = azimuth === codes.invalid; + context.fillStyle = unanalyzed + ? FLOW_UNKNOWN_FILL + : reaches + ? FLOW_TO_ROAD_FILL + : FLOW_AWAY_FILL; + context.fillRect(x, y, cellW, cellH); + if (cellPx >= 2) { + context.strokeStyle = GRID_LINE_COLOR; + context.lineWidth = 0.5; + context.strokeRect(x, y, cellW, cellH); + } + if (cellPx < ARROW_MIN_PX || unanalyzed) return; + const stroke = reaches ? FLOW_TO_ROAD_LINE : FLOW_AWAY_LINE; + const midX = x + cellW / 2; + const midY = y + cellH / 2; + if (azimuth === codes.sink) { + // 제자리(싱크) — 방향이 없으므로 점으로 표시한다. + context.fillStyle = stroke; + context.beginPath(); + context.arc(midX, midY, Math.max(1, cellPx * 0.12), 0, Math.PI * 2); + context.fill(); + return; + } + // 코드 0 = 화면 오른쪽(+x), 시계방향(캔버스 y는 아래가 +). + const angle = (azimuth * 2 * Math.PI) / codes.steps; + const unitX = Math.cos(angle); + const unitY = Math.sin(angle); + const reach = cellPx * 0.38; + const tipX = midX + unitX * reach; + const tipY = midY + unitY * reach; + context.strokeStyle = stroke; + context.lineWidth = Math.max(0.6, cellPx * 0.09); + context.beginPath(); + context.moveTo(midX - unitX * reach, midY - unitY * reach); + context.lineTo(tipX, tipY); + context.stroke(); + // 촉 — 진행 방향 기준 좌우로 짧게 접는다. + const head = cellPx * 0.18; + context.beginPath(); + context.moveTo(tipX, tipY); + context.lineTo(tipX - (unitX + unitY * 0.7) * head, tipY - (unitY - unitX * 0.7) * head); + context.moveTo(tipX, tipY); + context.lineTo(tipX - (unitX - unitY * 0.7) * head, tipY - (unitY + unitX * 0.7) * head); + context.stroke(); + } + + /** 1차 배수유역 근거를 겹쳐 그린다 — 단계 검증용. */ + function drawPrimaryRegion( + context: CanvasRenderingContext2D, + map: Normalizer, + view: ViewState, + region: WatershedAnalysis, + ): void { + context.save(); + // ① 해석 격자 — bbox 테두리 + 실제 셀 눈금. + drawGridCells(context, map, view, region); + // ② 1차 배수유역 = 상류 세류망의 반경 버퍼 합집합. + context.setLineDash([]); + context.lineWidth = 2; + context.strokeStyle = "rgba(5, 150, 105, 0.95)"; + context.fillStyle = "rgba(16, 185, 129, 0.12)"; + region.region_rings.forEach((ring) => { + strokeLonLat(context, ring, map, view); + context.fill(); + }); + // ③ 도로 아래로 이어진 하류망 — 판정이 맞는지 대조하도록 회색 파선으로 남긴다. + context.setLineDash([6, 5]); + context.lineWidth = 2; + context.strokeStyle = "rgba(120, 113, 108, 0.85)"; + region.downstream_lines.forEach((line) => strokeLonLat(context, line, map, view)); + // ④ 채택된 상류망 = 1차 영역의 기준선. 가장 굵게, 맨 위에. + context.setLineDash([]); + context.lineWidth = 4; + context.strokeStyle = "rgba(29, 78, 216, 0.95)"; + region.upstream_lines.forEach((line) => strokeLonLat(context, line, map, view)); + context.restore(); + } + + /** ⑦ 2차 전체 배수유역 외곽선(= 분수령)과 ⑧ 기본 관 위치. */ + function drawBasinAndPipes( + context: CanvasRenderingContext2D, + map: Normalizer, + view: ViewState, + region: WatershedAnalysis, + ): void { + context.save(); + if (region.basin_polygon_lonlat.length > 2) { + context.setLineDash([8, 5]); + context.lineWidth = 2.5; + context.strokeStyle = BASIN_RING_COLOR; + strokeLonLat(context, region.basin_polygon_lonlat, map, view); + } + context.setLineDash([]); + const ax = view.mapRect.width * view.scale; + const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX; + const ay = view.mapRect.height * view.scale; + const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY; + region.pipes.forEach((pipe, index) => { + const x = ((pipe.lon - map.lonMin) / map.lonRange) * ax + bx; + const y = (1 - (pipe.lat - map.latMin) / map.latRange) * ay + by; + context.beginPath(); + context.arc(x, y, 7, 0, Math.PI * 2); + context.fillStyle = PIPE_COLOR; + context.fill(); + context.lineWidth = 1.5; + context.strokeStyle = "#111827"; + context.stroke(); + context.fillStyle = "#111827"; + context.font = "bold 10px sans-serif"; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillText(String(index + 1), x, y); + }); + context.restore(); + } + + function regionSummary(region: WatershedAnalysis): string { + const cells = region.grid.cells.toLocaleString(); + const outside = + region.road_outside_m > 0 ? ` · 노선 이탈 ${Math.round(region.road_outside_m)}m` : ""; + const unknown = + region.flow && region.flow.unanalyzed > 0 + ? ` / 표고없음 ${region.flow.unanalyzed.toLocaleString()}(회)` + : ""; + const burned = + region.flow && region.flow.burned > 0 + ? ` · 세류망 새김 ${region.flow.burned.toLocaleString()}셀` + : ""; + const flow = region.flow + ? ` · 흐름 도로도달 ${region.flow.reaches_road.toLocaleString()}(적) / ` + + `미도달 ${region.flow.no_road.toLocaleString()}(청)${unknown}, ` + + `최외곽 출발 ${region.flow.outer_seeds.toLocaleString()} + ` + + `내부 보충 ${region.flow.interior_seeds.toLocaleString()}${burned}` + : " · 흐름 판정 없음"; + const expansion = region.expansion + ? ` · 확장 ${region.expansion.rounds}회` + + `(${region.expansion.initial_cells.toLocaleString()}→${cells}셀, ` + + `${region.expansion.closed ? "닫힘" : "상한 도달"})` + : ""; + const basin = region.basin_area_m2 + ? ` · 2차 유역 ${formatArea(region.basin_area_m2)}, 기본 관 ${region.pipes.length}개` + : ""; + return ( + `1차 영역(반경 ${region.radius_m}m): 상류망 ${region.upstream_lines.length}조각 채택 / ` + + `하류망 ${region.downstream_lines.length}조각·미연결 ${region.no_contact_count}개 제외 · ` + + `격자 ${region.grid.cell_m}m(도로 시점 기준) 셀 ${cells}개${outside}${expansion}${basin}${flow}` + ); + } + + function formatArea(areaM2: number): string { + return areaM2 >= 10000 ? `${(areaM2 / 10000).toFixed(2)}ha` : `${Math.round(areaM2)}㎡`; + } + + /** 켤 때마다 다시 요청한다 — config를 바꾸고 재시작했는데 캐시된 옛 결과가 나오면 + * 검증이 성립하지 않는다. 끌 때만 요청 없이 숨긴다. */ + async function toggle(): Promise { + if (!projectId) return; + if (shown) { + shown = false; + button.classList.remove("is-active"); + button.setAttribute("aria-pressed", "false"); + statusText = ""; + onChange(); + return; + } + button.disabled = true; + statusText = "배수유역을 분석하는 중… (30초 안팎)"; + onChange(); + try { + analysis = await fetchWatershedAnalysis(projectId); + shown = true; + button.classList.add("is-active"); + button.setAttribute("aria-pressed", "true"); + statusText = regionSummary(analysis); + } catch (error) { + statusText = error instanceof Error ? error.message : "배수유역 분석에 실패했습니다."; + } finally { + button.disabled = false; + onChange(); + } + } + + button.addEventListener("click", () => void toggle()); + + return { + button, + visible: () => shown && analysis !== null, + status: () => statusText, + reset() { + analysis = null; + flowCache = null; + shown = false; + statusText = ""; + button.classList.remove("is-active"); + button.setAttribute("aria-pressed", "false"); + }, + setProject(next: string) { + projectId = next; + }, + draw(context, map, view) { + if (!shown || !analysis) return; + drawGridCells(context, map, view, analysis); + drawPrimaryRegion(context, map, view, analysis); + drawBasinAndPipes(context, map, view, analysis); + }, + }; +} diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index c22140e9..3d934025 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -300,107 +300,15 @@ export interface DrainageBasinResponse { route_id: number; /** 산정에 실제 사용된 배관 지점 — 유역이 없는 관도 포함(마커 동기화용). */ pipes: DrainageCandidate[]; - /** 2차 전체 배수유역 외곽선 = 분수령. 세부유역은 전부 이 안쪽이라 능선을 따로 그리지 않는다. */ + /** B04가 분석에 쓴 계획 노선 선형(lon/lat). */ + route_lonlat: Array<[number, number]>; + /** 2차 전체 배수유역 외곽선 = 분수령. B04 산출물을 그대로 받는다. */ main_polygon_lonlat: Array<[number, number]>; - /** 도로 위 흐름 강도 — [누가거리 m, 그 구간으로 모이는 상류 면적 ㎡]. 관 추가 판단 근거. */ - strength_profile: Array<[number, number]>; - /** 해석에 실제 사용된 격자 한 변(m). 셀 수 상한에 걸리면 백엔드가 키워서 돌려준다. */ + /** B04 해석 격자 한 변(m). */ grid_cell_m: number; basins: DrainageBasin[]; } -/** 1차 배수유역 근거(단계 검증용). TIN·흐름 계산 없이 세류 상·하류 판정과 격자 범위만 준다. */ -export interface DrainagePrimaryRegion { - status: string; - project_id: string; - route_id: number; - radius_m: number; - /** 도로와 만난 세류선의 상류측 = 1차 영역의 기준선. */ - upstream_lines: Array>; - /** 교차했으나 하류로 판정해 제외한 조각. 판정이 맞는지 눈으로 대조하는 용도. */ - downstream_lines: Array>; - /** 상·하류 어느 망에도 이어지지 않아 제외한 세류 조각 수. */ - no_contact_count: number; - /** 노선이 1차 영역 밖으로 나간 길이(m). 크면 반경을 올려야 한다는 신호. */ - road_outside_m: number; - /** 1차 영역(상류 세류망 버퍼 합집합)의 외곽 링 목록. */ - region_rings: Array>; - grid: { - cell_m: number; - rows: number; - cols: number; - /** bbox 전체 셀 수(참고값). */ - bbox_cells: number; - /** 1차 영역에 걸쳐 실제로 생성된 셀 수. */ - cells: number; - width_m: number; - height_m: number; - /** 격자 bbox 링. 화면은 이 사각형을 rows×cols로 나눠 셀 좌표를 얻는다. */ - bbox_lonlat: Array<[number, number]>; - /** 실제 생성된 셀 구간 [행, 시작열, 끝열(포함)]. 낱개 셀 대신 구간으로 온다. */ - row_spans: Array<[number, number, number]>; - }; - /** 최외곽 적색 셀 주변 확장 결과. */ - expansion: { - rounds: number; - /** 새로 추가한 셀에 적색이 없어 스스로 멈췄는가. */ - closed: boolean; - added_cells: number; - /** 확장 전(1차 영역) 셀 수. */ - initial_cells: number; - }; - /** 셀별 흐름 방향과 도로 도달 여부. 등고선이 없어 판정을 못하면 null. */ - flow: { - encoding: "base64-uint8"; - /** 방위 분해능(32). 코드 0 = 화면 오른쪽, 시계방향 증가. */ - azimuth_steps: number; - /** 제자리(더 낮은 이웃 없음)를 뜻하는 코드. */ - sink_code: number; - /** 표고가 없어 판정 못한 셀 코드. */ - invalid_code: number; - cells: number; - reaches_road: number; - no_road: number; - /** 격자에는 있으나 등고선 TIN 밖이라 표고가 없어 판정 못한 셀. */ - unanalyzed: number; - /** 확정된 상류 세류망을 따라 흐름을 강제로 새긴 셀 수. */ - burned: number; - outer_seeds: number; - interior_seeds: number; - /** 셀당 1바이트. 하위 6비트=32방위 코드(32=제자리, 33=무효), 0x80=도로 도달. - * 순서는 grid.row_spans를 행 → 구간 → 열 오름차순으로 훑은 순서와 같다. */ - data: string; - } | null; - /** 2차 전체 배수유역 외곽선(= 분수령). 적색 셀 전체의 외곽. */ - basin_polygon_lonlat: Array<[number, number]>; - basin_area_m2: number; - /** 도로 위 흐름 강도 — [누가거리 m, 그 구간으로 모이는 상류 면적 ㎡]. */ - strength_profile: Array<[number, number]>; - /** 기본 관 매설 위치 — 도로 × 세류선 교차점. */ - pipes: DrainageCandidate[]; - /** 영구저장소에 남긴 검증용 GeoJSON 경로. */ - saved_to: string | null; -} - -export async function fetchDrainagePrimaryRegion( - projectId: string, -): Promise { - // 등고선 하강 방향 + 적색 확장 루프까지 도는 요청이라 수십 초가 걸린다. - return requestJson( - `/projects/${projectId}/drainage/primary-region`, - { method: "GET" }, - API_ANALYSIS_TIMEOUT_MS, - ); -} - -export async function fetchDrainageCandidates( - projectId: string, -): Promise { - return requestJson(`/projects/${projectId}/drainage/candidates`, { - method: "GET", - }); -} - /** chainages를 주면 그 위치로 확정 산정하고, 비우면 자동 제안분으로 산정한다. */ export async function fetchDrainageBasins( projectId: string, diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage.py index c46b037a..4b75de22 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage.py @@ -1,309 +1,14 @@ -"""배수유역 산정 엔진. - -관 매설 구조물 측점 후보를 제안하고, 각 측점이 받는 배수유역 경계를 산정한다. -지형 판단은 **도엽 등고선·세류선(하천중심선)**만 사용한다 — 3D 포인트클라우드나 지형 -메시는 쓰지 않고(2026-07-28 사용자 지시), 표고점도 유효 데이터가 적어 뺐다(2026-07-31). -유역 경계 산정 자체는 격자 흐름 해석(`..._Engine_Watershed_Basin`)이 맡고, 이 모듈은 -측점 후보 제안과 노선 정점·누가거리 보간만 담당한다. +"""배수 관경 산정. 유역을 나누는 최종 목적은 각 지점의 파이프 관경 결정이다. 유역 경사면에 100년 강우빈도를 -적용해 모이는 물의 양을 산정하고 그 유량으로 관경을 정한다. 관경 수식은 아직 미확정이라 -`estimate_pipe_diameter_mm()`은 골격만 두고 비워 둔다. +적용해 모이는 물의 양을 산정하고 그 유량으로 관경을 정한다. + +노선 기하(정점·누가거리·세류 교차점)는 `common_util_route_geometry`로 옮겼다 — B04 분석과 +B05 세부 설계가 같은 표현을 써야 하기 때문이다(2026-07-31 구조 개편). """ from __future__ import annotations -import logging -import math -from dataclasses import dataclass, field -from typing import Any - -from shapely.geometry import LineString, Point, shape - -logger = logging.getLogger(__name__) - -# 구조물 측점 사이 최대 허용 간격(m). 세류 교차가 없어도 이 간격을 넘으면 절토부에 추가 배치한다. -MAX_STRUCTURE_SPACING_M = 300 -# 같은 세류 교차로 볼 최소 이격(m). 이보다 가까운 교차점은 하나로 묶는다. -MIN_STRUCTURE_SPACING_M = 5.0 -# 유역 경계 탐색 반경(m). 측점에서 이 거리를 넘는 지형은 해당 유역으로 보지 않는다. -MAX_BASIN_RADIUS_M = 1000.0 - - -@dataclass -class RouteVertex: - """노선 폴리라인의 한 점. chainage는 시점 기준 누가거리(m).""" - - x: float - y: float - z: float - chainage_m: float - - -@dataclass -class StructureCandidate: - """관 매설 구조물 측점 후보.""" - - chainage_m: float - x: float - y: float - # "stream"=세류 교차, "spacing"=300m 규칙에 따른 보충 배치 - reason: str - stream_name: str | None = None - - -@dataclass -class DrainageBasin: - """한 구조물 측점이 받는 배수유역.""" - - index: int - chainage_m: float - outlet_x: float - outlet_y: float - polygon_lonlat: list[list[float]] = field(default_factory=list) - area_m2: float = 0.0 - # 유역 최고 표고 − 측점 표고(m). 경사면 낙차. - relief_m: float = 0.0 - # 유하거리: 측점에서 유역 최상단까지 물길 길이(m). - flow_length_m: float = 0.0 - pipe_diameter_mm: float | None = None - - -def build_route_vertices(points: list[dict[str, Any]]) -> list[RouteVertex]: - """DB route_points 행을 누가거리가 채워진 정점 목록으로 바꾼다.""" - vertices: list[RouteVertex] = [] - cumulative = 0.0 - previous: tuple[float, float] | None = None - for row in points: - x = float(row["x"]) - y = float(row["y"]) - z = float(row.get("z") or 0.0) - if previous is not None: - cumulative += math.dist(previous, (x, y)) - chainage = row.get("chainage_m") - vertices.append( - RouteVertex( - x=x, - y=y, - z=z, - chainage_m=float(chainage) if chainage is not None else cumulative, - ) - ) - previous = (x, y) - return vertices - - -def _interpolate_vertex( - vertices: list[RouteVertex], chainage_m: float -) -> tuple[float, float, float]: - """누가거리 위치의 (x, y, z)를 선형 보간한다.""" - if not vertices: - return (0.0, 0.0, 0.0) - if chainage_m <= vertices[0].chainage_m: - return (vertices[0].x, vertices[0].y, vertices[0].z) - for previous, current in zip(vertices, vertices[1:]): - if chainage_m <= current.chainage_m: - span = current.chainage_m - previous.chainage_m - ratio = 0.0 if span <= 0 else (chainage_m - previous.chainage_m) / span - return ( - previous.x + (current.x - previous.x) * ratio, - previous.y + (current.y - previous.y) * ratio, - previous.z + (current.z - previous.z) * ratio, - ) - last = vertices[-1] - return (last.x, last.y, last.z) - - -def is_uphill_at(vertices: list[RouteVertex], chainage_m: float, window_m: float = 20.0) -> bool: - """해당 위치가 오르막(절토부)인지 판정한다. - - 내리막(성토부)은 물이 노선 바깥으로 흘러나가므로 배수유역을 만들지 않는다 - (2026-07-28 사용자 지시). 판정은 종단 계획선의 국소 기울기 부호로 한다. - """ - _, _, back_z = _interpolate_vertex(vertices, max(0.0, chainage_m - window_m)) - _, _, forward_z = _interpolate_vertex(vertices, chainage_m + window_m) - return forward_z >= back_z - - -def find_stream_crossings( - vertices: list[RouteVertex], - stream_features: list[dict[str, Any]], -) -> list[StructureCandidate]: - """노선 평면 선형과 세류선의 교차 지점을 찾는다.""" - if len(vertices) < 2: - return [] - route_line = LineString([(vertex.x, vertex.y) for vertex in vertices]) - candidates: list[StructureCandidate] = [] - for feature in stream_features: - geometry = feature.get("geometry") - if not geometry: - continue - try: - stream = shape(geometry) - except Exception: # noqa: BLE001 - 손상된 피처는 건너뛴다 - continue - if stream.is_empty: - continue - intersection = route_line.intersection(stream) - if intersection.is_empty: - continue - name = _stream_name(feature) - for point in _collect_points(intersection): - candidates.append( - StructureCandidate( - chainage_m=route_line.project(point), - x=point.x, - y=point.y, - reason="stream", - stream_name=name, - ) - ) - candidates.sort(key=lambda item: item.chainage_m) - return candidates - - -def _stream_name(feature: dict[str, Any]) -> str | None: - properties = feature.get("properties") or {} - for key in ("명칭", "하천명", "NAME", "name"): - value = properties.get(key) - if value: - return str(value) - return None - - -def _collect_points(geometry: Any) -> list[Point]: - """교차 결과(Point/MultiPoint/LineString 등)에서 대표 점들을 뽑는다.""" - if geometry.geom_type == "Point": - return [geometry] - if geometry.geom_type in {"MultiPoint", "GeometryCollection"}: - points: list[Point] = [] - for part in geometry.geoms: - points.extend(_collect_points(part)) - return points - # 선분끼리 겹쳐 선으로 나온 경우는 중점을 대표로 쓴다. - if geometry.geom_type in {"LineString", "MultiLineString"}: - return [geometry.interpolate(0.5, normalized=True)] - return [] - - -def propose_structure_stations( - vertices: list[RouteVertex], - stream_features: list[dict[str, Any]], -) -> list[StructureCandidate]: - """구조물 측점 후보를 제안한다. - - ① 세류 교차 지점 ② 내리막(성토부) 제외 ③ 직전 측점에서 300m 초과 시 절토부에 보충 배치. - """ - if len(vertices) < 2: - return [] - total_length = vertices[-1].chainage_m - crossings = [ - candidate - for candidate in find_stream_crossings(vertices, stream_features) - if is_uphill_at(vertices, candidate.chainage_m) - ] - - # 너무 가까운 교차는 하나로 본다(같은 계곡을 여러 선분이 지나는 경우). - merged: list[StructureCandidate] = [] - for candidate in crossings: - if merged and candidate.chainage_m - merged[-1].chainage_m < MIN_STRUCTURE_SPACING_M: - continue - merged.append(candidate) - - # 300m 규칙: 빈 구간에 절토부 지점을 찾아 보충한다. - filled: list[StructureCandidate] = [] - previous_chainage = 0.0 - for candidate in [*merged, None]: - boundary = candidate.chainage_m if candidate else total_length - filled.extend(_fill_spacing(vertices, previous_chainage, boundary)) - if candidate: - filled.append(candidate) - previous_chainage = candidate.chainage_m - else: - previous_chainage = boundary - filled.sort(key=lambda item: item.chainage_m) - return filled - - -def _fill_spacing( - vertices: list[RouteVertex], - start_m: float, - end_m: float, -) -> list[StructureCandidate]: - """[start, end] 구간이 300m를 넘으면 보충 측점을 만든다. - - 종단도상 상대적으로 물이 모일 것으로 예상되는 지점(절토부 내 종단 저점)을 - 우선 배치한다(2026-07-29 사용자 지시). 저점이 없으면 목표 인근 절토부로 대체한다. - """ - added: list[StructureCandidate] = [] - cursor = start_m - while end_m - cursor > MAX_STRUCTURE_SPACING_M: - target = cursor + MAX_STRUCTURE_SPACING_M - placed = _gather_low_point(vertices, cursor, target, end_m) - if placed is None: - placed = _nearest_uphill(vertices, target, end_m) - if placed is None: - # 도로 연장 기준 300m 규칙 — 저점·절토부가 없어도 관 배치는 보장한다 - # (2026-07-29 사용자 지시: 도로 340m면 최소 1개). - placed = min(target, (cursor + end_m) / 2.0) - x, y, _ = _interpolate_vertex(vertices, placed) - added.append(StructureCandidate(chainage_m=placed, x=x, y=y, reason="spacing")) - cursor = placed - return added - - -def _gather_low_point( - vertices: list[RouteVertex], - cursor_m: float, - target_m: float, - limit_m: float, - step_m: float = 10.0, -) -> float | None: - """탐색창 [cursor+150, target] 안 절토부의 종단 국소 저점(사그) 중 가장 낮은 지점. - - 창 하한을 간격의 절반으로 두어 보충 측점이 과밀하게 몰리지 않게 하고, - 국소 저점만 인정해 일정 오르막에서는 None(300m 규칙 폴백)을 돌려준다. - """ - window_start = cursor_m + MAX_STRUCTURE_SPACING_M / 2.0 - probes: list[float] = [] - probe = window_start - step_m - while probe <= target_m + step_m: - probes.append(probe) - probe += step_m - heights = [_interpolate_vertex(vertices, position)[2] for position in probes] - best: tuple[float, float] | None = None # (계획고 z, 누가거리) - for i in range(1, len(probes) - 1): - position = probes[i] - if position >= limit_m or position > target_m or position < window_start: - continue - # 국소 저점(양쪽이 같거나 높음) = 물이 모여 더 못 흐르는 지점. 앞쪽이 오르막인 - # 조건을 내포하므로 별도의 절토부(is_uphill_at) 판정은 두지 않는다. - if heights[i] > heights[i - 1] or heights[i] > heights[i + 1]: - continue - if best is None or heights[i] < best[0]: - best = (heights[i], position) - return best[1] if best else None - - -def _nearest_uphill( - vertices: list[RouteVertex], - target_m: float, - limit_m: float, - step_m: float = 10.0, -) -> float | None: - """목표 위치에서 가장 가까운 절토부(오르막) 지점을 찾는다. 없으면 None.""" - if is_uphill_at(vertices, target_m): - return target_m - offset = step_m - while offset <= MAX_STRUCTURE_SPACING_M / 2: - for probe in (target_m - offset, target_m + offset): - if probe <= 0 or probe >= limit_m: - continue - if is_uphill_at(vertices, probe): - return probe - offset += step_m - return None - def estimate_pipe_diameter_mm( area_m2: float, diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py new file mode 100644 index 00000000..51be65f9 --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py @@ -0,0 +1,441 @@ +"""배수유역 세부 설계 (B05 — 일반 사용자용). + +**분석은 하지 않는다.** B04가 미리 돌려 저장한 결과를 읽어, 사용자가 실제로 손대는 두 가지만 +처리한다(2026-07-31 사용자 지시). + + ⑨ 관 간격이 최대치를 넘는 구간에 **최소 개수**로 관을 보충 + ⑩ 측구 흐름으로 도로 셀 → 담당 관을 정하고, 셀이 도달한 도로 셀의 담당 관을 그대로 + 그 셀의 유역 번호로 삼아 세부유역을 나눈다 + ⑪ 사용자가 관을 옮기거나 추가하면 ⑩만 다시 돈다 — 격자 해석은 재사용한다 + +읽어 오는 것(`B04_wf1_Surface/drainage/`): + · `03_road_routing.geojson` — 계획도로선 · 기본 배관 · 2차 전체 배수유역 + · `03_road_routing.npz` — 셀 → 도로 셀 귀속, 유하장, 강도, 도로 셀 제원, 셀 표고 +화살표(방향 코드)나 밴드 표고 같은 관리자 확인용 배열은 읽지 않는다 — 여기서는 필요 없고 +파일만 무거워진다. +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass, field +from pathlib import Path + +import numpy as np + +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Export import STAGES, drainage_dir +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Flow import largest_ring, polygonize_labels +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import GridSpec +from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import estimate_pipe_diameter_mm +from common_util.common_util_route_geometry import ( + RouteVertex, + StructureCandidate, + interpolate_vertex, + is_uphill_at, +) +from config.config_system import ( + DRAINAGE_DITCH_SAMPLE_M, + DRAINAGE_PIPE_MAX_SPACING_M, + DRAINAGE_PIPE_MIN_SPACING_M, +) + +logger = logging.getLogger(__name__) + +# 관 위치 선정 점수 배분 — 흐름 강도가 주, 종단 저점이 보조. +_SCORE_WEIGHT_STRENGTH = 0.7 +_SCORE_WEIGHT_SAG = 0.3 +# 성토부(내리막)는 물이 노선 밖으로 빠지므로 관 위치로 덜 선호한다. +_SCORE_FILL_PENALTY = 0.5 + + +@dataclass +class DrainageDetail: + """B05 산출물 — 화면에 그릴 기하와 세부유역.""" + + route_lonlat: list[list[float]] = field(default_factory=list) + basin_lonlat: list[list[float]] = field(default_factory=list) + pipes: list[StructureCandidate] = field(default_factory=list) + basins: list[WatershedBasin] = field(default_factory=list) + grid_cell_m: float = 1.0 + + +def build_drainage_detail( + stored_path: str, + vertices: list[RouteVertex], + confirmed_chainages: list[float] | None = None, +) -> DrainageDetail | None: + """B04 분석 결과를 읽어 관을 보충하고 세부유역을 나눈다. + + `confirmed_chainages`를 주면 그 위치를 관으로 확정하고(사용자 편집), 비우면 B04의 + 기본 관에 최대 간격 규칙으로 최소 개수만 보충한다. 어느 쪽이든 격자 해석은 하지 않는다. + """ + routing = load_road_routing(stored_path) + if routing is None or len(vertices) < 2: + return None + + if confirmed_chainages: + pipes = _pipes_from_chainages(vertices, confirmed_chainages) + else: + # 저장분의 기본 관은 누가거리만 신뢰한다 — 좌표는 현재 노선 위로 다시 찍는다. + base = [ + StructureCandidate( + chainage_m=pipe.chainage_m, + x=interpolate_vertex(vertices, pipe.chainage_m)[0], + y=interpolate_vertex(vertices, pipe.chainage_m)[1], + reason=pipe.reason, + ) + for pipe in routing.base_pipes + ] + pipes = place_pipes(vertices, base, routing.strength_curve) + + detail = DrainageDetail( + route_lonlat=routing.route_lonlat, + basin_lonlat=routing.basin_lonlat, + pipes=pipes, + grid_cell_m=routing.spec.cell_m, + ) + if not pipes: + return detail + pipe_of_slot = assign_road_cells_to_pipes(vertices, pipes, routing.road_chainage) + detail.basins = assemble_basins(routing, pipes, pipe_of_slot) + logger.info( + "배수유역: 세부 설계 — 관 %d개(기본 %d + 보충 %d), 세부유역 %d개", + len(pipes), + sum(1 for pipe in pipes if pipe.reason != "spacing"), + sum(1 for pipe in pipes if pipe.reason == "spacing"), + len(detail.basins), + ) + return detail + + +@dataclass +class WatershedBasin: + """관 하나가 받는 세부 배수유역.""" + + index: int + chainage_m: float + outlet_x: float + outlet_y: float + boundary_xy: list[tuple[float, float]] = field(default_factory=list) + area_m2: float = 0.0 + relief_m: float = 0.0 + flow_length_m: float = 0.0 + pipe_diameter_mm: float | None = None + + +@dataclass +class RoadRouting: + """B04가 남긴 배수유역 분석 결과 — B05가 세부유역을 나누는 데 필요한 최소 묶음.""" + + spec: GridSpec + # (R*C,) int32 — 셀이 물길을 따라 도달하는 도로 셀 슬롯(−1 = 미도달). + road_slot: np.ndarray + path_length: np.ndarray # (R*C,) float32 — 그 도로 셀까지 물길 길이(m) + elevation: np.ndarray # (R*C,) float32 — 셀 표고(유역 낙차 계산용) + road_cell_index: np.ndarray # (K,) int32 — 도로 셀의 평탄 인덱스 + road_chainage: np.ndarray # (K,) float64 — 도로 셀의 누가거리(m) + strength: np.ndarray # (K,) int64 — 도로 셀별 상류 셀 수 + # 화면에 그대로 그릴 기하(WGS84 lon/lat). + route_lonlat: list[list[float]] = field(default_factory=list) + basin_lonlat: list[list[float]] = field(default_factory=list) + base_pipes: list[StructureCandidate] = field(default_factory=list) + + @property + def strength_curve(self) -> np.ndarray: + """누가거리 1m 구간별 유입 면적(㎡) 곡선 — 관 보충 위치 점수의 근거.""" + if self.road_chainage.size == 0: + return np.zeros(1) + bins = max(1, int(np.ceil(self.road_chainage.max())) + 1) + index = np.clip(np.round(self.road_chainage).astype(np.int64), 0, bins - 1) + weights = self.strength.astype(np.float64) * self.spec.cell_area_m2 + return np.bincount(index, weights=weights, minlength=bins) + + +def load_road_routing(stored_path: str) -> RoadRouting | None: + """B04가 남긴 `03_road_routing` 산출물을 읽는다. 없으면 None.""" + directory = drainage_dir(stored_path) + prefix = STAGES["road_routing"] + array_path = directory / f"{prefix}_road_routing.npz" + if not array_path.exists(): + logger.warning("배수유역: B04 분석 결과가 없습니다 (%s).", array_path) + return None + try: + with np.load(array_path, allow_pickle=False) as data: + spec = GridSpec( + x_min=float(data["x_min"]), + y_max=float(data["y_max"]), + cell_m=float(data["cell_m"]), + n_rows=int(data["n_rows"]), + n_cols=int(data["n_cols"]), + ) + routing = RoadRouting( + spec=spec, + road_slot=data["road_slot"].reshape(-1), + path_length=data["path_length"].reshape(-1), + elevation=data["elevation"].reshape(-1), + road_cell_index=data["road_cell_index"], + road_chainage=data["road_chainage"], + strength=data["strength"], + ) + except (OSError, KeyError, ValueError): + logger.warning("배수유역: B04 분석 결과를 읽지 못했습니다 (%s).", array_path) + return None + + _read_geometry(directory / f"{prefix}_road_routing.geojson", routing) + logger.info( + "배수유역: B04 결과 로드 — 격자 %d×%d, 도로 셀 %d, 기본 관 %d", + spec.n_rows, + spec.n_cols, + routing.road_cell_index.size, + len(routing.base_pipes), + ) + return routing + + +def _read_geometry(path: Path, routing: RoadRouting) -> None: + """계획도로선·2차 유역 외곽선·기본 관을 GeoJSON에서 읽어 채운다.""" + if not path.exists(): + logger.warning("배수유역: B04 기하 산출물이 없습니다 (%s).", path) + return + try: + with path.open("r", encoding="utf-8") as file: + document = json.load(file) + except (OSError, json.JSONDecodeError): + logger.warning("배수유역: B04 기하 산출물을 읽지 못했습니다 (%s).", path) + return + for feature in document.get("features", []): + properties = feature.get("properties") or {} + geometry = feature.get("geometry") or {} + coordinates = geometry.get("coordinates") + kind = properties.get("kind") + if kind == "route" and geometry.get("type") == "LineString": + routing.route_lonlat = coordinates + elif kind == "basin_boundary" and geometry.get("type") == "Polygon" and coordinates: + routing.basin_lonlat = coordinates[0] + elif kind == "pipe" and geometry.get("type") == "Point": + routing.base_pipes.append( + StructureCandidate( + chainage_m=float(properties.get("chainage_m") or 0.0), + x=0.0, + y=0.0, + reason=str(properties.get("reason") or "stream"), + ) + ) + + +# ── ⑨ 관 최소 개수 보충 ───────────────────────────────────────────────────── + + +def place_pipes( + vertices: list[RouteVertex], + base_pipes: list[StructureCandidate], + strength_curve: np.ndarray, +) -> list[StructureCandidate]: + """B04가 정한 기본 관(세류 교차점)에, 최대 간격을 넘는 구간만 최소 개수로 보충한다. + + 기본 관은 여기서 다시 찾지 않는다 — B04 산출물에 이미 들어 있다. + """ + total_length = vertices[-1].chainage_m + base: list[StructureCandidate] = [] + for candidate in sorted(base_pipes, key=lambda item: item.chainage_m): + if base and candidate.chainage_m - base[-1].chainage_m < DRAINAGE_PIPE_MIN_SPACING_M: + continue + base.append(candidate) + + filled: list[StructureCandidate] = [] + previous = 0.0 + for candidate in [*base, None]: + boundary = candidate.chainage_m if candidate else total_length + filled.extend(_fill_gap(vertices, strength_curve, previous, boundary)) + if candidate: + filled.append(candidate) + previous = candidate.chainage_m + else: + previous = boundary + filled.sort(key=lambda item: item.chainage_m) + return filled + + +def _fill_gap( + vertices: list[RouteVertex], + strength_curve: np.ndarray, + start_m: float, + end_m: float, +) -> list[StructureCandidate]: + """[start, end] 구간에 최대 간격을 지키는 **최소 개수**의 관을 배치한다. + + 필요 개수 n은 구간 길이로 정해지고(ceil(L/max) − 1), 각 관은 등분 위치를 중심으로 + 허용 여유(slack) 안에서만 움직인다. 그래서 개수는 늘지 않으면서도 흐름 강도가 크고 + 종단이 낮은 지점으로 붙는다. + """ + span = end_m - start_m + if span <= DRAINAGE_PIPE_MAX_SPACING_M: + return [] + count = int(np.ceil(span / DRAINAGE_PIPE_MAX_SPACING_M)) - 1 + if count <= 0: + return [] + spacing = span / (count + 1) + slack = max(0.0, (DRAINAGE_PIPE_MAX_SPACING_M - spacing) / 2.0) + placed: list[StructureCandidate] = [] + for order in range(1, count + 1): + nominal = start_m + spacing * order + low = max(start_m + DRAINAGE_PIPE_MIN_SPACING_M, nominal - slack) + high = min(end_m - DRAINAGE_PIPE_MIN_SPACING_M, nominal + slack) + chosen = _best_position(vertices, strength_curve, low, high, nominal) + x, y, _ = interpolate_vertex(vertices, chosen) + placed.append(StructureCandidate(chainage_m=chosen, x=x, y=y, reason="spacing")) + return placed + + +def _best_position( + vertices: list[RouteVertex], + strength_curve: np.ndarray, + low_m: float, + high_m: float, + fallback_m: float, +) -> float: + """허용 구간 안에서 흐름 강도가 크고 종단이 낮은 위치를 고른다.""" + if high_m <= low_m: + return fallback_m + positions = np.arange(low_m, high_m + 1.0, 1.0) + if positions.size == 0: + return fallback_m + index = np.clip(np.round(positions).astype(np.int64), 0, strength_curve.size - 1) + strength = strength_curve[index] + heights = np.array([interpolate_vertex(vertices, float(p))[2] for p in positions]) + + strength_score = strength / strength.max() if strength.max() > 0 else np.zeros_like(strength) + height_span = float(heights.max() - heights.min()) + sag_score = ( + (heights.max() - heights) / height_span if height_span > 1e-6 else np.zeros_like(heights) + ) + score = _SCORE_WEIGHT_STRENGTH * strength_score + _SCORE_WEIGHT_SAG * sag_score + for order, position in enumerate(positions): + if not is_uphill_at(vertices, float(position)): + score[order] *= _SCORE_FILL_PENALTY + return float(positions[int(np.argmax(score))]) + + +def _pipes_from_chainages( + vertices: list[RouteVertex], chainages: list[float] +) -> list[StructureCandidate]: + """사용자가 확정·편집한 누가거리 목록을 관 후보로 되돌린다. + + 노선 밖 값은 시·종점으로 당긴다. 그대로 두면 마커는 끝점에 찍히는데 라벨만 −50m처럼 + 나와 좌표와 표기가 어긋난다. + """ + total_length = vertices[-1].chainage_m + clamped = {min(max(round(float(item), 2), 0.0), total_length) for item in chainages} + pipes: list[StructureCandidate] = [] + for value in sorted(clamped): + x, y, _ = interpolate_vertex(vertices, value) + pipes.append(StructureCandidate(chainage_m=value, x=x, y=y, reason="confirmed")) + return pipes + + +# ── ⑩ 측구 흐름으로 도로 셀 → 담당 관 ─────────────────────────────────────── + + +def assign_road_cells_to_pipes( + vertices: list[RouteVertex], + pipes: list[StructureCandidate], + road_chainage: np.ndarray, +) -> np.ndarray: + """도로 셀마다 물이 실제로 흘러가는 담당 관 번호를 정한다. + + 노면 물은 측구를 타고 종단 내리막으로 흐르므로, 종단 계획선을 1차원 지형으로 보고 + 같은 방식(내리막 추적 + 관에서 흡수)으로 푼다. 관이 없는 사그(저점)에 갇힌 구간은 + 가장 가까운 관이 받는 것으로 본다. + """ + total_length = vertices[-1].chainage_m + step = max(DRAINAGE_DITCH_SAMPLE_M, 0.5) + stations = np.arange(0.0, total_length + step, step) + heights = np.array([interpolate_vertex(vertices, float(s))[2] for s in stations]) + pipe_chainages = np.array([pipe.chainage_m for pipe in pipes]) + pipe_station = np.clip(np.round(pipe_chainages / step).astype(np.int64), 0, stations.size - 1) + + # 앞뒤 이웃 중 더 낮은 쪽으로 흘려보낸다(양쪽 다 높으면 사그 = 제자리). + back_z = np.full(stations.size, np.inf) + back_z[1:] = heights[:-1] + forward_z = np.full(stations.size, np.inf) + forward_z[:-1] = heights[1:] + go_back = (back_z < heights) & (back_z <= forward_z) + go_forward = (forward_z < heights) & ~go_back + receiver = np.arange(stations.size, dtype=np.int64) + receiver[go_back] -= 1 + receiver[go_forward] += 1 + receiver[pipe_station] = pipe_station # 관은 물을 흡수한다 + + owner = np.full(stations.size, -1, dtype=np.int64) + owner[pipe_station] = np.arange(pipe_chainages.size) + jump = receiver + for _ in range(40): + next_jump = jump[jump] + if np.array_equal(next_jump, jump): + break + jump = next_jump + resolved = owner[jump] + # 관 없는 사그에 갇힌 구간은 가장 가까운 관에 붙인다. + orphan = resolved < 0 + if orphan.any() and pipe_chainages.size: + nearest = np.abs(stations[orphan, None] - pipe_chainages[None, :]).argmin(axis=1) + resolved[orphan] = nearest + + slot_station = np.clip(np.round(road_chainage / step).astype(np.int64), 0, stations.size - 1) + return resolved[slot_station].astype(np.int32) + + +# ── ⑩ 세부유역 조립 ──────────────────────────────────────────────────────── + + +def assemble_basins( + solution: RoadRouting, + pipes: list[StructureCandidate], + pipe_of_slot: np.ndarray, +) -> list[WatershedBasin]: + """셀이 도달한 도로 셀의 담당 관을 그대로 유역 번호로 삼아 세부유역을 만든다.""" + spec = solution.spec + labels = np.full(spec.size, -1, dtype=np.int32) + reached = solution.road_slot >= 0 + labels[reached] = pipe_of_slot[solution.road_slot[reached]] + + polygons = polygonize_labels(spec, labels) + cell_area = spec.cell_area_m2 + basins: list[WatershedBasin] = [] + for order, pipe in enumerate(pipes): + member = labels == order + count = int(member.sum()) + if count == 0: + continue + geometry = polygons.get(order) + elevations = solution.elevation[member] + highest = float(np.nanmax(elevations)) if np.isfinite(elevations).any() else 0.0 + outlet_z = _outlet_elevation(solution, order, pipe_of_slot) + area = count * cell_area + relief = max(0.0, highest - outlet_z) + flow_length = float(solution.path_length[member].max()) + basins.append( + WatershedBasin( + index=len(basins) + 1, + chainage_m=pipe.chainage_m, + outlet_x=pipe.x, + outlet_y=pipe.y, + boundary_xy=largest_ring(geometry) if geometry is not None else [], + area_m2=area, + relief_m=relief, + flow_length_m=flow_length, + pipe_diameter_mm=estimate_pipe_diameter_mm(area, relief, flow_length), + ) + ) + return basins + + +def _outlet_elevation(solution: RoadRouting, pipe_order: int, pipe_of_slot: np.ndarray) -> float: + """관이 담당하는 도로 셀들의 최저 표고 = 유역 출구 표고.""" + slots = np.flatnonzero(pipe_of_slot == pipe_order) + if slots.size == 0: + return 0.0 + elevations = solution.elevation[solution.road_cell_index[slots]] + finite = elevations[np.isfinite(elevations)] + return float(finite.min()) if finite.size else 0.0 diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py b/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py deleted file mode 100644 index 128ca2d4..00000000 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Watershed_Basin.py +++ /dev/null @@ -1,723 +0,0 @@ -"""배수유역 산정 오케스트레이터 — 격자 해석 · 관 배치 · 세부유역 조립. - -전체 흐름 - ① 등고선 정리 → 상류 세류선 추출 → 1차 격자 범위(반경 버퍼 bbox) - ② 격자 지형 해석(TIN 보간 · 채움 · D8) → 도로에서 상류 추적 - ③ 활성 셀이 격자 최외곽에 닿으면 그 방향으로만 넓혀 다시 해석 (경계 링이 전부 - 비활성이 되면 정지 — 하드 반경 상한이 아니라 흐름 자체가 종료 조건이다) - ④ 도로 셀별 흐름 강도(상류 셀 수) 산출 → 2차 전체 배수유역 외곽선 확정 - ⑤ 관 배치: 세류 교차점이 기본, 간격이 최대치를 넘으면 흐름 강도·종단 저점을 보고 - **최소 개수**만 보충 - ⑥ 측구 흐름(종단 내리막)으로 도로 셀 → 담당 관을 정하고, 셀이 도달한 도로 셀의 - 담당 관을 그대로 그 셀의 유역 번호로 삼아 세부유역을 나눈다 - -②~④는 관 배치와 무관하므로 `.npz`로 캐시한다. 사용자가 관을 옮기거나 추가하면 -⑥만 다시 돌면 되고 격자 해석은 재사용한다. -""" - -from __future__ import annotations - -import hashlib -import logging -import time -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - -import numpy as np -from shapely.geometry import LineString - -from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import ( - RouteVertex, - StructureCandidate, - _interpolate_vertex, - estimate_pipe_diameter_mm, - find_stream_crossings, - is_uphill_at, -) -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Descent import ContourDescent -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Expand import expand_by_red_boundary -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Flow import ( - FlowClassification, - RoadRaster, - expand_until_closed, - largest_ring, - outer_boundary, - polygonize_labels, - trace_flow, -) -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import ( - GridSpec, - TerrainGrid, - build_contour_cloud, - route_elevation_floor, -) -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Stream import ( - PrimaryRegion, - build_primary_region, -) -from config.config_system import ( - DRAINAGE_DITCH_SAMPLE_M, - DRAINAGE_EXPAND_STEP_M, - DRAINAGE_GRID_SIZE_M, - DRAINAGE_INITIAL_RADIUS_M, - DRAINAGE_MAX_EXPAND_ROUNDS, - DRAINAGE_PIPE_MAX_SPACING_M, - DRAINAGE_PIPE_MIN_SPACING_M, - DRAINAGE_ROAD_WIDTH_M, -) - -logger = logging.getLogger(__name__) - -# 강도 곡선 응답 간격(m). 도로 위 흐름 강도 히트 표기는 이 간격으로 내보낸다. -_STRENGTH_OUTPUT_STEP_M = 5.0 -# 관 위치 선정 점수 배분 — 흐름 강도가 주, 종단 저점이 보조. -_SCORE_WEIGHT_STRENGTH = 0.7 -_SCORE_WEIGHT_SAG = 0.3 -# 성토부(내리막)는 물이 노선 밖으로 빠지므로 관 위치로 덜 선호한다. -_SCORE_FILL_PENALTY = 0.5 - - -@dataclass -class WatershedBasin: - """관 하나가 받는 세부 배수유역.""" - - index: int - chainage_m: float - outlet_x: float - outlet_y: float - boundary_xy: list[tuple[float, float]] = field(default_factory=list) - area_m2: float = 0.0 - relief_m: float = 0.0 - flow_length_m: float = 0.0 - pipe_diameter_mm: float | None = None - - -@dataclass -class WatershedResult: - """배수유역 산정 결과 일체.""" - - basins: list[WatershedBasin] = field(default_factory=list) - pipes: list[StructureCandidate] = field(default_factory=list) - # 2차 전체 배수유역 외곽선(= 분수령). 세부유역 경계는 이 안쪽에서만 그어진다. - main_boundary_xy: list[tuple[float, float]] = field(default_factory=list) - # 도로 위 흐름 강도 곡선 — (누가거리 m, 그 지점으로 모이는 상류 면적 ㎡). - strength_profile: list[tuple[float, float]] = field(default_factory=list) - grid_cell_m: float = DRAINAGE_GRID_SIZE_M - - -@dataclass -class _GridSolution: - """관 배치와 무관한 격자 해석 결과 묶음(캐시 대상).""" - - spec: GridSpec - elevation: np.ndarray # (R*C,) float32 - road_cell_index: np.ndarray # (K,) int32 - road_chainage: np.ndarray # (K,) float64 - road_slot: np.ndarray # (R*C,) int32 — 셀이 도달한 도로 셀 슬롯(−1=미도달) - path_length: np.ndarray # (R*C,) float32 - strength: np.ndarray # (K,) int64 - active: np.ndarray # (R*C,) bool - signature: str - - -# ── 진입점 ────────────────────────────────────────────────────────────────── - - -def build_drainage_watershed( - vertices: list[RouteVertex], - contour_features: list[dict[str, Any]], - stream_features: list[dict[str, Any]], - confirmed_chainages: list[float] | None = None, - cache_path: Path | None = None, -) -> WatershedResult: - """배수유역과 관 배치를 산정한다. - - `confirmed_chainages`를 주면 그 위치를 관으로 확정하고(사용자 편집), 비우면 세류 - 교차 + 최소 보충으로 자동 배치한다. 두 경우 모두 격자 해석은 캐시를 재사용한다. - """ - if len(vertices) < 2: - return WatershedResult() - route_line = LineString([(vertex.x, vertex.y) for vertex in vertices]) - solution = _solve_grid(vertices, route_line, contour_features, stream_features, cache_path) - if solution is None or solution.road_cell_index.size == 0: - return WatershedResult() - - strength_area = solution.strength.astype(np.float64) * solution.spec.cell_area_m2 - strength_curve = _strength_by_chainage(solution.road_chainage, strength_area, route_line.length) - - if confirmed_chainages: - pipes = _pipes_from_chainages(vertices, confirmed_chainages) - else: - pipes = _place_pipes(vertices, stream_features, strength_curve) - if not pipes: - return WatershedResult( - main_boundary_xy=_main_boundary(solution), - strength_profile=_downsample_strength(strength_curve), - grid_cell_m=solution.spec.cell_m, - ) - - pipe_of_slot = _assign_road_cells_to_pipes(vertices, pipes, solution.road_chainage) - basins = _assemble_basins(solution, pipes, pipe_of_slot) - return WatershedResult( - basins=basins, - pipes=pipes, - main_boundary_xy=_main_boundary(solution), - strength_profile=_downsample_strength(strength_curve), - grid_cell_m=solution.spec.cell_m, - ) - - -# ── ①~② 1차 배수유역 (단계 검증 대상) ────────────────────────────────────── - - -def resolve_primary_region( - vertices: list[RouteVertex], - route_line: LineString, - contour_features: list[dict[str, Any]], - stream_features: list[dict[str, Any]], -) -> PrimaryRegion | None: - """도로 교차 세류선(상류측)과 노선을 반경 버퍼한 1차 배수유역과 격자 범위를 정한다. - - 상·하류 판정에 쓸 등고선은 노선 주변만 있으면 된다(교차점이 전부 노선 위이므로). - 도엽 전체를 읽으면 이 단계에서만 수십 초가 날아간다. - """ - floor = route_elevation_floor([vertex.z for vertex in vertices]) - near_bounds = route_line.buffer(DRAINAGE_INITIAL_RADIUS_M * 2.0).bounds - cloud = build_contour_cloud(contour_features, floor, near_bounds) - if cloud.is_empty: - logger.warning("배수유역: 노선 주변에 등고선이 없어 1차 영역을 정할 수 없습니다.") - return None - return build_primary_region( - route_line, stream_features, cloud, DRAINAGE_INITIAL_RADIUS_M, DRAINAGE_GRID_SIZE_M - ) - - -def preview_primary_region( - vertices: list[RouteVertex], - contour_features: list[dict[str, Any]], - stream_features: list[dict[str, Any]], -) -> PrimaryRegion | None: - """단계 검증용 — TIN·흐름 계산 없이 1차 배수유역 근거만 뽑는다.""" - if len(vertices) < 2: - return None - route_line = LineString([(vertex.x, vertex.y) for vertex in vertices]) - return resolve_primary_region(vertices, route_line, contour_features, stream_features) - - -@dataclass -class StagePreview: - """단계 검증 산출물 묶음. 기능을 붙일 때마다 여기에 항목이 하나씩 늘어난다. - - 확장을 거치면 격자와 해석 영역이 1차 영역보다 커진다. 화면·저장은 `region.spec`이 - 아니라 여기 `spec`/`domain`을 봐야 한다. - """ - - region: PrimaryRegion - spec: GridSpec | None = None - domain: np.ndarray | None = None - terrain: TerrainGrid | None = None - road: RoadRaster | None = None - flow: FlowClassification | None = None - descent: ContourDescent | None = None - expand_rounds: int = 0 - expand_closed: bool = False - expand_added_cells: int = 0 - # ⑥ 도로 위 흐름 강도 — (누가거리 m, 그 구간으로 모이는 상류 면적 ㎡). - strength_profile: list[tuple[float, float]] = field(default_factory=list) - # ⑦ 2차 전체 배수유역 외곽선(= 분수령). 적색 셀 전체를 폴리곤화한 것. - basin_boundary_xy: list[tuple[float, float]] = field(default_factory=list) - basin_area_m2: float = 0.0 - # ⑧ 기본 관 매설 위치 — 도로 × 세류선 교차점. - pipes: list[StructureCandidate] = field(default_factory=list) - - -def preview_stages( - vertices: list[RouteVertex], - contour_features: list[dict[str, Any]], - stream_features: list[dict[str, Any]], -) -> StagePreview | None: - """지금까지 구현·검증된 단계를 순서대로 돌려 결과를 모은다. - - 현재 포함: ① 1차 배수유역 ② 격자 생성 ③ **등고선 하강 방향** ④ 도로 도달 판정 - ⑤ **최외곽 적색 셀 주변 확장**. - - ③은 보간면(TIN)을 쓰지 않는다. 높은 등고 라인에서 낮은 등고 라인으로 내려가며 방향을 - 세우므로 가짜 웅덩이·평탄면이 원리적으로 생기지 않는다(2026-07-31 사용자 지시로 방식 교체). - - ⑤는 최외곽에 적색이 남아 있으면 그 주변으로 넓혀 다시 분석하고, **새로 추가한 셀에 - 적색이 없으면** 멈춘다. - """ - if len(vertices) < 2: - return None - route_line = LineString([(vertex.x, vertex.y) for vertex in vertices]) - region = resolve_primary_region(vertices, route_line, contour_features, stream_features) - if region is None: - return None - - started = time.perf_counter() - floor = route_elevation_floor([vertex.z for vertex in vertices]) - expansion = expand_by_red_boundary( - region.spec, - region.cell_mask, - contour_features, - route_line, - region.split.upstream, - floor, - ) - if expansion is None: - logger.warning("배수유역: 등고선 하강 방향을 세우지 못해 흐름 판정을 건너뜁니다.") - return StagePreview(region=region) - - analysis = expansion.analysis - spec = analysis.spec - red = analysis.flow.reaches_road & analysis.flow.analyzed - - # ⑥ 흐름 강도 — 셀마다 물이 실제로 들어가는 도로 셀을 구해 도로 셀별로 센다. - # 색 판정은 세류 셀에서 멈추지만(거기서 도달이 확정되므로), 강도는 그 물이 세류를 타고 - # 최종적으로 어느 도로 셀로 들어가는지를 봐야 하므로 도로만 흡수점으로 두고 다시 따라간다. - strength_curve = _preview_strength(analysis, red, route_line.length) - - # ⑦ 2차 전체 배수유역 외곽선 = 적색 셀 전체의 외곽. - boundary = outer_boundary(spec, red.reshape(spec.n_rows, spec.n_cols)) - basin_ring = largest_ring(boundary) if boundary is not None else [] - - # ⑧ 기본 관 매설 위치 = 도로 × 세류선 교차점(너무 가까운 것은 하나로 합침). - pipes = _base_pipes(vertices, stream_features) - - logger.info( - "배수유역: 단계 분석 %.1fs (확장 %d회, 최종 셀 %d개) — " - "2차 유역 %.0f㎡, 기본 관 %d개, 강도 곡선 %d점", - time.perf_counter() - started, - expansion.rounds, - spec.size, - int(red.sum()) * spec.cell_area_m2, - len(pipes), - int((strength_curve > 0).sum()), - ) - return StagePreview( - region=region, - spec=spec, - domain=analysis.domain, - terrain=analysis.terrain, - road=analysis.road, - flow=analysis.flow, - descent=analysis.descent, - expand_rounds=expansion.rounds, - expand_closed=expansion.closed, - expand_added_cells=expansion.added_cells, - strength_profile=_downsample_strength(strength_curve), - basin_boundary_xy=basin_ring, - basin_area_m2=int(red.sum()) * spec.cell_area_m2, - pipes=pipes, - ) - - -def _preview_strength(analysis: Any, red: np.ndarray, route_length_m: float) -> np.ndarray: - """적색 셀이 실제로 들어가는 도로 셀을 세어 누가거리별 유입 면적 곡선을 만든다.""" - road = analysis.road - if road.count == 0: - return np.zeros(1) - routed = trace_flow(analysis.terrain, road) - slots = routed.road_slot - counted = red & (slots >= 0) - strength = np.bincount(slots[counted], minlength=road.count).astype(np.float64) - return _strength_by_chainage( - road.chainage, strength * analysis.spec.cell_area_m2, route_length_m - ) - - -def _base_pipes( - vertices: list[RouteVertex], stream_features: list[dict[str, Any]] -) -> list[StructureCandidate]: - """도로 × 세류선 교차점을 기본 관 위치로 삼는다. 300m 보충 배치는 다음 단계다.""" - 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: - continue - pipes.append(candidate) - return pipes - - -# ── ③~④ 격자 해석 (캐시 대상) ─────────────────────────────────────────────── - - -def _solve_grid( - vertices: list[RouteVertex], - route_line: LineString, - contour_features: list[dict[str, Any]], - stream_features: list[dict[str, Any]], - cache_path: Path | None, -) -> _GridSolution | None: - signature = _signature(vertices, len(contour_features), len(stream_features)) - cached = _load_cache(cache_path, signature) - if cached is not None: - logger.info("배수유역: 격자 캐시 재사용 (%s)", cache_path) - return cached - - region = resolve_primary_region(vertices, route_line, contour_features, stream_features) - if region is None: - return None - spec = region.spec - # TIN용 등고선은 격자가 확장될 여지까지 한 번에 읽어 두고, 회차마다 범위 안쪽만 골라 쓴다. - reach = DRAINAGE_EXPAND_STEP_M * DRAINAGE_MAX_EXPAND_ROUNDS - x_min, y_min, x_max, y_max = region.area.bounds - cloud = build_contour_cloud( - contour_features, - route_elevation_floor([vertex.z for vertex in vertices]), - (x_min - reach, y_min - reach, x_max + reach, y_max + reach), - ) - if cloud.is_empty: - logger.warning("배수유역: 1차 영역 안에 등고선이 없어 격자 해석을 건너뜁니다.") - return None - - # 확장 루프는 `Watershed_Flow.expand_until_closed()`로 분리했다(2026-07-31 사용자 지시). - # 단계 검증 미리보기(`preview_stages`)는 이 경로를 타지 않는다 — 확장 자체가 아직 검증 대상. - started = time.perf_counter() - expansion = expand_until_closed(spec, cloud, route_line) - spec, terrain, road, flow = expansion.spec, expansion.terrain, expansion.road, expansion.flow - logger.info( - "배수유역: 격자 해석 %.1fs (확장 %d회, %s, 셀 %d개)", - time.perf_counter() - started, - expansion.rounds, - "닫힘" if expansion.closed else "미닫힘", - spec.size, - ) - solution = _GridSolution( - spec=spec, - elevation=terrain.elevation.reshape(-1), - road_cell_index=road.cell_index, - road_chainage=road.chainage, - road_slot=flow.road_slot, - path_length=flow.path_length, - strength=flow.strength, - active=flow.active.reshape(-1), - signature=signature, - ) - _save_cache(cache_path, solution) - return solution - - -def _main_boundary(solution: _GridSolution) -> list[tuple[float, float]]: - boundary = outer_boundary( - solution.spec, solution.active.reshape(solution.spec.n_rows, solution.spec.n_cols) - ) - return largest_ring(boundary) if boundary is not None else [] - - -# ── 흐름 강도 곡선 ────────────────────────────────────────────────────────── - - -def _strength_by_chainage( - road_chainage: np.ndarray, strength_area: np.ndarray, total_length: float -) -> np.ndarray: - """도로 셀 강도를 1m 누가거리 구간으로 합산한 곡선(㎡/m 구간 합).""" - bins = max(1, int(np.ceil(total_length)) + 1) - if road_chainage.size == 0: - return np.zeros(bins) - index = np.clip(np.round(road_chainage).astype(np.int64), 0, bins - 1) - return np.bincount(index, weights=strength_area, minlength=bins) - - -def _downsample_strength(curve: np.ndarray) -> list[tuple[float, float]]: - """응답용으로 강도 곡선을 일정 간격으로 줄인다(구간 합 유지). - - 끝자락을 잘라내면 종점 부근 유입 면적이 통째로 사라지므로 0으로 채워 맞춘다. - """ - step = max(1, int(_STRENGTH_OUTPUT_STEP_M)) - if curve.size == 0: - return [] - padding = (-curve.size) % step - padded = np.append(curve, np.zeros(padding)) if padding else curve - summed = padded.reshape(-1, step).sum(axis=1) - return [ - (float(position * step), float(value)) for position, value in enumerate(summed) if value > 0 - ] - - -# ── ⑤ 관 배치 ─────────────────────────────────────────────────────────────── - - -def _place_pipes( - vertices: list[RouteVertex], - stream_features: list[dict[str, Any]], - strength_curve: np.ndarray, -) -> list[StructureCandidate]: - """세류 교차점을 기본 관 위치로 두고, 최대 간격을 넘는 구간만 최소 개수로 보충한다. - - 교차점은 종단 절·성토를 가리지 않고 모두 관으로 둔다. 하류측 세류선은 이미 격자 - 해석 전에 제거되었으므로, 남은 교차점은 전부 상류에서 물이 실제로 들어오는 지점이다. - """ - total_length = vertices[-1].chainage_m - base: list[StructureCandidate] = [] - for candidate in find_stream_crossings(vertices, stream_features): - if base and candidate.chainage_m - base[-1].chainage_m < DRAINAGE_PIPE_MIN_SPACING_M: - continue - base.append(candidate) - - filled: list[StructureCandidate] = [] - previous = 0.0 - for candidate in [*base, None]: - boundary = candidate.chainage_m if candidate else total_length - filled.extend(_fill_gap(vertices, strength_curve, previous, boundary)) - if candidate: - filled.append(candidate) - previous = candidate.chainage_m - else: - previous = boundary - filled.sort(key=lambda item: item.chainage_m) - return filled - - -def _fill_gap( - vertices: list[RouteVertex], - strength_curve: np.ndarray, - start_m: float, - end_m: float, -) -> list[StructureCandidate]: - """[start, end] 구간에 최대 간격을 지키는 **최소 개수**의 관을 배치한다. - - 필요 개수 n은 구간 길이로 정해지고(ceil(L/max) − 1), 각 관은 등분 위치를 중심으로 - 허용 여유(slack) 안에서만 움직인다. 그래서 개수는 늘지 않으면서도 흐름 강도가 크고 - 종단이 낮은 지점으로 붙는다. - """ - span = end_m - start_m - if span <= DRAINAGE_PIPE_MAX_SPACING_M: - return [] - count = int(np.ceil(span / DRAINAGE_PIPE_MAX_SPACING_M)) - 1 - if count <= 0: - return [] - spacing = span / (count + 1) - slack = max(0.0, (DRAINAGE_PIPE_MAX_SPACING_M - spacing) / 2.0) - placed: list[StructureCandidate] = [] - for order in range(1, count + 1): - nominal = start_m + spacing * order - low = max(start_m + DRAINAGE_PIPE_MIN_SPACING_M, nominal - slack) - high = min(end_m - DRAINAGE_PIPE_MIN_SPACING_M, nominal + slack) - chosen = _best_position(vertices, strength_curve, low, high, nominal) - x, y, _ = _interpolate_vertex(vertices, chosen) - placed.append(StructureCandidate(chainage_m=chosen, x=x, y=y, reason="spacing")) - return placed - - -def _best_position( - vertices: list[RouteVertex], - strength_curve: np.ndarray, - low_m: float, - high_m: float, - fallback_m: float, -) -> float: - """허용 구간 안에서 흐름 강도가 크고 종단이 낮은 위치를 고른다.""" - if high_m <= low_m: - return fallback_m - positions = np.arange(low_m, high_m + 1.0, 1.0) - if positions.size == 0: - return fallback_m - index = np.clip(np.round(positions).astype(np.int64), 0, strength_curve.size - 1) - strength = strength_curve[index] - heights = np.array([_interpolate_vertex(vertices, float(p))[2] for p in positions]) - - strength_score = strength / strength.max() if strength.max() > 0 else np.zeros_like(strength) - height_span = float(heights.max() - heights.min()) - sag_score = ( - (heights.max() - heights) / height_span if height_span > 1e-6 else np.zeros_like(heights) - ) - score = _SCORE_WEIGHT_STRENGTH * strength_score + _SCORE_WEIGHT_SAG * sag_score - for order, position in enumerate(positions): - if not is_uphill_at(vertices, float(position)): - score[order] *= _SCORE_FILL_PENALTY - return float(positions[int(np.argmax(score))]) - - -def _pipes_from_chainages( - vertices: list[RouteVertex], chainages: list[float] -) -> list[StructureCandidate]: - """사용자가 확정·편집한 누가거리 목록을 관 후보로 되돌린다. - - 노선 밖 값은 시·종점으로 당긴다. 그대로 두면 마커는 끝점에 찍히는데 라벨만 −50m처럼 - 나와 좌표와 표기가 어긋난다. - """ - total_length = vertices[-1].chainage_m - clamped = {min(max(round(float(item), 2), 0.0), total_length) for item in chainages} - pipes: list[StructureCandidate] = [] - for value in sorted(clamped): - x, y, _ = _interpolate_vertex(vertices, value) - pipes.append(StructureCandidate(chainage_m=value, x=x, y=y, reason="confirmed")) - return pipes - - -# ── ⑥ 측구 흐름으로 도로 셀 → 담당 관 ─────────────────────────────────────── - - -def _assign_road_cells_to_pipes( - vertices: list[RouteVertex], - pipes: list[StructureCandidate], - road_chainage: np.ndarray, -) -> np.ndarray: - """도로 셀마다 물이 실제로 흘러가는 담당 관 번호를 정한다. - - 노면 물은 측구를 타고 종단 내리막으로 흐르므로, 종단 계획선을 1차원 지형으로 보고 - 같은 방식(내리막 추적 + 관에서 흡수)으로 푼다. 관이 없는 사그(저점)에 갇힌 구간은 - 가장 가까운 관이 받는 것으로 본다. - """ - total_length = vertices[-1].chainage_m - step = max(DRAINAGE_DITCH_SAMPLE_M, 0.5) - stations = np.arange(0.0, total_length + step, step) - heights = np.array([_interpolate_vertex(vertices, float(s))[2] for s in stations]) - pipe_chainages = np.array([pipe.chainage_m for pipe in pipes]) - pipe_station = np.clip(np.round(pipe_chainages / step).astype(np.int64), 0, stations.size - 1) - - # 앞뒤 이웃 중 더 낮은 쪽으로 흘려보낸다(양쪽 다 높으면 사그 = 제자리). - back_z = np.full(stations.size, np.inf) - back_z[1:] = heights[:-1] - forward_z = np.full(stations.size, np.inf) - forward_z[:-1] = heights[1:] - go_back = (back_z < heights) & (back_z <= forward_z) - go_forward = (forward_z < heights) & ~go_back - receiver = np.arange(stations.size, dtype=np.int64) - receiver[go_back] -= 1 - receiver[go_forward] += 1 - receiver[pipe_station] = pipe_station # 관은 물을 흡수한다 - - owner = np.full(stations.size, -1, dtype=np.int64) - owner[pipe_station] = np.arange(pipe_chainages.size) - jump = receiver - for _ in range(40): - next_jump = jump[jump] - if np.array_equal(next_jump, jump): - break - jump = next_jump - resolved = owner[jump] - # 관 없는 사그에 갇힌 구간은 가장 가까운 관에 붙인다. - orphan = resolved < 0 - if orphan.any() and pipe_chainages.size: - nearest = np.abs(stations[orphan, None] - pipe_chainages[None, :]).argmin(axis=1) - resolved[orphan] = nearest - - slot_station = np.clip(np.round(road_chainage / step).astype(np.int64), 0, stations.size - 1) - return resolved[slot_station].astype(np.int32) - - -# ── 세부유역 조립 ─────────────────────────────────────────────────────────── - - -def _assemble_basins( - solution: _GridSolution, - pipes: list[StructureCandidate], - pipe_of_slot: np.ndarray, -) -> list[WatershedBasin]: - """셀이 도달한 도로 셀의 담당 관을 그대로 유역 번호로 삼아 세부유역을 만든다.""" - spec = solution.spec - labels = np.full(spec.size, -1, dtype=np.int32) - reached = solution.road_slot >= 0 - labels[reached] = pipe_of_slot[solution.road_slot[reached]] - - polygons = polygonize_labels(spec, labels) - cell_area = spec.cell_area_m2 - basins: list[WatershedBasin] = [] - for order, pipe in enumerate(pipes): - member = labels == order - count = int(member.sum()) - if count == 0: - continue - geometry = polygons.get(order) - elevations = solution.elevation[member] - highest = float(np.nanmax(elevations)) if np.isfinite(elevations).any() else 0.0 - outlet_z = _outlet_elevation(solution, order, pipe_of_slot) - area = count * cell_area - relief = max(0.0, highest - outlet_z) - flow_length = float(solution.path_length[member].max()) - basins.append( - WatershedBasin( - index=len(basins) + 1, - chainage_m=pipe.chainage_m, - outlet_x=pipe.x, - outlet_y=pipe.y, - boundary_xy=largest_ring(geometry) if geometry is not None else [], - area_m2=area, - relief_m=relief, - flow_length_m=flow_length, - pipe_diameter_mm=estimate_pipe_diameter_mm(area, relief, flow_length), - ) - ) - return basins - - -def _outlet_elevation(solution: _GridSolution, pipe_order: int, pipe_of_slot: np.ndarray) -> float: - """관이 담당하는 도로 셀들의 최저 표고 = 유역 출구 표고.""" - slots = np.flatnonzero(pipe_of_slot == pipe_order) - if slots.size == 0: - return 0.0 - elevations = solution.elevation[solution.road_cell_index[slots]] - finite = elevations[np.isfinite(elevations)] - return float(finite.min()) if finite.size else 0.0 - - -# ── 캐시 ──────────────────────────────────────────────────────────────────── - - -def _signature(vertices: list[RouteVertex], contour_count: int, stream_count: int) -> str: - """노선 기하와 해석 파라미터가 바뀌면 캐시를 버리도록 하는 지문.""" - digest = hashlib.sha1() - for vertex in vertices: - digest.update(f"{vertex.x:.2f},{vertex.y:.2f},{vertex.z:.2f};".encode()) - digest.update( - f"|{contour_count}|{stream_count}|{DRAINAGE_GRID_SIZE_M}|{DRAINAGE_INITIAL_RADIUS_M}" - f"|{DRAINAGE_EXPAND_STEP_M}|{DRAINAGE_ROAD_WIDTH_M}".encode() - ) - return digest.hexdigest() - - -def _load_cache(cache_path: Path | None, signature: str) -> _GridSolution | None: - if cache_path is None or not cache_path.exists(): - return None - try: - with np.load(cache_path, allow_pickle=False) as data: - if str(data["signature"]) != signature: - return None - spec = GridSpec( - x_min=float(data["x_min"]), - y_max=float(data["y_max"]), - cell_m=float(data["cell_m"]), - n_rows=int(data["n_rows"]), - n_cols=int(data["n_cols"]), - ) - return _GridSolution( - spec=spec, - elevation=data["elevation"], - road_cell_index=data["road_cell_index"], - road_chainage=data["road_chainage"], - road_slot=data["road_slot"], - path_length=data["path_length"], - strength=data["strength"], - active=data["active"], - signature=signature, - ) - except (OSError, KeyError, ValueError): - logger.warning("배수유역: 격자 캐시를 읽지 못해 다시 계산합니다 (%s).", cache_path) - return None - - -def _save_cache(cache_path: Path | None, solution: _GridSolution) -> None: - if cache_path is None: - return - try: - cache_path.parent.mkdir(parents=True, exist_ok=True) - np.savez_compressed( - cache_path, - signature=solution.signature, - x_min=solution.spec.x_min, - y_max=solution.spec.y_max, - cell_m=solution.spec.cell_m, - n_rows=solution.spec.n_rows, - n_cols=solution.spec.n_cols, - elevation=solution.elevation, - road_cell_index=solution.road_cell_index, - road_chainage=solution.road_chainage, - road_slot=solution.road_slot, - path_length=solution.path_length, - strength=solution.strength, - active=solution.active, - ) - except OSError: - logger.warning("배수유역: 격자 캐시를 저장하지 못했습니다 (%s).", cache_path) diff --git a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py index 6c5334d4..7861567a 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py +++ b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py @@ -1,124 +1,36 @@ -"""배수유역도 API 라우터. +"""배수유역 세부 설계 API 라우터 (B05 — 일반 사용자용). + +**분석하지 않는다.** B04가 미리 돌려 저장한 결과를 읽어 관을 보충하고 세부유역만 나눈다. +격자 해석은 30초가 걸려 일반 사용자를 붙잡아 두므로 여기서는 아예 돌리지 않는다 +(2026-07-31 사용자 지시). -구조물 측점(관 매설) 후보 제안과 배수유역 산정을 제공한다. 지형 근거는 **도엽 등고선과 -세류선 GeoJSON**뿐이며(표고점은 유효 데이터가 적어 2026-07-31 사용자 지시로 제외), 좌표는 사업지 CRS(m)에서 계산하고 응답만 WGS84로 바꿔 내보낸다. """ import asyncio -import base64 -import json import logging -from pathlib import Path from typing import Any from uuid import UUID -import numpy as np from fastapi import APIRouter from fastapi.responses import JSONResponse from pyproj import Transformer -from shapely.geometry import LineString, Point, Polygon, box from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path -from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import ( - StructureCandidate, - build_route_vertices, - propose_structure_stations, -) -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Basin import ( - build_drainage_watershed, - preview_stages, -) -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Export import write_grid_arrays, write_stage -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Grid import ( - AZIMUTH_INVALID, - AZIMUTH_SINK, - AZIMUTH_STEPS, - mask_row_spans, -) +from B05_wf2_Route.B05_wf2_Route_Engine_Drainage_Basin import build_drainage_detail from B05_wf2_Route.B05_wf2_Route_Repository import ( get_latest_route, get_route_points, get_surface_crs_epsg, ) -from common_util.common_util_storage import resolve_stored_project_path +from common_util.common_util_route_geometry import StructureCandidate, build_route_vertices from config.config_db import get_db_pool -from config.config_system import DRAINAGE_CACHE_DIRNAME, DRAINAGE_CACHE_FILENAME logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B05 Route Drainage"]) -# 도엽 레이어 파일명 (B04 전처리 산출물과 동일 위치) -_CONTOUR_FILE = "도엽_등고선.geojson" -_STREAM_FILE = "도엽_하천중심선.geojson" - -def _sheet_dir(stored_path: str) -> Path: - return Path(resolve_stored_project_path(stored_path)) / "B04_wf1_Surface" / "processed" - - -def _cache_path(stored_path: str) -> Path: - """격자 해석 캐시(.npz) 경로. 관을 옮겨도 격자를 다시 풀지 않게 여기에 남긴다.""" - root = Path(resolve_stored_project_path(stored_path)) / "B05_wf2_Route" - return root / DRAINAGE_CACHE_DIRNAME / DRAINAGE_CACHE_FILENAME - - -def _load_features(directory: Path, filename: str) -> list[dict[str, Any]]: - """도엽 GeoJSON을 읽어 피처 목록만 돌려준다. 없으면 빈 목록.""" - path = directory / filename - if not path.exists(): - return [] - try: - with path.open("r", encoding="utf-8") as file: - data = json.load(file) - except (OSError, json.JSONDecodeError): - logger.warning("도엽 GeoJSON을 읽지 못했습니다: %s", path) - return [] - features = data.get("features") - return features if isinstance(features, list) else [] - - -def _reproject_features( - features: list[dict[str, Any]], - transformer: Transformer | None, -) -> list[dict[str, Any]]: - """WGS84 도엽 좌표를 사업지 CRS(m)로 바꾼다. 거리·면적을 미터로 계산하기 위함.""" - if transformer is None: - return features - converted: list[dict[str, Any]] = [] - for feature in features: - geometry = feature.get("geometry") - if not geometry: - continue - coordinates = _map_coordinates(geometry.get("coordinates"), transformer) - if coordinates is None: - continue - converted.append( - { - "type": "Feature", - "properties": feature.get("properties") or {}, - "geometry": {"type": geometry.get("type"), "coordinates": coordinates}, - } - ) - return converted - - -def _map_coordinates(coordinates: Any, transformer: Transformer) -> Any: - """중첩 좌표 배열을 재귀적으로 변환한다.""" - if not isinstance(coordinates, list) or not coordinates: - return None - first = coordinates[0] - if isinstance(first, (int, float)): - x, y = transformer.transform(float(coordinates[0]), float(coordinates[1])) - return [x, y] - mapped = [_map_coordinates(item, transformer) for item in coordinates] - return [item for item in mapped if item is not None] - - -def _candidate_payload( - candidate: StructureCandidate, - to_lonlat: Any, -) -> dict[str, Any]: +def _candidate_payload(candidate: StructureCandidate, to_lonlat: Any) -> dict[str, Any]: lon, lat = to_lonlat(candidate.x, candidate.y) return { "chainage_m": round(candidate.chainage_m, 2), @@ -132,7 +44,7 @@ def _candidate_payload( async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse: - """노선 정점·도엽 피처·좌표 변환기를 한 번에 준비한다.""" + """확정 노선과 좌표 변환기를 준비한다. 도엽 피처는 읽지 않는다(분석을 안 하므로).""" pool = get_db_pool() async with pool.acquire() as connection: stored_path = await get_project_storage_relative_path(connection, project_id) @@ -154,346 +66,67 @@ async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse: status_code=400, content={"status": "error", "message": "노선 좌표가 부족합니다."}, ) - - source_crs = f"EPSG:{epsg}" if epsg else "EPSG:5186" - to_lonlat_transformer = Transformer.from_crs(source_crs, "EPSG:4326", always_xy=True) - to_metric_transformer = Transformer.from_crs("EPSG:4326", source_crs, always_xy=True) - - directory = _sheet_dir(stored_path) - streams = _reproject_features(_load_features(directory, _STREAM_FILE), to_metric_transformer) - contour_features = _reproject_features( - _load_features(directory, _CONTOUR_FILE), to_metric_transformer - ) + transformer = Transformer.from_crs(f"EPSG:{epsg or 5186}", "EPSG:4326", always_xy=True) return { "route_id": int(route["id"]), "vertices": vertices, - "route_line": LineString([(vertex.x, vertex.y) for vertex in vertices]), - "streams": streams, - "contours": contour_features, "stored_path": stored_path, - "cache_path": _cache_path(stored_path), - "to_lonlat": lambda x, y: to_lonlat_transformer.transform(x, y), + "to_lonlat": lambda x, y: transformer.transform(x, y), } -@router.get("/{project_id}/drainage/candidates", response_model=None) -async def get_structure_candidates(project_id: UUID) -> dict[str, Any] | JSONResponse: - """관 매설 구조물 측점 후보를 제안한다(세류 교차 + 300m 보충, 성토부 제외).""" - prepared = await _prepare(project_id) - if isinstance(prepared, JSONResponse): - return prepared - candidates = propose_structure_stations(prepared["vertices"], prepared["streams"]) - to_lonlat = prepared["to_lonlat"] - return { - "status": "success", - "project_id": str(project_id), - "route_id": prepared["route_id"], - "candidates": [_candidate_payload(candidate, to_lonlat) for candidate in candidates], - } - - -@router.get("/{project_id}/drainage/primary-region", response_model=None) -async def get_primary_region(project_id: UUID) -> dict[str, Any] | JSONResponse: - """1차 배수유역 근거를 돌려준다 — 단계 검증용, TIN·흐름 계산은 하지 않는다. - - 도로 교차점 상류로 이어진 세류망, 제외된 하류망, 그 상류망을 반경 버퍼한 1차 영역, - 그 bbox로 잡은 격자 정보를 함께 준다. 같은 내용을 영구저장소에 GeoJSON으로도 남겨 - QGIS 등으로 직접 열어 대조할 수 있게 한다. - """ - prepared = await _prepare(project_id) - if isinstance(prepared, JSONResponse): - return prepared - preview = await asyncio.to_thread( - preview_stages, - prepared["vertices"], - prepared["contours"], - prepared["streams"], - ) - if preview is None: - return JSONResponse( - status_code=400, - content={"status": "error", "message": "1차 배수유역을 정할 등고선이 없습니다."}, - ) - region = preview.region - to_lonlat = prepared["to_lonlat"] - # 확장을 거치면 격자·해석 영역이 1차 영역보다 커진다. 최종본을 써야 화면과 어긋나지 않는다. - spec = preview.spec or region.spec - domain = preview.domain if preview.domain is not None else region.cell_mask - payload = { - "status": "success", - "project_id": str(project_id), - "route_id": prepared["route_id"], - "radius_m": region.radius_m, - # 채택된 상류 세류망 = 1차 영역의 기준선. - "upstream_lines": [_line_lonlat(line, to_lonlat) for line in region.split.upstream], - # 도로 아래로 이어진 하류망 — 판정이 맞는지 눈으로 대조하기 위해 함께 준다. - "downstream_lines": [_line_lonlat(line, to_lonlat) for line in region.split.downstream], - "no_contact_count": region.split.no_contact, - # 노선이 1차 영역 밖으로 나간 길이(m). 크면 반경을 올려야 한다는 신호. - "road_outside_m": round(region.road_outside_m, 1), - # 1차 영역(버퍼 합집합) 외곽 링 목록. - "region_rings": _polygon_rings(region.area, to_lonlat), - "grid": { - "cell_m": spec.cell_m, - "rows": spec.n_rows, - "cols": spec.n_cols, - # bbox 전체 셀 수와, 해석 영역에 실제로 생성된 셀 수(확장 반영). - "bbox_cells": spec.size, - "cells": int(domain.sum()) if domain is not None else 0, - "width_m": round(spec.n_cols * spec.cell_m, 1), - "height_m": round(spec.n_rows * spec.cell_m, 1), - # 격자 bbox 링. 프론트는 이 사각형을 rows×cols로 나눠 행·열 좌표를 얻는다. - "bbox_lonlat": _grid_bbox_lonlat(spec, to_lonlat), - # 실제 생성된 셀을 행별 연속 구간 [행, 시작열, 끝열]으로 압축해 보낸다. - # 셀을 낱개로 보내면 수십만 건이라 응답이 감당되지 않는다. - "row_spans": [list(span) for span in mask_row_spans(domain)] - if domain is not None - else [], - }, - # 최외곽 적색 셀 주변 확장 결과. - "expansion": { - "rounds": preview.expand_rounds, - "closed": preview.expand_closed, - "added_cells": preview.expand_added_cells, - "initial_cells": region.active_cells, - }, - # 셀별 흐름 방향과 도로 도달 여부. row_spans 순서(행 → 구간 → 열 오름차순)로 1바이트씩. - "flow": _flow_payload(preview, domain), - # ⑦ 2차 전체 배수유역 외곽선(= 분수령)과 면적. - "basin_polygon_lonlat": [list(to_lonlat(x, y)) for x, y in preview.basin_boundary_xy], - "basin_area_m2": round(preview.basin_area_m2, 1), - # ⑥ 도로 위 흐름 강도 — [누가거리 m, 그 구간으로 모이는 상류 면적 ㎡]. - "strength_profile": [ - [round(chainage, 1), round(area, 1)] for chainage, area in preview.strength_profile - ], - # ⑧ 기본 관 매설 위치 — 도로 × 세류선 교차점. - "pipes": [_candidate_payload(pipe, to_lonlat) for pipe in preview.pipes], - } - # 단계 산출물을 영구저장소에 남긴다 — 기능을 붙일 때마다 여기에 단계가 하나씩 늘어난다. - payload["saved_to"] = write_stage( - prepared["stored_path"], - "primary_region", - { - "primary_region": _as_polygons(region.area), - "upstream": region.split.upstream, - "downstream": region.split.downstream, - "route": [prepared["route_line"]], - "grid_bbox": [_grid_bbox_polygon(spec)], - # ⑦ 2차 전체 배수유역 외곽선, ⑧ 기본 관 위치(누가거리·근거 포함). - "basin_boundary": _boundary_geometry(preview.basin_boundary_xy), - "pipe": [ - ( - Point(pipe.x, pipe.y), - { - "chainage_m": round(pipe.chainage_m, 2), - "reason": pipe.reason, - "stream_name": pipe.stream_name, - }, - ) - for pipe in preview.pipes - ], - }, - { - "radius_m": region.radius_m, - "road_outside_m": payload["road_outside_m"], - "no_contact_count": region.split.no_contact, - "basin_area_m2": payload["basin_area_m2"], - "pipe_count": len(preview.pipes), - # 기하(bbox·셀 구간)는 파일 본문에 있으므로 요약 수치만 남긴다. - "grid": { - key: value - for key, value in payload["grid"].items() - if key not in {"bbox_lonlat", "row_spans"} - }, - }, - to_lonlat, - ) - _write_stage_arrays(prepared["stored_path"], preview, domain, spec) - return payload - - -def _write_stage_arrays(stored_path: str, preview: Any, domain: Any, spec: Any) -> None: - """격자 규모 배열(셀 마스크·흐름 방향·도달 여부)을 단계별 `.npz`로 남긴다.""" - if domain is not None: - write_grid_arrays( - stored_path, - "primary_region", - spec, - {"mask": domain}, - { - "cells": int(domain.sum()), - "bbox_cells": spec.size, - "expand_rounds": preview.expand_rounds, - "expand_closed": preview.expand_closed, - }, - ) - flow = preview.flow - if flow is None: - return - arrays = { - "direction": flow.direction.reshape(spec.n_rows, spec.n_cols), - "reaches_road": flow.reaches_road.reshape(spec.n_rows, spec.n_cols), - "analyzed": flow.analyzed.reshape(spec.n_rows, spec.n_cols), - # 사후 진단용 — 수신 셀이 있어야 사슬을 다시 따라가 볼 수 있다. - "receiver": preview.terrain.receiver.reshape(spec.n_rows, spec.n_cols), - } - if flow.burned is not None: - arrays["burned"] = flow.burned.reshape(spec.n_rows, spec.n_cols) - if preview.descent is not None: - arrays["band_elevation"] = preview.descent.band_elevation - # ⑥ 흐름 강도 곡선 — 기하가 아니라 수치 곡선이라 GeoJSON이 아닌 여기에 함께 담는다. - if preview.strength_profile: - curve = np.asarray(preview.strength_profile, dtype=np.float64) - arrays["strength_chainage_m"] = curve[:, 0] - arrays["strength_area_m2"] = curve[:, 1] - write_grid_arrays( - stored_path, - "flow_direction", - spec, - arrays, - { - "azimuth_steps": AZIMUTH_STEPS, - "sink_code": AZIMUTH_SINK, - "invalid_code": AZIMUTH_INVALID, - "analyzed": int(flow.analyzed.sum()), - "reaches_road": int((flow.reaches_road & flow.analyzed).sum()), - "no_road": int((~flow.reaches_road & flow.analyzed).sum()), - "burned": 0 if flow.burned is None else int(flow.burned.sum()), - "outer_seeds": flow.outer_seeds, - "interior_seeds": flow.interior_seeds, - "strength_points": len(preview.strength_profile), - "strength_total_m2": round(sum(area for _, area in preview.strength_profile), 1), - }, - ) - - -def _flow_payload(preview: Any, domain: Any) -> dict[str, Any] | None: - """셀별 흐름 방향·도로 도달 여부를 바이트 배열로 압축한다. - - 셀이 수십만 개라 JSON 객체로는 못 보낸다. 셀 하나당 1바이트로 줄이고 base64로 싣는다: - 하위 6비트(0x3F) = 32방위 코드(0~31, 0=화면 오른쪽·시계방향), 32=제자리, 33=표고 없음 - 최상위 비트(0x80) = 도로 도달(적색). 꺼져 있으면 미도달(백색 화살표). - 바이트 순서는 `grid.row_spans`를 행 → 구간 → 열 오름차순으로 훑은 순서와 같다. - """ - flow = preview.flow - if flow is None or domain is None: - return None - order = np.flatnonzero(domain.reshape(-1)) - analyzed = flow.analyzed[order] - reaches = flow.reaches_road[order] - packed = np.clip(flow.direction[order], 0, AZIMUTH_INVALID).astype(np.uint8) - packed |= np.where(reaches, 0x80, 0).astype(np.uint8) - burned = flow.burned - return { - "encoding": "base64-uint8", - "azimuth_steps": AZIMUTH_STEPS, - "sink_code": AZIMUTH_SINK, - "invalid_code": AZIMUTH_INVALID, - "cells": int(order.size), - "reaches_road": int((reaches & analyzed).sum()), - "no_road": int((~reaches & analyzed).sum()), - # 격자에는 있으나 등고선 TIN 밖이라 표고가 없어 판정 못한 셀. - "unanalyzed": int((~analyzed).sum()), - # 확정된 상류 세류망을 따라 흐름을 강제로 새긴 셀 수. - "burned": 0 if burned is None else int(burned[order].sum()), - "outer_seeds": flow.outer_seeds, - "interior_seeds": flow.interior_seeds, - "data": base64.b64encode(packed.tobytes()).decode("ascii"), - } - - -def _boundary_geometry(ring: list[tuple[float, float]]) -> list[Any]: - """2차 유역 외곽 링을 저장용 폴리곤으로 만든다(정점 3개 미만이면 비운다).""" - return [Polygon(ring)] if len(ring) >= 4 else [] - - -def _as_polygons(geometry: Any) -> list[Any]: - if geometry is None or geometry.is_empty: - return [] - return list(geometry.geoms) if geometry.geom_type == "MultiPolygon" else [geometry] - - -def _grid_bbox_polygon(spec: Any) -> Polygon: - x_max = spec.x_min + spec.n_cols * spec.cell_m - y_min = spec.y_max - spec.n_rows * spec.cell_m - return box(spec.x_min, y_min, x_max, spec.y_max) - - -def _line_lonlat(line: Any, to_lonlat: Any) -> list[list[float]]: - return [list(to_lonlat(x, y)) for x, y in line.coords] - - -def _polygon_rings(geometry: Any, to_lonlat: Any) -> list[list[list[float]]]: - """폴리곤/멀티폴리곤의 외곽 링만 뽑아 lonlat으로 바꾼다.""" - if geometry is None or geometry.is_empty: - return [] - parts = geometry.geoms if geometry.geom_type == "MultiPolygon" else [geometry] - return [[list(to_lonlat(x, y)) for x, y in part.exterior.coords] for part in parts] - - -def _grid_bbox_lonlat(spec: Any, to_lonlat: Any) -> list[list[float]]: - x_min = spec.x_min - x_max = spec.x_min + spec.n_cols * spec.cell_m - y_max = spec.y_max - y_min = spec.y_max - spec.n_rows * spec.cell_m - corners = ((x_min, y_min), (x_min, y_max), (x_max, y_max), (x_max, y_min), (x_min, y_min)) - return [list(to_lonlat(x, y)) for x, y in corners] - - @router.post("/{project_id}/drainage/basins", response_model=None) async def post_drainage_basins( project_id: UUID, payload: dict[str, Any] | None = None, ) -> dict[str, Any] | JSONResponse: - """격자 흐름 해석으로 배수유역과 관 배치를 산정한다. + """B04 분석 결과로 관을 보충하고 세부유역을 나눈다. - payload에 `chainages`(누가거리 목록)를 주면 그 위치로 관을 확정하고, 없으면 세류 - 교차 + 최소 보충으로 자동 배치한다. 격자 해석은 `.npz` 캐시를 재사용하므로 관만 - 옮기는 재요청은 세부유역 분할만 다시 돈다. + payload에 `chainages`(누가거리 목록)를 주면 그 위치로 관을 확정하고(사용자 편집), + 없으면 B04의 기본 관에 최대 간격 규칙으로 최소 개수만 보충한다. """ prepared = await _prepare(project_id) if isinstance(prepared, JSONResponse): return prepared - raw_chainages = (payload or {}).get("chainages") - confirmed = _parse_chainages(raw_chainages) if isinstance(raw_chainages, list) else [] + raw = (payload or {}).get("chainages") + confirmed = _parse_chainages(raw) if isinstance(raw, list) else [] - # 격자 해석은 수백만 셀 numpy 연산이라 이벤트 루프를 막지 않도록 스레드로 뺀다. - result = await asyncio.to_thread( - build_drainage_watershed, - prepared["vertices"], - prepared["contours"], - prepared["streams"], - confirmed, - prepared["cache_path"], + detail = await asyncio.to_thread( + build_drainage_detail, prepared["stored_path"], prepared["vertices"], confirmed ) + if detail is None: + return JSONResponse( + status_code=404, + content={ + "status": "error", + "message": "배수유역 분석 결과가 없습니다. B04에서 먼저 분석을 실행하세요.", + }, + ) + to_lonlat = prepared["to_lonlat"] return { "status": "success", "project_id": str(project_id), "route_id": prepared["route_id"], - # 계획선 위 배관(관 매설) 지점 — 유역이 없는 관도 마커로 표시해야 하므로 별도 목록. - "pipes": [_candidate_payload(candidate, to_lonlat) for candidate in result.pipes], - # 2차 전체 배수유역 외곽선 = 분수령. 세부유역은 전부 이 안쪽에 들어간다. - "main_polygon_lonlat": [list(to_lonlat(x, y)) for x, y in result.main_boundary_xy], - # 도로 위 흐름 강도 — [누가거리 m, 그 지점으로 모이는 상류 면적 ㎡]. - "strength_profile": [ - [round(chainage, 1), round(area, 1)] for chainage, area in result.strength_profile - ], - "grid_cell_m": result.grid_cell_m, + # B04가 남긴 그대로 — 계획도로선과 2차 전체 배수유역 외곽선. + "route_lonlat": detail.route_lonlat, + "main_polygon_lonlat": detail.basin_lonlat, + "grid_cell_m": detail.grid_cell_m, + # 계획선 위 배관 지점 — 유역이 없는 관도 마커로 표시해야 하므로 별도 목록. + "pipes": [_candidate_payload(pipe, to_lonlat) for pipe in detail.pipes], "basins": [ { "index": basin.index, "chainage_m": round(basin.chainage_m, 2), - # 관(배관) 매설 지점 좌표 — 계획선 위 마커 렌더용. "outlet_lonlat": list(to_lonlat(basin.outlet_x, basin.outlet_y)), "polygon_lonlat": [list(to_lonlat(x, y)) for x, y in basin.boundary_xy], "area_m2": round(basin.area_m2, 1), "relief_m": round(basin.relief_m, 2), "flow_length_m": round(basin.flow_length_m, 1), - # 관경 수식 미확정 — 산정 함수가 None을 돌려주면 프론트가 "미정"으로 표기한다. + # 관경 수식 미확정 — None이면 프론트가 "미정"으로 표기한다. "pipe_diameter_mm": basin.pipe_diameter_mm, } - for basin in result.basins + for basin in detail.basins ], } diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts index a26e9c40..1527252a 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -21,9 +21,7 @@ import { } from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender"; import { fetchDrainageBasins, - fetchDrainagePrimaryRegion, type DrainageBasin, - type DrainagePrimaryRegion, type RoutePoint, } from "./B05_wf2_Route_Api_Fetch"; import { createPipeEditor } from "./B05_wf2_Route_UI_Drainage_Pipes"; @@ -51,18 +49,6 @@ const LAYER_LABELS: Record = { const ROUTE_COLOR = "#f97316"; const COLLAPSED_KEY = "b05-route-drainage-collapsed"; -// 해석 격자 셀 선 — 등고선·세류 위에 얹으므로 흰색으로 둔다(2026-07-31 사용자 지시). -const GRID_LINE_COLOR = "rgba(255, 255, 255, 0.55)"; -// 흐름 판정 색 — 도로로 물이 오는 셀은 적색, 오지 않는 셀은 파랑 채움 + 백색 화살표. -const FLOW_TO_ROAD_FILL = "rgba(220, 38, 38, 0.28)"; -const FLOW_TO_ROAD_LINE = "rgba(153, 27, 27, 0.95)"; -const FLOW_AWAY_FILL = "rgba(37, 99, 235, 0.22)"; -const FLOW_AWAY_LINE = "rgba(255, 255, 255, 0.95)"; -/** 등고선 TIN 밖이라 표고가 없어 판정하지 못한 셀 — 미도달(파랑)과 구분한다. */ -const FLOW_UNKNOWN_FILL = "rgba(120, 113, 108, 0.18)"; -/** 셀이 이보다 작으면 화살표가 뭉개져 읽히지 않으므로 채움색만 남긴다(px). */ -const ARROW_MIN_PX = 7; - /** 유역 오버레이 파스텔 색상. 번호 순으로 돌려쓴다(사용자 지시: 파스텔톤). */ const BASIN_COLORS = [ "rgba(167, 216, 199, 0.45)", @@ -97,13 +83,15 @@ export function createDrainagePanel(): DrainagePanel { layerButtons.className = "b05-drainage__layers"; header.append(title, layerButtons); - // 배수유역 계산 실행 — 등고선 격자 흐름 해석으로 유역·관 위치를 한 번에 산정한다. - // (격자 해석 결과는 백엔드가 캐시하므로 관만 바꾼 재계산은 즉시 끝난다.) + // 세부유역 산정 — B04가 미리 분석해 둔 결과를 읽어 관을 보충하고 세부유역만 나눈다. + // 격자 해석은 하지 않으므로 즉시 끝난다. const analyzeButton = document.createElement("button"); analyzeButton.type = "button"; analyzeButton.className = "b05-drainage__analyze"; - analyzeButton.textContent = "배수유역 계산"; - analyzeButton.title = "등고선·세류선으로 배수유역과 관 매설 위치를 다시 계산합니다."; + analyzeButton.textContent = "세부유역 산정"; + analyzeButton.title = + "B04에서 분석해 둔 배수유역을 불러와 관을 보충하고 세부유역을 나눕니다. " + + "분석 결과가 없으면 B04에서 먼저 실행해야 합니다."; // 배관 편집 토글 — 켜면 계획선 클릭으로 배관 추가, 마커 드래그로 이동. const editButton = document.createElement("button"); editButton.type = "button"; @@ -121,16 +109,7 @@ export function createDrainagePanel(): DrainagePanel { autoButton.type = "button"; autoButton.className = "b05-drainage__analyze b05-drainage__tool"; autoButton.textContent = "자동 제안"; - // 1차 영역 확인 — TIN·흐름 계산 없이 세류 상·하류 판정과 격자 범위만 그려 눈으로 검증한다. - const regionButton = document.createElement("button"); - regionButton.type = "button"; - regionButton.className = "b05-drainage__analyze b05-drainage__tool"; - regionButton.textContent = "1차 영역"; - regionButton.title = - "도로와 만나는 세류선의 상류측(파랑 굵은 선)·하류측(회색 파선)과 " + - "그 반경 버퍼로 잡은 1차 배수유역, 해석 격자 범위를 표시합니다."; - regionButton.setAttribute("aria-pressed", "false"); - header.append(analyzeButton, editButton, deleteButton, autoButton, regionButton); + header.append(analyzeButton, editButton, deleteButton, autoButton); const viewport = document.createElement("div"); viewport.className = "b05-drainage__viewport"; @@ -168,11 +147,6 @@ export function createDrainagePanel(): DrainagePanel { // 2차 전체 배수유역 외곽선 = 분수령. 별도 "능선" 레이어를 두지 않고 이 선 하나로 표시한다 // (전체 유역 외곽선과 능선이 같은 선이므로 — 2026-07-31 사용자 지시). let mainBoundary: Array<[number, number]> = []; - // 1차 영역 검증 오버레이. null이면 표시하지 않는다. - let primaryRegion: DrainagePrimaryRegion | null = null; - let showRegion = false; - // 흐름 방향 바이트 디코드 캐시 — 매 프레임 base64를 다시 풀지 않는다. - let flowCache: { source: string; bytes: Uint8Array } | null = null; let scale = 1; let offsetX = 0; let offsetY = 0; @@ -241,8 +215,6 @@ export function createDrainagePanel(): DrainagePanel { }); // 전체 유역 외곽선 = 분수령(능선). 세부유역 경계와 구분되게 파선 한 겹만 얹는다. if (mainBoundary.length > 2) drawRidgeRing(context, mainBoundary, normalizer, view); - // 1차 영역 검증 오버레이는 채움 위·등고선 아래에 깐다. - if (showRegion && primaryRegion) drawPrimaryRegion(context, normalizer, view); } // 등고선을 얇게 깔고 세류를 그 위에, 노선을 맨 위에 둔다. DRAINAGE_LAYERS.forEach((layer) => { @@ -258,219 +230,12 @@ export function createDrainagePanel(): DrainagePanel { context.strokeStyle = ROUTE_COLOR; drawPreparedLayer(context, routeLayer, view, "dot"); } - // 계획선 위 흐름 강도 띠 → 그 위에 배관 마커. - pipeEditor.drawStrength(context, view); + // 배관(관 매설) 마커 — 계획선 위 최상단. pipeEditor.draw(context, view, pipeColor); updateImageTransform(); } - /** lon/lat 폴리라인을 화면 좌표로 옮겨 한 줄 그린다(1차 영역 오버레이 전용). */ - function strokeLonLat( - context: CanvasRenderingContext2D, - line: ReadonlyArray, - map: Normalizer, - view: ViewState, - ): void { - if (line.length < 2) return; - const ax = view.mapRect.width * view.scale; - const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX; - const ay = view.mapRect.height * view.scale; - const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY; - context.beginPath(); - line.forEach(([lon, lat], index) => { - const x = ((lon - map.lonMin) / map.lonRange) * ax + bx; - const y = (1 - (lat - map.latMin) / map.latRange) * ay + by; - if (index === 0) context.moveTo(x, y); - else context.lineTo(x, y); - }); - context.stroke(); - } - - /** 흐름 방향 바이트를 셀 순서대로 디코드한다(캐시 — 매 프레임 다시 풀지 않는다). */ - function flowBytes(region: DrainagePrimaryRegion): Uint8Array | null { - if (!region.flow) return null; - if (flowCache?.source === region.flow.data) return flowCache.bytes; - const binary = atob(region.flow.data); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); - flowCache = { source: region.flow.data, bytes }; - return bytes; - } - - /** 1차 영역에 걸쳐 실제로 생성된 셀만 그린다. - * - * bbox 전체를 채우지 않는다 — 백엔드가 준 행별 구간(row_spans)만 그린다. 흐름 판정이 - * 있으면 셀마다 방향 화살표를 얹고, 도로에 물이 닿는 셀은 적색·닿지 않으면 파랑으로 - * 칠한다. 셀이 화면에서 작아지면 화살표가 안 보이므로 채움색만 남긴다. */ - function drawGridCells( - context: CanvasRenderingContext2D, - map: Normalizer, - view: ViewState, - region: DrainagePrimaryRegion, - ): void { - const ring = region.grid.bbox_lonlat; - if (ring.length < 4) return; - const lons = ring.map(([lon]) => lon); - const lats = ring.map(([, lat]) => lat); - const ax = view.mapRect.width * view.scale; - const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX; - const ay = view.mapRect.height * view.scale; - const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY; - const left = ((Math.min(...lons) - map.lonMin) / map.lonRange) * ax + bx; - const right = ((Math.max(...lons) - map.lonMin) / map.lonRange) * ax + bx; - const top = (1 - (Math.max(...lats) - map.latMin) / map.latRange) * ay + by; - const bottom = (1 - (Math.min(...lats) - map.latMin) / map.latRange) * ay + by; - - const { rows, cols, row_spans: spans } = region.grid; - const cellW = (right - left) / Math.max(cols, 1); - const cellH = (bottom - top) / Math.max(rows, 1); - const cellPx = Math.min(Math.abs(cellW), Math.abs(cellH)); - const bytes = flowBytes(region); - - context.save(); - context.setLineDash([]); - context.lineCap = "round"; - let cursor = 0; // row_spans를 훑은 순서 = 흐름 바이트 순서 - spans.forEach(([row, colStart, colEnd]) => { - const count = colEnd - colStart + 1; - const base = cursor; - cursor += count; - const y = top + cellH * row; - if (y + cellH < -40 || y > view.height + 40) return; - const x = left + cellW * colStart; - const width = cellW * count; - if (x + width < -40 || x > view.width + 40) return; - - if (!bytes) { - // 흐름 판정 전 — 격자만 흰 선으로 보여 준다. - if (cellPx >= 2) { - context.strokeStyle = GRID_LINE_COLOR; - context.lineWidth = 0.5; - context.beginPath(); - for (let col = colStart; col <= colEnd; col += 1) { - context.rect(left + cellW * col, y, cellW, cellH); - } - context.stroke(); - } else { - context.fillStyle = "rgba(255, 255, 255, 0.2)"; - context.fillRect(x, y, width, cellH); - } - return; - } - const sink = region.flow?.sink_code ?? 32; - const invalid = region.flow?.invalid_code ?? 33; - const steps = region.flow?.azimuth_steps ?? 32; - for (let offset = 0; offset < count; offset += 1) { - drawFlowCell( - context, - bytes[base + offset], - left + cellW * (colStart + offset), - y, - cellW, - cellH, - cellPx, - { sink, invalid, steps }, - ); - } - }); - context.restore(); - } - - /** 셀 하나 — 도달 여부로 칠하고, 충분히 크면 32방위 흐름 화살표를 얹는다. */ - function drawFlowCell( - context: CanvasRenderingContext2D, - code: number, - x: number, - y: number, - cellW: number, - cellH: number, - cellPx: number, - codes: { sink: number; invalid: number; steps: number }, - ): void { - const azimuth = code & 0x3f; - const reaches = (code & 0x80) !== 0; - // 표고가 없어 판정 못한 셀 — 미도달(파랑 채움)과 구분해야 오독이 없다. - const unanalyzed = azimuth === codes.invalid; - context.fillStyle = unanalyzed - ? FLOW_UNKNOWN_FILL - : reaches - ? FLOW_TO_ROAD_FILL - : FLOW_AWAY_FILL; - context.fillRect(x, y, cellW, cellH); - if (cellPx >= 2) { - context.strokeStyle = GRID_LINE_COLOR; - context.lineWidth = 0.5; - context.strokeRect(x, y, cellW, cellH); - } - if (cellPx < ARROW_MIN_PX || unanalyzed) return; - const stroke = reaches ? FLOW_TO_ROAD_LINE : FLOW_AWAY_LINE; - const midX = x + cellW / 2; - const midY = y + cellH / 2; - if (azimuth === codes.sink) { - // 제자리(싱크) — 방향이 없으므로 점으로 표시한다. - context.fillStyle = stroke; - context.beginPath(); - context.arc(midX, midY, Math.max(1, cellPx * 0.12), 0, Math.PI * 2); - context.fill(); - return; - } - // 코드 0 = 화면 오른쪽(+x), 시계방향(캔버스 y는 아래가 +). - const angle = (azimuth * 2 * Math.PI) / codes.steps; - const unitX = Math.cos(angle); - const unitY = Math.sin(angle); - const reach = cellPx * 0.38; - const tipX = midX + unitX * reach; - const tipY = midY + unitY * reach; - context.strokeStyle = stroke; - context.lineWidth = Math.max(0.6, cellPx * 0.09); - context.beginPath(); - context.moveTo(midX - unitX * reach, midY - unitY * reach); - context.lineTo(tipX, tipY); - context.stroke(); - // 촉 — 진행 방향 기준 좌우로 짧게 접는다. - const head = cellPx * 0.18; - context.beginPath(); - context.moveTo(tipX, tipY); - context.lineTo(tipX - (unitX + unitY * 0.7) * head, tipY - (unitY - unitX * 0.7) * head); - context.moveTo(tipX, tipY); - context.lineTo(tipX - (unitX - unitY * 0.7) * head, tipY - (unitY + unitX * 0.7) * head); - context.stroke(); - } - - /** 1차 배수유역 근거를 겹쳐 그린다 — 단계 검증용. */ - function drawPrimaryRegion( - context: CanvasRenderingContext2D, - map: Normalizer, - view: ViewState, - ): void { - const region = primaryRegion; - if (!region) return; - context.save(); - // ① 해석 격자 — bbox 테두리 + 실제 셀 눈금. - drawGridCells(context, map, view, region); - // ② 1차 배수유역 = 상류 세류망의 반경 버퍼 합집합. - context.setLineDash([]); - context.lineWidth = 2; - context.strokeStyle = "rgba(5, 150, 105, 0.95)"; - context.fillStyle = "rgba(16, 185, 129, 0.12)"; - region.region_rings.forEach((ring) => { - strokeLonLat(context, ring, map, view); - context.fill(); - }); - // ③ 도로 아래로 이어진 하류망 — 판정이 맞는지 대조하도록 회색 파선으로 남긴다. - context.setLineDash([6, 5]); - context.lineWidth = 2; - context.strokeStyle = "rgba(120, 113, 108, 0.85)"; - region.downstream_lines.forEach((line) => strokeLonLat(context, line, map, view)); - // ④ 채택된 상류망 = 1차 영역의 기준선. 가장 굵게, 맨 위에. - context.setLineDash([]); - context.lineWidth = 4; - context.strokeStyle = "rgba(29, 78, 216, 0.95)"; - region.upstream_lines.forEach((line) => strokeLonLat(context, line, map, view)); - context.restore(); - } - - /** 현재 프레임 뷰 상태 (draw()와 동일 계산 — 포인터 히트 판정용). */ + /** 현재 프레임 뷰 상태 (draw()와 동일 계산 — 배관 마커 포인터 히트 판정용). */ function currentView(): ViewState { const rect = viewport.getBoundingClientRect(); const width = Math.max(1, Math.floor(rect.width)); @@ -552,6 +317,7 @@ export function createDrainagePanel(): DrainagePanel { const response = await fetchDrainageBasins(projectId, chainages); basins = response.basins; mainBoundary = response.main_polygon_lonlat ?? []; + // 계획도로선·2차 유역 외곽선은 B04 산출물을 그대로 받는다 — 여기서 다시 계산하지 않는다. // 실제 사용된 배관 목록으로 마커를 동기화한다(유역 없는 관 포함). pipeEditor.setPipes( (response.pipes ?? []).map((pipe) => ({ @@ -559,7 +325,6 @@ export function createDrainagePanel(): DrainagePanel { reason: pipe.reason, })), ); - pipeEditor.setStrength(response.strength_profile ?? []); selectedBasin = null; renderBasinList(); syncPipeSelection(); @@ -587,87 +352,6 @@ export function createDrainagePanel(): DrainagePanel { void analyze(true); }); - /** 1차 영역 근거를 불러와 겹쳐 그린다. - * - * 켤 때는 **항상 다시 요청한다** — config(반경·격자)를 바꾸고 서버를 재시작한 뒤 - * 눌렀는데 캐시된 예전 결과가 나오면 검증이 성립하지 않는다. 끌 때만 요청 없이 숨긴다. */ - async function toggleRegion(): Promise { - if (!projectId) return; - if (showRegion) { - showRegion = false; - regionButton.classList.remove("is-active"); - regionButton.setAttribute("aria-pressed", "false"); - status.hidden = true; - // 이 버튼이 얹은 2차 유역선·강도 띠·관 마커도 함께 걷는다. - mainBoundary = []; - pipeEditor.setPipes([]); - pipeEditor.setStrength([]); - scheduleDraw(); - return; - } - regionButton.disabled = true; - status.hidden = false; - status.textContent = "1차 배수유역을 확인하는 중…"; - try { - primaryRegion = await fetchDrainagePrimaryRegion(projectId); - // 2차 유역 외곽선·흐름 강도·기본 관 위치를 기존 렌더 경로에 그대로 태운다. - mainBoundary = primaryRegion.basin_polygon_lonlat ?? []; - pipeEditor.setPipes( - (primaryRegion.pipes ?? []).map((pipe) => ({ - chainage_m: pipe.chainage_m, - reason: pipe.reason, - })), - ); - pipeEditor.setStrength(primaryRegion.strength_profile ?? []); - showRegion = true; - regionButton.classList.add("is-active"); - regionButton.setAttribute("aria-pressed", "true"); - status.textContent = regionSummary(primaryRegion); - } catch (error) { - status.textContent = - error instanceof Error ? error.message : "1차 배수유역을 확인하지 못했습니다."; - } finally { - regionButton.disabled = false; - scheduleDraw(); - } - } - - /** 상태줄에 띄울 1차 영역 요약 — 격자 셀 수를 보고 격자 크기를 조정할 근거가 된다. */ - function regionSummary(region: DrainagePrimaryRegion): string { - const cells = region.grid.cells.toLocaleString(); - const outside = - region.road_outside_m > 0 ? ` · 노선 이탈 ${Math.round(region.road_outside_m)}m` : ""; - const unknown = - region.flow && region.flow.unanalyzed > 0 - ? ` / 표고없음 ${region.flow.unanalyzed.toLocaleString()}(회)` - : ""; - const burned = - region.flow && region.flow.burned > 0 - ? ` · 세류망 새김 ${region.flow.burned.toLocaleString()}셀` - : ""; - const flow = region.flow - ? ` · 흐름 도로도달 ${region.flow.reaches_road.toLocaleString()}(적) / ` + - `미도달 ${region.flow.no_road.toLocaleString()}(청)${unknown}, ` + - `최외곽 출발 ${region.flow.outer_seeds.toLocaleString()} + ` + - `내부 보충 ${region.flow.interior_seeds.toLocaleString()}${burned}` - : " · 흐름 판정 없음"; - const expansion = region.expansion - ? ` · 확장 ${region.expansion.rounds}회` + - `(${region.expansion.initial_cells.toLocaleString()}→${cells}셀, ` + - `${region.expansion.closed ? "닫힘" : "상한 도달"})` - : ""; - const basin = region.basin_area_m2 - ? ` · 2차 유역 ${formatArea(region.basin_area_m2)}, 기본 관 ${region.pipes.length}개` - : ""; - return ( - `1차 영역(반경 ${region.radius_m}m): 상류망 ${region.upstream_lines.length}조각 채택 / ` + - `하류망 ${region.downstream_lines.length}조각·미연결 ${region.no_contact_count}개 제외 · ` + - `격자 ${region.grid.cell_m}m(도로 시점 기준) 셀 ${cells}개${outside}${expansion}${basin}${flow}` - ); - } - - regionButton.addEventListener("click", () => void toggleRegion()); - /** 노선 전체가 보이도록 배율·중심을 맞춘다. 노선이 없으면 도엽 전체를 그대로 보여준다. */ function fitToRoute(): void { scale = 1; @@ -705,12 +389,6 @@ export function createDrainagePanel(): DrainagePanel { const sequence = ++loadSequence; meta = null; preparedLayers.clear(); - // 1차 영역은 프로젝트·노선에 종속이므로 새로 불러올 때 버린다. - primaryRegion = null; - showRegion = false; - regionButton.classList.remove("is-active"); - regionButton.setAttribute("aria-pressed", "false"); - routeLayer = null; backgroundImage.removeAttribute("src"); status.hidden = false; status.textContent = "배경도를 불러오는 중…"; diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts index 52c14e29..a7ab7101 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts @@ -24,8 +24,6 @@ const ADD_SNAP_PX = 14; export interface PipeEditor { setContext(meta: VWorldMeta | null, points: ReadonlyArray): void; setPipes(pipes: ReadonlyArray): void; - /** 도로 위 흐름 강도 [누가거리 m, 상류 면적 ㎡]. 관 추가 판단 근거로 계획선에 덧그린다. */ - setStrength(profile: ReadonlyArray): void; pipes(): ReadonlyArray; chainages(): number[]; selected(): number | null; @@ -35,8 +33,6 @@ export interface PipeEditor { handleDown(view: ViewState, screenX: number, screenY: number, editMode: boolean): boolean; handleMove(view: ViewState, screenX: number, screenY: number): boolean; handleUp(): boolean; - /** 계획선 위 흐름 강도 띠. 마커보다 아래에 깔아야 하므로 draw()와 따로 호출한다. */ - drawStrength(context: CanvasRenderingContext2D, view: ViewState): void; draw( context: CanvasRenderingContext2D, view: ViewState, @@ -52,9 +48,6 @@ export function createPipeEditor(onChange: () => void): PipeEditor { let selectedIndex: number | null = null; let draggingIndex: number | null = null; let dragMoved = false; - let strength: Array = []; - let strengthPeak = 0; - let strengthSpan = 5; /** 화면 → 사업지 좌표계 m (MapRender affine의 역변환). */ function screenToMetric( @@ -153,17 +146,6 @@ export function createPipeEditor(onChange: () => void): PipeEditor { selectedIndex = null; draggingIndex = null; }, - setStrength(profile) { - strength = profile.map((entry) => [entry[0], entry[1]] as const); - strengthPeak = strength.reduce((peak, entry) => Math.max(peak, entry[1]), 0); - // 표본 간격은 백엔드 출력 간격을 그대로 따른다(값이 0인 구간은 빠져 있으므로 최소 간격 사용). - let span = Infinity; - for (let i = 1; i < strength.length; i += 1) { - const gap = strength[i][0] - strength[i - 1][0]; - if (gap > 0 && gap < span) span = gap; - } - strengthSpan = Number.isFinite(span) ? span : 5; - }, pipes: () => pipeList, chainages: () => pipeList.map((pipe) => Math.round(pipe.chainage_m * 100) / 100), selected: () => selectedIndex, @@ -229,28 +211,6 @@ export function createPipeEditor(onChange: () => void): PipeEditor { } return true; }, - drawStrength(context, view) { - if (strengthPeak <= 0 || route.length < 2) return; - context.save(); - context.lineCap = "butt"; - strength.forEach(([chainage, area]) => { - const from = chainageToXY(chainage); - const to = chainageToXY(Math.min(totalChainage, chainage + strengthSpan)); - if (!from || !to) return; - const start = metricToScreen(view, from.x, from.y); - const end = metricToScreen(view, to.x, to.y); - if (!start || !end) return; - // 강도는 편차가 커서(계곡 한 점에 수십 배 집중) 제곱근으로 눌러 표시한다. - const intensity = Math.sqrt(area / strengthPeak); - context.beginPath(); - context.moveTo(start.x, start.y); - context.lineTo(end.x, end.y); - context.lineWidth = 3 + 9 * intensity; - context.strokeStyle = `rgba(37, 99, 235, ${(0.15 + 0.5 * intensity).toFixed(3)})`; - context.stroke(); - }); - context.restore(); - }, draw(context, view, colorOf) { pipeList.forEach((pipe, position) => { const xy = chainageToXY(pipe.chainage_m); diff --git a/B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Drainage_Watershed.py b/B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Drainage_Watershed.py deleted file mode 100644 index 1966d8b8..00000000 --- a/B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Drainage_Watershed.py +++ /dev/null @@ -1,572 +0,0 @@ -"""배수유역 산정 엔진 — 세류 기반 등고선 기하 직접 분석 (2026-07-29 합의). - -DEM 보간·D8 전역 흐름분석을 쓰지 않는다. 계산 순서(사용자 정의 7단계): -① 도로(노선)가 유역의 하측 경계 ② 도로를 가로지르는 세류 교차점에서 출발 -③ 세류 상류망을 추적하고 연관 등고선만 분석해 메인 유역 선정(하류 무의미) -④ 세류 교차점 = 관매설 지점 ⑤ 300m 초과 구간은 종단 저점에 보충(제안 엔진 담당) -⑥ 관 사이 물갈림 고개에서 오르는 분할선(능선 근사)으로 유역을 세분화하고 - 번호·면적·표고차·유하장을 산출 ⑦ 관 추가·경로 변경 시 재호출로 재분석. - -유역 폴리곤 = 도로 구간(하측) + 좌우 분할선 + 최상위 공통 등고선 아크(상측)로 폐합. -등고선은 STRtree에서 필요한 것만 꺼내므로 분석량이 유역 크기에 비례한다(도엽 매수 무관). -""" - -from __future__ import annotations - -import logging -import math -from dataclasses import dataclass, field -from typing import Any - -from shapely.geometry import LineString, Point, Polygon -from shapely.ops import substring, unary_union -from shapely.strtree import STRtree - -from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import ( - StructureCandidate, - _interpolate_vertex, - estimate_pipe_diameter_mm, -) -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Assemble import ( - _assemble_polygon, - _road_segment_coords, -) -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Subdivide import subdivide_main_polygon -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Trace import ( - LOCAL_MAX_STEPS, - LOCAL_SEARCH_RADIUS_M, - STREAM_JOIN_TOL_M, - ContourIndex, - DividerStep, - _explode_lines, - rim_walk, - side_sign, - trace_divider, - trace_ridge_march, - trace_upstream_network, - valley_region_polygon, -) - -logger = logging.getLogger(__name__) - -# 유효 유역 최소 면적(m²)과 상류망 커버 보정 버퍼(m). -MIN_BASIN_AREA_M2 = 100.0 -STREAM_COVER_BUFFER_M = 20.0 -# 도로 양끝에서 하류측으로 뻗는 절단 차단선 길이(m). -DOWNHILL_BARRIER_M = 800.0 -# 유역이 상류망을 덮어야 하는 커버리지 목표(미달 시 버퍼 폴백). -CLOSING_COVERAGE_GOAL = 0.95 -# 산측 판정: 도로 접선의 좌우 수직 오프셋(m)과 표고 조회 반경(m). -UPHILL_PROBE_OFFSET_M = 40.0 -UPHILL_PROBE_RADIUS_M = 60.0 - - -@dataclass -class WatershedBasin: - """능선 기반으로 산정된 배수유역 1개.""" - - index: int - chainage_m: float - outlet_x: float - outlet_y: float - # 유역 경계(사업지 좌표계 m). 외곽선의 산측이 곧 분수령(능선), 하측이 도로선. - boundary_xy: list[list[float]] = field(default_factory=list) - area_m2: float = 0.0 - relief_m: float = 0.0 - flow_length_m: float = 0.0 - pipe_diameter_mm: float | None = None - - -def _divide_chainages(vertices: list[Any], ordered: list[StructureCandidate]) -> list[float]: - """유역 분할 누가거리 목록(양끝 포함, 관 개수+1개). - - 인접한 두 관 사이 **종단 계획선의 최고점(물갈림 고개)**이 분할점이다 — - "도로에 닿은 물은 측구를 타고 내리막의 첫 관으로 들어간다". - """ - divides = [vertices[0].chainage_m] - for left, right in zip(ordered, ordered[1:]): - window = [ - vertex for vertex in vertices if left.chainage_m < vertex.chainage_m < right.chainage_m - ] - if window: - divides.append(max(window, key=lambda vertex: vertex.z).chainage_m) - else: - divides.append((left.chainage_m + right.chainage_m) / 2.0) - divides.append(vertices[-1].chainage_m) - return divides - - -def _uphill_sign_at(vertices: list[Any], chainage_m: float, contour_index: ContourIndex) -> int: - """해당 측점의 산측이 도로 진행방향 기준 좌(+1)인지 우(-1)인지. 불명이면 0.""" - x, y, _ = _interpolate_vertex(vertices, chainage_m) - back = _interpolate_vertex(vertices, max(0.0, chainage_m - 10.0)) - forward = _interpolate_vertex(vertices, chainage_m + 10.0) - dx, dy = forward[0] - back[0], forward[1] - back[1] - norm = math.hypot(dx, dy) - if norm < 1e-6: - return 0 - dx, dy = dx / norm, dy / norm - # 좌측 법선 (-dy, dx) 방향 오프셋이 side_sign +1에 대응한다. - left_z = contour_index.nearest_elevation( - Point(x - dy * UPHILL_PROBE_OFFSET_M, y + dx * UPHILL_PROBE_OFFSET_M), - UPHILL_PROBE_RADIUS_M, - ) - right_z = contour_index.nearest_elevation( - Point(x + dy * UPHILL_PROBE_OFFSET_M, y - dx * UPHILL_PROBE_OFFSET_M), - UPHILL_PROBE_RADIUS_M, - ) - if left_z is None or right_z is None or left_z == right_z: - return 0 - return 1 if left_z > right_z else -1 - - -def _clip_to_uphill( - polygon: Polygon, - road_line: LineString, - uphill_sign: int, - contour_index: ContourIndex, - keep_geom: Any = None, -) -> Polygon | None: - """도로 하류측 조각을 잘라낸다 — 도로가 유역의 한쪽 경계(2026-07-29 사용자 지시). - - 절단선 = 실제 도로선 + 양끝에서 **하류측으로 뻗는 수직 차단선** 2개. (후방 접선 - 연장은 곡선 노선에서 계곡 내부를 관통해 오절단을 일으켰다.) 노선 끝을 감아 도는 - 산측 사면은 남고, 도로 하류측 주머니만 분리된다. - 조각 분류는 표고 기반: 대표점의 등고선 표고 > 최근접 도로 지점 표고 → 산측. - keep_geom(세류 상류망)이 실제로 지나가는 조각은 무조건 유지한다. - """ - length = road_line.length - cutters: list[LineString] = [road_line] - for t_end, t_inner in ((0.0, min(30.0, length)), (length, max(0.0, length - 30.0))): - end = road_line.interpolate(t_end) - inner = road_line.interpolate(t_inner) - dx, dy = end.x - inner.x, end.y - inner.y - norm = math.hypot(dx, dy) or 1.0 - for nx, ny in ((-dy / norm, dx / norm), (dy / norm, -dx / norm)): - probe = Point(end.x + nx * 30.0, end.y + ny * 30.0) - if side_sign(road_line, probe) == -uphill_sign: - cutters.append( - LineString( - [ - (end.x, end.y), - (end.x + nx * DOWNHILL_BARRIER_M, end.y + ny * DOWNHILL_BARRIER_M), - ] - ) - ) - break - # split()은 절단선이 폴리곤 경계와 겹치면(도로 = 유역 하측 경계) 동작하지 않는다. - # 얇은 스트립을 차감해 조각을 분리하고, 분류 후 buffer-교집합으로 원형을 복원한다. - try: - strip = unary_union([cutter.buffer(0.5) for cutter in cutters]) - separated = polygon.difference(strip) - except Exception: # noqa: BLE001 - 절단 실패 시 원본 유지 - return polygon - pieces = [ - part - for part in (separated.geoms if separated.geom_type.startswith("Multi") else [separated]) - if part.geom_type == "Polygon" and not part.is_empty - ] - kept = [] - for piece in pieces: - if keep_geom is not None and piece.intersection(keep_geom).length > 5.0: - kept.append(piece) - continue - representative = piece.representative_point() - piece_z = contour_index.nearest_elevation(representative, 2.0 * UPHILL_PROBE_RADIUS_M) - foot = road_line.interpolate(road_line.project(representative)) - road_z = contour_index.nearest_elevation(foot, 2.0 * UPHILL_PROBE_RADIUS_M) - if piece_z is not None and road_z is not None and piece_z != road_z: - if piece_z > road_z: - kept.append(piece) - continue - # 표고 판정 불가(등고선 공백·동일 표고) 시에만 좌우 부호로 판정한다. - if side_sign(road_line, representative) == uphill_sign: - kept.append(piece) - if not kept: - return None - # 스트립 차감으로 깎인 0.5m를 되붙이되 원본 폴리곤 밖으로는 나가지 않는다. - merged = unary_union(kept).buffer(0.7).intersection(polygon).buffer(0) - if merged.geom_type == "MultiPolygon": - merged = max(merged.geoms, key=lambda part: part.area) - if merged.is_empty or merged.geom_type != "Polygon": - return None - return merged - - -def _assemble_march_polygon( - vertices: list[Any], - start_m: float, - end_m: float, - contour_index: ContourIndex, - road_line: LineString, - uphill_sign: int, - network_union: Any, - stream_lines: list[Any], - valley_top_z: float, -) -> Polygon | None: - """개선 2안 — 도로 양끝 물갈림점에서 능선 행진으로 경계를 직접 조립한다. - - 좌·우 능선 행진 체인(세류 제약이 분수령을 강제) + 발원부 위 공통 등고선 아크로 - 폐합(기존 `_assemble_polygon` 재사용). 상류망 커버리지가 목표 미달이면 None을 - 돌려 1안(등거리+체인) 폴백을 태운다. - """ - others = [line for line in stream_lines if line.distance(network_union) > STREAM_JOIN_TOL_M] - other_tree = STRtree(others) if others else None - sx, sy, _ = _interpolate_vertex(vertices, start_m) - ex, ey, _ = _interpolate_vertex(vertices, end_m) - left = trace_ridge_march( - Point(sx, sy), contour_index, network_union, others, other_tree, road_line, uphill_sign - ) - right = trace_ridge_march( - Point(ex, ey), contour_index, network_union, others, other_tree, road_line, uphill_sign - ) - if len(left) < 5 or len(right) < 5: - return None - # 상측 폐합: 우측 정상 → 좌측 정상을 능선마루 보행으로 잇는다. - rim = rim_walk( - right[-1].point, - left[-1].point, - contour_index, - network_union, - others, - other_tree, - road_line, - uphill_sign, - ) - if rim is None: - return None - ring = _road_segment_coords(vertices, start_m, end_m) - ring.extend((step.point.x, step.point.y) for step in right[1:]) - ring.extend((point.x, point.y) for point in rim) - ring.extend((step.point.x, step.point.y) for step in reversed(left[1:])) - if len(ring) < 4: - return None - polygon = Polygon(ring).buffer(0) - if polygon.geom_type == "MultiPolygon": - polygon = max(polygon.geoms, key=lambda part: part.area) - if polygon.is_empty or polygon.geom_type != "Polygon": - return None - coverage = network_union.intersection(polygon).length / max(network_union.length, 1.0) - if coverage < CLOSING_COVERAGE_GOAL: - return None - return polygon - - -def _refine_road_edge(polygon: Polygon, road_line: LineString) -> Polygon: - """경계의 도로변 구간을 도로선 원해상도 좌표로 치환한다. - - 단순화(simplify)가 도로변 경계를 뭉개 도로를 가로지르는 것을 막는다 - (2026-07-29 사용자 지시: 경계 참조를 도로선 해상도와 매칭). - """ - coords = list(polygon.exterior.coords)[:-1] - count = len(coords) - near = [road_line.distance(Point(c)) < 6.0 for c in coords] - if not any(near) or all(near): - return polygon - start = next(i for i in range(count) if not near[i]) - coords = coords[start:] + coords[:start] - near = near[start:] + near[:start] - ring: list[tuple[float, float]] = [] - i = 0 - while i < count: - if not near[i]: - ring.append(coords[i]) - i += 1 - continue - j = i - while j < count and near[j]: - j += 1 - t1 = road_line.project(Point(coords[i])) - t2 = road_line.project(Point(coords[j - 1])) - segment = substring(road_line, min(t1, t2), max(t1, t2)) - if segment.geom_type == "LineString" and len(segment.coords) >= 2: - segment_coords = list(segment.coords) - if t1 > t2: - segment_coords.reverse() - ring.extend(segment_coords) - else: - ring.extend(coords[i:j]) - i = j - if len(ring) < 4: - return polygon - refined = Polygon(ring).buffer(0) - if refined.geom_type == "MultiPolygon": - refined = max(refined.geoms, key=lambda part: part.area) - if refined.is_empty or refined.geom_type != "Polygon": - return polygon - return refined - - -def _main_watershed_polygon( - vertices: list[Any], - divides: list[float], - dividers: list[list[DividerStep]], - contour_index: ContourIndex, - road_line: LineString, - uphill_sign: int, - network_union: Any, - stream_lines: list[Any], - stream_features: list[dict[str, Any]], - outlet: Point, -) -> Polygon | None: - """메인 배수유역 폴리곤 1회 산정 — f72017a 채택 산식 그대로. - - 불변 조건(2026-07-30 사용자 지시): 이 함수의 산식은 전체 유역 경계를 결정하므로 - 변경 금지. 세분화는 이 결과를 내부에서만 쪼갠다(`subdivide_main_polygon`). - """ - valley_top_z = contour_index.max_elevation_within(network_union.buffer(10.0)) - # 개선 2안: 도로 양끝 물갈림점에서 능선 행진으로 경계를 직접 그린다. - polygon = None - if valley_top_z is not None: - polygon = _assemble_march_polygon( - vertices, - divides[0], - divides[-1], - contour_index, - road_line, - uphill_sign, - network_union, - stream_lines, - valley_top_z, - ) - if polygon is None: - # 개선 1안(폴백): 도로변 스트립 + 세류 계곡 영역(등거리+등고선 체인) 합집합. - base = _assemble_polygon( - vertices, - divides[0], - divides[-1], - dividers[0], - dividers[-1], - contour_index, - road_line, - network_union, - valley_top_z, - ) - valley = valley_region_polygon(network_union, stream_features, outlet, contour_index) - if base is None and valley is None: - return None - if base is not None and valley is not None: - merged = base.union(valley).buffer(0) - if merged.geom_type == "MultiPolygon": - merged = max(merged.geoms, key=lambda part: part.area) - polygon = merged if merged.geom_type == "Polygon" and not merged.is_empty else base - else: - polygon = base if base is not None else valley - coverage = network_union.intersection(polygon).length / max(network_union.length, 1.0) - if coverage < CLOSING_COVERAGE_GOAL: - # 계곡 영역이 못 덮은 상류망만 버퍼로 보정한다(최후 폴백). - covered = polygon.union(network_union.buffer(STREAM_COVER_BUFFER_M)).buffer(0) - if covered.geom_type == "MultiPolygon": - covered = max(covered.geoms, key=lambda part: part.area) - if covered.geom_type == "Polygon" and not covered.is_empty: - polygon = covered - # 마지막에 도로선으로 절단해 하류측을 제거한다 — 도로가 유역의 한쪽 경계. - polygon = _clip_to_uphill(polygon, road_line, uphill_sign, contour_index, network_union) - if polygon is None or polygon.area < MIN_BASIN_AREA_M2: - return None - return polygon - - -def _local_polygon( - vertices: list[Any], - divides: list[float], - dividers: list[list[DividerStep]], - position: int, - contour_index: ContourIndex, - road_line: LineString, - uphill_sign: int, -) -> Polygon | None: - """세류 없는 관 구간의 소범위 유역 — 도로 상측 첫 능선까지(기존 경로 유지).""" - base = _assemble_polygon( - vertices, - divides[position], - divides[position + 1], - dividers[position], - dividers[position + 1], - contour_index, - road_line, - None, - None, - ) - if base is None: - return None - return _clip_to_uphill(base, road_line, uphill_sign, contour_index, None) - - -def _basin_from_polygon( - polygon: Polygon, - candidate: StructureCandidate, - index: int, - flow_length: float, - contour_index: ContourIndex, - road_line: LineString, - vertices: list[Any], - simplify: bool = True, -) -> WatershedBasin: - """확정된 유역 폴리곤에서 산출값(면적·표고차·유하장·관경)을 계산한다. - - 세분화 조각(simplify=False)은 단순화하지 않는다 — 조각별 독립 단순화는 공유 - 분할선 경계를 어긋나게 해 겹침·틈을 만든다(배타적 타일링 유지). - """ - outlet = Point(candidate.x, candidate.y) - outlet_z = contour_index.nearest_elevation(outlet, UPHILL_PROBE_RADIUS_M) - if outlet_z is None: - outlet_z = _interpolate_vertex(vertices, candidate.chainage_m)[2] - # 표고차는 등고선만으로 계산한다(표고점 미참조 — 사용자 지시). - top_z = contour_index.max_elevation_within(polygon) or outlet_z - boundary_line = polygon.simplify(5.0, preserve_topology=True) if simplify else polygon - if boundary_line.is_empty or boundary_line.geom_type != "Polygon": - boundary_line = polygon - # 도로변 경계는 단순화 없이 도로선 해상도를 유지한다. - boundary_line = _refine_road_edge(boundary_line, road_line) - boundary = [[float(x), float(y)] for x, y in boundary_line.exterior.coords] - if flow_length <= 0.0: - flow_length = max( - (math.dist((candidate.x, candidate.y), point) for point in boundary), - default=0.0, - ) - basin = WatershedBasin( - index=index, - chainage_m=candidate.chainage_m, - outlet_x=candidate.x, - outlet_y=candidate.y, - boundary_xy=boundary, - area_m2=float(polygon.area), - relief_m=max(0.0, float(top_z) - float(outlet_z)), - flow_length_m=float(flow_length), - ) - basin.pipe_diameter_mm = estimate_pipe_diameter_mm( - basin.area_m2, basin.relief_m, basin.flow_length_m - ) - return basin - - -def build_watershed_basins( - vertices: list[Any], - candidates: list[StructureCandidate], - contour_features: list[dict[str, Any]], - spot_features: list[dict[str, Any]], - elevation_keys: tuple[str, ...], - stream_features: list[dict[str, Any]] | None = None, -) -> list[WatershedBasin]: - """메인 배수유역을 1회 산정하고 관 지점 기준으로 내부 세분화한다. - - 메인 유역 경계는 관 개수와 무관하게 항상 동일하다(불변 조건 — 2026-07-30 사용자 - 지시). 세부유역 = 메인 폴리곤을 관 사이 분할선으로 쪼갠 조각(배타적, 합집합 = - 메인). 세류 없는 관이 메인 범위 밖이면 소범위 유역을 별도 생성(기존 동작). - 번호는 노선 시점에 가까운 순. - """ - if not candidates or len(vertices) < 2: - return [] - contour_index = ContourIndex(contour_features, elevation_keys) - if contour_index.tree is None: - logger.warning("표고 속성이 있는 등고선이 없어 유역을 산정하지 못했습니다.") - return [] - # 표고점은 참조하지 않는다(2026-07-29 사용자 지시: 계측 측점 데이터라 오류 유입). - _ = spot_features - road_line = LineString([(vertex.x, vertex.y) for vertex in vertices]) - - ordered = sorted(candidates, key=lambda item: item.chainage_m) - divides = _divide_chainages(vertices, ordered) - signs = [_uphill_sign_at(vertices, item.chainage_m, contour_index) for item in ordered] - majority = 1 if sum(signs) >= 0 else -1 - signs = [sign or majority for sign in signs] - # 사용자 확정("confirmed") 측점도 세류에 닿아 있으면 세류 유역으로 취급한다. - stream_lines = _explode_lines(stream_features) if stream_features else [] - is_stream = [ - item.reason == "stream" - or any(line.distance(Point(item.x, item.y)) <= STREAM_JOIN_TOL_M for line in stream_lines) - for item in ordered - ] - - # 분할선은 인접 유역과 공유하므로 분할점마다 1회만 추적한다. - dividers: list[list[DividerStep]] = [] - for position, chainage in enumerate(divides): - neighbor_streams = [] - if position > 0: - neighbor_streams.append(is_stream[position - 1]) - if position < len(ordered): - neighbor_streams.append(is_stream[position]) - wide = any(neighbor_streams) - sign = signs[position - 1] if position > 0 else signs[0] - x, y, _ = _interpolate_vertex(vertices, chainage) - if wide: - steps = trace_divider(Point(x, y), contour_index, road_line, sign) - else: - steps = trace_divider( - Point(x, y), - contour_index, - road_line, - sign, - radius_m=LOCAL_SEARCH_RADIUS_M, - max_steps=LOCAL_MAX_STEPS, - ) - dividers.append(steps) - - # 관별 상류망은 1회만 추적한다(유하장 계산에도 사용). - networks: list[list[Any]] = [] - flows: list[float] = [] - for position, candidate in enumerate(ordered): - network: list[Any] = [] - flow_length = 0.0 - if is_stream[position] and stream_features: - network, flow_length = trace_upstream_network( - Point(candidate.x, candidate.y), stream_features, road_line, signs[position] - ) - networks.append(network) - flows.append(flow_length) - - main_polygon = None - combined = [line for network in networks for line in network] - first_stream = next((position for position in range(len(ordered)) if networks[position]), None) - if combined and first_stream is not None: - main_polygon = _main_watershed_polygon( - vertices, - divides, - dividers, - contour_index, - road_line, - majority, - unary_union(combined), - stream_lines, - stream_features or [], - Point(ordered[first_stream].x, ordered[first_stream].y), - ) - - pieces: list[Polygon | None] = [None] * len(ordered) - if main_polygon is not None: - divide_points = [ - Point(*_interpolate_vertex(vertices, chainage)[:2]) for chainage in divides - ] - pieces = subdivide_main_polygon(main_polygon, divide_points, dividers, road_line) - - basins: list[WatershedBasin] = [] - for position, candidate in enumerate(ordered): - polygon = pieces[position] if position < len(pieces) else None - from_subdivision = polygon is not None and len(ordered) > 1 - if polygon is None: - if networks[position] and main_polygon is not None: - logger.warning( - "세류 관(%.0fm) 구간에 세부유역 조각이 없습니다 — 건너뜁니다.", - candidate.chainage_m, - ) - continue - # 메인 유역 밖(또는 상류망 없음) 관은 소범위 유역을 별도 생성한다. - polygon = _local_polygon( - vertices, divides, dividers, position, contour_index, road_line, signs[position] - ) - if polygon is None or polygon.area < MIN_BASIN_AREA_M2: - continue - basins.append( - _basin_from_polygon( - polygon, - candidate, - len(basins) + 1, - flows[position], - contour_index, - road_line, - vertices, - simplify=not from_subdivision, - ) - ) - return basins diff --git a/B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Watershed_Assemble.py b/B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Watershed_Assemble.py deleted file mode 100644 index 8bff4e40..00000000 --- a/B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Watershed_Assemble.py +++ /dev/null @@ -1,205 +0,0 @@ -"""배수유역 폴리곤 폐합 조립 — 도로 구간 + 분할선 + 등고선 아크 (700줄 분리, 2026-07-30). - -`B05_wf2_Route_Engine_Drainage_Watershed.py`에서 산식 변경 없이 그대로 옮겨온 -개선 1안(등거리+체인) 폐합 헬퍼 모음이다. 불변 조건: 산식 수정 금지(메인 유역 -경계가 바뀐다 — 2026-07-30 사용자 지시). -""" - -from __future__ import annotations - -from typing import Any - -from shapely.geometry import LineString, Point, Polygon -from shapely.ops import nearest_points, substring - -from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import _interpolate_vertex -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Trace import ContourIndex, DividerStep - -# 폐합 등고선 기하 탐색: 분할선 경로와 등고선의 근접 허용 거리(m). -CLOSING_SEARCH_M = 30.0 - - -def _road_segment_coords( - vertices: list[Any], start_m: float, end_m: float -) -> list[tuple[float, float]]: - """분할점 사이 도로 구간의 평면 좌표열(유역 폴리곤의 하측 경계).""" - sx, sy, _ = _interpolate_vertex(vertices, start_m) - ex, ey, _ = _interpolate_vertex(vertices, end_m) - coords = [(sx, sy)] - coords.extend( - (vertex.x, vertex.y) for vertex in vertices if start_m < vertex.chainage_m < end_m - ) - coords.append((ex, ey)) - return coords - - -def _contour_arc( - line: Any, - p_from: Point, - p_to: Point, - road_line: LineString, - network_union: Any = None, -) -> list[tuple[float, float]]: - """등고선에서 두 분할선 접점 사이 아크(상측 경계)를 뽑는다. - - 폐합 등고선은 두 방향 아크가 생기므로, 세류 상류망을 가로지르지 않고(계곡을 - 자르지 않고) 도로와도 교차하지 않는(=산측) 쪽을 고른다. - """ - t1, t2 = sorted((line.project(p_from), line.project(p_to))) - arcs = [] - inner = substring(line, t1, t2) - if inner.geom_type == "LineString" and len(inner.coords) >= 2: - arcs.append(inner) - if getattr(line, "is_closed", False): - head = substring(line, t2, line.length) - tail = substring(line, 0.0, t1) - coords = list(head.coords) + list(tail.coords)[1:] - if len(coords) >= 2: - arcs.append(LineString(coords)) - if not arcs: - return [] - scored = [] - for arc in arcs: - crosses_stream = bool(network_union is not None and arc.crosses(network_union)) - crosses_road = arc.crosses(road_line) - midpoint = arc.interpolate(0.5, normalized=True) - scored.append((crosses_stream, crosses_road, -road_line.distance(midpoint), arc)) - scored.sort(key=lambda item: (item[0], item[1], item[2])) - arc = scored[0][3] - coords = list(arc.coords) - if Point(coords[0]).distance(p_from) > Point(coords[-1]).distance(p_from): - coords.reverse() - return [(float(x), float(y)) for x, y in coords] - - -def _junction( - left: list[DividerStep], - right: list[DividerStep], - min_z: float | None = None, -) -> tuple[int, int, int] | None: - """두 분할선이 같은 등고선 지오메트리를 밟은 폐합 지점(좌 idx, 우 idx, geom idx). - - min_z(세류 상류망 최고 표고)가 있으면 그 **이상인 가장 낮은** 공통 등고선을 고른다 — - "세류로 영역을 지정한 뒤 가까운 등고선으로 바로 올려치면 안 된다"(2026-07-29 사용자 - 지시). 계곡 발원부를 넘긴 첫 등고선이 유역 상측 경계가 된다. 없으면 최고 공통 등고선. - """ - left_keys = { - (step.z, step.geom_index): position - for position, step in enumerate(left) - if step.geom_index >= 0 - } - matches: list[tuple[float, int, int, int]] = [] - for position, step in enumerate(right): - if step.geom_index < 0: - continue - left_position = left_keys.get((step.z, step.geom_index)) - if left_position is None: - continue - matches.append((step.z, left_position, position, step.geom_index)) - if not matches: - return None - if min_z is not None: - above = [match for match in matches if match[0] >= min_z] - if above: - best = min(above) - return best[1], best[2], best[3] - best = max(matches) - return best[1], best[2], best[3] - - -def _closing_contour( - left: list[DividerStep], - right: list[DividerStep], - contour_index: ContourIndex, - min_z: float, -) -> tuple[int, int, int, Point, Point] | None: - """두 분할선 경로에 모두 근접한 등고선 중 min_z 이상 최저를 찾는다. - - 분할선이 같은 스텝에서 같은 지오메트리를 밟지 못해도(도엽 분할 등) 계곡 발원부 - 위를 지나는 폐합 등고선을 기하적으로 찾아낸다. - 반환: (좌 절단 idx, 우 절단 idx, 등고선 geom idx, 좌 접점, 우 접점). - """ - if len(left) < 2 or len(right) < 2: - return None - left_line = LineString([step.point for step in left]) - right_line = LineString([step.point for step in right]) - shared = set(contour_index.query(left_line.buffer(CLOSING_SEARCH_M))) & set( - contour_index.query(right_line.buffer(CLOSING_SEARCH_M)) - ) - best: tuple[float, int] | None = None - for index in shared: - z = contour_index.zs[index] - if z < min_z: - continue - geom = contour_index.geoms[index] - if ( - geom.distance(left_line) > CLOSING_SEARCH_M - or geom.distance(right_line) > CLOSING_SEARCH_M - ): - continue - if best is None or z < best[0]: - best = (z, index) - if best is None: - return None - geom = contour_index.geoms[best[1]] - left_touch = nearest_points(geom, left_line)[0] - right_touch = nearest_points(geom, right_line)[0] - left_position = min(range(len(left)), key=lambda i: left[i].point.distance(left_touch)) - right_position = min(range(len(right)), key=lambda i: right[i].point.distance(right_touch)) - return left_position, right_position, best[1], left_touch, right_touch - - -def _assemble_polygon( - vertices: list[Any], - start_m: float, - end_m: float, - left: list[DividerStep], - right: list[DividerStep], - contour_index: ContourIndex, - road_line: LineString, - network_union: Any = None, - valley_top_z: float | None = None, -) -> Polygon | None: - """도로 구간 + 우측 분할선 + 상측 등고선 아크 + 좌측 분할선으로 폴리곤을 폐합한다. - - 세류 유역(valley_top_z 지정)은 발원부 위를 지나는 폐합 등고선을 기하 탐색으로 - 먼저 찾고, 실패 시 같은 스텝 매칭(_junction)으로 폐합한다. - """ - ring = _road_segment_coords(vertices, start_m, end_m) - left_used, right_used, arc = left, right, [] - closure = ( - _closing_contour(left, right, contour_index, valley_top_z) - if valley_top_z is not None - else None - ) - if closure is not None: - left_position, right_position, geom_index, left_touch, right_touch = closure - left_used = left[: left_position + 1] - right_used = right[: right_position + 1] - arc = _contour_arc( - contour_index.geoms[geom_index], right_touch, left_touch, road_line, network_union - ) - else: - junction = _junction(left, right, min_z=valley_top_z) - if junction is not None: - left_position, right_position, geom_index = junction - left_used = left[: left_position + 1] - right_used = right[: right_position + 1] - arc = _contour_arc( - contour_index.geoms[geom_index], - right_used[-1].point, - left_used[-1].point, - road_line, - network_union, - ) - ring.extend((step.point.x, step.point.y) for step in right_used[1:]) - ring.extend(arc) - ring.extend((step.point.x, step.point.y) for step in reversed(left_used[1:])) - if len(ring) < 4: - return None - polygon = Polygon(ring).buffer(0) - if polygon.geom_type == "MultiPolygon": - polygon = max(polygon.geoms, key=lambda part: part.area) - if polygon.is_empty or polygon.geom_type != "Polygon": - return None - return polygon diff --git a/B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Watershed_Subdivide.py b/B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Watershed_Subdivide.py deleted file mode 100644 index 8efdb68f..00000000 --- a/B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Watershed_Subdivide.py +++ /dev/null @@ -1,171 +0,0 @@ -"""메인 배수유역 내부 세분화 — 분할선으로 폴리곤을 쪼갠다 (2026-07-30). - -불변 조건(사용자 지시): 전체(메인) 배수유역 경계는 절대 변경하지 않는다. -세분화는 확정된 메인 유역 폴리곤을 관 사이 분할선(물갈림 고개에서 오르는 -능선 근사선)으로 **내부에서만** 쪼개는 방식이다 — 외곽 재추적 금지. -따라서 세부유역은 서로 배타적이고 합집합은 항상 메인 유역과 동일하다. -""" - -from __future__ import annotations - -import logging -import math - -from shapely.geometry import LineString, Point, Polygon -from shapely.ops import split as shapely_split -from shapely.ops import substring, unary_union - -from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Trace import DividerStep - -logger = logging.getLogger(__name__) - -# 분할 절단선 연장: 도로 하류측(m)과 능선 너머(폴리곤 대각선 배수). -CUT_ROAD_TAIL_M = 40.0 -CUT_RIDGE_EXTEND_RATIO = 1.5 - - -def _cut_line( - road_point: Point, - steps: list[DividerStep], - road_line: LineString, - main_polygon: Polygon, -) -> LineString | None: - """분할선 스텝을 절단선으로 확장한다 — 도로 하류측과 능선 너머까지 관통. - - split()은 절단선이 폴리곤 경계를 완전히 넘어야 동작하므로 양끝을 연장한다. - 스텝이 없으면(등고선 공백) 도로 법선 직선으로 폴백한다. - """ - bounds = main_polygon.bounds - reach = CUT_RIDGE_EXTEND_RATIO * math.hypot(bounds[2] - bounds[0], bounds[3] - bounds[1]) - points = [(road_point.x, road_point.y)] - points.extend((step.point.x, step.point.y) for step in steps if step.geom_index >= 0) - if len(points) < 2: - # 폴백: 도로 접선의 법선 방향으로 폴리곤을 관통하는 직선. - t = road_line.project(road_point) - a = road_line.interpolate(max(0.0, t - 5.0)) - b = road_line.interpolate(min(road_line.length, t + 5.0)) - dx, dy = b.x - a.x, b.y - a.y - norm = math.hypot(dx, dy) - if norm < 1e-6: - return None - nx, ny = -dy / norm, dx / norm - head = (road_point.x + nx * reach, road_point.y + ny * reach) - tail = (road_point.x - nx * reach, road_point.y - ny * reach) - return LineString([tail, (road_point.x, road_point.y), head]) - # 능선 너머 연장: 마지막 진행 방향 유지. - (px, py), (qx, qy) = points[-2], points[-1] - dx, dy = qx - px, qy - py - norm = math.hypot(dx, dy) - if norm >= 1e-6: - points.append((qx + dx / norm * reach, qy + dy / norm * reach)) - # 도로 하류측 연장: 첫 스텝 → 도로점 방향을 그대로 지나쳐 내려간다. - (fx, fy) = points[1] - dx, dy = road_point.x - fx, road_point.y - fy - norm = math.hypot(dx, dy) - if norm >= 1e-6: - points.insert( - 0, - ( - road_point.x + dx / norm * CUT_ROAD_TAIL_M, - road_point.y + dy / norm * CUT_ROAD_TAIL_M, - ), - ) - return LineString(points) - - -def _interval_index(piece: Polygon, road_line: LineString, divide_ts: list[float]) -> int: - """조각이 어느 관 구간(k)에 속하는지 — 구간 도로와 맞닿는 길이가 최대인 곳. - - 대표점 투영은 대형 계곡 조각에서 오판한다(상류로 길게 뻗은 조각의 대표점이 - 엉뚱한 구간에 떨어짐). 도로 접촉이 전혀 없는 조각만 대표점 투영으로 폴백. - """ - strip = piece.buffer(1.0) - best_k, best_length = -1, 0.0 - for k in range(len(divide_ts) - 1): - segment = substring(road_line, divide_ts[k], divide_ts[k + 1]) - if segment.is_empty: - continue - length = segment.intersection(strip).length - if length > best_length: - best_k, best_length = k, length - if best_k >= 0: - return best_k - t = road_line.project(piece.representative_point()) - for k in range(len(divide_ts) - 1): - if divide_ts[k] <= t <= divide_ts[k + 1]: - return k - return 0 if t < divide_ts[0] else len(divide_ts) - 2 - - -def subdivide_main_polygon( - main_polygon: Polygon, - divide_points: list[Point], - dividers: list[list[DividerStep]], - road_line: LineString, -) -> list[Polygon | None]: - """메인 유역 폴리곤을 내부 분할선으로 쪼개 관 구간별 조각을 돌려준다. - - 반환 길이 = 관 개수(구간 수). 조각이 없는 구간은 None. - split() 기반이므로 조각들은 배타적이고 합집합 == 메인 폴리곤이 보장된다. - 구간에 여러 조각이 잡히면(절단선 재진입) 모두 합쳐 가장 큰 폴리곤을 쓴다. - """ - interval_count = len(divide_points) - 1 - if interval_count <= 1: - return [main_polygon] - pieces: list[Polygon] = [main_polygon] - for position in range(1, interval_count): - cut = _cut_line(divide_points[position], dividers[position], road_line, main_polygon) - if cut is None: - logger.warning("분할 절단선 생성 실패(구간 %d) — 해당 분할을 건너뜁니다.", position) - continue - next_pieces: list[Polygon] = [] - for piece in pieces: - try: - parts = shapely_split(piece, cut) - except Exception: # noqa: BLE001 - 절단 실패 시 조각 유지 - next_pieces.append(piece) - continue - split_parts = [ - part - for part in getattr(parts, "geoms", [parts]) - if part.geom_type == "Polygon" and not part.is_empty - ] - next_pieces.extend(split_parts if split_parts else [piece]) - pieces = next_pieces - divide_ts = [road_line.project(point) for point in divide_points] - assigned: list[list[Polygon]] = [[] for _ in range(interval_count)] - for piece in pieces: - assigned[_interval_index(piece, road_line, divide_ts)].append(piece) - # 구간별 대표 조각 = 최대 조각과 그에 붙는 조각들. 비연결 잔여 조각은 버리지 - # 않고(합집합 불변 조건) 맞닿는 인접 구간으로 재배정한다. - result: list[Polygon | None] = [] - leftovers: list[Polygon] = [] - for group in assigned: - merged = _merge_touching(group) - result.append(merged[0] if merged else None) - leftovers.extend(merged[1:]) - for extra in leftovers: - for position in sorted( - range(interval_count), - key=lambda k: extra.distance(result[k]) if result[k] is not None else math.inf, - ): - base = result[position] - if base is None or not extra.touches(base): - continue - candidate = base.union(extra).buffer(0) - if candidate.geom_type == "Polygon": - result[position] = candidate - break - else: - logger.warning("세분화 잔여 조각(%.0f m²)을 재배정하지 못해 제외합니다.", extra.area) - return result - - -def _merge_touching(group: list[Polygon]) -> list[Polygon]: - """조각 묶음을 서로 맞닿는 것끼리 합쳐 면적 내림차순으로 돌려준다.""" - if not group: - return [] - merged = unary_union(group).buffer(0) - parts = list(merged.geoms) if merged.geom_type == "MultiPolygon" else [merged] - parts = [part for part in parts if part.geom_type == "Polygon" and not part.is_empty] - return sorted(parts, key=lambda part: part.area, reverse=True) diff --git a/B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Watershed_Trace.py b/B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Watershed_Trace.py deleted file mode 100644 index a6de8c24..00000000 --- a/B05_wf2_Route/_legacy_watershed/B05_wf2_Route_Engine_Watershed_Trace.py +++ /dev/null @@ -1,657 +0,0 @@ -"""배수유역 추적 유틸 — 등고선 공간 인덱스·세류 상류망·분수령(능선) 추적. - -등고선 기하 직접 분석(2026-07-29 합의)의 하위 도구 모음. DEM 보간 없이: -- `ContourIndex`: 등고선(선)·표고점(점)을 STRtree에 1회 적재하고 필요한 것만 꺼낸다. -- `trace_upstream_network`: 세류 교차점에서 도로 산측 상류망만 추적한다(하류 무시). -- `trace_divider`: 물갈림 지점에서 상향 등고선을 한 겹씩 따라 오르는 유역 분할선(능선 근사). - -전체 등고선을 순회하는 연산을 두지 않아 분석량이 유역 크기에 비례한다(도엽 매수와 무관). -""" - -from __future__ import annotations - -import bisect -from dataclasses import dataclass -from typing import Any - -from shapely.geometry import LineString, Point, Polygon, box, shape -from shapely.ops import nearest_points, substring, unary_union -from shapely.strtree import STRtree - -# 분할선(능선 근사) 추적: 다음 상위 등고선을 찾는 탐색 반경(m)과 최대 단계 수. -DIVIDER_SEARCH_RADIUS_M = 120.0 -DIVIDER_MAX_STEPS = 60 -# 세류 없는 소규모 유역: 도로 상측 첫 능선까지만 오르도록 좁힌 한계(영역 선정 주의). -LOCAL_SEARCH_RADIUS_M = 80.0 -LOCAL_MAX_STEPS = 12 -# 세류 연결 판정 이격(m)과 상류망 총연장 상한(m). -STREAM_JOIN_TOL_M = 15.0 -MAX_UPSTREAM_TOTAL_M = 5000.0 -# 계곡 유역 근사 격자: 셀 크기(m)와 상류망에서의 최대 이격(m). -VALLEY_CELL_M = 12.0 -VALLEY_CAP_M = 350.0 -# 능선 스냅: 경계 정점에서 등고선 탐색 반경(m)과 등고선 위 능선 꼭짓점 탐색 폭(m). -RIDGE_SNAP_M = 40.0 -RIDGE_WALK_M = 80.0 -# 능선 행진(개선 2안): 다음 상위 등고선 탐색 반경(m)·등고선 위 꼭짓점 탐색 폭(m)·최대 단계. -# 탐색 폭을 좁게 유지해야 체인이 자기 능선을 국소 추종한다(넓으면 이웃 능선으로 가로 이탈). -MARCH_RADIUS_M = 120.0 -MARCH_WALK_M = 40.0 -MARCH_MAX_STEPS = 150 -# 첫 스텝(시드)만 넓게 탐색 — 물갈림점이 능선 위가 아닐 수 있어 국소 분수령을 먼저 찾는다. -MARCH_SEED_WALK_M = 250.0 -# 이탈 제약 완화 비율 — 등거리선(dn=do) 부근에서 체인이 멈추지 않게 소폭 허용. -MARCH_OTHER_RATIO = 0.7 - - -@dataclass -class DividerStep: - """분할선의 한 단계 — 어느 등고선(geom_index)의 어느 지점을 밟았는지.""" - - point: Point - z: float - geom_index: int - - -class ContourIndex: - """표고 속성이 있는 등고선·표고점 피처의 STRtree 래퍼.""" - - def __init__(self, features: list[dict[str, Any]], elevation_keys: tuple[str, ...]) -> None: - self.geoms: list[Any] = [] - self.zs: list[float] = [] - for feature in features: - elevation = _feature_elevation(feature, elevation_keys) - if elevation is None: - continue - geometry = feature.get("geometry") or {} - try: - geom = shape(geometry) - except Exception: # noqa: BLE001 - 손상된 피처는 건너뛴다 - continue - if geom.is_empty: - continue - parts = list(geom.geoms) if geom.geom_type.startswith("Multi") else [geom] - for part in parts: - self.geoms.append(part) - self.zs.append(elevation) - self.tree = STRtree(self.geoms) if self.geoms else None - - def query(self, geometry: Any) -> list[int]: - """geometry 근방(bbox 교차) 피처의 인덱스만 돌려준다.""" - if self.tree is None: - return [] - return [int(i) for i in self.tree.query(geometry)] - - def nearest_elevation(self, point: Point, radius_m: float) -> float | None: - """point에서 radius 안 가장 가까운 피처의 표고. 없으면 None.""" - best_z: float | None = None - best_distance = radius_m - for index in self.query(point.buffer(radius_m)): - distance = self.geoms[index].distance(point) - if distance <= best_distance: - best_distance = distance - best_z = self.zs[index] - return best_z - - def max_elevation_within(self, polygon: Any) -> float | None: - """polygon과 실제로 교차하는 피처들의 최고 표고.""" - best: float | None = None - for index in self.query(polygon): - if not polygon.intersects(self.geoms[index]): - continue - if best is None or self.zs[index] > best: - best = self.zs[index] - return best - - -def _feature_elevation(feature: dict[str, Any], elevation_keys: tuple[str, ...]) -> float | None: - properties = feature.get("properties") or {} - for key in elevation_keys: - value = properties.get(key) - if value is None: - continue - try: - return float(value) - except (TypeError, ValueError): - continue - return None - - -def side_sign(road_line: LineString, point: Point) -> int: - """도로선 기준 point가 어느 쪽인지(+1/-1, 선상이면 0). 국소 접선과의 외적 부호.""" - t = road_line.project(point) - a = road_line.interpolate(max(0.0, t - 5.0)) - b = road_line.interpolate(min(road_line.length, t + 5.0)) - cross = (b.x - a.x) * (point.y - a.y) - (b.y - a.y) * (point.x - a.x) - if cross > 0: - return 1 - if cross < 0: - return -1 - return 0 - - -def trace_divider( - start: Point, - contour_index: ContourIndex, - road_line: LineString, - uphill_sign: int, - radius_m: float = DIVIDER_SEARCH_RADIUS_M, - max_steps: int = DIVIDER_MAX_STEPS, -) -> list[DividerStep]: - """물갈림 지점에서 상향 등고선을 한 겹씩 밟아 오르는 분할선을 만든다. - - 각 단계에서 반경 안의 "현재보다 높은 등고선 중 가장 낮은 것"의 최근접점으로 이동한다. - 도로 산측(uphill_sign)을 벗어나거나 도로 쪽으로 되돌아가는 이동은 막는다. - 더 높은 등고선이 반경 안에 없으면 능선(분수령)에 닿은 것으로 보고 멈춘다. - """ - z = contour_index.nearest_elevation(start, radius_m) - if z is None: - return [] - steps = [DividerStep(point=start, z=z, geom_index=-1)] - current = start - road_distance = road_line.distance(start) - for _ in range(max_steps): - best: tuple[float, float, Point, int] | None = None - for index in contour_index.query(current.buffer(radius_m)): - candidate_z = contour_index.zs[index] - if candidate_z <= z + 0.01: - continue - if best is not None and candidate_z > best[0]: - continue - point = nearest_points(contour_index.geoms[index], current)[0] - distance = current.distance(point) - if distance > radius_m: - continue - # 도로 반대편·도로 방향 후퇴 금지 — 분할선은 산측으로만 오른다. - if side_sign(road_line, point) == -uphill_sign: - continue - if road_line.distance(point) + 1.0 < road_distance: - continue - if ( - best is None - or candidate_z < best[0] - or (candidate_z == best[0] and distance < best[1]) - ): - best = (candidate_z, distance, point, index) - if best is None: - break - z, _, current, geom_index = best - road_distance = max(road_distance, road_line.distance(current)) - steps.append(DividerStep(point=current, z=z, geom_index=geom_index)) - return steps - - -def _explode_lines(stream_features: list[dict[str, Any]]) -> list[LineString]: - lines: list[LineString] = [] - for feature in stream_features: - geometry = feature.get("geometry") or {} - try: - geom = shape(geometry) - except Exception: # noqa: BLE001 - continue - if geom.is_empty: - continue - parts = list(geom.geoms) if geom.geom_type.startswith("Multi") else [geom] - lines.extend(part for part in parts if part.geom_type == "LineString") - return lines - - -def _oriented_from(line: LineString, origin: Point) -> LineString: - """origin에 가까운 끝이 시작점이 되도록 방향을 맞춘다.""" - if Point(line.coords[0]).distance(origin) <= Point(line.coords[-1]).distance(origin): - return line - return LineString(list(line.coords)[::-1]) - - -def _clip_uphill(line: LineString, road_line: LineString, origin: Point) -> LineString | None: - """도로를 다시 가로지르면 교차 지점에서 잘라 origin 쪽 조각만 남긴다.""" - if not line.crosses(road_line): - return line - t = line.project(nearest_points(line.intersection(road_line), origin)[0]) - piece = substring(line, 0.0, t) if line.project(origin) < t else substring(line, t, line.length) - if piece.geom_type != "LineString" or piece.length < 1.0: - return None - return piece - - -def valley_region_polygon( - network_union: Any, - stream_features: list[dict[str, Any]], - crossing: Point, - contour_index: Any = None, -) -> Any | None: - """상류망 계곡의 유역 영역 — 경계는 등고선을 참고한 능선(분수령)으로 긋는다. - - ① 등거리 1차 근사: 격자 셀 중심이 인접 계곡 세류보다 우리 상류망에 가깝고 상한 - 거리 이내이면 유역 소속. 지류 사이 사면(지능선) 포함, 인접 계곡 자동 제외. - ② 등고선 스냅(2026-07-29 사용자 지시): 경계는 세류들 사이 중간 어딘가가 아니라 - **등고선을 참고해** 그어야 한다 — 각 경계 정점을 근처 등고선 위에서 두 세류 - 모두로부터 가장 먼 지점(능선 꼭짓점)으로 이동. 단 우리 세류를 침범하거나 - 인접 세류 너머로 나가지 않는다. - 반환: 폴리곤(간략화됨) 또는 None. - """ - lines = _explode_lines(stream_features) - others = [line for line in lines if line.distance(network_union) > STREAM_JOIN_TOL_M] - other_tree = STRtree(others) if others else None - min_x, min_y, max_x, max_y = network_union.buffer(VALLEY_CAP_M).bounds - cells = [] - y = min_y - while y < max_y: - x = min_x - while x < max_x: - center = Point(x + VALLEY_CELL_M / 2.0, y + VALLEY_CELL_M / 2.0) - distance = network_union.distance(center) - if distance <= VALLEY_CAP_M: - if other_tree is not None: - nearest = others[int(other_tree.nearest(center))] - if nearest.distance(center) < distance: - x += VALLEY_CELL_M - continue - cells.append(box(x, y, x + VALLEY_CELL_M, y + VALLEY_CELL_M)) - x += VALLEY_CELL_M - y += VALLEY_CELL_M - if not cells: - return None - region = unary_union(cells).buffer(0) - if region.geom_type == "MultiPolygon": - touching = [ - part - for part in region.geoms - if part.intersects(network_union) or part.distance(crossing) < VALLEY_CELL_M * 2 - ] - region = unary_union(touching) if touching else max(region.geoms, key=lambda p: p.area) - if region.geom_type == "MultiPolygon": - region = max(region.geoms, key=lambda p: p.area) - region = region.simplify(VALLEY_CELL_M, preserve_topology=True) - if region.is_empty or region.geom_type != "Polygon": - return None - if contour_index is not None: - region = _contour_chain_boundary(region, contour_index, network_union, others, other_tree) - return region - - -def _collect_intersection_points(geometry: Any) -> list[Point]: - """교차 결과에서 대표 점들을 뽑는다.""" - if geometry.is_empty: - return [] - if geometry.geom_type == "Point": - return [geometry] - if geometry.geom_type in {"MultiPoint", "GeometryCollection"}: - points: list[Point] = [] - for part in geometry.geoms: - points.extend(_collect_intersection_points(part)) - return points - if geometry.geom_type in {"LineString", "MultiLineString"}: - return [geometry.interpolate(0.5, normalized=True)] - return [] - - -def _contour_chain_boundary( - region: Any, - contour_index: Any, - network_union: Any, - others: list[LineString], - other_tree: STRtree | None, -) -> Any: - """유역 경계를 **등고선마다 능선 포인트 1개씩 찍어 연결**한 체인으로 재구성한다. - - (2026-07-29 사용자 지시: 정점 스냅은 점이 듬성듬성해 등고선을 건너뛴다.) - 등거리 1차 경계 링은 순서 뼈대로만 쓴다: 링을 가로지르는 모든 등고선 교차점마다 - 그 등고선 위에서 두 세류(우리 상류망·인접 세류) 모두로부터 가장 먼 지점(능선 - 꼭짓점)을 정제해 포인트를 얻고, 링 위 위치 순으로 연결한다. 등고선이 없는 구간은 - 원래 링 정점으로 메운다. 제약: 우리 세류 침범·인접 세류 이탈 금지. - - 2차 보정(2026-07-29 사용자 채택, "2번 방식"): 능선은 등고선의 **직교 궤적**이므로, - 각 포인트를 등고선 위에서 미세 이동해 경계선이 그 등고선과 수직으로 교차하도록 - 반복 조정한다(TOPOG/TAPES-C 계열 개념). 능선 점수(세류 최소거리)가 꼭짓점 대비 - 크게 떨어지는 이동은 막는다. - """ - ring = LineString(region.exterior.coords) - - def _score(point: Point) -> tuple[float, float]: - to_network = network_union.distance(point) - to_other = ( - others[int(other_tree.nearest(point))].distance(point) - if other_tree is not None - else float("inf") - ) - return to_network, to_other - - # ① 링을 가로지르는 등고선 교차점마다 능선 꼭짓점 1개. - # entry = [링 위치 s, Point, geom_index(-1=링 정점), 등고선 파라미터 t, 꼭짓점 점수] - entries: list[list[Any]] = [] - for index in contour_index.query(ring.buffer(1.0)): - geom = contour_index.geoms[index] - try: - crossings = _collect_intersection_points(geom.intersection(ring)) - except Exception: # noqa: BLE001 - continue - for crossing in crossings: - s = ring.project(crossing) - t0 = geom.project(crossing) - best = None - best_t = t0 - best_value = -1.0 - steps = int(RIDGE_WALK_M / 10.0) - for offset in [0.0] + [ - sign * k * 10.0 for k in range(1, steps + 1) for sign in (1, -1) - ]: - t = min(max(t0 + offset, 0.0), geom.length) - candidate = geom.interpolate(t) - if candidate.distance(crossing) > RIDGE_WALK_M + RIDGE_SNAP_M: - continue - to_network, to_other = _score(candidate) - if to_network < STREAM_JOIN_TOL_M: - continue # 우리 세류 침범 금지 - if to_other < to_network: - continue # 인접 세류 쪽으로 이탈 금지 - if min(to_network, to_other) > best_value: - best_value = min(to_network, to_other) - best = candidate - best_t = t - if best is not None: - entries.append([s, best, index, best_t, best_value]) - if len(entries) < 4: - return region - entries.sort(key=lambda entry: entry[0]) - # ② 등고선 공백 구간(교차점 사이가 먼 곳)은 원래 링 정점으로 메운다. - positions = [entry[0] for entry in entries] - for x, y in list(region.exterior.coords)[:-1]: - s = ring.project(Point(x, y)) - slot = bisect.bisect_left(positions, s) - before = positions[slot - 1] if slot > 0 else positions[-1] - ring.length - after = positions[slot] if slot < len(positions) else positions[0] + ring.length - if min(s - before, after - s) > VALLEY_CELL_M * 2.5: - entries.append([s, Point(x, y), -1, 0.0, 0.0]) - entries.sort(key=lambda entry: entry[0]) - # ③ 직교 보정: 경계 진행방향과 등고선 접선이 수직이 되도록 포인트를 미세 이동. - entries = _orthogonalize_chain(entries, contour_index, _score) - polygon = Polygon([(entry[1].x, entry[1].y) for entry in entries]).buffer(0) - if polygon.geom_type == "MultiPolygon": - polygon = max(polygon.geoms, key=lambda part: part.area) - if polygon.is_empty or polygon.geom_type != "Polygon": - return region - return polygon - - -def _orthogonalize_chain( - entries: list[list[Any]], - contour_index: Any, - score: Any, -) -> list[list[Any]]: - """체인 포인트를 등고선 위에서 이동해 경계가 등고선과 직교하게 만든다. - - 각 포인트에서 |등고선 접선 · 체인 진행방향| (수직이면 0)을 최소화한다. 이동 허용 - 조건: 세류 침범·이탈 금지 + 능선 점수(두 세류 최소거리)가 꼭짓점 값의 70% 이상. - 2회 반복으로 이웃 이동의 영향을 수렴시킨다. - """ - count = len(entries) - for _ in range(2): - for i, entry in enumerate(entries): - geom_index = entry[2] - if geom_index < 0: - continue - geom = contour_index.geoms[geom_index] - previous = entries[i - 1][1] - following = entries[(i + 1) % count][1] - dx, dy = following.x - previous.x, following.y - previous.y - norm = (dx * dx + dy * dy) ** 0.5 - if norm < 1.0: - continue - dx, dy = dx / norm, dy / norm - floor = 0.7 * entry[4] - best_t = entry[3] - best_point = entry[1] - best_dot = None - for offset in range(-int(RIDGE_SNAP_M), int(RIDGE_SNAP_M) + 1, 5): - t = min(max(entry[3] + float(offset), 0.0), geom.length) - candidate = geom.interpolate(t) - ahead = geom.interpolate(min(t + 4.0, geom.length)) - behind = geom.interpolate(max(t - 4.0, 0.0)) - tx, ty = ahead.x - behind.x, ahead.y - behind.y - tangent_norm = (tx * tx + ty * ty) ** 0.5 - if tangent_norm < 0.5: - continue - to_network, to_other = score(candidate) - if to_network < STREAM_JOIN_TOL_M or to_other < to_network: - continue - if min(to_network, to_other) < floor: - continue - dot = abs((tx * dx + ty * dy) / tangent_norm) - if best_dot is None or dot < best_dot: - best_dot = dot - best_t = t - best_point = candidate - entry[1] = best_point - entry[3] = best_t - return entries - - -def trace_ridge_march( - start: Point, - contour_index: Any, - network_union: Any, - others: list[LineString], - other_tree: STRtree | None, - road_line: LineString, - uphill_sign: int, -) -> list[DividerStep]: - """능선 행진(개선 2안) — 상위 등고선마다 능선 꼭짓점을 한 칸씩 밟아 오른다. - - 수작업 유역도 작도법의 자동화: 물갈림점에서 출발해 매 단계 "반경 안 현재보다 높은 - 등고선 중 가장 낮은 것" 위에서 **|우리 상류망까지 거리 − 인접 세류까지 거리|가 - 최소인 지점**(분수령 = 두 세류망 등거리점)으로 이동한다. 두 세류에서 가장 먼 점을 - 고르면 우리 지류들 사이 내부 지능선으로 새므로, 등거리 조건이 바깥 분수령을 강제 - 한다. 꼭짓점 연결선은 등고선과 자연히 직교한다. - 제약: 도로 산측 유지, 우리 세류 침범(15m)·인접 세류 과이탈 금지. 더 높은 등고선이 - 없으면(능선 정상) 자연 종료. 반환은 DividerStep 목록 — 기존 폐합 로직과 호환. - """ - - def _score(point: Point) -> tuple[float, float]: - to_network = network_union.distance(point) - to_other = ( - others[int(other_tree.nearest(point))].distance(point) - if other_tree is not None - else float("inf") - ) - return to_network, to_other - - z = contour_index.nearest_elevation(start, MARCH_RADIUS_M) - if z is None: - return [] - steps_out = [DividerStep(point=start, z=z, geom_index=-1)] - current = start - for step_no in range(MARCH_MAX_STEPS): - walk_m = MARCH_SEED_WALK_M if step_no == 0 else MARCH_WALK_M - reach_m = max(MARCH_RADIUS_M, walk_m) - best: tuple[float, float, Point, int] | None = None # (레벨, |dn-do|, 지점, geom idx) - for index in contour_index.query(current.buffer(reach_m)): - level = contour_index.zs[index] - if level <= z + 0.01: - continue - if best is not None and level > best[0]: - continue - geom = contour_index.geoms[index] - if geom.distance(current) > reach_m: - continue - t0 = geom.project(current) - walk = int(walk_m / 10.0) - for offset in [0.0] + [sign * k * 10.0 for k in range(1, walk + 1) for sign in (1, -1)]: - t = min(max(t0 + offset, 0.0), geom.length) - candidate = geom.interpolate(t) - if candidate.distance(current) > reach_m: - continue - # 도로 하류측 이탈 금지 — 단, 노선 끝 너머(투영이 끝점에 걸림)는 좌우 - # 부호가 무의미하므로 세류 제약에만 맡긴다(끝을 감아 도는 분수령 허용). - projection = road_line.project(candidate) - if ( - 5.0 < projection < road_line.length - 5.0 - and side_sign(road_line, candidate) == -uphill_sign - ): - continue - to_network, to_other = _score(candidate) - if to_network < STREAM_JOIN_TOL_M: - continue # 우리 세류 침범 금지 - if to_other < to_network * MARCH_OTHER_RATIO: - continue # 인접 세류 쪽 과이탈 금지(등거리선 부근 소폭 허용) - balance = abs(to_network - to_other) - if best is None or level < best[0] or (level == best[0] and balance < best[1]): - best = (level, balance, candidate, index) - if best is None: - break - z = best[0] - current = best[2] - steps_out.append(DividerStep(point=current, z=z, geom_index=best[3])) - return steps_out - - -def rim_walk( - start: Point, - target: Point, - contour_index: Any, - network_union: Any, - others: list[LineString], - other_tree: STRtree | None, - road_line: LineString, - uphill_sign: int, -) -> list[Point] | None: - """능선마루를 따라 두 행진 정상을 잇는다(개선 2안 상측 폐합). - - 좌·우 능선 정상 높이가 달라 단일 등고선 아크로 못 닫는 경우, 매 단계 target에 - 가까워지는 등고선 위 지점 중 |우리 세류 거리 − 인접 세류 거리|가 최소인 곳 - (분수령)으로 이동한다. 레벨 제한 없음(마루는 오르내린다). 막히면 None. - """ - - def _score(point: Point) -> tuple[float, float]: - to_network = network_union.distance(point) - to_other = ( - others[int(other_tree.nearest(point))].distance(point) - if other_tree is not None - else float("inf") - ) - return to_network, to_other - - points: list[Point] = [] - current = start - remaining = current.distance(target) - for _ in range(MARCH_MAX_STEPS): - if remaining <= MARCH_RADIUS_M: - return points - best: tuple[float, Point] | None = None # (|dn-do|, 지점) - for index in contour_index.query(current.buffer(MARCH_RADIUS_M)): - geom = contour_index.geoms[index] - if geom.distance(current) > MARCH_RADIUS_M: - continue - t0 = geom.project(current) - walk = int(MARCH_WALK_M / 10.0) - for offset in [0.0] + [sign * k * 10.0 for k in range(1, walk + 1) for sign in (1, -1)]: - t = min(max(t0 + offset, 0.0), geom.length) - candidate = geom.interpolate(t) - if candidate.distance(current) > MARCH_RADIUS_M: - continue - if candidate.distance(target) > remaining - 5.0: - continue # target에 실질적으로 가까워지는 이동만 허용 - projection = road_line.project(candidate) - if ( - 5.0 < projection < road_line.length - 5.0 - and side_sign(road_line, candidate) == -uphill_sign - ): - continue - to_network, to_other = _score(candidate) - if to_network < STREAM_JOIN_TOL_M: - continue - if to_other < to_network * MARCH_OTHER_RATIO: - continue - balance = abs(to_network - to_other) - if best is None or balance < best[0]: - best = (balance, candidate) - if best is None: - return None - current = best[1] - remaining = current.distance(target) - points.append(current) - return None - - -def trace_upstream_network( - crossing: Point, - stream_features: list[dict[str, Any]], - road_line: LineString, - uphill_sign: int, -) -> tuple[list[LineString], float]: - """세류 교차점에서 도로 산측으로 뻗는 상류망을 추적한다. - - ① 교차한 세류를 교차점에서 잘라 산측 조각을 뿌리로 삼는다. - ② 끝점이 기존 망에 근접(STREAM_JOIN_TOL_M)한 세류를 반복 편입한다(분기 포함). - 도로를 다시 가로지르는 조각은 절단하고, 총연장 상한을 두어 폭주를 막는다. - 반환: (상류망 폴리라인 목록, 최장 유하 경로 길이 m). - """ - lines = _explode_lines(stream_features) - network: list[tuple[LineString, float]] = [] # (폴리라인, 뿌리에서 시작점까지 누적거리) - used: set[int] = set() - total = 0.0 - - # ① 뿌리: 교차점을 지나는 세류의 산측 조각. 도엽 세류는 교차점 부근에서 별도 - # 피처로 조각나 있는 경우가 많아, 근접(STREAM_JOIN_TOL_M) 조각도 뿌리로 받는다. - for index, line in enumerate(lines): - distance = line.distance(crossing) - if distance > STREAM_JOIN_TOL_M: - continue - used.add(index) - if distance <= 1.0: - t = line.project(crossing) - pieces = [substring(line, 0.0, t), substring(line, t, line.length)] - else: - pieces = [line] - for piece in pieces: - if piece.geom_type != "LineString" or piece.length < 1.0: - continue - oriented = _oriented_from(piece, crossing) - probe = oriented.interpolate(min(10.0, oriented.length)) - if side_sign(road_line, probe) != uphill_sign: - continue - clipped = _clip_uphill(oriented, road_line, crossing) - if clipped is None: - continue - network.append((clipped, 0.0)) - total += clipped.length - if not network: - return [], 0.0 - - # ② 편입 반복: 끝점이 망에 닿는 세류를 상류로 붙인다. - grew = True - while grew and total < MAX_UPSTREAM_TOTAL_M: - grew = False - for index, line in enumerate(lines): - if index in used: - continue - attach: tuple[float, Point, LineString, float] | None = None - for endpoint in (Point(line.coords[0]), Point(line.coords[-1])): - for parent, parent_cum in network: - distance = parent.distance(endpoint) - if distance > STREAM_JOIN_TOL_M: - continue - cum = parent_cum + parent.project(endpoint) - if attach is None or distance < attach[0]: - attach = (distance, endpoint, parent, cum) - if attach is None: - continue - used.add(index) - _, endpoint, _, cum = attach - # 좌우(산측) 판정은 도로 근처에서만 신뢰 — 노선에서 먼 상류는 투영 기준이 - # 뒤틀려 부호가 뒤집히므로 연결성과 도로 재교차 절단만으로 판단한다. - near_road = road_line.distance(endpoint) < 2.0 * STREAM_JOIN_TOL_M - if near_road and side_sign(road_line, line.interpolate(0.5, normalized=True)) == ( - -uphill_sign - ): - continue - oriented = _oriented_from(line, endpoint) - clipped = _clip_uphill(oriented, road_line, endpoint) - if clipped is None: - continue - network.append((clipped, cum)) - total += clipped.length - grew = True - - flow_length = max((cum + line.length for line, cum in network), default=0.0) - return [line for line, _ in network], flow_length diff --git a/B05_wf2_Route/_legacy_watershed/README.md b/B05_wf2_Route/_legacy_watershed/README.md deleted file mode 100644 index 7eab5969..00000000 --- a/B05_wf2_Route/_legacy_watershed/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# _legacy_watershed (보관용, 실행 경로 아님) - -2026-07-31 배수유역 전면 재설계로 폐기된 **등고선 아크 추적 + 능선 행진** 방식 엔진 4종이다. -능선/계곡 분리가 안정적이지 않아 격자 흐름(D8 + 상류 BFS) 방식으로 교체되었다. - -| 파일 | 폐기 당시 역할 | -|---|---| -| `B05_wf2_Route_Engine_Drainage_Watershed.py` | 유역 산정 오케스트레이터 (`build_watershed_basins`) | -| `B05_wf2_Route_Engine_Watershed_Trace.py` | 등고선 아크 인덱싱·분수계 행진 | -| `B05_wf2_Route_Engine_Watershed_Assemble.py` | 아크+능선+도로선 폐합 폴리곤 조립 | -| `B05_wf2_Route_Engine_Watershed_Subdivide.py` | 메인 유역 내부 세부유역 분할 | - -**주의** -- 내용은 이동 당시 그대로이며 수정하지 않는다. 서로를 `B05_wf2_Route.B05_wf2_Route_Engine_Watershed_*` - 경로로 import하므로 이 폴더에서는 그대로 실행되지 않는다(의도된 상태 — 참고용 보관). -- 현행 엔진: `B05_wf2_Route_Engine_Watershed_Grid.py` / `_Flow.py` / `_Basin.py`. diff --git a/common_util/common_util_route_geometry.py b/common_util/common_util_route_geometry.py new file mode 100644 index 00000000..22913bcd --- /dev/null +++ b/common_util/common_util_route_geometry.py @@ -0,0 +1,255 @@ +"""계획 노선 기하 공용 유틸 — 정점·누가거리·세류 교차점. + +배수유역 분석(B04)과 관 편집·세부유역(B05)이 같은 노선 표현을 써야 하므로 여기 한 곳에만 +정의한다. 어느 한쪽 페이지 폴더에 두면 반대 방향 import가 생긴다. + +노선 원천은 두 가지다. + · B03에 업로드된 **계획 노선 파일**(CSV) — 배수유역 분석의 입력 + · DB `route_points` — B05에서 탐색·확정한 노선 +둘 다 같은 `RouteVertex` 목록으로 바꿔 아래 함수들이 그대로 받는다. +""" + +from __future__ import annotations + +import csv +import logging +import math +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from shapely.geometry import LineString, Point, shape + +logger = logging.getLogger(__name__) + +# 계획 노선 CSV 열 이름 후보. B03이 여러 형식을 받게 되므로 흔한 표기를 모두 받아 준다. +_X_KEYS = ("x", "X", "동", "easting", "EASTING") +_Y_KEYS = ("y", "Y", "북", "northing", "NORTHING") +_Z_KEYS = ("z", "Z", "표고", "elevation", "ELEV") +_ORDER_KEYS = ("sequence", "order", "seq", "no", "번호") +_EPSG_KEYS = ("crs_epsg", "epsg", "EPSG") + + +@dataclass +class RouteVertex: + """노선 폴리라인의 한 점. chainage는 시점 기준 누가거리(m).""" + + x: float + y: float + z: float + chainage_m: float + + +@dataclass +class StructureCandidate: + """관 매설 구조물 측점 후보.""" + + chainage_m: float + x: float + y: float + # "stream"=세류 교차, "spacing"=최대 간격 규칙 보충, "confirmed"=사용자 확정 + reason: str + stream_name: str | None = None + + +@dataclass +class PlannedRoute: + """계획 노선 파일에서 읽은 노선.""" + + vertices: list[RouteVertex] + epsg: int | None + name: str | None + source: Path + + @property + def line(self) -> LineString: + return LineString([(vertex.x, vertex.y) for vertex in self.vertices]) + + +def read_planned_route_csv(path: Path) -> PlannedRoute | None: + """계획 노선 CSV를 읽어 정점 목록으로 바꾼다. + + 열 이름은 대소문자·한글 표기를 함께 받아 준다(B03이 여러 형식을 수용할 예정). + `sequence`가 있으면 그 순서로 정렬하고, 없으면 파일에 적힌 순서를 그대로 쓴다. + """ + try: + with path.open("r", encoding="utf-8-sig", newline="") as file: + rows = list(csv.DictReader(file)) + except (OSError, csv.Error, UnicodeDecodeError): + logger.warning("계획 노선 CSV를 읽지 못했습니다: %s", path) + return None + if not rows: + return None + + epsg = _first_int(rows[0], _EPSG_KEYS) + name = _first_text(rows[0], ("route_name", "name", "노선명")) + parsed: list[tuple[float, float, float, float]] = [] # (정렬키, x, y, z) + for index, row in enumerate(rows): + x = _first_float(row, _X_KEYS) + y = _first_float(row, _Y_KEYS) + if x is None or y is None: + continue + order = _first_float(row, _ORDER_KEYS) + parsed.append( + (float(index) if order is None else order, x, y, _first_float(row, _Z_KEYS) or 0.0) + ) + if len(parsed) < 2: + logger.warning("계획 노선 CSV에 좌표가 2점 미만입니다: %s", path) + return None + + parsed.sort(key=lambda item: item[0]) + vertices: list[RouteVertex] = [] + cumulative = 0.0 + previous: tuple[float, float] | None = None + for _, x, y, z in parsed: + if previous is not None: + cumulative += math.dist(previous, (x, y)) + vertices.append(RouteVertex(x=x, y=y, z=z, chainage_m=cumulative)) + previous = (x, y) + logger.info( + "계획 노선 %s: 정점 %d개, 연장 %.0fm, EPSG %s", path.name, len(vertices), cumulative, epsg + ) + return PlannedRoute(vertices=vertices, epsg=epsg, name=name, source=path) + + +def find_planned_route_file(input_dir: Path) -> Path | None: + """B03 입력 폴더에서 계획 노선 파일을 찾는다. 여러 개면 가장 최근 것.""" + if not input_dir.exists(): + return None + candidates = sorted( + input_dir.rglob("*.csv"), key=lambda item: item.stat().st_mtime, reverse=True + ) + return candidates[0] if candidates else None + + +def build_route_vertices(points: list[dict[str, Any]]) -> list[RouteVertex]: + """DB route_points 행을 누가거리가 채워진 정점 목록으로 바꾼다.""" + vertices: list[RouteVertex] = [] + cumulative = 0.0 + previous: tuple[float, float] | None = None + for row in points: + x = float(row["x"]) + y = float(row["y"]) + z = float(row.get("z") or 0.0) + if previous is not None: + cumulative += math.dist(previous, (x, y)) + chainage = row.get("chainage_m") + vertices.append( + RouteVertex( + x=x, y=y, z=z, chainage_m=float(chainage) if chainage is not None else cumulative + ) + ) + previous = (x, y) + return vertices + + +def interpolate_vertex( + vertices: list[RouteVertex], chainage_m: float +) -> tuple[float, float, float]: + """누가거리 위치의 (x, y, z)를 선형 보간한다. 범위 밖은 끝점으로 당긴다.""" + if not vertices: + return (0.0, 0.0, 0.0) + if chainage_m <= vertices[0].chainage_m: + return (vertices[0].x, vertices[0].y, vertices[0].z) + for previous, current in zip(vertices, vertices[1:]): + if chainage_m <= current.chainage_m: + span = current.chainage_m - previous.chainage_m + ratio = 0.0 if span <= 0 else (chainage_m - previous.chainage_m) / span + return ( + previous.x + (current.x - previous.x) * ratio, + previous.y + (current.y - previous.y) * ratio, + previous.z + (current.z - previous.z) * ratio, + ) + last = vertices[-1] + return (last.x, last.y, last.z) + + +def is_uphill_at(vertices: list[RouteVertex], chainage_m: float, window_m: float = 20.0) -> bool: + """해당 위치가 오르막(절토부)인지 종단 계획선의 국소 기울기 부호로 판정한다.""" + _, _, back_z = interpolate_vertex(vertices, max(0.0, chainage_m - window_m)) + _, _, forward_z = interpolate_vertex(vertices, chainage_m + window_m) + return forward_z >= back_z + + +def find_stream_crossings( + vertices: list[RouteVertex], + stream_features: list[dict[str, Any]], +) -> list[StructureCandidate]: + """노선 평면 선형과 세류선의 교차 지점을 누가거리 순으로 찾는다.""" + if len(vertices) < 2: + return [] + route_line = LineString([(vertex.x, vertex.y) for vertex in vertices]) + candidates: list[StructureCandidate] = [] + for feature in stream_features: + geometry = feature.get("geometry") + if not geometry: + continue + try: + stream = shape(geometry) + except Exception: # noqa: BLE001 - 손상된 피처는 건너뛴다 + continue + if stream.is_empty: + continue + intersection = route_line.intersection(stream) + if intersection.is_empty: + continue + name = _stream_name(feature) + for point in _collect_points(intersection): + candidates.append( + StructureCandidate( + chainage_m=route_line.project(point), + x=point.x, + y=point.y, + reason="stream", + stream_name=name, + ) + ) + candidates.sort(key=lambda item: item.chainage_m) + return candidates + + +def _stream_name(feature: dict[str, Any]) -> str | None: + properties = feature.get("properties") or {} + for key in ("명칭", "하천명", "NAME", "name"): + value = properties.get(key) + if value: + return str(value) + return None + + +def _collect_points(geometry: Any) -> list[Point]: + """교차 결과(Point/MultiPoint/LineString 등)에서 대표 점들을 뽑는다.""" + if geometry.geom_type == "Point": + return [geometry] + if geometry.geom_type in {"MultiPoint", "GeometryCollection"}: + points: list[Point] = [] + for part in geometry.geoms: + points.extend(_collect_points(part)) + return points + # 선분끼리 겹쳐 선으로 나온 경우는 중점을 대표로 쓴다. + if geometry.geom_type in {"LineString", "MultiLineString"}: + return [geometry.interpolate(0.5, normalized=True)] + return [] + + +def _first_text(row: dict[str, Any], keys: tuple[str, ...]) -> str | None: + for key in keys: + value = row.get(key) + if value not in (None, ""): + return str(value).strip() + return None + + +def _first_float(row: dict[str, Any], keys: tuple[str, ...]) -> float | None: + text = _first_text(row, keys) + if text is None: + return None + try: + return float(text) + except ValueError: + return None + + +def _first_int(row: dict[str, Any], keys: tuple[str, ...]) -> int | None: + value = _first_float(row, keys) + return None if value is None else int(value) diff --git a/config/config_frontend.ts b/config/config_frontend.ts index 3e0f6e56..55a4ddde 100644 --- a/config/config_frontend.ts +++ b/config/config_frontend.ts @@ -41,8 +41,8 @@ export const PROGRESS_UPDATE_INTERVAL_MS = 10_000; /** B03 업로드 Service Worker 번들 경로 */ export const SERVICE_WORKER_PATH = "/assets/B03_FileInput_ServiceWorker.js"; -/** 허용 확장자 (지형/포인트클라우드/도면) */ -export const UPLOAD_ALLOWED_EXT = [".las", ".laz", ".tif", ".tfw", ".prj", ".dxf"] as const; +/** 허용 확장자 (계획노선/지형/포인트클라우드/도면) */ +export const UPLOAD_ALLOWED_EXT = [".csv", ".las", ".laz", ".tif", ".tfw", ".prj", ".dxf"] as const; /* ----------------------------------------------------------------------------- * 3. WebCAD / 3D 렌더링 옵션 diff --git a/config/config_system.py b/config/config_system.py index 51c5e2fc..771fedcc 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -48,7 +48,7 @@ DB_POOL_MAX = int(os.getenv("DB_POOL_MAX", "20")) UPLOAD_MAX_MB = int(os.getenv("UPLOAD_MAX_MB", str(30 * 1024))) UPLOAD_MAX_FILES = int(os.getenv("UPLOAD_MAX_FILES", "5")) UPLOAD_CHUNK_SIZE_BYTES = int(os.getenv("UPLOAD_CHUNK_SIZE_BYTES", str(1024 * 1024 * 1024))) -UPLOAD_ALLOWED_EXT = [".las", ".laz", ".tif", ".tfw", ".prj", ".dxf"] +UPLOAD_ALLOWED_EXT = [".csv", ".las", ".laz", ".tif", ".tfw", ".prj", ".dxf"] CHUNK_TEMP_DIR = os.getenv("CHUNK_TEMP_DIR", "B03_FileInput/chunks_temp") CHUNK_RETENTION_HOURS = int(os.getenv("CHUNK_RETENTION_HOURS", "24")) MERGE_TIMEOUT_SECONDS = int(os.getenv("MERGE_TIMEOUT_SECONDS", "3600")) diff --git a/main.py b/main.py index 50680a2a..0f491da1 100644 --- a/main.py +++ b/main.py @@ -31,6 +31,7 @@ from B04_wf1_Surface.B04_wf1_Surface_Router import router as b04_surface_router from B04_wf1_Surface.B04_wf1_Surface_Router_Contour import router as b04_surface_contour_router from B04_wf1_Surface.B04_wf1_Surface_Router_GIS import router as b04_surface_gis_router from B04_wf1_Surface.B04_wf1_Surface_Router_GIS import tiles_router +from B04_wf1_Surface.B04_wf1_Surface_Router_Watershed import router as b04_watershed_router from B05_wf2_Route.B05_wf2_Route_Router import router as b05_route_router from B05_wf2_Route.B05_wf2_Route_Router_Drainage import router as b05_drainage_router from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Router import router as b06_section_router @@ -273,6 +274,7 @@ app.include_router(b03_file_input_router, dependencies=protected_with_company) app.include_router(b04_surface_router, dependencies=protected_with_company) app.include_router(b04_surface_contour_router, dependencies=protected_with_company) app.include_router(b04_surface_gis_router, dependencies=protected_with_company) +app.include_router(b04_watershed_router, dependencies=protected_with_company) app.include_router(tiles_router, dependencies=protected_with_company) app.include_router(b05_route_router, dependencies=protected_with_company) app.include_router(b05_drainage_router, dependencies=protected_with_company) diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index 952e6a17..8061fda4 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -527,13 +527,13 @@ export const ui_locales = { /* --- B03_FileInput 파일 입력 --- */ B03_File_Title: ["파일 입력", "File Input"], B03_File_Subtitle: [ - "지형·포인트클라우드·도면 파일을 업로드하세요.", - "Upload terrain, point cloud, and drawing files.", + "필수 계획노선과 지형·포인트클라우드 파일을 업로드하세요.", + "Upload the required planned route, terrain, and point cloud files.", ], B03_File_Select_Label: ["입력 파일 선택", "Select input files"], B03_File_Select_Hint: [ - "LAS/LAZ 1개를 포함해 관련 PRJ, TFW, TIF 또는 도면 파일을 선택하세요.", - "Select exactly one LAS/LAZ file with related PRJ, TFW, TIF, or drawing files.", + "계획노선 CSV, LAS/LAZ 1개, PRJ, TFW를 선택하세요. TIF는 선택 사항입니다.", + "Select a planned-route CSV, one LAS/LAZ, PRJ, and TFW. TIF is optional.", ], B03_File_Selected_Title: ["선택한 파일", "Selected files"], B03_File_Selected_Empty: ["선택한 파일이 없습니다.", "No files selected."], @@ -566,6 +566,9 @@ export const ui_locales = { B03_File_Result_Path: ["저장 경로", "Stored path"], B03_File_Group_Required: ["필수 파일", "Required files"], B03_File_Group_Optional: ["선택 파일", "Optional files"], + B03_File_Group_Route: ["원청 계획노선 (필수)", "Client Planned Route (Required)"], + B03_File_Group_Terrain: ["지형 분석자료", "Terrain Analysis Files"], + B03_File_Slot_PlannedRoute: ["계획노선 좌표", "Planned Route Coordinates"], B03_File_Slot_PointCloud: ["포인트클라우드", "Point Cloud"], B03_File_Slot_Projection: ["좌표계 정의", "Projection"], B03_File_Slot_RasterCoord: ["래스터 좌표", "Raster Coord 1"], @@ -578,8 +581,8 @@ export const ui_locales = { "A file for this slot is already selected.", ], B03_File_Error_RequiredSlots: [ - "필수 파일(LAS/LAZ, PRJ, TFW)을 모두 선택하세요.", - "Select all required files: LAS/LAZ, PRJ, and TFW.", + "필수 파일(계획노선 CSV, LAS/LAZ, PRJ, TFW)을 모두 선택하세요.", + "Select all required files: planned-route CSV, LAS/LAZ, PRJ, and TFW.", ], B03_File_Error_SlotType: [ "선택한 파일 유형이 이 카드와 맞지 않습니다.", From 395e6b4b1391a697b50ee83d787490cb3fe4b3a4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 31 Jul 2026 21:02:52 +0900 Subject: [PATCH 41/61] =?UTF-8?q?fix(B04):=20=EB=B0=B0=EC=88=98=EC=9C=A0?= =?UTF-8?q?=EC=97=AD=20=EC=98=A4=EB=B2=84=EB=A0=88=EC=9D=B4=EA=B0=80=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=97=90=20=EC=95=88=20=EA=B7=B8=EB=A0=A4?= =?UTF-8?q?=EC=A7=80=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 분석은 돌고 상태 문구도 나오는데 지도에는 아무것도 안 보였다. 원인: 프레임 함수 이름이 draw() 가 아니라 drawVectorLayer() 라 오버레이 draw 호출을 끼워 넣는 치환이 조용히 실패했다. 호출 자체가 소스에 없었다. - drawVectorLayer() 안, GIS 레이어와 등고 라벨 뒤에 오버레이 draw 삽입. 격자/화살표가 등고선을 덮어야 읽히므로 맨 위에 얹는다. - drawPrimaryRegion 이 drawGridCells 를 한 번 더 부르던 중복 제거 (draw 체인이 이미 먼저 호출한다). Co-Authored-By: Claude Fable 5 --- B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts | 2 ++ B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts | 4 +--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index b65f302a..58749d60 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -329,6 +329,8 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { if (prepared) drawPreparedLabels(context, prepared, view, GIS_LAYER_COLORS[layer]); }); } + // 배수유역 오버레이는 GIS 레이어 위에 얹는다 — 격자·화살표가 등고선을 덮어야 읽힌다. + if (normalizer) watershed.draw(context, normalizer, view); updateImageTransform(); drawScaleBar(mapRect); } diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts index 0f55463e..bf7ad5bf 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts @@ -242,9 +242,7 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { region: WatershedAnalysis, ): void { context.save(); - // ① 해석 격자 — bbox 테두리 + 실제 셀 눈금. - drawGridCells(context, map, view, region); - // ② 1차 배수유역 = 상류 세류망의 반경 버퍼 합집합. + // 1차 배수유역 = 상류 세류망의 반경 버퍼 합집합. (격자는 draw()에서 먼저 깔았다) context.setLineDash([]); context.lineWidth = 2; context.strokeStyle = "rgba(5, 150, 105, 0.95)"; From 79fe2fb7c9c1f67d98e0586d2e87b15775753564 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 31 Jul 2026 21:09:20 +0900 Subject: [PATCH 42/61] =?UTF-8?q?feat(B04):=20=ED=91=9C=EB=B3=B8=20?= =?UTF-8?q?=ED=99=94=EC=82=B4=ED=91=9C=20+=20=EC=98=A4=EB=B2=84=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=20=EA=B0=88=EB=9E=98=EB=B3=84=20=ED=86=A0=EA=B8=80=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 화살표가 안 보이던 문제 1m 격자를 도엽 전체 배율로 보면 셀이 1~2px 다. 줌 상한이 8배라 셀이 화살표 최소 크기(7px)에 절대 못 미쳐 채움색만 보였다. -> 셀마다 그리지 않고 화면상 약 22px 간격이 되도록 건너뛰며 표본만 그린다. 화살표 크기는 그 간격에 맞춰 커진다. 흐름장을 읽는 표준 방식이다. -> 채움색 위에서도 읽히도록 흰 테두리를 한 겹 깔고 그 위에 그린다. 갈래별 토글 버튼 추가 (1차 유역 / 2차 유역 / 유역 방향) GIS 버튼 줄에 배수유역 전체 토글 옆으로 붙는다. 전체 토글이 꺼져 있으면 갈래 설정과 무관하게 아무것도 그리지 않는다. 그리는 순서는 유역방향(격자/화살표) -> 1차 유역 -> 2차 유역/관. Co-Authored-By: Claude Fable 5 --- .../B04_wf1_Surface_UI_MapViewer.ts | 2 +- .../B04_wf1_Surface_UI_Watershed.ts | 98 ++++++++++++++----- 2 files changed, 76 insertions(+), 24 deletions(-) diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index 58749d60..66c51074 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -227,7 +227,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { if (text) status.textContent = text; scheduleDraw(); }); - gisButtons.append(watershed.button); + gisButtons.append(watershed.button, ...watershed.partButtons); function updateImageTransform(): void { backgroundImages.forEach((image) => { diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts index bf7ad5bf..8950ac7f 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts @@ -23,16 +23,29 @@ const FLOW_AWAY_FILL = "rgba(37, 99, 235, 0.22)"; const FLOW_AWAY_LINE = "rgba(255, 255, 255, 0.95)"; /** 등고선 TIN 밖이라 표고가 없어 판정하지 못한 셀 — 미도달(파랑)과 구분한다. */ const FLOW_UNKNOWN_FILL = "rgba(120, 113, 108, 0.18)"; -/** 셀이 이보다 작으면 화살표가 뭉개져 읽히지 않으므로 채움색만 남긴다(px). */ +/** 화살표가 이보다 작으면 뭉개져 읽히지 않으므로 채움색만 남긴다(px). */ const ARROW_MIN_PX = 7; +/** 화면상 화살표 간격 목표(px). 1m 격자를 도엽 배율로 보면 셀이 1~2px라 셀마다 그릴 수 없다. + * 이 간격이 되도록 셀을 건너뛰며 표본만 그린다 — 흐름장을 읽는 표준 방식이다. */ +const ARROW_SPACING_PX = 22; /** 2차 전체 배수유역 외곽선 = 분수령. */ const BASIN_RING_COLOR = "rgba(146, 64, 14, 0.95)"; /** 기본 관 마커. */ const PIPE_COLOR = "rgba(249, 115, 22, 0.95)"; +/** 개별로 켜고 끌 수 있는 오버레이 갈래. */ +const PARTS = [ + { key: "primary", label: "1차 유역", color: "#059669" }, + { key: "basin", label: "2차 유역", color: "#92400e" }, + { key: "flow", label: "유역 방향", color: "#2563eb" }, +] as const; +type PartKey = (typeof PARTS)[number]["key"]; + export interface WatershedOverlay { - /** 레이어 토글 버튼. 지도 헤더의 GIS 버튼 줄에 넣는다. */ + /** 분석 실행 + 전체 토글 버튼. 지도 헤더의 GIS 버튼 줄에 넣는다. */ button: HTMLButtonElement; + /** 갈래별 표시 토글 버튼(1차 유역 / 2차 유역 / 유역 방향). */ + partButtons: HTMLButtonElement[]; /** 켜져 있는지. draw() 호출 전에 확인한다. */ visible: () => boolean; /** 상태 문구(분석 요약 또는 오류). 없으면 빈 문자열. */ @@ -60,6 +73,24 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { "계획 노선(B03 업로드)과 도엽 등고선·세류선으로 배수유역을 분석합니다. " + "30초 안팎이 걸리며 결과는 영구저장소에 남습니다."; + // 갈래별 표시 여부. 전체 토글(button)이 꺼져 있으면 이 값과 무관하게 아무것도 안 그린다. + const shownParts: Record = { primary: true, basin: true, flow: true }; + const partButtons = PARTS.map((part) => { + const element = document.createElement("button"); + element.type = "button"; + element.className = "b04-map__layer-button b04-map__layer-button--gis is-active"; + element.textContent = part.label; + element.style.setProperty("--b04-layer-color", part.color); + element.setAttribute("aria-pressed", "true"); + element.addEventListener("click", () => { + shownParts[part.key] = !shownParts[part.key]; + element.classList.toggle("is-active", shownParts[part.key]); + element.setAttribute("aria-pressed", String(shownParts[part.key])); + onChange(); + }); + return element; + }); + let projectId: string | null = null; function strokeLonLat( @@ -123,6 +154,10 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { const cellH = (bottom - top) / Math.max(rows, 1); const cellPx = Math.min(Math.abs(cellW), Math.abs(cellH)); const bytes = flowBytes(region); + // 1m 격자를 도엽 전체 배율로 보면 셀이 1~2px라 셀마다 화살표를 그리면 아무것도 안 보인다. + // 화면에서 대략 ARROW_SPACING_PX 간격이 되도록 셀을 건너뛰며 표본만 그린다. + const stride = Math.max(1, Math.ceil(ARROW_SPACING_PX / Math.max(cellPx, 0.01))); + const arrowPx = cellPx * stride; context.save(); context.setLineDash([]); @@ -158,14 +193,18 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { const invalid = region.flow?.invalid_code ?? 33; const steps = region.flow?.azimuth_steps ?? 32; for (let offset = 0; offset < count; offset += 1) { + const col = colStart + offset; + // 화살표는 표본만 그린다 — 격자가 촘촘하면 셀마다 그려 봐야 뭉개져서 안 보인다. + const sampled = row % stride === 0 && col % stride === 0; drawFlowCell( context, bytes[base + offset], - left + cellW * (colStart + offset), + left + cellW * col, y, cellW, cellH, cellPx, + sampled ? arrowPx : 0, { sink, invalid, steps }, ); } @@ -182,6 +221,7 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { cellW: number, cellH: number, cellPx: number, + arrowPx: number, codes: { sink: number; invalid: number; steps: number }, ): void { const azimuth = code & 0x3f; @@ -199,7 +239,8 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { context.lineWidth = 0.5; context.strokeRect(x, y, cellW, cellH); } - if (cellPx < ARROW_MIN_PX || unanalyzed) return; + // arrowPx = 0 이면 표본에서 빠진 셀이라 채움만 하고 끝낸다. + if (arrowPx < ARROW_MIN_PX || unanalyzed) return; const stroke = reaches ? FLOW_TO_ROAD_LINE : FLOW_AWAY_LINE; const midX = x + cellW / 2; const midY = y + cellH / 2; @@ -207,7 +248,7 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { // 제자리(싱크) — 방향이 없으므로 점으로 표시한다. context.fillStyle = stroke; context.beginPath(); - context.arc(midX, midY, Math.max(1, cellPx * 0.12), 0, Math.PI * 2); + context.arc(midX, midY, Math.max(1, arrowPx * 0.12), 0, Math.PI * 2); context.fill(); return; } @@ -215,23 +256,32 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { const angle = (azimuth * 2 * Math.PI) / codes.steps; const unitX = Math.cos(angle); const unitY = Math.sin(angle); - const reach = cellPx * 0.38; + const reach = arrowPx * 0.38; const tipX = midX + unitX * reach; const tipY = midY + unitY * reach; - context.strokeStyle = stroke; - context.lineWidth = Math.max(0.6, cellPx * 0.09); - context.beginPath(); - context.moveTo(midX - unitX * reach, midY - unitY * reach); - context.lineTo(tipX, tipY); - context.stroke(); - // 촉 — 진행 방향 기준 좌우로 짧게 접는다. - const head = cellPx * 0.18; - context.beginPath(); - context.moveTo(tipX, tipY); - context.lineTo(tipX - (unitX + unitY * 0.7) * head, tipY - (unitY - unitX * 0.7) * head); - context.moveTo(tipX, tipY); - context.lineTo(tipX - (unitX - unitY * 0.7) * head, tipY - (unitY + unitX * 0.7) * head); - context.stroke(); + // 표본 화살표는 채움색 위에서도 읽혀야 하므로 흰 테두리를 한 겹 깔고 그 위에 그린다. + const width = Math.max(1, arrowPx * 0.08); + const head = arrowPx * 0.18; + const stem: [number, number][] = [ + [midX - unitX * reach, midY - unitY * reach], + [tipX, tipY], + ]; + for (const [color, lineWidth] of [ + ["rgba(255, 255, 255, 0.85)", width + 1.6] as const, + [stroke, width] as const, + ]) { + context.strokeStyle = color; + context.lineWidth = lineWidth; + context.beginPath(); + context.moveTo(stem[0][0], stem[0][1]); + context.lineTo(stem[1][0], stem[1][1]); + // 촉 — 진행 방향 기준 좌우로 짧게 접는다. + context.moveTo(tipX, tipY); + context.lineTo(tipX - (unitX + unitY * 0.7) * head, tipY - (unitY - unitX * 0.7) * head); + context.moveTo(tipX, tipY); + context.lineTo(tipX - (unitX - unitY * 0.7) * head, tipY - (unitY + unitX * 0.7) * head); + context.stroke(); + } } /** 1차 배수유역 근거를 겹쳐 그린다 — 단계 검증용. */ @@ -372,6 +422,7 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { return { button, + partButtons, visible: () => shown && analysis !== null, status: () => statusText, reset() { @@ -387,9 +438,10 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { }, draw(context, map, view) { if (!shown || !analysis) return; - drawGridCells(context, map, view, analysis); - drawPrimaryRegion(context, map, view, analysis); - drawBasinAndPipes(context, map, view, analysis); + // 격자·화살표(유역 방향) → 1차 영역 → 2차 유역·관 순으로 아래에서 위로 쌓는다. + if (shownParts.flow) drawGridCells(context, map, view, analysis); + if (shownParts.primary) drawPrimaryRegion(context, map, view, analysis); + if (shownParts.basin) drawBasinAndPipes(context, map, view, analysis); }, }; } From 2ddf81c86caf25f59b1efab2b1633c149645c43e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 31 Jul 2026 21:20:44 +0900 Subject: [PATCH 43/61] =?UTF-8?q?feat(B04/B05):=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EB=B6=84=20=EC=9E=90=EB=8F=99=20=ED=91=9C=EC=8B=9C=20+=20?= =?UTF-8?q?=ED=8F=89=EA=B7=A0=20=ED=9D=90=EB=A6=84=20=ED=99=94=EC=82=B4?= =?UTF-8?q?=ED=91=9C=20+=20=EA=B0=88=EB=9E=98=EB=B3=84=20=ED=86=A0?= =?UTF-8?q?=EA=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 재산정하지 않는 한 저장분을 그대로 쓴다 GET /drainage/primary-region 이 기본으로 영구저장소의 응답 캐시를 그대로 돌려준다(0.0004s). refresh=true 일 때만 다시 계산한다. 저장 배열에서 응답을 재조립하지 않고 **응답 자체**를 남긴다 — 재조립하면 원본과 어긋날 여지가 생긴다. 버튼 이름 배수유역 -> 배수유역 재산정. 지도를 열면 저장분이 자동으로 뜬다. 2. B05용 평균 흐름 화살표 (build_flow_arrows) 셀 화살표는 1m 라 도면 배율에서 경향이 안 보인다. 겹치지 않는 10m 블록으로 나눠 방향을 원형 평균하고, 40m 간격으로 솎아낸다. - 세류선 셀과 도로 셀은 뺀다. 그 자리는 확정된 물길/노면을 따르는 값이라 사면 경향을 왜곡한다. - 산술평균이 아니라 원형 평균(0도와 359도의 평균은 180도가 아니다). 평균 벡터 길이가 방향 일치도이므로 0.7 미만이면 그 블록은 버린다. - 블록 유효 셀이 절반 미만이면 건너뛴다(가장자리 조각 방지). 실데이터 377개, 간격 40m 균일, 블록당 평균 98셀, 적 278 / 청 99. config: ARROW_BLOCK_M / ARROW_SPACING_M / ARROW_MIN_COVERAGE / ARROW_MIN_AGREEMENT 3. 갈래별 하이드/쇼 버튼 4종 1차 유역 / 2차 유역 / 유역 방향 / 평균 흐름. 평균 화살표는 B04 에도 올려 형태를 확인할 수 있게 했다. 4. B05 는 페이지 진입 즉시 유역도를 올린다 B04 결과를 읽기만 해 0.1초면 끝나므로 버튼을 기다릴 이유가 없다. Co-Authored-By: Claude Fable 5 --- B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts | 19 ++- ...04_wf1_Surface_Engine_Watershed_Analyze.py | 76 ++++++++++ .../B04_wf1_Surface_Router_Watershed.py | 65 ++++++++- .../B04_wf1_Surface_UI_Watershed.ts | 134 ++++++++++++++---- .../B05_wf2_Route_UI_Drainage_Panel.ts | 8 +- config/config_system.py | 15 +- 6 files changed, 276 insertions(+), 41 deletions(-) diff --git a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts index 7650e13f..f85dc842 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts @@ -330,15 +330,26 @@ export interface WatershedAnalysis { strength_profile: Array<[number, number]>; /** 기본 관 매설 위치 — 도로 × 세류선 교차점. */ pipes: WatershedPipe[]; + /** B05용 평균 흐름 화살표 — [lon, lat, 방위(도), 도로도달, 셀 수]. + * 세류·도로 셀을 뺀 10m 블록 평균이라 사면 경향만 남는다. */ + flow_arrows: Array<[number, number, number, boolean, number]>; + /** 계산하지 않고 저장분을 그대로 돌려준 응답인지. */ + from_cache: boolean; /** 영구저장소에 남긴 검증용 GeoJSON 경로. */ saved_to: string | null; } -export async function fetchWatershedAnalysis(projectId: string): Promise { - // 등고선 하강 방향 + 적색 확장 루프까지 도는 요청이라 수십 초가 걸린다. +/** 배수유역 분석 결과를 받는다. + * + * `refresh`를 주지 않으면 영구저장소에 남은 결과를 그대로 받아 즉시 끝난다. + * `refresh=true`면 처음부터 다시 계산하므로 수십 초가 걸린다. */ +export async function fetchWatershedAnalysis( + projectId: string, + refresh = false, +): Promise { return requestJson( - `/projects/${projectId}/drainage/primary-region`, + `/projects/${projectId}/drainage/primary-region?refresh=${refresh}`, { method: "GET" }, - API_ANALYSIS_TIMEOUT_MS, + refresh ? API_ANALYSIS_TIMEOUT_MS : API_TIMEOUT_MS, ); } diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Analyze.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Analyze.py index 7fcc071d..8955406c 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Analyze.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Analyze.py @@ -21,6 +21,7 @@ B05에 남긴다. from __future__ import annotations import logging +import math import time from dataclasses import dataclass, field from typing import Any @@ -38,6 +39,7 @@ from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Flow import ( trace_flow, ) from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import ( + AZIMUTH_STEPS, GridSpec, TerrainGrid, build_contour_cloud, @@ -53,6 +55,10 @@ from common_util.common_util_route_geometry import ( find_stream_crossings, ) from config.config_system import ( + DRAINAGE_ARROW_BLOCK_M, + DRAINAGE_ARROW_MIN_AGREEMENT, + DRAINAGE_ARROW_MIN_COVERAGE, + DRAINAGE_ARROW_SPACING_M, DRAINAGE_GRID_SIZE_M, DRAINAGE_INITIAL_RADIUS_M, DRAINAGE_PIPE_MIN_SPACING_M, @@ -126,6 +132,8 @@ class StagePreview: basin_area_m2: float = 0.0 # 셀 → 도로 셀 귀속. B05가 세부유역을 나눌 때 이 배열이 있어야 한다. routing: Any = None + # B05용 평균 흐름 화살표 — (x, y, 방위 라디안, 도로 도달, 셀 수). + flow_arrows: list[tuple[float, float, float, bool, int]] = field(default_factory=list) # ⑧ 기본 관 매설 위치 — 도로 × 세류선 교차점. pipes: list[StructureCandidate] = field(default_factory=list) @@ -184,6 +192,9 @@ def preview_stages( # ⑧ 기본 관 매설 위치 = 도로 × 세류선 교차점(너무 가까운 것은 하나로 합침). pipes = _base_pipes(vertices, stream_features) + # B05에 얹을 평균 흐름 화살표 — 셀 화살표는 도면 배율에서 안 보인다. + flow_arrows = build_flow_arrows(analysis, analysis.flow) + logger.info( "배수유역: 단계 분석 %.1fs (확장 %d회, 최종 셀 %d개) — " "2차 유역 %.0f㎡, 기본 관 %d개, 강도 곡선 %d점", @@ -210,6 +221,7 @@ def preview_stages( basin_area_m2=int(red.sum()) * spec.cell_area_m2, routing=routing, pipes=pipes, + flow_arrows=flow_arrows, ) @@ -228,6 +240,70 @@ def _preview_strength( ) +def build_flow_arrows(analysis: Any, flow: Any) -> list[tuple[float, float, float, bool, int]]: + """셀 흐름을 블록 단위로 평균해 B05에 얹을 화살표를 뽑는다. + + 셀 화살표는 1m라 도면 배율에서 경향이 안 보인다. 겹치지 않는 블록으로 나눠 방향을 + 평균하고, 화살표끼리 최소 간격을 두어 솎아낸다(2026-07-31 사용자 지시). + + **세류선 셀과 도로 셀은 뺀다.** 그 자리 흐름은 지형 경사가 아니라 확정된 물길·노면을 + 따르는 값이라 사면 경향을 왜곡한다. + + 방향 평균은 산술평균이 아니라 **원형 평균**으로 낸다(0°와 359°의 평균은 180°가 아니라 + 0°다). 평균 벡터 길이가 일치도이므로, 블록 안 방향이 제각각이면 그 블록은 버린다. + """ + spec = analysis.spec + rows, cols = spec.n_rows, spec.n_cols + direction = flow.direction.reshape(rows, cols) + usable = ( + analysis.domain + & flow.analyzed.reshape(rows, cols) + & (direction < AZIMUTH_STEPS) # 싱크·무효 제외 + & ~analysis.road.mask + ) + if flow.burned is not None: + usable &= ~flow.burned.reshape(rows, cols) + if not usable.any(): + return [] + + block = max(1, int(round(DRAINAGE_ARROW_BLOCK_M / spec.cell_m))) + stride = max(1, int(round(DRAINAGE_ARROW_SPACING_M / (block * spec.cell_m)))) + angle = direction.astype(np.float64) * (2.0 * math.pi / AZIMUTH_STEPS) + reaches = flow.reaches_road.reshape(rows, cols) + + arrows: list[tuple[float, float, float, bool, int]] = [] + for row0 in range(0, rows - block + 1, block * stride): + for col0 in range(0, cols - block + 1, block * stride): + window = usable[row0 : row0 + block, col0 : col0 + block] + count = int(window.sum()) + if count < DRAINAGE_ARROW_MIN_COVERAGE * block * block: + continue + local = angle[row0 : row0 + block, col0 : col0 + block][window] + mean_x = float(np.cos(local).mean()) + mean_y = float(np.sin(local).mean()) + agreement = math.hypot(mean_x, mean_y) + if agreement < DRAINAGE_ARROW_MIN_AGREEMENT: + continue # 방향이 제각각인 블록 — 평균이 경향을 대표하지 못한다 + centre_row = row0 + block / 2.0 + centre_col = col0 + block / 2.0 + arrows.append( + ( + spec.x_min + centre_col * spec.cell_m, + spec.y_max - centre_row * spec.cell_m, + math.atan2(mean_y, mean_x), + bool(reaches[row0 : row0 + block, col0 : col0 + block][window].mean() >= 0.5), + count, + ) + ) + logger.info( + "배수유역: 평균 흐름 화살표 %d개 (블록 %.0fm, 간격 %.0fm, 세류·도로 셀 제외)", + len(arrows), + block * spec.cell_m, + block * stride * spec.cell_m, + ) + return arrows + + def _base_pipes( vertices: list[RouteVertex], stream_features: list[dict[str, Any]] ) -> list[StructureCandidate]: diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router_Watershed.py b/B04_wf1_Surface/B04_wf1_Surface_Router_Watershed.py index 248a69f1..757a0e0d 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Router_Watershed.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Router_Watershed.py @@ -11,6 +11,7 @@ import asyncio import base64 import json import logging +import math from pathlib import Path from typing import Any from uuid import UUID @@ -23,7 +24,11 @@ from shapely.geometry import Point, Polygon, box from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Analyze import preview_stages -from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Export import write_grid_arrays, write_stage +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Export import ( + drainage_dir, + write_grid_arrays, + write_stage, +) from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import ( AZIMUTH_INVALID, AZIMUTH_SINK, @@ -38,6 +43,7 @@ from common_util.common_util_route_geometry import ( ) from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool +from config.config_system import DRAINAGE_RESPONSE_FILENAME logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B04 Surface Watershed"]) @@ -172,14 +178,52 @@ async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse: } -@router.get("/{project_id}/drainage/primary-region", response_model=None) -async def get_primary_region(project_id: UUID) -> dict[str, Any] | JSONResponse: - """1차 배수유역 근거를 돌려준다 — 단계 검증용, TIN·흐름 계산은 하지 않는다. +def _response_path(stored_path: str) -> Path: + """분석 응답 캐시 경로. 재산정하지 않는 한 이 파일을 그대로 돌려준다.""" + return drainage_dir(stored_path) / DRAINAGE_RESPONSE_FILENAME - 도로 교차점 상류로 이어진 세류망, 제외된 하류망, 그 상류망을 반경 버퍼한 1차 영역, - 그 bbox로 잡은 격자 정보를 함께 준다. 같은 내용을 영구저장소에 GeoJSON으로도 남겨 - QGIS 등으로 직접 열어 대조할 수 있게 한다. + +def _load_saved_response(stored_path: str) -> dict[str, Any] | None: + path = _response_path(stored_path) + if not path.exists(): + return None + try: + with path.open("r", encoding="utf-8") as file: + return json.load(file) + except (OSError, json.JSONDecodeError): + logger.warning("배수유역: 저장된 분석 응답을 읽지 못했습니다 (%s).", path) + return None + + +def _save_response(stored_path: str, payload: dict[str, Any]) -> None: + path = _response_path(stored_path) + try: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as file: + json.dump(payload, file, ensure_ascii=False) + except OSError: + logger.warning("배수유역: 분석 응답을 저장하지 못했습니다 (%s).", path) + + +@router.get("/{project_id}/drainage/primary-region", response_model=None) +async def get_primary_region( + project_id: UUID, refresh: bool = False +) -> dict[str, Any] | JSONResponse: + """배수유역 분석 결과를 돌려준다. + + 기본은 **영구저장소에 남은 결과를 그대로** 준다 — 분석이 30초 걸리므로 화면을 열 + 때마다 다시 돌릴 이유가 없다. `refresh=true`면 처음부터 다시 계산하고 덮어쓴다 + (2026-07-31 사용자 지시). """ + pool = get_db_pool() + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + if not refresh: + saved = _load_saved_response(stored_path) + if saved is not None: + logger.info("배수유역: 저장된 분석 결과를 그대로 돌려줍니다 (%s).", stored_path) + return {**saved, "from_cache": True} + prepared = await _prepare(project_id) if isinstance(prepared, JSONResponse): return prepared @@ -248,6 +292,13 @@ async def get_primary_region(project_id: UUID) -> dict[str, Any] | JSONResponse: ], # ⑧ 기본 관 매설 위치 — 도로 × 세류선 교차점. "pipes": [_candidate_payload(pipe, to_lonlat) for pipe in preview.pipes], + # B05용 평균 흐름 화살표 — [lon, lat, 방위(도), 도로도달, 셀 수]. + # 세류·도로 셀을 뺀 블록 평균이라 사면 경향만 남는다. + "flow_arrows": [ + [*to_lonlat(x, y), round(math.degrees(angle), 1), reaches, cells] + for x, y, angle, reaches, cells in preview.flow_arrows + ], + "from_cache": False, } # 단계 산출물을 영구저장소에 남긴다 — 기능을 붙일 때마다 여기에 단계가 하나씩 늘어난다. payload["saved_to"] = write_stage( diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts index 8950ac7f..7add560a 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts @@ -32,12 +32,18 @@ const ARROW_SPACING_PX = 22; const BASIN_RING_COLOR = "rgba(146, 64, 14, 0.95)"; /** 기본 관 마커. */ const PIPE_COLOR = "rgba(249, 115, 22, 0.95)"; +/** 평균 흐름 화살표 — 10m 블록 평균. 셀 화살표보다 크게 그려 경향을 읽는다. */ +const MEAN_ARROW_TO_ROAD = "rgba(153, 27, 27, 0.95)"; +const MEAN_ARROW_AWAY = "rgba(30, 64, 175, 0.95)"; +const MEAN_ARROW_PX = 14; +const MEAN_ARROW_MAX_PX = 46; /** 개별로 켜고 끌 수 있는 오버레이 갈래. */ const PARTS = [ { key: "primary", label: "1차 유역", color: "#059669" }, { key: "basin", label: "2차 유역", color: "#92400e" }, { key: "flow", label: "유역 방향", color: "#2563eb" }, + { key: "arrows", label: "평균 흐름", color: "#7c3aed" }, ] as const; type PartKey = (typeof PARTS)[number]["key"]; @@ -62,19 +68,26 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { let shown = false; let statusText = ""; let flowCache: { source: string; bytes: Uint8Array } | null = null; + let busy = false; const button = document.createElement("button"); button.type = "button"; button.className = "b04-map__layer-button b04-map__layer-button--gis"; - button.textContent = "배수유역"; + button.textContent = "배수유역 재산정"; button.style.setProperty("--b04-layer-color", "#dc2626"); button.setAttribute("aria-pressed", "false"); button.title = - "계획 노선(B03 업로드)과 도엽 등고선·세류선으로 배수유역을 분석합니다. " + - "30초 안팎이 걸리며 결과는 영구저장소에 남습니다."; + "계획 노선(B03 업로드)과 도엽 등고선·세류선으로 배수유역을 처음부터 다시 분석합니다. " + + "30초 안팎이 걸리며 결과는 영구저장소에 남습니다. " + + "저장된 결과는 지도를 열 때 자동으로 표시되므로, 조건을 바꿨을 때만 누르면 됩니다."; // 갈래별 표시 여부. 전체 토글(button)이 꺼져 있으면 이 값과 무관하게 아무것도 안 그린다. - const shownParts: Record = { primary: true, basin: true, flow: true }; + const shownParts: Record = { + primary: true, + basin: true, + flow: true, + arrows: true, + }; const partButtons = PARTS.map((part) => { const element = document.createElement("button"); element.type = "button"; @@ -314,6 +327,60 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { context.restore(); } + /** B05에 얹을 평균 흐름 화살표 — 10m 블록 평균이라 축소해도 경향이 읽힌다. */ + function drawFlowArrows( + context: CanvasRenderingContext2D, + map: Normalizer, + view: ViewState, + region: WatershedAnalysis, + ): void { + const arrows = region.flow_arrows ?? []; + if (arrows.length === 0) return; + const ax = view.mapRect.width * view.scale; + const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX; + const ay = view.mapRect.height * view.scale; + const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY; + // 블록 간격(m)이 화면에서 몇 px인지로 화살표 크기를 정한다 — 확대하면 같이 커진다. + const pxPerLon = ax / map.lonRange; + const spacingPx = + arrows.length > 1 + ? Math.abs(arrows[1][0] - arrows[0][0]) * pxPerLon || MEAN_ARROW_PX + : MEAN_ARROW_PX; + const size = Math.max(MEAN_ARROW_PX, Math.min(spacingPx * 0.8, MEAN_ARROW_MAX_PX)); + + context.save(); + context.lineCap = "round"; + arrows.forEach(([lon, lat, degrees, reaches]) => { + const x = ((lon - map.lonMin) / map.lonRange) * ax + bx; + const y = (1 - (lat - map.latMin) / map.latRange) * ay + by; + if (x < -size || x > view.width + size || y < -size || y > view.height + size) return; + const angle = (degrees * Math.PI) / 180; + const unitX = Math.cos(angle); + const unitY = Math.sin(angle); + const reach = size / 2; + const tipX = x + unitX * reach; + const tipY = y + unitY * reach; + const head = size * 0.32; + // 배경 대비를 위해 흰 테두리를 깔고 그 위에 색을 얹는다. + for (const [color, lineWidth] of [ + ["rgba(255, 255, 255, 0.9)", size * 0.18 + 2] as const, + [reaches ? MEAN_ARROW_TO_ROAD : MEAN_ARROW_AWAY, size * 0.18] as const, + ]) { + context.strokeStyle = color; + context.lineWidth = lineWidth; + context.beginPath(); + context.moveTo(x - unitX * reach, y - unitY * reach); + context.lineTo(tipX, tipY); + context.moveTo(tipX, tipY); + context.lineTo(tipX - (unitX + unitY * 0.7) * head, tipY - (unitY - unitX * 0.7) * head); + context.moveTo(tipX, tipY); + context.lineTo(tipX - (unitX - unitY * 0.7) * head, tipY - (unitY + unitX * 0.7) * head); + context.stroke(); + } + }); + context.restore(); + } + /** ⑦ 2차 전체 배수유역 외곽선(= 분수령)과 ⑧ 기본 관 위치. */ function drawBasinAndPipes( context: CanvasRenderingContext2D, @@ -389,10 +456,38 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { return areaM2 >= 10000 ? `${(areaM2 / 10000).toFixed(2)}ha` : `${Math.round(areaM2)}㎡`; } - /** 켤 때마다 다시 요청한다 — config를 바꾸고 재시작했는데 캐시된 옛 결과가 나오면 - * 검증이 성립하지 않는다. 끌 때만 요청 없이 숨긴다. */ - async function toggle(): Promise { - if (!projectId) return; + /** 분석 결과를 받아 화면에 올린다. + * + * `refresh=false`면 영구저장소에 남은 결과를 그대로 받아 즉시 끝나므로 지도를 열 때 + * 자동으로 부른다. `refresh=true`(재산정 버튼)면 처음부터 다시 계산한다. */ + async function loadAnalysis(refresh: boolean): Promise { + if (!projectId || busy) return; + busy = true; + button.disabled = true; + statusText = refresh + ? "배수유역을 다시 분석하는 중… (30초 안팎)" + : "저장된 배수유역을 불러오는 중…"; + onChange(); + try { + analysis = await fetchWatershedAnalysis(projectId, refresh); + shown = true; + button.classList.add("is-active"); + button.setAttribute("aria-pressed", "true"); + statusText = regionSummary(analysis); + } catch (error) { + // 저장분이 없어 자동 조회가 실패한 경우는 오류가 아니다 — 재산정하면 된다. + const message = error instanceof Error ? error.message : "배수유역을 불러오지 못했습니다."; + statusText = refresh ? message : ""; + if (!refresh) analysis = null; + } finally { + busy = false; + button.disabled = false; + onChange(); + } + } + + // 재산정 버튼: 켜져 있으면 끄고, 꺼져 있으면 처음부터 다시 분석한다. + button.addEventListener("click", () => { if (shown) { shown = false; button.classList.remove("is-active"); @@ -401,24 +496,8 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { onChange(); return; } - button.disabled = true; - statusText = "배수유역을 분석하는 중… (30초 안팎)"; - onChange(); - try { - analysis = await fetchWatershedAnalysis(projectId); - shown = true; - button.classList.add("is-active"); - button.setAttribute("aria-pressed", "true"); - statusText = regionSummary(analysis); - } catch (error) { - statusText = error instanceof Error ? error.message : "배수유역 분석에 실패했습니다."; - } finally { - button.disabled = false; - onChange(); - } - } - - button.addEventListener("click", () => void toggle()); + void loadAnalysis(true); + }); return { button, @@ -435,12 +514,15 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { }, setProject(next: string) { projectId = next; + // 저장분이 있으면 즉시 올린다 — 없으면 조용히 넘어가고, 재산정 버튼을 누르면 계산한다. + void loadAnalysis(false); }, draw(context, map, view) { if (!shown || !analysis) return; // 격자·화살표(유역 방향) → 1차 영역 → 2차 유역·관 순으로 아래에서 위로 쌓는다. if (shownParts.flow) drawGridCells(context, map, view, analysis); if (shownParts.primary) drawPrimaryRegion(context, map, view, analysis); + if (shownParts.arrows) drawFlowArrows(context, map, view, analysis); if (shownParts.basin) drawBasinAndPipes(context, map, view, analysis); }, }; diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts index 1527252a..5349c7a4 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -121,7 +121,7 @@ export function createDrainagePanel(): DrainagePanel { canvas.className = "b05-drainage__canvas"; const status = document.createElement("span"); status.className = "b05-drainage__status"; - status.textContent = "노선을 확정하면 배수유역 배경도가 표시됩니다."; + status.textContent = "노선을 확정하면 배수유역도가 표시됩니다."; viewport.append(backgroundImage, canvas, status); // 유역 제원 목록(면적·표고·유하거리·관경). 관경 수식 미확정이라 당분간 "미정"으로 나온다. const basinList = document.createElement("div"); @@ -311,7 +311,7 @@ export function createDrainagePanel(): DrainagePanel { if (!projectId) return; analyzeButton.disabled = true; status.hidden = false; - status.textContent = "배수유역을 산정하는 중…"; + status.textContent = "세부유역을 산정하는 중…"; try { const chainages = !auto && pipeEditor.pipes().length > 0 ? pipeEditor.chainages() : undefined; const response = await fetchDrainageBasins(projectId, chainages); @@ -333,7 +333,7 @@ export function createDrainagePanel(): DrainagePanel { scheduleDraw(); } catch (error) { status.hidden = false; - status.textContent = error instanceof Error ? error.message : "배수유역 산정에 실패했습니다."; + status.textContent = error instanceof Error ? error.message : "세부유역 산정에 실패했습니다."; } finally { analyzeButton.disabled = false; } @@ -420,6 +420,8 @@ export function createDrainagePanel(): DrainagePanel { if (featureCount === 0) status.textContent = "도엽 레이어가 없습니다. B04에서 임포트하세요."; fitToRoute(); scheduleDraw(); + // B04 분석 결과를 읽어 오는 것뿐이라 즉시 끝난다 — 페이지에 들어오면 바로 보여 준다. + void analyze(true); } catch (error) { if (sequence !== loadSequence) return; status.hidden = false; diff --git a/config/config_system.py b/config/config_system.py index 771fedcc..9679bc92 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -281,9 +281,22 @@ DRAINAGE_DITCH_SAMPLE_M = float(os.getenv("DRAINAGE_DITCH_SAMPLE_M", "1.0")) DRAINAGE_POLYGON_SIMPLIFY_M = float(os.getenv("DRAINAGE_POLYGON_SIMPLIFY_M", "2.0")) # 이 면적(㎡) 미만의 유역 조각은 버린다(격자 노이즈 제거). DRAINAGE_MIN_BASIN_AREA_M2 = float(os.getenv("DRAINAGE_MIN_BASIN_AREA_M2", "100.0")) -# 격자 해석 결과 캐시 파일명. 프로젝트 저장소의 B05_wf2_Route/drainage/ 아래에 놓인다. +# 격자 해석 결과 캐시 파일명. 프로젝트 저장소의 B04_wf1_Surface/drainage/ 아래에 놓인다. DRAINAGE_CACHE_DIRNAME = "drainage" DRAINAGE_CACHE_FILENAME = "watershed_grid.npz" +# 분석 응답 자체를 그대로 담아 두는 파일. 재산정하지 않는 한 이걸 그대로 돌려준다 — +# 저장 배열에서 응답을 다시 조립하면 원본과 어긋날 여지가 생긴다(2026-07-31 사용자 지시). +DRAINAGE_RESPONSE_FILENAME = "00_watershed_response.json" + +# ── B05용 평균 흐름 화살표 ── +# 셀 화살표는 1m라 축소하면 경향이 안 보인다. 이 크기의 블록으로 묶어 방향을 평균한다. +DRAINAGE_ARROW_BLOCK_M = float(os.getenv("DRAINAGE_ARROW_BLOCK_M", "10.0")) +# 화살표끼리 최소 이 간격을 두고 솎아낸다. 촘촘하면 도면이 지저분해진다. +DRAINAGE_ARROW_SPACING_M = float(os.getenv("DRAINAGE_ARROW_SPACING_M", "40.0")) +# 블록 안에서 화살표를 낼 수 있는 셀이 이 비율 미만이면 건너뛴다(가장자리 조각 방지). +DRAINAGE_ARROW_MIN_COVERAGE = float(os.getenv("DRAINAGE_ARROW_MIN_COVERAGE", "0.5")) +# 방향 일치도 하한(원형 평균 결과 길이 0~1). 블록 안 방향이 제각각이면 평균이 무의미하므로 버린다. +DRAINAGE_ARROW_MIN_AGREEMENT = float(os.getenv("DRAINAGE_ARROW_MIN_AGREEMENT", "0.7")) # 단계별 검증 산출물은 같은 폴더에 `{번호}_{단계}.geojson` + `manifest.json`으로 쌓인다. # 파일명 규칙은 B05_wf2_Route_Engine_Watershed_Export.STAGES가 유일한 정의처다. From 500a3c904755df9d8839c7760ae6b72375f4ddef Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 31 Jul 2026 21:26:32 +0900 Subject: [PATCH 44/61] =?UTF-8?q?fix(B04):=20=EB=B0=B0=EC=88=98=EC=9C=A0?= =?UTF-8?q?=EC=97=AD=20=EC=83=81=ED=83=9C=20=EB=AC=B8=EA=B5=AC=20=EC=A0=84?= =?UTF-8?q?=EC=9A=A9=20=EC=A4=84=20=EB=B6=84=EB=A6=AC=20+=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EB=B6=84=20=EC=97=86=EC=9D=84=20=EB=95=8C=20=EC=95=88?= =?UTF-8?q?=EB=82=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 증상 두 가지 1. 재산정 버튼을 눌러도 프론트에 아무 안내가 없었다. 오버레이가 지도 자체 상태(.b04-map__status)에 문구를 썼는데, loadLayers 가 같은 칸에 3곳에서 덮어써 즉시 지워졌다. 2. 페이지 접근 시 배수유역이 안 보였다. 응답 캐시 파일(00_watershed_response.json)이 아직 없어 자동 조회가 빈 손으로 끝나는데, 그 사실을 화면에 알리지 않아 이유를 알 수 없었다. 조치 - 배수유역 전용 상태 줄(.b04-map__watershed-status)을 컨트롤 영역에 따로 둔다. 지도 로딩 문구와 서로 덮어쓰지 않는다. - 컨트롤에 배수유역 그룹을 만들어 재산정 버튼 + 갈래 토글 4종 + 상태 줄을 묶었다. - 진행 상태를 버튼 라벨에도 반영: 배수유역 재산정 -> 분석 중… / 불러오는 중… - 결과 문구에 출처와 소요를 붙인다: [저장분] ... / [재산정 28.4초] ... - 저장분이 없으면 오류가 아니라 안내로 표시: "저장된 배수유역 분석이 없습니다. [배수유역 재산정]을 누르세요." - 실패하면 버튼 활성 상태를 되돌려 껐다 켠 것처럼 보이지 않게 했다. Co-Authored-By: Claude Fable 5 --- .../B04_wf1_Surface_UI_MapViewer.ts | 18 ++++--- B04_wf1_Surface/B04_wf1_Surface_UI_Style.css | 19 +++++++ .../B04_wf1_Surface_UI_Watershed.ts | 51 ++++++++++++++----- 3 files changed, 70 insertions(+), 18 deletions(-) diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index 66c51074..01ee066e 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -222,12 +222,18 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { gisButtons.append(contourLabelButton); // 배수유역 분석 오버레이 — 계산은 백엔드가 하고 여기서는 겹쳐 그리기만 한다. - const watershed = createWatershedOverlay(() => { - const text = watershed.status(); - if (text) status.textContent = text; - scheduleDraw(); - }); - gisButtons.append(watershed.button, ...watershed.partButtons); + // 상태 문구는 오버레이 전용 줄에 쓴다 — 지도 자체 상태(레이어 로딩)와 같은 칸을 쓰면 + // 나중에 끝난 쪽이 상대 문구를 지워 버린다. + const watershed = createWatershedOverlay(() => scheduleDraw()); + const watershedGroup = document.createElement("div"); + watershedGroup.className = "b04-map__control-group"; + const watershedTitle = document.createElement("span"); + watershedTitle.textContent = "배수유역"; + const watershedButtons = document.createElement("div"); + watershedButtons.className = "b04-map__layer-buttons"; + watershedButtons.append(watershed.button, ...watershed.partButtons); + watershedGroup.append(watershedTitle, watershedButtons, watershed.statusElement); + controls.insertBefore(watershedGroup, resetButton); function updateImageTransform(): void { backgroundImages.forEach((image) => { diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css b/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css index eb960f2b..4261ba7a 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css @@ -624,6 +624,25 @@ background: var(--color-surface-raised); } +/* 배수유역 전용 상태 줄 — 지도 자체 상태(.b04-map__status)와 칸을 나눠 쓰면 + 나중에 끝난 쪽이 상대 문구를 지운다. 그래서 컨트롤 영역에 따로 둔다. */ +.b04-map__watershed-status { + display: block; + width: 100%; + margin-top: var(--spacing-4); + padding: var(--spacing-4) var(--spacing-8); + border-radius: var(--radius-inputs); + background: var(--color-surface-raised); + color: var(--color-text-secondary); + font-size: var(--text-caption); + line-height: 1.5; + word-break: keep-all; +} + +.b04-map__watershed-status[hidden] { + display: none; +} + @media (max-width: 760px) { .b04-map__header { align-items: flex-start; diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts index 7add560a..1561e647 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts @@ -50,8 +50,10 @@ type PartKey = (typeof PARTS)[number]["key"]; export interface WatershedOverlay { /** 분석 실행 + 전체 토글 버튼. 지도 헤더의 GIS 버튼 줄에 넣는다. */ button: HTMLButtonElement; - /** 갈래별 표시 토글 버튼(1차 유역 / 2차 유역 / 유역 방향). */ + /** 갈래별 표시 토글 버튼(1차 유역 / 2차 유역 / 유역 방향 / 평균 흐름). */ partButtons: HTMLButtonElement[]; + /** 배수유역 전용 상태 줄. 지도 자체 상태(레이어 로딩 등)와 섞이면 서로 덮어쓴다. */ + statusElement: HTMLElement; /** 켜져 있는지. draw() 호출 전에 확인한다. */ visible: () => boolean; /** 상태 문구(분석 요약 또는 오류). 없으면 빈 문자열. */ @@ -70,6 +72,17 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { let flowCache: { source: string; bytes: Uint8Array } | null = null; let busy = false; + const statusElement = document.createElement("span"); + statusElement.className = "b04-map__watershed-status"; + statusElement.hidden = true; + + /** 상태 줄을 갱신한다. 빈 문자열이면 줄 자체를 숨긴다. */ + function say(text: string): void { + statusText = text; + statusElement.textContent = text; + statusElement.hidden = text === ""; + } + const button = document.createElement("button"); button.type = "button"; button.className = "b04-map__layer-button b04-map__layer-button--gis"; @@ -457,31 +470,44 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { } /** 분석 결과를 받아 화면에 올린다. - * + * `refresh=false`면 영구저장소에 남은 결과를 그대로 받아 즉시 끝나므로 지도를 열 때 * 자동으로 부른다. `refresh=true`(재산정 버튼)면 처음부터 다시 계산한다. */ async function loadAnalysis(refresh: boolean): Promise { if (!projectId || busy) return; busy = true; button.disabled = true; - statusText = refresh - ? "배수유역을 다시 분석하는 중… (30초 안팎)" - : "저장된 배수유역을 불러오는 중…"; - onChange(); + button.textContent = refresh ? "분석 중…" : "불러오는 중…"; + say( + refresh + ? "배수유역을 처음부터 다시 분석하는 중입니다. 30초 안팎 걸립니다…" + : "저장된 배수유역 분석을 불러오는 중…", + ); + const started = performance.now(); try { analysis = await fetchWatershedAnalysis(projectId, refresh); shown = true; button.classList.add("is-active"); button.setAttribute("aria-pressed", "true"); - statusText = regionSummary(analysis); + const seconds = ((performance.now() - started) / 1000).toFixed(1); + const origin = analysis.from_cache ? "저장분" : `재산정 ${seconds}초`; + say(`[${origin}] ${regionSummary(analysis)}`); } catch (error) { - // 저장분이 없어 자동 조회가 실패한 경우는 오류가 아니다 — 재산정하면 된다. + analysis = null; + shown = false; + button.classList.remove("is-active"); + button.setAttribute("aria-pressed", "false"); const message = error instanceof Error ? error.message : "배수유역을 불러오지 못했습니다."; - statusText = refresh ? message : ""; - if (!refresh) analysis = null; + // 저장분이 아직 없는 것은 오류가 아니다 — 무엇을 눌러야 하는지 알려 준다. + say( + refresh + ? `배수유역 재산정 실패: ${message}` + : "저장된 배수유역 분석이 없습니다. [배수유역 재산정]을 누르세요.", + ); } finally { busy = false; button.disabled = false; + button.textContent = "배수유역 재산정"; onChange(); } } @@ -492,7 +518,7 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { shown = false; button.classList.remove("is-active"); button.setAttribute("aria-pressed", "false"); - statusText = ""; + say(""); onChange(); return; } @@ -502,13 +528,14 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { return { button, partButtons, + statusElement, visible: () => shown && analysis !== null, status: () => statusText, reset() { analysis = null; flowCache = null; shown = false; - statusText = ""; + say(""); button.classList.remove("is-active"); button.setAttribute("aria-pressed", "false"); }, From 4aa211ceada9eeec59965b0eb8e6599297cde5f1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 31 Jul 2026 21:47:17 +0900 Subject: [PATCH 45/61] =?UTF-8?q?feat(drainage):=20B05=20=ED=8F=89?= =?UTF-8?q?=EA=B7=A0=20=ED=9D=90=EB=A6=84=20=ED=99=94=EC=82=B4=ED=91=9C=20?= =?UTF-8?q?=ED=91=9C=EA=B8=B0=20+=20=EC=9D=91=EB=8B=B5=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=EC=A0=80=EC=9E=A5=20=EB=88=84=EB=9D=BD=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts | 2 + .../B04_wf1_Surface_Router_Watershed.py | 28 ++++-- .../B04_wf1_Surface_UI_FlowArrows.ts | 88 +++++++++++++++++++ .../B04_wf1_Surface_UI_Watershed.ts | 69 +++++---------- B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts | 4 + .../B05_wf2_Route_Engine_Drainage_Basin.py | 23 +++++ .../B05_wf2_Route_Router_Drainage.py | 3 + .../B05_wf2_Route_UI_Drainage_Panel.ts | 43 ++++++++- 8 files changed, 204 insertions(+), 56 deletions(-) create mode 100644 B04_wf1_Surface/B04_wf1_Surface_UI_FlowArrows.ts diff --git a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts index f85dc842..bfbfb09e 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts @@ -333,6 +333,8 @@ export interface WatershedAnalysis { /** B05용 평균 흐름 화살표 — [lon, lat, 방위(도), 도로도달, 셀 수]. * 세류·도로 셀을 뺀 10m 블록 평균이라 사면 경향만 남는다. */ flow_arrows: Array<[number, number, number, boolean, number]>; + /** 화살표 사이 실제 간격(m). 화면이 화살표를 이보다 짧게 그려 서로 닿지 않게 한다. */ + arrow_spacing_m: number; /** 계산하지 않고 저장분을 그대로 돌려준 응답인지. */ from_cache: boolean; /** 영구저장소에 남긴 검증용 GeoJSON 경로. */ diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router_Watershed.py b/B04_wf1_Surface/B04_wf1_Surface_Router_Watershed.py index 757a0e0d..068418e1 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Router_Watershed.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Router_Watershed.py @@ -43,7 +43,7 @@ from common_util.common_util_route_geometry import ( ) from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool -from config.config_system import DRAINAGE_RESPONSE_FILENAME +from config.config_system import DRAINAGE_ARROW_SPACING_M, DRAINAGE_RESPONSE_FILENAME logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B04 Surface Watershed"]) @@ -298,6 +298,8 @@ async def get_primary_region( [*to_lonlat(x, y), round(math.degrees(angle), 1), reaches, cells] for x, y, angle, reaches, cells in preview.flow_arrows ], + # 화살표 간격(m). 화면이 화살표 크기를 정할 때 쓴다 — 서로 닿지 않게 이 값보다 짧게 그린다. + "arrow_spacing_m": DRAINAGE_ARROW_SPACING_M, "from_cache": False, } # 단계 산출물을 영구저장소에 남긴다 — 기능을 붙일 때마다 여기에 단계가 하나씩 늘어난다. @@ -341,6 +343,8 @@ async def get_primary_region( ) _write_stage_arrays(prepared["stored_path"], preview, domain, spec) _write_road_routing(prepared["stored_path"], preview, spec, prepared["route_line"], to_lonlat) + # 응답 자체를 캐시로 남긴다 — 다음 조회는 배열을 재조립하지 않고 이 파일을 그대로 준다. + _save_response(prepared["stored_path"], payload) return payload @@ -390,11 +394,28 @@ def _write_road_routing( ) for pipe in preview.pipes ], + # 평균 흐름 화살표 — B05도 같은 그림을 그려야 하므로 여기 함께 남긴다. + "flow_arrow": [ + ( + Point(x, y), + { + # B05 화면은 사업지 CRS(m)로 그리므로 미터 좌표도 함께 남긴다. + "x": round(x, 2), + "y": round(y, 2), + "azimuth_deg": round(math.degrees(angle), 1), + "reaches_road": reaches, + "cells": cells, + }, + ) + for x, y, angle, reaches, cells in preview.flow_arrows + ], }, { "basin_area_m2": round(preview.basin_area_m2, 1), "pipe_count": len(preview.pipes), "route_length_m": round(route_line.length, 1), + "arrow_count": len(preview.flow_arrows), + "arrow_spacing_m": DRAINAGE_ARROW_SPACING_M, }, to_lonlat, ) @@ -495,11 +516,6 @@ def _boundary_geometry(ring: list[tuple[float, float]]) -> list[Any]: return [Polygon(ring)] if len(ring) >= 4 else [] -def _boundary_geometry(ring: list[tuple[float, float]]) -> list[Any]: - """2차 유역 외곽 링을 저장용 폴리곤으로 만든다(정점 3개 미만이면 비운다).""" - return [Polygon(ring)] if len(ring) >= 4 else [] - - def _as_polygons(geometry: Any) -> list[Any]: if geometry is None or geometry.is_empty: return [] diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_FlowArrows.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_FlowArrows.ts new file mode 100644 index 00000000..632c197f --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_FlowArrows.ts @@ -0,0 +1,88 @@ +/* ============================================================================= + * 평균 흐름 화살표 렌더러 (B04·B05 공용) + * + * 백엔드가 10m 블록 평균으로 뽑아 둔 화살표를 그린다. 셀 화살표(1m)는 도면 배율에서 + * 경향이 안 보이므로 B05는 이 화살표만 쓴다. + * + * 화살표 길이는 **간격보다 짧게** 잡아 서로 닿지 않게 한다 — 격자처럼 맞물리면 + * 방향이 아니라 그물망으로 읽힌다. + * + * 좌표계는 페이지마다 다르다(B04는 lon/lat 정규화, B05는 사업지 CRS 미터). 그래서 + * 화면 변환은 호출부가 `project`로 넘긴다 — 이 파일은 좌표계를 모른다. + * ========================================================================== */ + +/** 화살표 1개 — [가로, 세로, 방위(도), 도로 도달, 셀 수]. 앞 두 값의 좌표계는 호출부가 정한다. */ +export type FlowArrow = [number, number, number, boolean, number]; + +/** 화살표 길이를 간격의 몇 배로 할지. 1보다 작아야 서로 닿지 않는다. */ +const LENGTH_RATIO = 0.55; +/** 선 두께를 길이의 몇 배로 할지. */ +const WIDTH_RATIO = 0.07; +/** 이보다 짧으면 방향이 안 읽히므로 그리지 않는다(px). */ +const MIN_LENGTH_PX = 9; +/** 화면을 가득 채우지 않도록 두는 상한(px). */ +const MAX_LENGTH_PX = 40; + +const TO_ROAD_COLOR = "rgba(153, 27, 27, 0.95)"; +const AWAY_COLOR = "rgba(30, 64, 175, 0.95)"; +const HALO_COLOR = "rgba(255, 255, 255, 0.9)"; + +/** 화살표 좌표를 캔버스 픽셀로 옮기는 함수. */ +export type ArrowProjector = (a: number, b: number) => readonly [number, number]; + +/** + * 평균 흐름 화살표를 그린다. + * + * `spacingM`은 화살표 사이 실제 간격(m), `pxPerMeter`는 현재 배율에서 1m가 몇 px인지. + * 둘을 곱해 길이를 정하므로 확대·축소에 따라 화살표도 같이 커지고 작아진다. + */ +export function drawFlowArrows( + context: CanvasRenderingContext2D, + arrows: ReadonlyArray, + spacingM: number, + pxPerMeter: number, + project: ArrowProjector, + canvas: { readonly width: number; readonly height: number }, +): void { + if (arrows.length === 0 || spacingM <= 0 || pxPerMeter <= 0) return; + const length = Math.min(spacingM * pxPerMeter * LENGTH_RATIO, MAX_LENGTH_PX); + if (length < MIN_LENGTH_PX) return; + + const reach = length / 2; + const head = length * 0.26; + const width = Math.max(0.8, length * WIDTH_RATIO); + + context.save(); + context.lineCap = "round"; + context.lineJoin = "round"; + context.setLineDash([]); + arrows.forEach(([a, b, degrees, reaches]) => { + const [x, y] = project(a, b); + if (x < -length || x > canvas.width + length) return; + if (y < -length || y > canvas.height + length) return; + const angle = (degrees * Math.PI) / 180; + const unitX = Math.cos(angle); + const unitY = Math.sin(angle); + const tailX = x - unitX * reach; + const tailY = y - unitY * reach; + const tipX = x + unitX * reach; + const tipY = y + unitY * reach; + // 어두운 배경·채움색 위에서도 읽히도록 흰 테두리를 한 겹 깔고 그 위에 색을 얹는다. + for (const [color, lineWidth] of [ + [HALO_COLOR, width + 1.4] as const, + [reaches ? TO_ROAD_COLOR : AWAY_COLOR, width] as const, + ]) { + context.strokeStyle = color; + context.lineWidth = lineWidth; + context.beginPath(); + context.moveTo(tailX, tailY); + context.lineTo(tipX, tipY); + context.moveTo(tipX, tipY); + context.lineTo(tipX - (unitX + unitY * 0.65) * head, tipY - (unitY - unitX * 0.65) * head); + context.moveTo(tipX, tipY); + context.lineTo(tipX - (unitX - unitY * 0.65) * head, tipY - (unitY + unitX * 0.65) * head); + context.stroke(); + } + }); + context.restore(); +} diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts index 1561e647..7bceeb65 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts @@ -1,4 +1,5 @@ import { fetchWatershedAnalysis, type WatershedAnalysis } from "./B04_wf1_Surface_Api_Fetch"; +import { drawFlowArrows } from "./B04_wf1_Surface_UI_FlowArrows"; import type { Normalizer, ViewState } from "./B04_wf1_Surface_UI_MapRender"; /* ============================================================================= @@ -32,11 +33,6 @@ const ARROW_SPACING_PX = 22; const BASIN_RING_COLOR = "rgba(146, 64, 14, 0.95)"; /** 기본 관 마커. */ const PIPE_COLOR = "rgba(249, 115, 22, 0.95)"; -/** 평균 흐름 화살표 — 10m 블록 평균. 셀 화살표보다 크게 그려 경향을 읽는다. */ -const MEAN_ARROW_TO_ROAD = "rgba(153, 27, 27, 0.95)"; -const MEAN_ARROW_AWAY = "rgba(30, 64, 175, 0.95)"; -const MEAN_ARROW_PX = 14; -const MEAN_ARROW_MAX_PX = 46; /** 개별로 켜고 끌 수 있는 오버레이 갈래. */ const PARTS = [ @@ -340,58 +336,33 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { context.restore(); } - /** B05에 얹을 평균 흐름 화살표 — 10m 블록 평균이라 축소해도 경향이 읽힌다. */ - function drawFlowArrows( + /** B05에도 같이 쓰는 평균 흐름 화살표. 그리기는 공용 렌더러에 맡긴다. */ + function drawMeanArrows( context: CanvasRenderingContext2D, map: Normalizer, view: ViewState, region: WatershedAnalysis, ): void { - const arrows = region.flow_arrows ?? []; - if (arrows.length === 0) return; + // 격자 bbox의 경도 폭과 실폭(m)으로 1m당 픽셀을 환산한다. + const lons = region.grid.bbox_lonlat.map(([lon]) => lon); + const spanLon = Math.max(...lons) - Math.min(...lons); + if (!(spanLon > 0) || !(region.grid.width_m > 0)) return; const ax = view.mapRect.width * view.scale; + const pxPerMeter = ((spanLon / map.lonRange) * ax) / region.grid.width_m; const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX; const ay = view.mapRect.height * view.scale; const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY; - // 블록 간격(m)이 화면에서 몇 px인지로 화살표 크기를 정한다 — 확대하면 같이 커진다. - const pxPerLon = ax / map.lonRange; - const spacingPx = - arrows.length > 1 - ? Math.abs(arrows[1][0] - arrows[0][0]) * pxPerLon || MEAN_ARROW_PX - : MEAN_ARROW_PX; - const size = Math.max(MEAN_ARROW_PX, Math.min(spacingPx * 0.8, MEAN_ARROW_MAX_PX)); - - context.save(); - context.lineCap = "round"; - arrows.forEach(([lon, lat, degrees, reaches]) => { - const x = ((lon - map.lonMin) / map.lonRange) * ax + bx; - const y = (1 - (lat - map.latMin) / map.latRange) * ay + by; - if (x < -size || x > view.width + size || y < -size || y > view.height + size) return; - const angle = (degrees * Math.PI) / 180; - const unitX = Math.cos(angle); - const unitY = Math.sin(angle); - const reach = size / 2; - const tipX = x + unitX * reach; - const tipY = y + unitY * reach; - const head = size * 0.32; - // 배경 대비를 위해 흰 테두리를 깔고 그 위에 색을 얹는다. - for (const [color, lineWidth] of [ - ["rgba(255, 255, 255, 0.9)", size * 0.18 + 2] as const, - [reaches ? MEAN_ARROW_TO_ROAD : MEAN_ARROW_AWAY, size * 0.18] as const, - ]) { - context.strokeStyle = color; - context.lineWidth = lineWidth; - context.beginPath(); - context.moveTo(x - unitX * reach, y - unitY * reach); - context.lineTo(tipX, tipY); - context.moveTo(tipX, tipY); - context.lineTo(tipX - (unitX + unitY * 0.7) * head, tipY - (unitY - unitX * 0.7) * head); - context.moveTo(tipX, tipY); - context.lineTo(tipX - (unitX - unitY * 0.7) * head, tipY - (unitY + unitX * 0.7) * head); - context.stroke(); - } - }); - context.restore(); + drawFlowArrows( + context, + region.flow_arrows ?? [], + region.arrow_spacing_m ?? 0, + pxPerMeter, + (lon, lat) => [ + ((lon - map.lonMin) / map.lonRange) * ax + bx, + (1 - (lat - map.latMin) / map.latRange) * ay + by, + ], + view, + ); } /** ⑦ 2차 전체 배수유역 외곽선(= 분수령)과 ⑧ 기본 관 위치. */ @@ -549,7 +520,7 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { // 격자·화살표(유역 방향) → 1차 영역 → 2차 유역·관 순으로 아래에서 위로 쌓는다. if (shownParts.flow) drawGridCells(context, map, view, analysis); if (shownParts.primary) drawPrimaryRegion(context, map, view, analysis); - if (shownParts.arrows) drawFlowArrows(context, map, view, analysis); + if (shownParts.arrows) drawMeanArrows(context, map, view, analysis); if (shownParts.basin) drawBasinAndPipes(context, map, view, analysis); }, }; diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index 3d934025..30a3154a 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -306,6 +306,10 @@ export interface DrainageBasinResponse { main_polygon_lonlat: Array<[number, number]>; /** B04 해석 격자 한 변(m). */ grid_cell_m: number; + /** 평균 흐름 화살표 — [x, y(사업지 CRS m), 방위(도), 도로도달, 셀 수]. */ + flow_arrows: Array<[number, number, number, boolean, number]>; + /** 화살표 사이 실제 간격(m). */ + arrow_spacing_m: number; basins: DrainageBasin[]; } diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py index 51be65f9..50f5b630 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py @@ -21,6 +21,7 @@ import json import logging from dataclasses import dataclass, field from pathlib import Path +from typing import Any import numpy as np @@ -58,6 +59,9 @@ class DrainageDetail: pipes: list[StructureCandidate] = field(default_factory=list) basins: list[WatershedBasin] = field(default_factory=list) grid_cell_m: float = 1.0 + # B04가 계산해 둔 평균 흐름 화살표를 그대로 넘긴다 — B05는 다시 계산하지 않는다. + flow_arrows: list[list[Any]] = field(default_factory=list) + arrow_spacing_m: float = 0.0 def build_drainage_detail( @@ -94,6 +98,8 @@ def build_drainage_detail( basin_lonlat=routing.basin_lonlat, pipes=pipes, grid_cell_m=routing.spec.cell_m, + flow_arrows=routing.flow_arrows, + arrow_spacing_m=routing.arrow_spacing_m, ) if not pipes: return detail @@ -140,6 +146,9 @@ class RoadRouting: route_lonlat: list[list[float]] = field(default_factory=list) basin_lonlat: list[list[float]] = field(default_factory=list) base_pipes: list[StructureCandidate] = field(default_factory=list) + # 평균 흐름 화살표 — [x, y, 방위(도), 도로도달, 셀 수]. B04가 계산해 둔 그대로. + flow_arrows: list[list[Any]] = field(default_factory=list) + arrow_spacing_m: float = 0.0 @property def strength_curve(self) -> np.ndarray: @@ -204,6 +213,9 @@ def _read_geometry(path: Path, routing: RoadRouting) -> None: except (OSError, json.JSONDecodeError): logger.warning("배수유역: B04 기하 산출물을 읽지 못했습니다 (%s).", path) return + routing.arrow_spacing_m = float( + (document.get("properties") or {}).get("arrow_spacing_m") or 0.0 + ) for feature in document.get("features", []): properties = feature.get("properties") or {} geometry = feature.get("geometry") or {} @@ -213,6 +225,17 @@ def _read_geometry(path: Path, routing: RoadRouting) -> None: routing.route_lonlat = coordinates elif kind == "basin_boundary" and geometry.get("type") == "Polygon" and coordinates: routing.basin_lonlat = coordinates[0] + elif kind == "flow_arrow" and geometry.get("type") == "Point": + # 화면이 미터로 그리므로 속성의 x·y를 쓴다(기하는 저장 규약상 lon/lat). + routing.flow_arrows.append( + [ + float(properties.get("x") or 0.0), + float(properties.get("y") or 0.0), + float(properties.get("azimuth_deg") or 0.0), + bool(properties.get("reaches_road")), + int(properties.get("cells") or 0), + ] + ) elif kind == "pipe" and geometry.get("type") == "Point": routing.base_pipes.append( StructureCandidate( diff --git a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py index 7861567a..1b99378e 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py +++ b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py @@ -112,6 +112,9 @@ async def post_drainage_basins( "route_lonlat": detail.route_lonlat, "main_polygon_lonlat": detail.basin_lonlat, "grid_cell_m": detail.grid_cell_m, + # 평균 흐름 화살표 — B04가 계산해 저장한 것을 그대로 넘긴다(사업지 CRS m). + "flow_arrows": detail.flow_arrows, + "arrow_spacing_m": detail.arrow_spacing_m, # 계획선 위 배관 지점 — 유역이 없는 관도 마커로 표시해야 하므로 별도 목록. "pipes": [_candidate_payload(pipe, to_lonlat) for pipe in detail.pipes], "basins": [ diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts index 5349c7a4..4197183a 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -24,6 +24,7 @@ import { type DrainageBasin, type RoutePoint, } from "./B05_wf2_Route_Api_Fetch"; +import { drawFlowArrows, type FlowArrow } from "../B04_wf1_Surface/B04_wf1_Surface_UI_FlowArrows"; import { createPipeEditor } from "./B05_wf2_Route_UI_Drainage_Pipes"; // 배수유역도 패널 — 하단 종단 패널 안쪽 우측에 도킹되는 2단 사이드 패널. @@ -109,7 +110,20 @@ export function createDrainagePanel(): DrainagePanel { autoButton.type = "button"; autoButton.className = "b05-drainage__analyze b05-drainage__tool"; autoButton.textContent = "자동 제안"; - header.append(analyzeButton, editButton, deleteButton, autoButton); + // 흐름 화살표 보기/숨기기 — 도면이 지저분해질 때 끄기 위한 토글. + const arrowButton = document.createElement("button"); + arrowButton.type = "button"; + arrowButton.className = "b05-drainage__analyze b05-drainage__tool is-active"; + arrowButton.textContent = "흐름 화살표"; + arrowButton.title = "B04에서 산출한 평균 흐름 방향을 보이거나 숨깁니다."; + arrowButton.setAttribute("aria-pressed", "true"); + arrowButton.addEventListener("click", () => { + showArrows = !showArrows; + arrowButton.classList.toggle("is-active", showArrows); + arrowButton.setAttribute("aria-pressed", String(showArrows)); + scheduleDraw(); + }); + header.append(analyzeButton, editButton, deleteButton, autoButton, arrowButton); const viewport = document.createElement("div"); viewport.className = "b05-drainage__viewport"; @@ -147,6 +161,10 @@ export function createDrainagePanel(): DrainagePanel { // 2차 전체 배수유역 외곽선 = 분수령. 별도 "능선" 레이어를 두지 않고 이 선 하나로 표시한다 // (전체 유역 외곽선과 능선이 같은 선이므로 — 2026-07-31 사용자 지시). let mainBoundary: Array<[number, number]> = []; + // 평균 흐름 화살표 — B04가 계산해 둔 것을 그대로 받아 그린다(여기서 계산하지 않는다). + let flowArrows: FlowArrow[] = []; + let arrowSpacingM = 0; + let showArrows = true; let scale = 1; let offsetX = 0; let offsetY = 0; @@ -230,6 +248,27 @@ export function createDrainagePanel(): DrainagePanel { context.strokeStyle = ROUTE_COLOR; drawPreparedLayer(context, routeLayer, view, "dot"); } + // 평균 흐름 화살표 — 유역 채움 위, 배관 마커 아래. 좌표는 사업지 CRS(m)라 + // 도엽 메타로 바로 화면에 옮긴다(배관 마커와 같은 변환). + if (showArrows && meta && flowArrows.length > 0) { + const spanX = meta.width_meters || 1; + const spanY = meta.height_meters || 1; + const pxPerMeter = (view.mapRect.width * view.scale) / spanX; + const ax = view.mapRect.width * view.scale; + const bx = view.mapRect.x * view.scale + (width / 2) * (1 - view.scale) + view.offsetX; + const ay = view.mapRect.height * view.scale; + const by = view.mapRect.y * view.scale + (height / 2) * (1 - view.scale) + view.offsetY; + const originX = meta.x_min; + const originY = meta.y_min; + drawFlowArrows( + context, + flowArrows, + arrowSpacingM, + pxPerMeter, + (x, y) => [((x - originX) / spanX) * ax + bx, (1 - (y - originY) / spanY) * ay + by], + view, + ); + } // 배관(관 매설) 마커 — 계획선 위 최상단. pipeEditor.draw(context, view, pipeColor); updateImageTransform(); @@ -317,6 +356,8 @@ export function createDrainagePanel(): DrainagePanel { const response = await fetchDrainageBasins(projectId, chainages); basins = response.basins; mainBoundary = response.main_polygon_lonlat ?? []; + flowArrows = (response.flow_arrows ?? []) as FlowArrow[]; + arrowSpacingM = response.arrow_spacing_m ?? 0; // 계획도로선·2차 유역 외곽선은 B04 산출물을 그대로 받는다 — 여기서 다시 계산하지 않는다. // 실제 사용된 배관 목록으로 마커를 동기화한다(유역 없는 관 포함). pipeEditor.setPipes( From 4501f3b19e7e06bcd4ac5a22db86bce696594e91 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 1 Aug 2026 08:55:01 +0900 Subject: [PATCH 46/61] auto: 2026-08-01 08:55 (EOMSANGDON-HOME) --- B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts | 125 +++++++++ .../B04_wf1_Surface_UI_MapRender.ts | 34 +++ .../B04_wf1_Surface_UI_MapViewer.ts | 11 +- B04_wf1_Surface/B04_wf1_Surface_UI_Style.css | 38 +-- .../B04_wf1_Surface_UI_TerrainViewer.ts | 10 + B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts | 9 + .../B04_wf1_Surface_UI_Watershed.ts | 21 +- B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts | 21 +- .../B05_wf2_Route_Engine_Drainage_Basin.py | 44 +++- .../B05_wf2_Route_Engine_Drainage_Store.py | 245 ++++++++++++++++++ .../B05_wf2_Route_Router_Drainage.py | 52 +++- .../B05_wf2_Route_UI_Drainage_Boundary.ts | 188 ++++++++++++++ .../B05_wf2_Route_UI_Drainage_Panel.ts | 148 +++++++++-- B05_wf2_Route/B05_wf2_Route_UI_Page.ts | 135 +++++++--- .../B05_wf2_Route_UI_Profile_Panel.ts | 2 + B05_wf2_Route/B05_wf2_Route_UI_Style.css | 37 ++- B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts | 11 + config/config_system.py | 12 + ui_template/ui_template_progress.css | 78 ++++++ ui_template/ui_template_progress.ts | 89 +++++++ 20 files changed, 1205 insertions(+), 105 deletions(-) create mode 100644 B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Store.py create mode 100644 B05_wf2_Route/B05_wf2_Route_UI_Drainage_Boundary.ts create mode 100644 ui_template/ui_template_progress.css create mode 100644 ui_template/ui_template_progress.ts diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts index 7b359fd7..b7a5eb0e 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts @@ -1,3 +1,5 @@ +import * as THREE from "three"; +import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import type { SurfaceBounds } from "./B04_wf1_Surface_Api_Fetch"; export const SURFACE_CAMERA_FOV = 50; @@ -43,6 +45,129 @@ export function niceScaleDistance(roughMeters: number): number { return step * base; } +/* ----------------------------------------------------------------------------- + * 커서 기준 회전·줌 (B04 포인트클라우드/지형, B05 노선 뷰어 공용) + * + * OrbitControls 기본값은 화면 중앙(target)을 축으로 돌아서, 커서로 보고 있는 봉우리가 + * 화면 밖으로 밀려난다. 여기서는 커서 아래 지형 지점을 축으로 삼아 카메라와 target을 + * 함께 돌린다 — 축이 화면에 고정되므로 시점이 튀지 않는다. + * 줌은 OrbitControls의 zoomToCursor로 같은 기준을 쓴다. + * -------------------------------------------------------------------------- */ + +/** 극점을 넘어 화면이 뒤집히지 않도록 남기는 여유각(rad). */ +const POLAR_EPSILON = 0.02; + +export interface CursorPivotOptions { + camera: THREE.PerspectiveCamera; + controls: OrbitControls; + /** 포인터 이벤트를 받는 캔버스. */ + element: HTMLElement; + /** 커서 아래에서 찾을 대상(지형 메시·포인트클라우드). 없으면 기존 축을 유지한다. */ + pickables: () => THREE.Object3D[]; + /** 마커 드래그 등 다른 조작이 잡고 있으면 회전을 넘긴다. */ + blocked?: () => boolean; +} + +/** 커서 기준 회전·줌을 붙이고, 해제 함수를 돌려준다. */ +export function bindCursorPivotControls(options: CursorPivotOptions): () => void { + const { camera, controls, element } = options; + // 회전은 여기서 직접 처리하므로 OrbitControls 쪽 회전은 끈다(팬·줌은 그대로 둔다). + controls.enableRotate = false; + controls.zoomToCursor = true; + + const raycaster = new THREE.Raycaster(); + const pointer = new THREE.Vector2(); + const pivot = new THREE.Vector3(); + let pointerId: number | null = null; + let lastX = 0; + let lastY = 0; + + /** 커서 아래 지형 지점. 못 찾으면 기존 target을 축으로 쓴다. */ + function pickPivot(event: PointerEvent): void { + pivot.copy(controls.target); + const targets = options.pickables(); + if (targets.length === 0) return; + const rect = element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return; + pointer.set( + ((event.clientX - rect.left) / rect.width) * 2 - 1, + -((event.clientY - rect.top) / rect.height) * 2 + 1, + ); + raycaster.setFromCamera(pointer, camera); + const hit = raycaster.intersectObjects(targets, true)[0]; + if (hit) pivot.copy(hit.point); + } + + function onPointerDown(event: PointerEvent): void { + if (event.button !== 0 || pointerId !== null) return; + if (options.blocked?.() || !controls.enabled) return; + pickPivot(event); + pointerId = event.pointerId; + lastX = event.clientX; + lastY = event.clientY; + } + + function onPointerMove(event: PointerEvent): void { + if (pointerId !== event.pointerId) return; + if (options.blocked?.()) { + stop(); + return; + } + const height = Math.max(element.clientHeight, 1); + // OrbitControls와 같은 감도: 화면 높이만큼 끌면 한 바퀴. + const yaw = (2 * Math.PI * (event.clientX - lastX)) / height; + const pitch = (2 * Math.PI * (event.clientY - lastY)) / height; + lastX = event.clientX; + lastY = event.clientY; + if (yaw === 0 && pitch === 0) return; + + const up = camera.up.clone().normalize(); + const cameraOffset = camera.position.clone().sub(pivot); + const targetOffset = controls.target.clone().sub(pivot); + // 수평 회전 — 화면 상하축(카메라 up) 기준. + cameraOffset.applyAxisAngle(up, -yaw); + targetOffset.applyAxisAngle(up, -yaw); + // 수직 회전 — 시선의 오른쪽 축 기준. 극점을 넘으면 상하만 버린다. + const viewDirection = cameraOffset.clone().sub(targetOffset); + const right = viewDirection.clone().cross(up); + if (right.lengthSq() > 1e-8) { + right.normalize(); + const rotated = viewDirection.clone().applyAxisAngle(right, -pitch); + const polar = rotated.angleTo(up); + if (polar > POLAR_EPSILON && polar < Math.PI - POLAR_EPSILON) { + cameraOffset.applyAxisAngle(right, -pitch); + targetOffset.applyAxisAngle(right, -pitch); + } + } + camera.position.copy(pivot).add(cameraOffset); + controls.target.copy(pivot).add(targetOffset); + camera.lookAt(controls.target); + controls.update(); + } + + function stop(): void { + pointerId = null; + } + + function onPointerEnd(event: PointerEvent): void { + if (pointerId === event.pointerId) stop(); + } + + element.addEventListener("pointerdown", onPointerDown); + element.addEventListener("pointermove", onPointerMove); + element.addEventListener("pointerup", onPointerEnd); + element.addEventListener("pointercancel", onPointerEnd); + element.addEventListener("pointerleave", onPointerEnd); + + return () => { + element.removeEventListener("pointerdown", onPointerDown); + element.removeEventListener("pointermove", onPointerMove); + element.removeEventListener("pointerup", onPointerEnd); + element.removeEventListener("pointercancel", onPointerEnd); + element.removeEventListener("pointerleave", onPointerEnd); + }; +} + export function bindSurfaceViewerTheme( applyBackground: (color: string | number) => void, ): () => void { diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts index ad2a9efe..077f95de 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts @@ -21,6 +21,9 @@ export type GeoJsonCollection = { export type MarkerKind = "dot" | "x"; +/** 상류 세류망 강조 색 — 유역 판정의 기준선이라 가장 굵고 진하게 둔다. */ +const UPSTREAM_LINE_COLOR = "rgba(29, 78, 216, 0.95)"; + /** * 사전 투영된 하나의 파트(선/링/점 묶음). 좌표는 정규화 맵 좌표(0~1) x,y 교차 배열. * weights: Douglas-Peucker 가중치(정점 제거 시 발생하는 최대 오차, 종횡비 보정 좌표계). @@ -522,6 +525,37 @@ export function drawFilledRing( context.fillText(entry.label, centerX, centerY); } +/** 상류 세류망 강조 — B04 분석 오버레이와 B05 배수유역도가 같은 굵기·색으로 그린다. */ +export function drawUpstreamLines( + context: CanvasRenderingContext2D, + lines: ReadonlyArray>, + normalizer: Normalizer, + view: ViewState, +): void { + if (lines.length === 0) return; + const affine = affineOf(view); + context.save(); + context.setLineDash([]); + context.lineWidth = 4; + context.lineCap = "round"; + context.lineJoin = "round"; + context.strokeStyle = UPSTREAM_LINE_COLOR; + lines.forEach((line) => { + if (line.length < 2) return; + context.beginPath(); + line.forEach(([lon, lat], index) => { + const nx = (lon - normalizer.lonMin) / normalizer.lonRange; + const ny = 1 - (lat - normalizer.latMin) / normalizer.latRange; + const x = nx * affine.ax + affine.bx; + const y = ny * affine.ay + affine.by; + if (index === 0) context.moveTo(x, y); + else context.lineTo(x, y); + }); + context.stroke(); + }); + context.restore(); +} + /** 유역 경계(분수령=능선)를 능선 스타일(갈색 파선)로 강조해 그린다. */ export function drawRidgeRing( context: CanvasRenderingContext2D, diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index 01ee066e..c23319a8 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -116,6 +116,11 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { empty.textContent = L("B04_Surface_Map_Empty"); const status = document.createElement("span"); status.className = "b04-map__status"; + // 지도 위 좌상단 문구 묶음 — 지도 상태와 배수유역 상태를 세로로 쌓는다. + // 배경지도가 복잡해 글자가 묻히므로 각 문구에 배경 칩을 깐다(2026-08-01 사용자 지시). + const statusStack = document.createElement("div"); + statusStack.className = "b04-map__status-stack"; + statusStack.append(status); const scaleBar = document.createElement("div"); scaleBar.className = "b04-map__scale"; const scaleText = document.createElement("span"); @@ -124,7 +129,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { ...BACKGROUND_LAYERS.map((layer) => backgroundImages.get(layer)!), canvas, empty, - status, + statusStack, scaleBar, ); root.append(header, viewport); @@ -232,8 +237,10 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { const watershedButtons = document.createElement("div"); watershedButtons.className = "b04-map__layer-buttons"; watershedButtons.append(watershed.button, ...watershed.partButtons); - watershedGroup.append(watershedTitle, watershedButtons, watershed.statusElement); + watershedGroup.append(watershedTitle, watershedButtons); controls.insertBefore(watershedGroup, resetButton); + // 안내·결과 문구는 컨트롤 줄이 아니라 지도 위에 얹는다 — 컨트롤 영역 세로 공간을 먹지 않는다. + statusStack.append(watershed.statusElement); function updateImageTransform(): void { backgroundImages.forEach((image) => { diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css b/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css index 4261ba7a..d8036e11 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css @@ -603,40 +603,46 @@ pointer-events: none; } -.b04-map__empty, -.b04-map__status { +.b04-map__empty { position: absolute; z-index: 2; + inset: 50% auto auto 50%; + transform: translate(-50%, -50%); color: var(--color-text-secondary); font-size: var(--text-caption); } -.b04-map__empty { - inset: 50% auto auto 50%; - transform: translate(-50%, -50%); -} - -.b04-map__status { +/* 지도 위 좌상단 문구 묶음 — 지도 상태와 배수유역 상태를 세로로 쌓는다. + 지도 조작을 가리지 않도록 포인터 이벤트는 통과시킨다. */ +.b04-map__status-stack { + position: absolute; + z-index: 2; top: var(--spacing-12); left: var(--spacing-12); - padding: var(--spacing-4) var(--spacing-8); - border-radius: var(--radius-inputs); - background: var(--color-surface-raised); + display: flex; + max-width: min(62%, 900px); + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-4); + pointer-events: none; } /* 배수유역 전용 상태 줄 — 지도 자체 상태(.b04-map__status)와 칸을 나눠 쓰면 - 나중에 끝난 쪽이 상대 문구를 지운다. 그래서 컨트롤 영역에 따로 둔다. */ + 나중에 끝난 쪽이 상대 문구를 지운다. 그래서 줄을 따로 둔다. + 배경지도(위성·지적·등고선)가 복잡해 글자가 묻히므로 배경 칩을 깐다. */ +.b04-map__status, .b04-map__watershed-status { display: block; - width: 100%; - margin-top: var(--spacing-4); padding: var(--spacing-4) var(--spacing-8); + border: 1px solid var(--color-border); border-radius: var(--radius-inputs); - background: var(--color-surface-raised); - color: var(--color-text-secondary); + background: color-mix(in srgb, var(--color-surface-raised) 92%, transparent); + color: var(--color-text-body); font-size: var(--text-caption); line-height: 1.5; word-break: keep-all; + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); } .b04-map__watershed-status[hidden] { diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts index 87a7f162..b5cde486 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts @@ -5,6 +5,7 @@ import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js"; import { API_BASE_URL } from "@config/config_frontend"; import type { SurfaceBounds, SurfaceModelSummary } from "./B04_wf1_Surface_Api_Fetch"; import { + bindCursorPivotControls, bindSurfaceViewerTheme, getTopFitDistance, niceScaleDistance, @@ -209,6 +210,13 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { let terrainMesh: THREE.Object3D | null = null; const labelElements: HTMLDivElement[] = []; + // 회전·줌 중심을 커서 아래 지형 지점으로 (포인트클라우드 뷰어·B05와 공용 유틸). + const releaseCursorPivot = bindCursorPivotControls({ + camera, + controls, + element: renderer.domElement, + pickables: () => (terrainMesh ? [terrainMesh] : []), + }); function disposeObject(obj: THREE.Object3D) { obj.traverse((child) => { @@ -547,6 +555,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { clearMesh(); clearContours(); releaseTheme(); + releaseCursorPivot(); controls.dispose(); renderer.dispose(); } @@ -675,6 +684,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { cancelAnimationFrame(animationFrameId); resizeObserver.disconnect(); releaseTheme(); + releaseCursorPivot(); clearMesh(); clearContours(); controls.dispose(); diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts index a9ad9012..ae2c5c87 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts @@ -3,6 +3,7 @@ import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import type { SurfaceBounds, SurfacePointCloudSampleResponse } from "./B04_wf1_Surface_Api_Fetch"; import { + bindCursorPivotControls, bindSurfaceViewerTheme, getReferenceCenter, getTopFitDistance, @@ -120,6 +121,13 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { scene.add(axes); let pointsObject: THREE.Points | null = null; + // 회전·줌 중심을 커서 아래 지점으로 (지형 뷰어·B05와 공용 유틸). + const releaseCursorPivot = bindCursorPivotControls({ + camera, + controls: orbit, + element: canvas, + pickables: () => (pointsObject ? [pointsObject] : []), + }); let currentData: SurfacePointCloudSampleResponse | null = null; let animationFrame = 0; let hasConnected = false; @@ -272,6 +280,7 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { disposed = true; cancelAnimationFrame(animationFrame); releaseTheme(); + releaseCursorPivot(); clearPoints(); orbit.dispose(); renderer.dispose(); diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts index 7bceeb65..fe074a60 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts @@ -1,6 +1,6 @@ import { fetchWatershedAnalysis, type WatershedAnalysis } from "./B04_wf1_Surface_Api_Fetch"; import { drawFlowArrows } from "./B04_wf1_Surface_UI_FlowArrows"; -import type { Normalizer, ViewState } from "./B04_wf1_Surface_UI_MapRender"; +import { drawUpstreamLines, type Normalizer, type ViewState } from "./B04_wf1_Surface_UI_MapRender"; /* ============================================================================= * 배수유역 분석 오버레이 (B04 — 관리자 확인용) @@ -91,19 +91,22 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { "저장된 결과는 지도를 열 때 자동으로 표시되므로, 조건을 바꿨을 때만 누르면 됩니다."; // 갈래별 표시 여부. 전체 토글(button)이 꺼져 있으면 이 값과 무관하게 아무것도 안 그린다. + // 1차 유역·유역 방향은 격자가 지도를 덮어 판독을 방해하므로 기본 꺼짐(2026-08-01 사용자 지시). const shownParts: Record = { - primary: true, + primary: false, basin: true, - flow: true, + flow: false, arrows: true, }; const partButtons = PARTS.map((part) => { const element = document.createElement("button"); element.type = "button"; - element.className = "b04-map__layer-button b04-map__layer-button--gis is-active"; + const initialActive = shownParts[part.key]; + element.className = + "b04-map__layer-button b04-map__layer-button--gis" + (initialActive ? " is-active" : ""); element.textContent = part.label; element.style.setProperty("--b04-layer-color", part.color); - element.setAttribute("aria-pressed", "true"); + element.setAttribute("aria-pressed", String(initialActive)); element.addEventListener("click", () => { shownParts[part.key] = !shownParts[part.key]; element.classList.toggle("is-active", shownParts[part.key]); @@ -328,12 +331,10 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { context.lineWidth = 2; context.strokeStyle = "rgba(120, 113, 108, 0.85)"; region.downstream_lines.forEach((line) => strokeLonLat(context, line, map, view)); - // ④ 채택된 상류망 = 1차 영역의 기준선. 가장 굵게, 맨 위에. - context.setLineDash([]); - context.lineWidth = 4; - context.strokeStyle = "rgba(29, 78, 216, 0.95)"; - region.upstream_lines.forEach((line) => strokeLonLat(context, line, map, view)); context.restore(); + // ④ 채택된 상류망 = 1차 영역의 기준선. 가장 굵게, 맨 위에. + // 그리기는 B05 배수유역도와 같은 공용 렌더러에 맡긴다. + drawUpstreamLines(context, region.upstream_lines, map, view); } /** B05에도 같이 쓰는 평균 흐름 화살표. 그리기는 공용 렌더러에 맡긴다. */ diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index 30a3154a..e06feb17 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -302,8 +302,16 @@ export interface DrainageBasinResponse { pipes: DrainageCandidate[]; /** B04가 분석에 쓴 계획 노선 선형(lon/lat). */ route_lonlat: Array<[number, number]>; - /** 2차 전체 배수유역 외곽선 = 분수령. B04 산출물을 그대로 받는다. */ + /** 2차 전체 배수유역 외곽선 = 분수령. 편집 핸들 간격으로 다시 찍고 저장된 편집분이 반영된 값. */ main_polygon_lonlat: Array<[number, number]>; + /** 외곽선 편집 핸들 간격(m). */ + boundary_spacing_m: number; + /** 저장돼 있던 외곽선 편집 포인트(원래 자리 base, 옮긴 자리 moved). */ + boundary_overrides: Array<{ base: [number, number]; moved: [number, number] }>; + /** 새 유역 안쪽으로 들어가 버려진 편집 포인트 수. */ + boundary_dropped: number; + /** 유역 안쪽 상류 세류망 — 하이라이트 토글용. */ + upstream_lonlat: Array>; /** B04 해석 격자 한 변(m). */ grid_cell_m: number; /** 평균 흐름 화살표 — [x, y(사업지 CRS m), 방위(도), 도로도달, 셀 수]. */ @@ -325,3 +333,14 @@ export async function fetchDrainageBasins( API_ANALYSIS_TIMEOUT_MS, ); } + +/** 사용자가 옮긴 유역 외곽선 포인트만 저장한다(종단 경로 확정 시 모달 승인 후 호출). */ +export async function saveDrainageBoundary( + projectId: string, + points: Array<{ base: [number, number]; moved: [number, number] }>, +): Promise<{ status: string; saved: number }> { + return requestJson<{ status: string; saved: number }>( + `/projects/${projectId}/drainage/boundary`, + { method: "PUT", body: JSON.stringify({ points }) }, + ); +} diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py index 50f5b630..4e0dbaef 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py @@ -25,10 +25,11 @@ from typing import Any import numpy as np -from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Export import STAGES, drainage_dir +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Export import STAGES from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Flow import largest_ring, polygonize_labels from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import GridSpec from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import estimate_pipe_diameter_mm +from B05_wf2_Route.B05_wf2_Route_Engine_Drainage_Store import b05_drainage_dir, sync_from_b04 from common_util.common_util_route_geometry import ( RouteVertex, StructureCandidate, @@ -62,6 +63,8 @@ class DrainageDetail: # B04가 계산해 둔 평균 흐름 화살표를 그대로 넘긴다 — B05는 다시 계산하지 않는다. flow_arrows: list[list[Any]] = field(default_factory=list) arrow_spacing_m: float = 0.0 + # 유역 안쪽 상류 세류망(WGS84 lon/lat 조각들). 화면 강조 표시용 — 계산에는 쓰지 않는다. + upstream_lonlat: list[list[list[float]]] = field(default_factory=list) def build_drainage_detail( @@ -100,6 +103,7 @@ def build_drainage_detail( grid_cell_m=routing.spec.cell_m, flow_arrows=routing.flow_arrows, arrow_spacing_m=routing.arrow_spacing_m, + upstream_lonlat=load_upstream_lines(stored_path), ) if not pipes: return detail @@ -162,8 +166,13 @@ class RoadRouting: def load_road_routing(stored_path: str) -> RoadRouting | None: - """B04가 남긴 `03_road_routing` 산출물을 읽는다. 없으면 None.""" - directory = drainage_dir(stored_path) + """`03_road_routing` 산출물을 읽는다. 없으면 None. + + 읽는 대상은 B04 원본이 아니라 **B05 사본**이다. B04가 다시 해석했으면 사본을 먼저 + 갱신한다 — 편집분(`boundary_overrides.json`)은 사본 갱신과 무관하게 남는다. + """ + sync_from_b04(stored_path) + directory = b05_drainage_dir(stored_path) prefix = STAGES["road_routing"] array_path = directory / f"{prefix}_road_routing.npz" if not array_path.exists(): @@ -247,6 +256,35 @@ def _read_geometry(path: Path, routing: RoadRouting) -> None: ) +def load_upstream_lines(stored_path: str) -> list[list[list[float]]]: + """`01_primary_region`에서 상류 세류망만 읽는다(화면 강조용). + + 유역 판정의 기준선이라 B04 오버레이에서도 같은 선을 굵게 그린다 — B05는 그 선을 + 그대로 받아 표시만 한다. + """ + path = b05_drainage_dir(stored_path) / f"{STAGES['primary_region']}_primary_region.geojson" + if not path.exists(): + return [] + try: + with path.open("r", encoding="utf-8") as file: + document = json.load(file) + except (OSError, json.JSONDecodeError): + logger.warning("배수유역: 상류 세류망을 읽지 못했습니다 (%s).", path) + return [] + lines: list[list[list[float]]] = [] + for feature in document.get("features", []): + properties = feature.get("properties") or {} + geometry = feature.get("geometry") or {} + if properties.get("kind") != "upstream": + continue + coordinates = geometry.get("coordinates") + if geometry.get("type") == "LineString" and coordinates: + lines.append(coordinates) + elif geometry.get("type") == "MultiLineString" and coordinates: + lines.extend(part for part in coordinates if part) + return lines + + # ── ⑨ 관 최소 개수 보충 ───────────────────────────────────────────────────── diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Store.py b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Store.py new file mode 100644 index 00000000..1ee4e508 --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Store.py @@ -0,0 +1,245 @@ +"""B05 전용 배수유역 저장소 (사본 관리 + 유역 외곽선 편집분 보존). + +B04는 배수유역을 해석해 `B04_wf1_Surface/drainage/`에 남긴다. B05는 그 결과를 읽어 관을 +보충하고 세부유역을 나누는데, 같은 폴더를 그대로 쓰면 B05에서 손댄 내용이 B04 원본을 +덮어쓴다. 그래서 여기서 사본을 따로 둔다(2026-08-01 사용자 지시). + + · `B05_wf2_Route/drainage/` — B04 산출물의 사본. B04가 다시 해석하면 자동으로 갱신된다. + · `boundary_overrides.json` — 사용자가 옮긴 유역 외곽선 포인트만. 사본이 갱신돼도 남는다. + +노선이 바뀌지 않으면 유역도 바뀌지 않는다. 노선이 바뀌어 B04가 재계산하면 사본은 새 결과로 +덮어쓰고, 저장해 둔 편집 포인트는 좌표 근접으로 새 외곽선에 다시 붙인다. 새 유역 **안쪽**으로 +들어간 포인트는 경계를 넓히는 의미가 없으므로 버린다. +""" + +from __future__ import annotations + +import json +import logging +import math +import shutil +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Export import drainage_dir +from common_util.common_util_storage import resolve_stored_project_path +from config.config_system import ( + DRAINAGE_B05_DIRNAME, + DRAINAGE_BOUNDARY_HANDLE_SPACING_M, + DRAINAGE_BOUNDARY_MATCH_RADIUS_M, + DRAINAGE_BOUNDARY_OVERRIDE_FILENAME, +) + +logger = logging.getLogger(__name__) + +# 위도 1도 ≈ 110540m, 경도 1도 ≈ 111320m·cos(위도). 30m 안팎의 근접 판정에는 충분하다. +_METERS_PER_LAT_DEGREE = 110540.0 +_METERS_PER_LON_DEGREE = 111320.0 + +LonLatPoint = list[float] + + +@dataclass +class BoundaryOverride: + """사용자가 옮긴 외곽선 포인트 하나 — 원래 자리(base)와 옮긴 자리(moved).""" + + base: tuple[float, float] + moved: tuple[float, float] + + +def b05_drainage_dir(stored_path: str) -> Path: + """B05 전용 배수유역 폴더. B04 원본과 분리된 사본이 여기 들어간다.""" + return Path(resolve_stored_project_path(stored_path)) / "B05_wf2_Route" / DRAINAGE_B05_DIRNAME + + +def sync_from_b04(stored_path: str) -> bool: + """B04 산출물을 B05 사본으로 맞춘다. 실제로 복사했으면 True. + + 사본이 없으면 초안으로 1회 복사하고, B04 쪽이 더 최신이면(재해석) 그 파일만 덮어쓴다. + `boundary_overrides.json`은 B04에 없는 파일이라 이 과정에서 손대지 않는다. + """ + source = drainage_dir(stored_path) + if not source.is_dir(): + return False + target = b05_drainage_dir(stored_path) + copied = 0 + try: + target.mkdir(parents=True, exist_ok=True) + for item in source.iterdir(): + if not item.is_file(): + continue + destination = target / item.name + if destination.exists() and destination.stat().st_mtime >= item.stat().st_mtime: + continue + shutil.copy2(item, destination) + copied += 1 + except OSError: + logger.warning("배수유역: B05 사본 갱신 실패 (%s → %s)", source, target) + return False + if copied: + logger.info("배수유역: B05 사본 갱신 — %d개 파일 (%s)", copied, target) + return copied > 0 + + +def _overrides_path(stored_path: str) -> Path: + return b05_drainage_dir(stored_path) / DRAINAGE_BOUNDARY_OVERRIDE_FILENAME + + +def load_boundary_overrides(stored_path: str) -> list[BoundaryOverride]: + """저장된 외곽선 편집 포인트를 읽는다. 없으면 빈 목록.""" + path = _overrides_path(stored_path) + if not path.exists(): + return [] + try: + with path.open("r", encoding="utf-8") as file: + document = json.load(file) + except (OSError, json.JSONDecodeError): + logger.warning("배수유역: 외곽선 편집 파일을 읽지 못했습니다 (%s).", path) + return [] + overrides: list[BoundaryOverride] = [] + for entry in (document or {}).get("points", []): + base = _as_point(entry.get("base")) + moved = _as_point(entry.get("moved")) + if base and moved: + overrides.append(BoundaryOverride(base=base, moved=moved)) + return overrides + + +def save_boundary_overrides(stored_path: str, overrides: list[BoundaryOverride]) -> int: + """외곽선 편집 포인트를 저장한다. 저장된 개수를 돌려준다.""" + path = _overrides_path(stored_path) + document = { + "version": 1, + "spacing_m": DRAINAGE_BOUNDARY_HANDLE_SPACING_M, + "points": [{"base": list(item.base), "moved": list(item.moved)} for item in overrides], + } + try: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as file: + json.dump(document, file, ensure_ascii=False) + except OSError: + logger.warning("배수유역: 외곽선 편집 저장 실패 (%s).", path) + return 0 + logger.info("배수유역: 외곽선 편집 %d개 저장 — %s", len(overrides), path) + return len(overrides) + + +def parse_overrides(values: Any) -> list[BoundaryOverride]: + """프론트가 보낸 편집 포인트 목록을 정리한다(형식이 어긋난 항목은 버린다).""" + if not isinstance(values, list): + return [] + parsed: list[BoundaryOverride] = [] + for entry in values: + if not isinstance(entry, dict): + continue + base = _as_point(entry.get("base")) + moved = _as_point(entry.get("moved")) + if base and moved: + parsed.append(BoundaryOverride(base=base, moved=moved)) + return parsed + + +def _as_point(value: Any) -> tuple[float, float] | None: + if not isinstance(value, (list, tuple)) or len(value) < 2: + return None + try: + return (float(value[0]), float(value[1])) + except (TypeError, ValueError): + return None + + +def resample_boundary( + polygon_lonlat: list[LonLatPoint], spacing_m: float = DRAINAGE_BOUNDARY_HANDLE_SPACING_M +) -> list[LonLatPoint]: + """외곽선을 일정 간격(m)으로 다시 찍어 편집 핸들 목록을 만든다. + + 격자 경계라 원래 정점이 1m 간격으로 촘촘해 그대로 핸들로 쓸 수 없다. + """ + points = [p for p in polygon_lonlat if isinstance(p, (list, tuple)) and len(p) >= 2] + if len(points) < 3 or spacing_m <= 0: + return [list(p[:2]) for p in points] + + # 폐합 고리로 다룬다 — 끝점이 시작점과 같으면 중복을 뺀다. + ring = [(float(p[0]), float(p[1])) for p in points] + if _distance_m(ring[0], ring[-1]) < 0.001: + ring = ring[:-1] + if len(ring) < 3: + return [list(p) for p in ring] + + handles: list[LonLatPoint] = [list(ring[0])] + carried = 0.0 + for index in range(len(ring)): + start = ring[index] + end = ring[(index + 1) % len(ring)] + segment = _distance_m(start, end) + if segment <= 0: + continue + position = spacing_m - carried + while position <= segment: + ratio = position / segment + handles.append( + [ + start[0] + (end[0] - start[0]) * ratio, + start[1] + (end[1] - start[1]) * ratio, + ] + ) + position += spacing_m + carried = (carried + segment) % spacing_m + return handles + + +def apply_boundary_overrides( + handles: list[LonLatPoint], overrides: list[BoundaryOverride] +) -> tuple[list[LonLatPoint], list[BoundaryOverride]]: + """저장된 편집 포인트를 새 핸들 목록에 다시 붙인다. + + · 인덱스가 아니라 **좌표 근접**으로 맞춘다 — 재계산하면 핸들 수가 달라진다. + · 옮긴 자리가 새 유역 **안쪽**이면 경계를 넓히지 않으므로 버린다. + 돌려주는 값은 (편집이 반영된 핸들, 살아남은 편집 목록). + """ + if not overrides or len(handles) < 3: + return handles, list(overrides) + + polygon = [(float(p[0]), float(p[1])) for p in handles] + applied = [list(p) for p in handles] + kept: list[BoundaryOverride] = [] + for override in overrides: + if _point_in_polygon(override.moved, polygon): + continue + nearest = -1 + nearest_distance = DRAINAGE_BOUNDARY_MATCH_RADIUS_M + for index, point in enumerate(polygon): + distance = _distance_m(override.base, point) + if distance <= nearest_distance: + nearest = index + nearest_distance = distance + if nearest < 0: + continue + # 붙인 자리를 새 base로 잡아 둔다 — 다음 재계산에서도 같은 지점에 다시 붙는다. + applied[nearest] = [override.moved[0], override.moved[1]] + kept.append(BoundaryOverride(base=polygon[nearest], moved=override.moved)) + return applied, kept + + +def _distance_m(a: tuple[float, float], b: tuple[float, float]) -> float: + """두 lon/lat 사이 거리(m) 근사. 수십 m 범위 판정에만 쓴다.""" + mean_lat = math.radians((a[1] + b[1]) / 2) + dx = (b[0] - a[0]) * _METERS_PER_LON_DEGREE * math.cos(mean_lat) + dy = (b[1] - a[1]) * _METERS_PER_LAT_DEGREE + return math.hypot(dx, dy) + + +def _point_in_polygon(point: tuple[float, float], polygon: list[tuple[float, float]]) -> bool: + """레이 캐스팅 내부 판정 (lon/lat 평면에서 그대로 계산).""" + x, y = point + inside = False + count = len(polygon) + for index in range(count): + x1, y1 = polygon[index] + x2, y2 = polygon[(index + 1) % count] + if (y1 > y) != (y2 > y): + crossing = x1 + (y - y1) * (x2 - x1) / (y2 - y1) + if crossing > x: + inside = not inside + return inside diff --git a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py index 1b99378e..72e52e1e 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py +++ b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py @@ -18,6 +18,13 @@ from pyproj import Transformer from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_wf2_Route.B05_wf2_Route_Engine_Drainage_Basin import build_drainage_detail +from B05_wf2_Route.B05_wf2_Route_Engine_Drainage_Store import ( + apply_boundary_overrides, + load_boundary_overrides, + parse_overrides, + resample_boundary, + save_boundary_overrides, +) from B05_wf2_Route.B05_wf2_Route_Repository import ( get_latest_route, get_route_points, @@ -25,6 +32,7 @@ from B05_wf2_Route.B05_wf2_Route_Repository import ( ) from common_util.common_util_route_geometry import StructureCandidate, build_route_vertices from config.config_db import get_db_pool +from config.config_system import DRAINAGE_BOUNDARY_HANDLE_SPACING_M logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B05 Route Drainage"]) @@ -104,17 +112,34 @@ async def post_drainage_basins( ) to_lonlat = prepared["to_lonlat"] + # 2차 전체 유역 외곽선 — 편집 핸들 간격으로 다시 찍고 저장된 편집분을 얹는다. + # 격자 경계라 원래 정점이 1m 간격이라 그대로는 손으로 잡을 수 없다. + boundary = resample_boundary(detail.basin_lonlat) + stored_overrides = load_boundary_overrides(prepared["stored_path"]) + boundary, kept = apply_boundary_overrides(boundary, stored_overrides) + dropped = len(stored_overrides) - len(kept) + if dropped > 0: + # 새 유역 안쪽으로 들어갔거나 붙일 자리가 없어진 편집분은 파일에서도 지운다. + save_boundary_overrides(prepared["stored_path"], kept) + return { "status": "success", "project_id": str(project_id), "route_id": prepared["route_id"], - # B04가 남긴 그대로 — 계획도로선과 2차 전체 배수유역 외곽선. + # B04가 남긴 그대로 — 계획도로선. 외곽선만 편집분을 반영해 내보낸다. "route_lonlat": detail.route_lonlat, - "main_polygon_lonlat": detail.basin_lonlat, + "main_polygon_lonlat": boundary, + "boundary_spacing_m": DRAINAGE_BOUNDARY_HANDLE_SPACING_M, + "boundary_overrides": [ + {"base": list(item.base), "moved": list(item.moved)} for item in kept + ], + "boundary_dropped": dropped, "grid_cell_m": detail.grid_cell_m, # 평균 흐름 화살표 — B04가 계산해 저장한 것을 그대로 넘긴다(사업지 CRS m). "flow_arrows": detail.flow_arrows, "arrow_spacing_m": detail.arrow_spacing_m, + # 유역 안쪽 상류 세류망 — 화면 강조 토글용(WGS84). + "upstream_lonlat": detail.upstream_lonlat, # 계획선 위 배관 지점 — 유역이 없는 관도 마커로 표시해야 하므로 별도 목록. "pipes": [_candidate_payload(pipe, to_lonlat) for pipe in detail.pipes], "basins": [ @@ -134,6 +159,29 @@ async def post_drainage_basins( } +@router.put("/{project_id}/drainage/boundary", response_model=None) +async def put_drainage_boundary( + project_id: UUID, + payload: dict[str, Any] | None = None, +) -> dict[str, Any] | JSONResponse: + """사용자가 옮긴 유역 외곽선 포인트만 저장한다(종단 경로 확정 시 모달 승인 후 호출). + + 폴리곤 전체가 아니라 이동한 포인트만 남긴다 — 노선이 바뀌어 유역을 다시 계산해도 + 좌표 근접으로 다시 붙일 수 있어야 하기 때문이다. + """ + pool = get_db_pool() + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + if not stored_path: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트 저장 경로가 없습니다."}, + ) + overrides = parse_overrides((payload or {}).get("points")) + saved = save_boundary_overrides(stored_path, overrides) + return {"status": "success", "project_id": str(project_id), "saved": saved} + + def _parse_chainages(values: list[Any]) -> list[float]: """사용자가 확정·편집한 누가거리 목록을 숫자로 정리한다.""" parsed: list[float] = [] diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Boundary.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Boundary.ts new file mode 100644 index 00000000..747930a3 --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Boundary.ts @@ -0,0 +1,188 @@ +import type { Normalizer, ViewState } from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender"; + +/* ============================================================================= + * 2차 전체 배수유역 외곽선 편집 (B05 배수유역도) + * + * 백엔드가 외곽선을 일정 간격(기본 20m)으로 다시 찍어 주면, 그 점들을 잡아 끌어 + * 유역 경계를 손으로 고친다. 옮긴 값은 **화면에만** 들고 있다가 종단 경로 확정 시 + * 사용자가 승인하면 옮긴 포인트만 저장한다(2026-08-01 사용자 지시). + * + * 저장 대상은 폴리곤 전체가 아니라 `{원래 자리, 옮긴 자리}` 짝이다 — 노선이 바뀌어 + * 유역을 다시 계산하면 점 개수가 달라지므로 좌표 근접으로 다시 붙여야 하기 때문이다. + * ========================================================================== */ + +/** 편집 핸들 반경(px)과 잡을 수 있는 여유. */ +const HANDLE_RADIUS_PX = 4; +const HANDLE_HIT_PX = 9; +const HANDLE_COLOR = "rgba(146, 64, 14, 0.95)"; +const HANDLE_MOVED_COLOR = "rgba(220, 38, 38, 0.95)"; +/** 같은 자리로 볼 오차(도). 대략 0.1m 수준. */ +const SAME_POINT_EPSILON = 1e-6; + +export type LonLat = [number, number]; + +export interface BoundaryOverrideEntry { + /** 재계산된 외곽선 위의 원래 자리. 다음 계산에서 이 좌표로 다시 붙인다. */ + base: LonLat; + /** 사용자가 옮긴 자리. */ + moved: LonLat; +} + +export interface BoundaryEditor { + /** 서버가 준 외곽선(편집 반영분)과 저장돼 있던 편집 목록을 싣는다. */ + setBoundary: ( + points: ReadonlyArray, + overrides: ReadonlyArray, + ) => void; + /** 현재 화면에 그릴 외곽선. */ + points: () => LonLat[]; + /** 저장 대상 — 원래 자리와 다른 포인트만. */ + overrides: () => BoundaryOverrideEntry[]; + /** 이번 화면에서 사용자가 옮긴 것이 있는지(저장 여부를 물어볼 근거). */ + isDirty: () => boolean; + markSaved: () => void; + setEditMode: (on: boolean) => void; + /** 편집 모드에서 핸들을 그린다. 외곽선 자체는 패널이 능선 스타일로 그린다. */ + draw: (context: CanvasRenderingContext2D, normalizer: Normalizer, view: ViewState) => void; + /** 핸들을 잡았으면 true — 지도 팬을 시작하지 않는다. */ + handleDown: (normalizer: Normalizer, view: ViewState, x: number, y: number) => boolean; + /** 드래그 중이면 true. */ + handleMove: (normalizer: Normalizer, view: ViewState, x: number, y: number) => boolean; + handleUp: () => void; +} + +type Affine = { ax: number; bx: number; ay: number; by: number }; + +/** 지도 렌더러와 같은 화면 변환. (MapRender 내부 계산과 동일 식) */ +function affineOf(view: ViewState): Affine { + return { + ax: view.mapRect.width * view.scale, + bx: view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX, + ay: view.mapRect.height * view.scale, + by: view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY, + }; +} + +function toScreen(point: LonLat, normalizer: Normalizer, affine: Affine): [number, number] { + const nx = (point[0] - normalizer.lonMin) / normalizer.lonRange; + const ny = 1 - (point[1] - normalizer.latMin) / normalizer.latRange; + return [nx * affine.ax + affine.bx, ny * affine.ay + affine.by]; +} + +function toLonLat(x: number, y: number, normalizer: Normalizer, affine: Affine): LonLat | null { + if (!(affine.ax > 0) || !(affine.ay > 0)) return null; + const nx = (x - affine.bx) / affine.ax; + const ny = (y - affine.by) / affine.ay; + return [ + normalizer.lonMin + nx * normalizer.lonRange, + normalizer.latMin + (1 - ny) * normalizer.latRange, + ]; +} + +function samePoint(a: LonLat, b: LonLat): boolean { + return Math.abs(a[0] - b[0]) < SAME_POINT_EPSILON && Math.abs(a[1] - b[1]) < SAME_POINT_EPSILON; +} + +export function createBoundaryEditor(onChange: () => void): BoundaryEditor { + // 화면에 그리는 현재 외곽선. + let points: LonLat[] = []; + // 같은 인덱스의 "원래 자리". 저장분이 있는 핸들은 서버가 준 base를 그대로 쓴다. + let bases: LonLat[] = []; + let editMode = false; + let dragIndex: number | null = null; + let dirty = false; + + function setBoundary( + nextPoints: ReadonlyArray, + nextOverrides: ReadonlyArray, + ): void { + points = nextPoints.map((point) => [point[0], point[1]] as LonLat); + bases = points.map((point) => [point[0], point[1]] as LonLat); + // 저장분이 반영된 자리는 원래 자리를 서버 값으로 되돌려 둔다 — 다음 재계산에서 + // 옮긴 자리가 아니라 외곽선 위 원래 자리로 다시 붙어야 하기 때문이다. + nextOverrides.forEach((override) => { + const index = points.findIndex((point) => samePoint(point, override.moved)); + if (index >= 0) bases[index] = [override.base[0], override.base[1]]; + }); + dragIndex = null; + dirty = false; + } + + function overrides(): BoundaryOverrideEntry[] { + const list: BoundaryOverrideEntry[] = []; + points.forEach((point, index) => { + const base = bases[index]; + if (!base || samePoint(point, base)) return; + list.push({ base: [base[0], base[1]], moved: [point[0], point[1]] }); + }); + return list; + } + + function draw(context: CanvasRenderingContext2D, normalizer: Normalizer, view: ViewState): void { + if (!editMode || points.length === 0) return; + const affine = affineOf(view); + context.save(); + context.lineWidth = 1.2; + context.strokeStyle = "rgba(255, 255, 255, 0.9)"; + points.forEach((point, index) => { + const [x, y] = toScreen(point, normalizer, affine); + if (x < -20 || y < -20 || x > view.width + 20 || y > view.height + 20) return; + const base = bases[index]; + context.beginPath(); + context.arc(x, y, HANDLE_RADIUS_PX, 0, Math.PI * 2); + context.fillStyle = base && !samePoint(point, base) ? HANDLE_MOVED_COLOR : HANDLE_COLOR; + context.fill(); + context.stroke(); + }); + context.restore(); + } + + function handleDown(normalizer: Normalizer, view: ViewState, x: number, y: number): boolean { + if (!editMode || points.length === 0) return false; + const affine = affineOf(view); + let nearest = -1; + let nearestDistance = HANDLE_HIT_PX; + points.forEach((point, index) => { + const [px, py] = toScreen(point, normalizer, affine); + const distance = Math.hypot(px - x, py - y); + if (distance <= nearestDistance) { + nearest = index; + nearestDistance = distance; + } + }); + if (nearest < 0) return false; + dragIndex = nearest; + return true; + } + + function handleMove(normalizer: Normalizer, view: ViewState, x: number, y: number): boolean { + if (dragIndex === null) return false; + const moved = toLonLat(x, y, normalizer, affineOf(view)); + if (!moved) return true; + points[dragIndex] = moved; + dirty = true; + onChange(); + return true; + } + + return { + setBoundary, + points: () => points.map((point) => [point[0], point[1]] as LonLat), + overrides, + isDirty: () => dirty, + markSaved: () => { + dirty = false; + }, + setEditMode(on: boolean) { + editMode = on; + dragIndex = null; + onChange(); + }, + draw, + handleDown, + handleMove, + handleUp() { + dragIndex = null; + }, + }; +} diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts index 4197183a..84187701 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -11,6 +11,7 @@ import { drawFilledRing, drawPreparedLayer, drawRidgeRing, + drawUpstreamLines, prepareLayer, prepareMetricPolyline, type GeoJsonCollection, @@ -25,6 +26,10 @@ import { type RoutePoint, } from "./B05_wf2_Route_Api_Fetch"; import { drawFlowArrows, type FlowArrow } from "../B04_wf1_Surface/B04_wf1_Surface_UI_FlowArrows"; +import { + createBoundaryEditor, + type BoundaryOverrideEntry, +} from "./B05_wf2_Route_UI_Drainage_Boundary"; import { createPipeEditor } from "./B05_wf2_Route_UI_Drainage_Pipes"; // 배수유역도 패널 — 하단 종단 패널 안쪽 우측에 도킹되는 2단 사이드 패널. @@ -47,6 +52,10 @@ const LAYER_LABELS: Record = { 도엽_하천중심선: "세류", }; +/** 도엽 레이어가 아닌 표시 토글의 띠 색 — 지도에 그려지는 선 색과 맞춘다. */ +const ARROW_TOGGLE_COLOR = "#7c3aed"; +const UPSTREAM_TOGGLE_COLOR = "#1d4ed8"; + const ROUTE_COLOR = "#f97316"; const COLLAPSED_KEY = "b05-route-drainage-collapsed"; @@ -68,6 +77,12 @@ export interface DrainagePanel { load: (projectId: string) => void; /** 확정된 노선 평면 선형(사업지 좌표계 m)을 지도 위에 겹친다. */ setRoute: (points: ReadonlyArray) => void; + /** 이번 화면에서 사용자가 유역선 포인트를 옮겼는지(경로 확정 시 저장 여부를 묻는 근거). */ + hasBoundaryEdits: () => boolean; + /** 저장 대상 — 원래 자리와 옮긴 자리 짝. 폴리곤 전체가 아니다. */ + boundaryOverrides: () => BoundaryOverrideEntry[]; + /** 저장이 끝났음을 알린다(다시 묻지 않도록). */ + markBoundarySaved: () => void; dispose: () => void; } @@ -110,20 +125,16 @@ export function createDrainagePanel(): DrainagePanel { autoButton.type = "button"; autoButton.className = "b05-drainage__analyze b05-drainage__tool"; autoButton.textContent = "자동 제안"; - // 흐름 화살표 보기/숨기기 — 도면이 지저분해질 때 끄기 위한 토글. - const arrowButton = document.createElement("button"); - arrowButton.type = "button"; - arrowButton.className = "b05-drainage__analyze b05-drainage__tool is-active"; - arrowButton.textContent = "흐름 화살표"; - arrowButton.title = "B04에서 산출한 평균 흐름 방향을 보이거나 숨깁니다."; - arrowButton.setAttribute("aria-pressed", "true"); - arrowButton.addEventListener("click", () => { - showArrows = !showArrows; - arrowButton.classList.toggle("is-active", showArrows); - arrowButton.setAttribute("aria-pressed", String(showArrows)); - scheduleDraw(); - }); - header.append(analyzeButton, editButton, deleteButton, autoButton, arrowButton); + // 유역선 편집 토글 — 켜면 외곽선 위 핸들을 잡아 유역 경계를 손으로 고친다. + const boundaryButton = document.createElement("button"); + boundaryButton.type = "button"; + boundaryButton.className = "b05-drainage__analyze b05-drainage__tool"; + boundaryButton.textContent = "유역선 편집"; + boundaryButton.title = + "2차 전체 배수유역 외곽선 위 포인트를 끌어 경계를 고칩니다. " + + "옮긴 값은 종단 경로 확정 시 저장 여부를 묻습니다."; + boundaryButton.setAttribute("aria-pressed", "false"); + header.append(analyzeButton, editButton, deleteButton, autoButton, boundaryButton); const viewport = document.createElement("div"); viewport.className = "b05-drainage__viewport"; @@ -165,6 +176,11 @@ export function createDrainagePanel(): DrainagePanel { let flowArrows: FlowArrow[] = []; let arrowSpacingM = 0; let showArrows = true; + // 유역 안쪽 상류 세류망 — B04가 채택한 기준선을 그대로 받아 강조만 한다. + let upstreamLines: Array> = []; + let showUpstream = true; + let boundaryMode = false; + const boundaryEditor = createBoundaryEditor(() => scheduleDraw()); let scale = 1; let offsetX = 0; let offsetY = 0; @@ -175,23 +191,59 @@ export function createDrainagePanel(): DrainagePanel { let canvasHeight = 0; let canvasDpr = 0; - DRAINAGE_LAYERS.forEach((layer) => { + /** 제목 우측 표시 토글 — 등고선·세류·흐름 화살표·상류 세류가 모두 같은 양식을 쓴다. */ + function addLayerToggle( + label: string, + color: string, + initial: boolean, + onToggle: (next: boolean) => void, + title?: string, + ): HTMLButtonElement { const button = document.createElement("button"); button.type = "button"; - button.className = "b05-drainage__layer-button is-active"; - button.textContent = LAYER_LABELS[layer]; - button.style.setProperty("--b05-layer-color", LAYER_COLORS[layer]); - button.setAttribute("aria-pressed", "true"); + button.className = "b05-drainage__layer-button" + (initial ? " is-active" : ""); + button.textContent = label; + button.style.setProperty("--b05-layer-color", color); + button.setAttribute("aria-pressed", String(initial)); + if (title) button.title = title; + let active = initial; button.addEventListener("click", () => { - if (activeLayers.has(layer)) activeLayers.delete(layer); - else activeLayers.add(layer); - const isActive = activeLayers.has(layer); - button.classList.toggle("is-active", isActive); - button.setAttribute("aria-pressed", String(isActive)); + active = !active; + button.classList.toggle("is-active", active); + button.setAttribute("aria-pressed", String(active)); + onToggle(active); scheduleDraw(); }); layerButtons.append(button); + return button; + } + + DRAINAGE_LAYERS.forEach((layer) => { + addLayerToggle(LAYER_LABELS[layer], LAYER_COLORS[layer], true, (next) => { + if (next) activeLayers.add(layer); + else activeLayers.delete(layer); + }); }); + // 흐름 화살표 — 도면이 지저분해질 때 끄기 위한 토글(등고선·세류와 같은 줄·같은 양식). + addLayerToggle( + "흐름 화살표", + ARROW_TOGGLE_COLOR, + showArrows, + (next) => { + showArrows = next; + }, + "B04에서 산출한 평균 흐름 방향을 보이거나 숨깁니다.", + ); + // 상류 세류선 강조 — 유역 판정의 기준선이라 항상 같은 굵기·색으로 얹는다. + addLayerToggle( + "상류 세류", + UPSTREAM_TOGGLE_COLOR, + showUpstream, + (next) => { + showUpstream = next; + }, + "유역 안쪽 상류 세류망을 굵게 강조합니다.", + ); function updateImageTransform(): void { backgroundImage.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`; @@ -232,7 +284,9 @@ export function createDrainagePanel(): DrainagePanel { ); }); // 전체 유역 외곽선 = 분수령(능선). 세부유역 경계와 구분되게 파선 한 겹만 얹는다. - if (mainBoundary.length > 2) drawRidgeRing(context, mainBoundary, normalizer, view); + // 편집 중이면 사용자가 끌어 옮긴 외곽선을 그대로 보여 준다. + const boundary = boundaryEditor.points(); + if (boundary.length > 2) drawRidgeRing(context, boundary, normalizer, view); } // 등고선을 얇게 깔고 세류를 그 위에, 노선을 맨 위에 둔다. DRAINAGE_LAYERS.forEach((layer) => { @@ -243,6 +297,11 @@ export function createDrainagePanel(): DrainagePanel { context.strokeStyle = LAYER_COLORS[layer]; drawPreparedLayer(context, prepared, view, "dot"); }); + // 상류 세류선 강조 — 유역 채움 위, 흐름 화살표 아래(2026-08-01 사용자 지시). + // 그리기는 B04 오버레이와 같은 공용 렌더러를 쓴다. + if (showUpstream && normalizer && upstreamLines.length > 0) { + drawUpstreamLines(context, upstreamLines, normalizer, view); + } if (routeLayer) { context.lineWidth = 2.4; context.strokeStyle = ROUTE_COLOR; @@ -269,6 +328,8 @@ export function createDrainagePanel(): DrainagePanel { view, ); } + // 유역선 편집 핸들 — 편집 모드에서만. 화살표 위, 배관 마커 아래. + if (normalizer) boundaryEditor.draw(context, normalizer, view); // 배관(관 매설) 마커 — 계획선 위 최상단. pipeEditor.draw(context, view, pipeColor); updateImageTransform(); @@ -356,6 +417,9 @@ export function createDrainagePanel(): DrainagePanel { const response = await fetchDrainageBasins(projectId, chainages); basins = response.basins; mainBoundary = response.main_polygon_lonlat ?? []; + // 외곽선은 편집 핸들 간격으로 다시 찍힌 값이며, 저장된 편집분은 이미 반영돼 있다. + boundaryEditor.setBoundary(mainBoundary, response.boundary_overrides ?? []); + upstreamLines = (response.upstream_lonlat ?? []) as Array>; flowArrows = (response.flow_arrows ?? []) as FlowArrow[]; arrowSpacingM = response.arrow_spacing_m ?? 0; // 계획도로선·2차 유역 외곽선은 B04 산출물을 그대로 받는다 — 여기서 다시 계산하지 않는다. @@ -392,6 +456,12 @@ export function createDrainagePanel(): DrainagePanel { pipeEditor.setPipes([]); void analyze(true); }); + boundaryButton.addEventListener("click", () => { + boundaryMode = !boundaryMode; + boundaryButton.classList.toggle("is-active", boundaryMode); + boundaryButton.setAttribute("aria-pressed", String(boundaryMode)); + boundaryEditor.setEditMode(boundaryMode); + }); /** 노선 전체가 보이도록 배율·중심을 맞춘다. 노선이 없으면 도엽 전체를 그대로 보여준다. */ function fitToRoute(): void { @@ -491,6 +561,19 @@ export function createDrainagePanel(): DrainagePanel { // 중간 버튼 기본 동작(페이지 자동 스크롤)이 지도 팬과 겹치지 않게 막는다. if (event.button === 1) event.preventDefault(); const rect = viewport.getBoundingClientRect(); + // 유역선 편집 중이면 외곽선 핸들을 먼저 본다 — 잡았으면 지도 팬을 시작하지 않는다. + if ( + normalizer && + boundaryEditor.handleDown( + normalizer, + currentView(), + event.clientX - rect.left, + event.clientY - rect.top, + ) + ) { + viewport.setPointerCapture(event.pointerId); + return; + } // 배관 마커 클릭/추가가 처리되면 지도 팬은 시작하지 않는다. if ( pipeEditor.handleDown( @@ -508,6 +591,17 @@ export function createDrainagePanel(): DrainagePanel { }); viewport.addEventListener("pointermove", (event) => { const rect = viewport.getBoundingClientRect(); + // 외곽선 핸들을 끌고 있으면 그것만 처리한다. + if ( + normalizer && + boundaryEditor.handleMove( + normalizer, + currentView(), + event.clientX - rect.left, + event.clientY - rect.top, + ) + ) + return; // 배관 드래그 중이면 마커 이동(계획선 스냅)만 처리한다. if (pipeEditor.handleMove(currentView(), event.clientX - rect.left, event.clientY - rect.top)) return; @@ -517,6 +611,7 @@ export function createDrainagePanel(): DrainagePanel { scheduleDraw(); }); const stopDragging = (): void => { + boundaryEditor.handleUp(); pipeEditor.handleUp(); dragStart = null; }; @@ -551,6 +646,9 @@ export function createDrainagePanel(): DrainagePanel { if (routeLayer) fitToRoute(); scheduleDraw(); }, + hasBoundaryEdits: boundaryEditor.isDirty, + boundaryOverrides: boundaryEditor.overrides, + markBoundarySaved: boundaryEditor.markSaved, dispose() { loadSequence += 1; if (frameHandle) { diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index 3f1ed77b..c83c7a60 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -1,5 +1,6 @@ import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; +import { createProgressCircle } from "@ui/ui_template_progress"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { @@ -15,6 +16,7 @@ import { import { confirmRoute, fetchLatestRoute, + saveDrainageBoundary, solveRoute, updateContourInterval, type CirclePoint, @@ -540,8 +542,32 @@ export async function renderB05Route(root: HTMLElement): Promise { } } + /** 유역선을 손으로 고쳤으면 확정 전에 저장 여부를 묻는다. 승인해야만 옮긴 포인트를 남긴다. */ + async function saveBoundaryEditsIfWanted(): Promise { + const drainage = profilePanel.drainage; + if (!drainage.hasBoundaryEdits()) return; + const overrides = drainage.boundaryOverrides(); + const accepted = window.confirm( + `배수유역 외곽선에서 옮긴 포인트 ${overrides.length}개를 저장할까요?\n` + + "저장하면 노선이 바뀌어 유역을 다시 계산해도 옮긴 자리가 유지됩니다.", + ); + if (!accepted) return; + try { + await saveDrainageBoundary(activeProjectId, overrides); + drainage.markBoundarySaved(); + showToast("배수유역 외곽선 편집을 저장했습니다.", "success"); + } catch (error) { + // 유역선 저장 실패가 경로 확정 자체를 막지는 않는다. + showToast( + error instanceof Error ? error.message : "배수유역 외곽선 저장에 실패했습니다.", + "error", + ); + } + } + async function confirm(): Promise { if (!routeReady || stale) return; + await saveBoundaryEditsIfWanted(); showLoadingOverlay(); try { // 종단 계획선 편집은 화면에서만 계산해 두었으므로 확정 직전에 영속화한다. @@ -572,43 +598,24 @@ export async function renderB05Route(root: HTMLElement): Promise { } } - const [workflowState, models, latestResponse, sectionContext, configuredRoadWidths] = - await Promise.all([ - fetchWorkflowState(activeProjectId), - listSurfaceModels(activeProjectId), - // 세션 캐시 우선(응답속도) — 최초 진입/캐시 미스 시에만 DB(latest)를 읽는다. - loadLatest(), - fetchSectionContext(activeProjectId), - fetchRoadWidths(activeProjectId), - ]); - roadWidths = configuredRoadWidths; - confirmedSurface = models.models.find((model) => model.status === "CONFIRMED") ?? null; - if (!confirmedSurface) { - showToast("확정된 지표면 모델이 없습니다.", "error"); - } else { - const cloud = await fetchSurfacePointCloud( - activeProjectId, - latestResponse.surface_params.source_filter, - ); - panel.restore({ - stationInterval: sectionContext.defaults.station_interval_m, - crossSampleInterval: sectionContext.defaults.cross_sample_interval_m, - longSampleInterval: sectionContext.defaults.long_sample_interval_m, - }); - restorePanel(latestResponse); - await viewer.loadSurface( - activeProjectId, - confirmedSurface.id, - latestResponse.surface_params.method, - latestResponse.surface_params.smooth, - latestResponse.surface_params.contour_interval_m, - toBounds(cloud.bounds), - ); - renderLatest(latestResponse); - if (latestResponse.route) await restoreSections(latestResponse.route.id); + /* ── 진입 로딩 ───────────────────────────────────────────────────────── + * 전부 받아 놓고 한 번에 그리면 몇 초 동안 빈 화면만 보인다. 화면 틀을 먼저 띄우고 + * 자료가 끝나는 순서대로 채운다. 3D 지형이 가장 느리므로 맨 마지막에 올리고, 그동안 + * 3D 뷰포트에 공통 프로그레스 서클을 띄운다(2026-08-01 사용자 지시). */ + const LOAD_STEP_COUNT = 5; + const progress = createProgressCircle({ label: "화면 틀을 준비하는 중…" }); + // 하단 종단 패널(z-index 3)보다 아래에 둔다 — 패널을 볼 때 서클이 방해하지 않는다. + progress.root.classList.add("b05-route__progress"); + viewer.root.append(progress.root); + let loadedSteps = 0; + function advanceLoading(label: string): void { + loadedSteps += 1; + progress.set(Math.min(1, loadedSteps / LOAD_STEP_COUNT), label); } - latest = latestResponse; - restoring = false; + + // ① 워크플로우 상태 — 화면 틀(레이아웃·단계바)을 세우는 데 필요한 최소 자료. + const workflowState = await fetchWorkflowState(activeProjectId); + advanceLoading("노선 정보를 불러오는 중…"); const mainContent = document.createElement("div"); mainContent.className = "b05-route__main"; @@ -626,4 +633,60 @@ export async function renderB05Route(root: HTMLElement): Promise { }); layout.root.classList.add("b05-route-layout"); root.replaceChildren(layout.root); + + try { + // ② 좌측 폼·노선 설정값 — 도착하는 대로 폼과 3D 마커 복원에 쓴다. + const [latestResponse, sectionContext, configuredRoadWidths] = await Promise.all([ + // 세션 캐시 우선(응답속도) — 최초 진입/캐시 미스 시에만 DB(latest)를 읽는다. + loadLatest(), + fetchSectionContext(activeProjectId), + fetchRoadWidths(activeProjectId), + ]); + roadWidths = configuredRoadWidths; + panel.restore({ + stationInterval: sectionContext.defaults.station_interval_m, + crossSampleInterval: sectionContext.defaults.cross_sample_interval_m, + longSampleInterval: sectionContext.defaults.long_sample_interval_m, + }); + restorePanel(latestResponse); + renderLatest(latestResponse); + latest = latestResponse; + advanceLoading("확정 지표면 모델을 확인하는 중…"); + + // ③ 확정 지표면 모델 목록. + const models = await listSurfaceModels(activeProjectId); + confirmedSurface = models.models.find((model) => model.status === "CONFIRMED") ?? null; + advanceLoading("종단면 자료를 불러오는 중…"); + + // ④ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다. + if (latestResponse.route) await restoreSections(latestResponse.route.id); + advanceLoading("3D 지형을 불러오는 중…"); + + // ⑤ 3D 지형 — 가장 무거우므로 맨 마지막. + if (!confirmedSurface) { + showToast("확정된 지표면 모델이 없습니다.", "error"); + } else { + const cloud = await fetchSurfacePointCloud( + activeProjectId, + latestResponse.surface_params.source_filter, + ); + await viewer.loadSurface( + activeProjectId, + confirmedSurface.id, + latestResponse.surface_params.method, + latestResponse.surface_params.smooth, + latestResponse.surface_params.contour_interval_m, + toBounds(cloud.bounds), + ); + // 지형이 올라온 뒤에야 마커·측점선이 지형 위에 놓인다. + renderLatest(latestResponse); + if (currentSectionDetail) renderStationLines(currentSectionDetail); + } + advanceLoading(""); + } catch (error) { + showToast(error instanceof Error ? error.message : "화면을 불러오지 못했습니다.", "error"); + } finally { + progress.remove(); + restoring = false; + } } diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts index ac71c7b2..58f426c4 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -637,6 +637,8 @@ export function createRouteProfilePanel( balanceBar.replaceChildren(); body.replaceChildren(empty); }, + /** 배수유역도 패널 — 경로 확정 흐름에서 유역선 편집 저장 여부를 묻는 데 쓴다. */ + drainage: drainagePanel, dispose() { window.clearTimeout(resizeTimer); resizeObserver.disconnect(); diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index 85d09e4d..9c066216 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -167,19 +167,31 @@ height: 100%; } +/* 3D 뷰포트 안내 문구 — 우상단 텍스트만(배경·테두리 없음, 회색). 2026-08-01 사용자 지시. + 우측 세로 진행단계 오버레이와 겹치지 않도록 그 폭만큼 안쪽으로 들여 놓는다. */ .b05-route__viewer-status { position: absolute; - inset: var(--spacing-16) auto auto var(--spacing-16); + z-index: 2; + top: var(--spacing-16); + right: calc(var(--wf-left-panel-width) / 2 + var(--spacing-48)); + left: auto; max-width: 420px; - padding: var(--spacing-8) var(--spacing-16); - border: 1px solid var(--color-border); - border-radius: var(--radius-inputs); - background: var(--color-surface-raised); - color: var(--color-text-body); + color: var(--color-text-muted); font-size: var(--text-caption); + text-align: right; pointer-events: none; } +/* 진입 로딩 서클 — 3D 뷰포트 위쪽 가운데. 하단 종단 패널(z-index 3)보다 아래에 둬서 + 패널을 볼 때 가리지 않는다(2026-08-01 사용자 지시). */ +.b05-route__progress { + position: absolute; + z-index: 1; + top: 18%; + left: 50%; + transform: translateX(-50%); +} + .b05-route__view-controls { position: absolute; z-index: 2; @@ -870,24 +882,29 @@ .b05-drainage__layers { display: flex; + flex-wrap: wrap; gap: var(--spacing-4); } +/* 표시 토글 버튼 — B04 지도 레이어 버튼(.b04-map__layer-button--gis)과 같은 양식을 쓴다. + 크기(패딩·글자)만 이 패널 기준을 유지한다(2026-08-01 사용자 지시). */ .b05-drainage__layer-button { padding: 2px var(--spacing-8); border: 1px solid var(--color-border); border-radius: var(--radius-inputs); background: var(--color-surface); - color: var(--color-text-muted); + color: var(--color-text-secondary); font-size: var(--text-caption); + opacity: 0.55; cursor: pointer; } -/* 켜진 레이어는 그 레이어의 선 색을 그대로 띠 색으로 써서 지도와 바로 대조된다. */ +/* 켜진 레이어는 그 레이어의 선 색을 테두리·글자·안쪽 링에 그대로 쓴다(지도와 바로 대조). */ .b05-drainage__layer-button.is-active { border-color: var(--b05-layer-color, var(--color-border)); - box-shadow: inset 3px 0 0 var(--b05-layer-color, transparent); - color: var(--color-text-body); + box-shadow: inset 0 0 0 1px var(--b05-layer-color, transparent); + color: var(--b05-layer-color, var(--color-text-body)); + opacity: 1; } .b05-drainage__viewport { diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts b/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts index 5f3f5f47..06f00775 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts @@ -3,6 +3,7 @@ import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js"; import { API_BASE_URL } from "@config/config_frontend"; +import { bindCursorPivotControls } from "../B04_wf1_Surface/B04_wf1_Surface_UI_Camera"; import { createRouteMarkers, sceneToModel, @@ -112,6 +113,15 @@ export function createRouteViewer(): RouteViewer { let draggingMarker = false; let lastDragPoint: { x: number; y: number; z: number } | null = null; const markers = createRouteMarkers(scene, () => bounds); + // 회전·줌 중심을 커서 아래 지형 지점으로 (B04 뷰어들과 공용 유틸). + // 마커를 잡고 있는 동안에는 회전을 넘겨 드래그 이동이 우선하게 한다. + const releaseCursorPivot = bindCursorPivotControls({ + camera, + controls, + element: canvas, + pickables: () => (terrain ? [terrain] : []), + blocked: () => dragCandidate !== null || draggingMarker || movingSelected, + }); function clearContours(): void { disposeObject(contours); @@ -370,6 +380,7 @@ export function createRouteViewer(): RouteViewer { canvas.removeEventListener("pointerup", handlePointerUp, true); canvas.removeEventListener("pointerleave", handlePointerExit, true); canvas.removeEventListener("pointercancel", handlePointerExit, true); + releaseCursorPivot(); markers.dispose(); clearContours(); disposeObject(terrain); diff --git a/config/config_system.py b/config/config_system.py index 9679bc92..acba027c 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -288,6 +288,18 @@ DRAINAGE_CACHE_FILENAME = "watershed_grid.npz" # 저장 배열에서 응답을 다시 조립하면 원본과 어긋날 여지가 생긴다(2026-07-31 사용자 지시). DRAINAGE_RESPONSE_FILENAME = "00_watershed_response.json" +# ── B05 전용 배수유역 사본 ── +# B04 산출물을 그대로 쓰면 B05 편집이 원본을 덮어쓴다. 프로젝트 저장소의 +# B05_wf2_Route/drainage/ 아래로 복사해 두고 B05는 사본만 읽고 쓴다. +DRAINAGE_B05_DIRNAME = "drainage" +# 사용자가 옮긴 유역 외곽선 포인트만 담는 파일. B04 재계산으로 사본이 갱신돼도 남는다. +DRAINAGE_BOUNDARY_OVERRIDE_FILENAME = "boundary_overrides.json" +# 유역 외곽선 편집 핸들 간격(m). 화면에서 보고 조정할 값(2026-08-01 사용자 지시). +DRAINAGE_BOUNDARY_HANDLE_SPACING_M = float(os.getenv("DRAINAGE_BOUNDARY_HANDLE_SPACING_M", "20.0")) +# 재계산된 외곽선에 저장 포인트를 다시 붙일 때 허용하는 최대 거리(m). +# 인덱스는 재계산으로 어긋나므로 좌표 근접으로만 맞춘다. +DRAINAGE_BOUNDARY_MATCH_RADIUS_M = float(os.getenv("DRAINAGE_BOUNDARY_MATCH_RADIUS_M", "30.0")) + # ── B05용 평균 흐름 화살표 ── # 셀 화살표는 1m라 축소하면 경향이 안 보인다. 이 크기의 블록으로 묶어 방향을 평균한다. DRAINAGE_ARROW_BLOCK_M = float(os.getenv("DRAINAGE_ARROW_BLOCK_M", "10.0")) diff --git a/ui_template/ui_template_progress.css b/ui_template/ui_template_progress.css new file mode 100644 index 00000000..29f463bf --- /dev/null +++ b/ui_template/ui_template_progress.css @@ -0,0 +1,78 @@ +/* 공통 프로그레스 서클 — 뷰포트 위에 얹는 원형 진행 표시. */ +.ui-progress-circle { + --ui-progress-size: 96px; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-8); + pointer-events: none; + user-select: none; +} + +.ui-progress-circle__dial { + position: relative; + width: var(--ui-progress-size); + height: var(--ui-progress-size); +} + +.ui-progress-circle__svg { + width: 100%; + height: 100%; + /* 12시 방향에서 시계 방향으로 채운다. */ + transform: rotate(-90deg); +} + +.ui-progress-circle__track { + fill: none; + stroke: color-mix(in srgb, var(--color-border) 70%, transparent); + stroke-width: 8; +} + +.ui-progress-circle__bar { + fill: none; + stroke: var(--color-royal-amethyst); + stroke-width: 8; + stroke-linecap: round; + transition: stroke-dashoffset var(--transition-base); +} + +/* 진행률을 모르는 구간 — 호 하나를 계속 돌린다. */ +.ui-progress-circle.is-indeterminate .ui-progress-circle__svg { + animation: ui-progress-spin 1s linear infinite; +} + +@keyframes ui-progress-spin { + from { + transform: rotate(-90deg); + } + to { + transform: rotate(270deg); + } +} + +.ui-progress-circle__percent { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-text-body); + font-family: var(--font-body); + font-size: var(--text-body-sm); + font-weight: var(--font-weight-semibold); +} + +.ui-progress-circle__label { + max-width: 22ch; + padding: var(--spacing-4) var(--spacing-8); + border-radius: var(--radius-inputs); + background: color-mix(in srgb, var(--color-surface-raised) 82%, transparent); + color: var(--color-text-secondary); + font-size: var(--text-caption); + text-align: center; + word-break: keep-all; +} + +.ui-progress-circle__label:empty { + display: none; +} diff --git a/ui_template/ui_template_progress.ts b/ui_template/ui_template_progress.ts new file mode 100644 index 00000000..b133fed5 --- /dev/null +++ b/ui_template/ui_template_progress.ts @@ -0,0 +1,89 @@ +import "./ui_template_progress.css"; + +/* ============================================================================= + * ui_template_progress.ts + * 공통 프로그레스 서클 (원형 진행 표시) + * + * 뷰포트 위에 얹어 "지금 무엇을 얼마나 불러왔는지"를 보여 주는 용도. + * 진행률을 모르는 구간은 set()에 ratio를 주지 않으면 회전 애니메이션으로 표시한다. + * 색상/치수는 theme.css 변수만 사용한다. + * ========================================================================== */ + +const SVG_NS = "http://www.w3.org/2000/svg"; +/** viewBox 기준 반지름 — 실제 크기는 CSS(--ui-progress-size)로 정한다. */ +const RADIUS = 42; +const CIRCUMFERENCE = 2 * Math.PI * RADIUS; + +export interface ProgressCircleOptions { + /** 지름(px). 기본 96. */ + size?: number; + /** 서클 아래 안내 문구. */ + label?: string; +} + +export interface ProgressCircleHandle { + root: HTMLDivElement; + /** 진행률(0~1)과 문구 갱신. ratio가 null이면 진행률 미상(회전 표시). */ + set: (ratio: number | null, label?: string) => void; + /** 화면에서 제거. */ + remove: () => void; +} + +export function createProgressCircle(options: ProgressCircleOptions = {}): ProgressCircleHandle { + const root = document.createElement("div"); + root.className = "ui-progress-circle"; + root.setAttribute("role", "status"); + root.setAttribute("aria-live", "polite"); + if (options.size) root.style.setProperty("--ui-progress-size", `${options.size}px`); + + const svg = document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("class", "ui-progress-circle__svg"); + svg.setAttribute("viewBox", "0 0 100 100"); + const track = document.createElementNS(SVG_NS, "circle"); + track.setAttribute("class", "ui-progress-circle__track"); + track.setAttribute("cx", "50"); + track.setAttribute("cy", "50"); + track.setAttribute("r", String(RADIUS)); + const bar = document.createElementNS(SVG_NS, "circle"); + bar.setAttribute("class", "ui-progress-circle__bar"); + bar.setAttribute("cx", "50"); + bar.setAttribute("cy", "50"); + bar.setAttribute("r", String(RADIUS)); + bar.setAttribute("stroke-dasharray", String(CIRCUMFERENCE)); + bar.setAttribute("stroke-dashoffset", String(CIRCUMFERENCE)); + svg.append(track, bar); + + const percent = document.createElement("span"); + percent.className = "ui-progress-circle__percent"; + const label = document.createElement("span"); + label.className = "ui-progress-circle__label"; + label.textContent = options.label ?? ""; + + const dial = document.createElement("div"); + dial.className = "ui-progress-circle__dial"; + dial.append(svg, percent); + root.append(dial, label); + + function set(ratio: number | null, nextLabel?: string): void { + if (nextLabel !== undefined) label.textContent = nextLabel; + if (ratio === null) { + // 진행률 미상 — 4분의 1 호를 돌려 "돌아가는 중"만 알린다. + root.classList.add("is-indeterminate"); + bar.setAttribute("stroke-dashoffset", String(CIRCUMFERENCE * 0.75)); + percent.textContent = ""; + return; + } + const clamped = Math.min(1, Math.max(0, ratio)); + root.classList.remove("is-indeterminate"); + bar.setAttribute("stroke-dashoffset", String(CIRCUMFERENCE * (1 - clamped))); + percent.textContent = `${Math.round(clamped * 100)}%`; + } + + set(0, options.label); + + return { + root, + set, + remove: () => root.remove(), + }; +} From f6be8d73071fe4ef8a9881bc0044586818025c50 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 1 Aug 2026 09:19:59 +0900 Subject: [PATCH 47/61] =?UTF-8?q?fix(B04/B05):=203D=20=EC=A1=B0=EC=9E=91?= =?UTF-8?q?=EA=B0=90=20=EB=B3=B4=EC=A0=95=20=E2=80=94=20=EC=BB=A4=EC=84=9C?= =?UTF-8?q?=20=ED=91=9C=EB=A9=B4=20=ED=9A=8C=EC=A0=84=EC=B6=95,=20?= =?UTF-8?q?=EC=83=81=ED=95=98=20=EB=B0=98=EC=A0=84,=20=EA=B0=80=EC=9A=B4?= =?UTF-8?q?=EB=8D=B0=20=EB=B2=84=ED=8A=BC=20=ED=8C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 회전축을 커서가 가리키는 지형 표면 지점으로. 포인트클라우드는 카메라 거리의 1%를 클릭 허용 반경으로 주고, 못 맞히면 시선 수직 평면과 만나 커서 방향 지점을 쓴다 (화면 중앙으로 되돌아가지 않음) - 수직 회전 부호 반전 (마우스를 내리면 내려다보는 방향) - controls.mouseButtons.MIDDLE = PAN (전역 공통). 브라우저 자동 스크롤 차단 - B05 뷰셋 버튼 그룹을 좌상단으로 이동 (.b05-route__view-controls top 58px → 16px) --- B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts | 43 ++++++++++++++------ B05_wf2_Route/B05_wf2_Route_UI_Style.css | 4 +- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts index b7a5eb0e..72902bee 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts @@ -56,6 +56,8 @@ export function niceScaleDistance(roughMeters: number): number { /** 극점을 넘어 화면이 뒤집히지 않도록 남기는 여유각(rad). */ const POLAR_EPSILON = 0.02; +/** 포인트클라우드 클릭 허용 반경 — 카메라 거리에 비례(멀수록 점이 성기게 보인다). */ +const POINT_PICK_RATIO = 0.01; export interface CursorPivotOptions { camera: THREE.PerspectiveCamera; @@ -71,22 +73,27 @@ export interface CursorPivotOptions { /** 커서 기준 회전·줌을 붙이고, 해제 함수를 돌려준다. */ export function bindCursorPivotControls(options: CursorPivotOptions): () => void { const { camera, controls, element } = options; - // 회전은 여기서 직접 처리하므로 OrbitControls 쪽 회전은 끈다(팬·줌은 그대로 둔다). + // 회전은 여기서 직접 처리하므로 OrbitControls 쪽 회전은 끈다(줌은 그대로 둔다). controls.enableRotate = false; controls.zoomToCursor = true; + // 가운데 버튼 드래그 = 화면 이동(전역 공통). 기본값(DOLLY)은 휠 줌과 겹쳐 쓸모가 없다. + controls.mouseButtons.MIDDLE = THREE.MOUSE.PAN; const raycaster = new THREE.Raycaster(); const pointer = new THREE.Vector2(); const pivot = new THREE.Vector3(); + const viewDirection = new THREE.Vector3(); + const fallbackPlane = new THREE.Plane(); let pointerId: number | null = null; let lastX = 0; let lastY = 0; - /** 커서 아래 지형 지점. 못 찾으면 기존 target을 축으로 쓴다. */ + /** 커서가 가리키는 지형 위 지점을 회전축으로 잡는다. + * + * 지형을 맞히면 그 점을 쓰고, 하늘·구멍이라 못 맞히면 시선에 수직이고 현재 target을 + * 지나는 평면과 광선을 만나게 해 **커서 방향**의 점을 쓴다(화면 중앙으로 돌아가지 않는다). */ function pickPivot(event: PointerEvent): void { pivot.copy(controls.target); - const targets = options.pickables(); - if (targets.length === 0) return; const rect = element.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) return; pointer.set( @@ -94,11 +101,22 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void -((event.clientY - rect.top) / rect.height) * 2 + 1, ); raycaster.setFromCamera(pointer, camera); - const hit = raycaster.intersectObjects(targets, true)[0]; - if (hit) pivot.copy(hit.point); + // 포인트클라우드는 점 사이가 비어 있어 정확히 맞히기 어렵다 — 거리에 비례한 허용 반경을 준다. + raycaster.params.Points.threshold = + camera.position.distanceTo(controls.target) * POINT_PICK_RATIO; + const hit = raycaster.intersectObjects(options.pickables(), true)[0]; + if (hit) { + pivot.copy(hit.point); + return; + } + camera.getWorldDirection(viewDirection); + fallbackPlane.setFromNormalAndCoplanarPoint(viewDirection, controls.target); + raycaster.ray.intersectPlane(fallbackPlane, pivot); } function onPointerDown(event: PointerEvent): void { + // 가운데 버튼은 브라우저 기본 동작(페이지 자동 스크롤)이 화면 이동과 겹치므로 막는다. + if (event.button === 1) event.preventDefault(); if (event.button !== 0 || pointerId !== null) return; if (options.blocked?.() || !controls.enabled) return; pickPivot(event); @@ -127,16 +145,17 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void // 수평 회전 — 화면 상하축(카메라 up) 기준. cameraOffset.applyAxisAngle(up, -yaw); targetOffset.applyAxisAngle(up, -yaw); - // 수직 회전 — 시선의 오른쪽 축 기준. 극점을 넘으면 상하만 버린다. - const viewDirection = cameraOffset.clone().sub(targetOffset); - const right = viewDirection.clone().cross(up); + // 수직 회전 — 시선의 오른쪽 축 기준. 마우스를 내리면 위에서 내려다보는 방향(2026-08-01 + // 사용자 지시로 상하 반전). 극점을 넘으면 상하 성분만 버린다. + const eyeDirection = cameraOffset.clone().sub(targetOffset); + const right = eyeDirection.clone().cross(up); if (right.lengthSq() > 1e-8) { right.normalize(); - const rotated = viewDirection.clone().applyAxisAngle(right, -pitch); + const rotated = eyeDirection.clone().applyAxisAngle(right, pitch); const polar = rotated.angleTo(up); if (polar > POLAR_EPSILON && polar < Math.PI - POLAR_EPSILON) { - cameraOffset.applyAxisAngle(right, -pitch); - targetOffset.applyAxisAngle(right, -pitch); + cameraOffset.applyAxisAngle(right, pitch); + targetOffset.applyAxisAngle(right, pitch); } } camera.position.copy(pivot).add(cameraOffset); diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index 9c066216..e6674dca 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -192,10 +192,12 @@ transform: translateX(-50%); } +/* 뷰셋·표시 토글 버튼 묶음 — 좌상단(2026-08-01 사용자 지시). + 안내 문구가 우상단으로 옮겨져 좌상단이 비었다. */ .b05-route__view-controls { position: absolute; z-index: 2; - top: 58px; + top: var(--spacing-16); left: var(--spacing-16); display: flex; align-items: center; From 13c2522f47b7cbc94935a82ea5b1e29263c1cb90 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 1 Aug 2026 09:29:53 +0900 Subject: [PATCH 48/61] =?UTF-8?q?feat(B04/B05):=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=8A=A4=20=EC=84=9C=ED=81=B4=EC=9D=84=203D?= =?UTF-8?q?=C2=B7=EC=A7=80=EB=8F=84=C2=B7=EA=B7=B8=EB=9E=98=ED=94=84=20?= =?UTF-8?q?=EC=A0=84=20=EC=98=81=EC=97=AD=EC=97=90=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공통 서클 양식을 로딩 스피너(.ui-spinner) 기준으로 통일하고 overlay 옵션 신설 (컨테이너 정중앙 배치를 페이지별 CSS 없이 처리) - B05 3D: 위쪽 18% → 뷰포트 정중앙 (하단 패널에 가려져도 무방) - B04 포인트클라우드 뷰어: setLoading() 신설, render() 시 자동 해제 - B04 지형 뷰어: GLTF/PLY 로더 진행 이벤트로 실제 바이트 진행률 표시 - B04 2D 지도: 도엽 레이어 n/10 진행률 - B05 종단면 그래프: body-wrap으로 감싸 서클 유지, 자료 조회 전후로 토글 - B05 배수유역도: 배경도 → 도엽 레이어 → 세부유역 산정 단계 진행률 --- .../B04_wf1_Surface_UI_MapViewer.ts | 21 ++++++++++++ B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts | 2 ++ .../B04_wf1_Surface_UI_TerrainViewer.ts | 33 +++++++++++++++++-- B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts | 14 ++++++++ .../B05_wf2_Route_UI_Drainage_Panel.ts | 18 +++++++++- B05_wf2_Route/B05_wf2_Route_UI_Page.ts | 9 +++-- .../B05_wf2_Route_UI_Profile_Panel.ts | 15 ++++++++- B05_wf2_Route/B05_wf2_Route_UI_Style.css | 20 +++++------ ui_template/ui_template_progress.css | 16 +++++++-- ui_template/ui_template_progress.ts | 6 ++-- 10 files changed, 132 insertions(+), 22 deletions(-) diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index c23319a8..30499e4e 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -1,4 +1,5 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; +import { createProgressCircle } from "@ui/ui_template_progress"; import { fetchGisGeoJson, fetchVWorldMeta, @@ -125,13 +126,23 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { scaleBar.className = "b04-map__scale"; const scaleText = document.createElement("span"); scaleBar.append(scaleText); + // 지도 정중앙 로딩 서클 — 도엽 레이어가 10종이라 다 받을 때까지 화면이 비어 보인다. + const progress = createProgressCircle({ overlay: true }); + progress.root.hidden = true; viewport.append( ...BACKGROUND_LAYERS.map((layer) => backgroundImages.get(layer)!), canvas, empty, statusStack, scaleBar, + progress.root, ); + + /** 진행률(0~1, 모르면 null)과 문구. label이 null이면 서클을 감춘다. */ + function showProgress(ratio: number | null, label: string | null): void { + progress.root.hidden = label === null; + if (label !== null) progress.set(ratio, label); + } root.append(header, viewport); let currentProjectId: string | null = null; @@ -366,8 +377,11 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { preparedLayers.clear(); resetView(); status.textContent = L("B04_Surface_Map_Loading"); + showProgress(0, L("B04_Surface_Map_Loading")); try { const nextMeta = await fetchVWorldMeta(projectId, "satellite"); + // 레이어가 끝나는 대로 진행률을 올린다 — 10종을 다 받을 때까지 화면이 비어 있어서다. + let done = 0; const loadedLayers = await Promise.all( GIS_LAYERS.map(async (layer) => { try { @@ -375,6 +389,11 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { return [layer, data] as const; } catch { return [layer, null] as const; + } finally { + done += 1; + if (sequence === loadSequence) { + showProgress(done / GIS_LAYERS.length, `도엽 레이어 ${done}/${GIS_LAYERS.length}`); + } } }), ); @@ -397,9 +416,11 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { ); resetView(); syncLayerVisibility(); + showProgress(null, null); } catch (error) { if (sequence !== loadSequence) return; status.textContent = error instanceof Error ? error.message : L("B04_Surface_Map_LoadFailed"); + showProgress(null, null); } } diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts index 4b2d5a50..19137412 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts @@ -300,6 +300,7 @@ export async function renderB04Surface(root: HTMLElement): Promise { const projectId = getProjectId(); if (!projectId) return; showLoadingOverlay(); + viewer.setLoading("포인트 데이터 로딩 중…"); try { pointCloud = await fetchSurfacePointCloud(projectId, filterGroup.select.value); terrainViewer.setReferenceBounds(pointCloud.bounds); @@ -324,6 +325,7 @@ export async function renderB04Surface(root: HTMLElement): Promise { models = modelResponse.models; renderInputFiles(inputs.files); renderStatus(status); + viewer.setLoading("포인트 데이터 로딩 중…"); try { pointCloud = await fetchSurfacePointCloud(projectId, filterGroup.select.value); terrainViewer.setReferenceBounds(pointCloud.bounds); diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts index b5cde486..6160fa2f 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts @@ -3,6 +3,7 @@ import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js"; import { API_BASE_URL } from "@config/config_frontend"; +import { createProgressCircle } from "@ui/ui_template_progress"; import type { SurfaceBounds, SurfaceModelSummary } from "./B04_wf1_Surface_Api_Fetch"; import { bindCursorPivotControls, @@ -168,6 +169,17 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { legendBar.append(maxValSpan, gradientDiv, minValSpan); viewerArea.append(legendBar); + // 뷰포트 정중앙 로딩 서클 — 메쉬 파일은 수십 MB라 내려받는 동안 화면이 비어 보인다. + const progress = createProgressCircle({ overlay: true }); + progress.root.hidden = true; + viewerArea.append(progress.root); + + /** 진행률(0~1, 모르면 null)과 문구를 표시한다. label이 null이면 서클을 감춘다. */ + function showProgress(ratio: number | null, label: string | null): void { + progress.root.hidden = label === null; + if (label !== null) progress.set(ratio, label); + } + // Three.js context variables let currentProjectId = ""; let currentModelsList: readonly SurfaceModelSummary[] = []; @@ -311,6 +323,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { currentModelId = null; scaleBar.hidden = true; statusSpan.textContent = "모델 조회 중..."; + showProgress(null, "모델 조회 중…"); // 1. Find matching model in list // model_type is TIN / DTM / NURBS / Implicit / Meshfree (we match activeMethod) @@ -327,6 +340,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { if (!match) { statusSpan.textContent = "일치하는 완성된 모델을 찾을 수 없습니다."; + showProgress(null, null); return; } @@ -337,8 +351,16 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { const generation = ++loadGeneration; statusSpan.textContent = "3D 메쉬 파일 다운로드 중..."; + showProgress(0, "3D 메쉬 내려받는 중…"); const previewUrl = `${API_BASE_URL}/projects/${currentProjectId}/surface/models/${modelId}/preview?smooth=${isSmooth}`; + /** 로더 진행 이벤트 → 서클. 서버가 길이를 안 주면(gzip) 진행률 없이 회전만 시킨다. */ + const onDownload = (event: ProgressEvent): void => { + if (generation !== loadGeneration) return; + const ratio = event.lengthComputable && event.total > 0 ? event.loaded / event.total : null; + showProgress(ratio, "3D 메쉬 내려받는 중…"); + }; + try { if (activeMethod === "meshfree") { new PLYLoader().load( @@ -359,12 +381,15 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { terrainMesh = points; scene.add(points); fitCamera(points); + showProgress(1, "등고선을 그리는 중…"); await loadSelectedContours(modelId, isSmooth); + showProgress(null, null); }, - undefined, + onDownload, () => { if (generation !== loadGeneration) return; statusSpan.textContent = "3D 파일 로드에 실패했습니다."; + showProgress(null, null); }, ); } else { @@ -385,17 +410,21 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { terrainMesh = gltf.scene; scene.add(gltf.scene); fitCamera(gltf.scene); + showProgress(1, "등고선을 그리는 중…"); await loadSelectedContours(modelId, isSmooth); + showProgress(null, null); }, - undefined, + onDownload, () => { if (generation !== loadGeneration) return; statusSpan.textContent = "3D 메쉬 파일이 없거나 로드할 수 없습니다."; + showProgress(null, null); }, ); } } catch (e) { statusSpan.textContent = "에러 발생"; + showProgress(null, null); } } diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts index ae2c5c87..8f1160cc 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts @@ -1,4 +1,5 @@ import { RENDER_OPTIONS } from "@config/config_frontend"; +import { createProgressCircle } from "@ui/ui_template_progress"; import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import type { SurfaceBounds, SurfacePointCloudSampleResponse } from "./B04_wf1_Surface_Api_Fetch"; @@ -20,6 +21,8 @@ export interface SurfacePointCloudViewer { controlsGroup: HTMLElement; optionsGroup: HTMLElement; statusSpan: HTMLElement; + /** 로딩 서클 표시. 문구를 주면 켜고, null이면 끈다. `render()` 시 자동으로 꺼진다. */ + setLoading: (label: string | null) => void; render: (data: SurfacePointCloudSampleResponse | null) => void; setAxesVisible: (visible: boolean) => void; applyCameraState: (state: SurfaceCameraState) => void; @@ -101,6 +104,15 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { scaleBar.append(scaleText); viewerArea.append(canvas, scaleBar, statusSpan); root.append(viewerArea); + // 뷰포트 정중앙 로딩 서클 — 지도·그래프·다른 3D 뷰어와 같은 공통 컴포넌트. + const progress = createProgressCircle({ overlay: true }); + progress.root.hidden = true; + viewerArea.append(progress.root); + + function setLoading(label: string | null): void { + progress.root.hidden = label === null; + if (label !== null) progress.set(null, label); + } const renderer = new THREE.WebGLRenderer({ canvas, @@ -306,7 +318,9 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { controlsGroup, optionsGroup, statusSpan, + setLoading, render(data) { + setLoading(null); currentData = data; clearPoints(); if (!data) { diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts index 84187701..226e7682 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -31,6 +31,7 @@ import { type BoundaryOverrideEntry, } from "./B05_wf2_Route_UI_Drainage_Boundary"; import { createPipeEditor } from "./B05_wf2_Route_UI_Drainage_Pipes"; +import { createProgressCircle } from "@ui/ui_template_progress"; // 배수유역도 패널 — 하단 종단 패널 안쪽 우측에 도킹되는 2단 사이드 패널. // 하단 패널이 닫히면 이 패널도 함께 화면에서 사라진다(부모 안에 들어 있으므로 자동). @@ -147,7 +148,16 @@ export function createDrainagePanel(): DrainagePanel { const status = document.createElement("span"); status.className = "b05-drainage__status"; status.textContent = "노선을 확정하면 배수유역도가 표시됩니다."; - viewport.append(backgroundImage, canvas, status); + // 지도 정중앙 로딩 서클 — 배경도·도엽 레이어·유역 산정이 끝날 때까지 화면이 비어 보인다. + const progress = createProgressCircle({ overlay: true }); + progress.root.hidden = true; + viewport.append(backgroundImage, canvas, status, progress.root); + + /** 진행률(0~1, 모르면 null)과 문구. label이 null이면 서클을 감춘다. */ + function showProgress(ratio: number | null, label: string | null): void { + progress.root.hidden = label === null; + if (label !== null) progress.set(ratio, label); + } // 유역 제원 목록(면적·표고·유하거리·관경). 관경 수식 미확정이라 당분간 "미정"으로 나온다. const basinList = document.createElement("div"); basinList.className = "b05-drainage__basins"; @@ -412,6 +422,7 @@ export function createDrainagePanel(): DrainagePanel { analyzeButton.disabled = true; status.hidden = false; status.textContent = "세부유역을 산정하는 중…"; + showProgress(null, "세부유역을 산정하는 중…"); try { const chainages = !auto && pipeEditor.pipes().length > 0 ? pipeEditor.chainages() : undefined; const response = await fetchDrainageBasins(projectId, chainages); @@ -441,6 +452,7 @@ export function createDrainagePanel(): DrainagePanel { status.textContent = error instanceof Error ? error.message : "세부유역 산정에 실패했습니다."; } finally { analyzeButton.disabled = false; + showProgress(null, null); } } @@ -503,8 +515,10 @@ export function createDrainagePanel(): DrainagePanel { backgroundImage.removeAttribute("src"); status.hidden = false; status.textContent = "배경도를 불러오는 중…"; + showProgress(0, "배경도를 불러오는 중…"); try { const nextMeta = await fetchVWorldMeta(activeProjectId, "satellite"); + showProgress(1 / 3, "도엽 레이어를 불러오는 중…"); const loaded = await Promise.all( DRAINAGE_LAYERS.map(async (layer) => { try { @@ -531,12 +545,14 @@ export function createDrainagePanel(): DrainagePanel { if (featureCount === 0) status.textContent = "도엽 레이어가 없습니다. B04에서 임포트하세요."; fitToRoute(); scheduleDraw(); + showProgress(2 / 3, "세부유역을 산정하는 중…"); // B04 분석 결과를 읽어 오는 것뿐이라 즉시 끝난다 — 페이지에 들어오면 바로 보여 준다. void analyze(true); } catch (error) { if (sequence !== loadSequence) return; status.hidden = false; status.textContent = error instanceof Error ? error.message : "배경도를 불러오지 못했습니다."; + showProgress(null, null); } } diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index c83c7a60..d811af61 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -409,15 +409,18 @@ export async function renderB05Route(root: HTMLElement): Promise { async function restoreSections(routeId: number): Promise { let detail: SectionDetailResponse; + profilePanel.setLoading("종단면 자료를 불러오는 중…"); try { detail = await fetchSectionDetail(activeProjectId, routeId); } catch { // 종횡단 데이터 자체가 없는 경우(생성 실패·최초 진입)는 빈 안내로 둔다. currentSectionDetail = null; + profilePanel.setLoading(null); profilePanel.clear(); viewer.renderStationLines([], 0); return; } + profilePanel.setLoading(null); try { renderSections(detail, routeId); // 복귀/최초 진입 시(클라이언트 목록이 비어 있을 때만) 확정된 비정규 측점을 사이드바에 복원한다. @@ -603,9 +606,9 @@ export async function renderB05Route(root: HTMLElement): Promise { * 자료가 끝나는 순서대로 채운다. 3D 지형이 가장 느리므로 맨 마지막에 올리고, 그동안 * 3D 뷰포트에 공통 프로그레스 서클을 띄운다(2026-08-01 사용자 지시). */ const LOAD_STEP_COUNT = 5; - const progress = createProgressCircle({ label: "화면 틀을 준비하는 중…" }); - // 하단 종단 패널(z-index 3)보다 아래에 둔다 — 패널을 볼 때 서클이 방해하지 않는다. - progress.root.classList.add("b05-route__progress"); + // 3D 뷰포트 정중앙. 하단 종단 패널(z-index 3)보다 아래라 패널에 가려지는 것은 무방하다 + // (2026-08-01 사용자 지시). + const progress = createProgressCircle({ label: "화면 틀을 준비하는 중…", overlay: true }); viewer.root.append(progress.root); let loadedSteps = 0; function advanceLoading(label: string): void { diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts index 58f426c4..9b9cece8 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -22,6 +22,7 @@ import { import { LONG_PAD } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createDrainagePanel } from "./B05_wf2_Route_UI_Drainage_Panel"; +import { createProgressCircle } from "@ui/ui_template_progress"; import { showToast } from "@ui/ui_template_elements"; import { saveProfileAlignment } from "./B05_wf2_Route_Api_Fetch"; import type { @@ -272,10 +273,17 @@ export function createRouteProfilePanel( body.append(empty); // 종단면 본문 + 우측 배수유역 패널을 나란히 놓는 2단 구성. // 배수유역 패널이 이 안에 있으므로 하단 패널을 접으면 함께 사라진다(사용자 지시). + // 그래프 영역 정중앙 로딩 서클 — 종단면 자료가 도착할 때까지 빈 안내만 보인다. + // body는 그릴 때마다 자식이 통째로 교체되므로 서클은 감싸는 칸에 둔다. + const progress = createProgressCircle({ overlay: true }); + progress.root.hidden = true; + const bodyWrap = document.createElement("div"); + bodyWrap.className = "b05-route-profile__body-wrap"; + bodyWrap.append(body, progress.root); const content = document.createElement("div"); content.className = "b05-route-profile__content"; const drainagePanel = createDrainagePanel(); - content.append(body, drainagePanel.root); + content.append(bodyWrap, drainagePanel.root); root.append(panelHandle.root, balanceBar, content); drainagePanel.load(projectId); @@ -639,6 +647,11 @@ export function createRouteProfilePanel( }, /** 배수유역도 패널 — 경로 확정 흐름에서 유역선 편집 저장 여부를 묻는 데 쓴다. */ drainage: drainagePanel, + /** 그래프 영역 로딩 서클. 문구를 주면 켜고 null이면 끈다. */ + setLoading(label: string | null) { + progress.root.hidden = label === null; + if (label !== null) progress.set(null, label); + }, dispose() { window.clearTimeout(resizeTimer); resizeObserver.disconnect(); diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index e6674dca..d09c8761 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -69,6 +69,15 @@ min-width: 0; } +/* 그래프 본문 + 로딩 서클을 겹치기 위한 칸. body는 그릴 때마다 자식이 교체된다. */ +.b05-route-profile__body-wrap { + position: relative; + display: flex; + flex: 1 1 auto; + min-width: 0; + min-height: 0; +} + .b05-route-profile__body { box-sizing: border-box; flex: 1 1 auto; @@ -126,6 +135,7 @@ transform: translateY(-50%); } +.b05-route-profile.is-collapsed .b05-route-profile__body-wrap, .b05-route-profile.is-collapsed .b05-route-profile__body, .b05-route-profile.is-collapsed .b05-route-profile__balance { display: none; @@ -182,16 +192,6 @@ pointer-events: none; } -/* 진입 로딩 서클 — 3D 뷰포트 위쪽 가운데. 하단 종단 패널(z-index 3)보다 아래에 둬서 - 패널을 볼 때 가리지 않는다(2026-08-01 사용자 지시). */ -.b05-route__progress { - position: absolute; - z-index: 1; - top: 18%; - left: 50%; - transform: translateX(-50%); -} - /* 뷰셋·표시 토글 버튼 묶음 — 좌상단(2026-08-01 사용자 지시). 안내 문구가 우상단으로 옮겨져 좌상단이 비었다. */ .b05-route__view-controls { diff --git a/ui_template/ui_template_progress.css b/ui_template/ui_template_progress.css index 29f463bf..9deb740c 100644 --- a/ui_template/ui_template_progress.css +++ b/ui_template/ui_template_progress.css @@ -1,6 +1,7 @@ -/* 공통 프로그레스 서클 — 뷰포트 위에 얹는 원형 진행 표시. */ +/* 공통 프로그레스 서클 — 뷰포트 위에 얹는 원형 진행 표시. + 테두리 두께·색은 공통 로딩 스피너(.ui-spinner)와 같게 맞추고, 가운데에 진행률만 더한다. */ .ui-progress-circle { - --ui-progress-size: 96px; + --ui-progress-size: 72px; display: flex; flex-direction: column; align-items: center; @@ -9,6 +10,15 @@ user-select: none; } +/* 컨테이너 정중앙 오버레이 — 3D 뷰포트·지도·그래프 어디에나 같은 방식으로 얹는다. + 컨테이너에 position: relative 가 있어야 한다. */ +.ui-progress-circle--overlay { + position: absolute; + z-index: 1; + inset: 0; + justify-content: center; +} + .ui-progress-circle__dial { position: relative; width: var(--ui-progress-size); @@ -24,7 +34,7 @@ .ui-progress-circle__track { fill: none; - stroke: color-mix(in srgb, var(--color-border) 70%, transparent); + stroke: var(--color-mist-violet); stroke-width: 8; } diff --git a/ui_template/ui_template_progress.ts b/ui_template/ui_template_progress.ts index b133fed5..00c12cfb 100644 --- a/ui_template/ui_template_progress.ts +++ b/ui_template/ui_template_progress.ts @@ -15,10 +15,12 @@ const RADIUS = 42; const CIRCUMFERENCE = 2 * Math.PI * RADIUS; export interface ProgressCircleOptions { - /** 지름(px). 기본 96. */ + /** 지름(px). 기본 72(공통 로딩 스피너와 같은 무게감). */ size?: number; /** 서클 아래 안내 문구. */ label?: string; + /** 컨테이너 정중앙에 띄우는 오버레이로 만든다(컨테이너는 position: relative 여야 한다). */ + overlay?: boolean; } export interface ProgressCircleHandle { @@ -31,7 +33,7 @@ export interface ProgressCircleHandle { export function createProgressCircle(options: ProgressCircleOptions = {}): ProgressCircleHandle { const root = document.createElement("div"); - root.className = "ui-progress-circle"; + root.className = "ui-progress-circle" + (options.overlay ? " ui-progress-circle--overlay" : ""); root.setAttribute("role", "status"); root.setAttribute("aria-live", "polite"); if (options.size) root.style.setProperty("--ui-progress-size", `${options.size}px`); From c970812cf3e65ef182ccc5aaee74107f4c3b3080 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 1 Aug 2026 09:58:44 +0900 Subject: [PATCH 49/61] =?UTF-8?q?feat(3D):=20=ED=9C=A0=20=EB=B0=A9?= =?UTF-8?q?=ED=96=A5=20=EB=B0=98=EC=A0=84=C2=B7=ED=9A=8C=EC=A0=84=EC=A0=90?= =?UTF-8?q?=20=ED=91=9C=EC=8B=9C=20+=203D/=EB=93=B1=EA=B3=A0=EC=84=A0=20?= =?UTF-8?q?=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=20=EB=B3=B4=EA=B4=80?= =?UTF-8?q?=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 휠 위 = 축소로 반전, 커서 지점을 축으로 한 dolly를 공용 유틸에서 직접 처리 - 회전 중심을 작은 구로 표시(돌리는 동안만, 화면상 크기 일정, 항상 위에 그림) - 등고선 렌더 비용 감소: 폴리라인을 주곡선·보조곡선 2덩어리로 병합(드로우콜 424 → 2), 라벨은 카메라가 움직였을 때만 재배치 - common_util_http_cache: 파일 mtime+크기 ETag, If-None-Match 일치 시 304 (preview·contour 적용, 파일이 바뀌면 자동 무효화) - A00_Common/b_asset_cache: IndexedDB 보관함(키 = projectId|url, 값 = 바이트+ETag). 보관본 즉시 사용 후 백그라운드 재검증, 3D는 보관 바이트를 직접 파싱 - 프로젝트 전환 시 타 프로젝트 보관분 삭제, 대시보드→B그룹 이동 시 확정 모델의 3D 프리뷰·등고선(1.0m) 미리 받기(포인트클라우드 제외) --- A00_Common/b_asset_cache.ts | 238 ++++++++++++++++++ A00_Common/b_workflow_nav.ts | 4 + B04_wf1_Surface/B04_wf1_Surface_Router.py | 11 +- .../B04_wf1_Surface_Router_Contour.py | 11 +- B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts | 71 +++++- B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts | 3 + .../B04_wf1_Surface_UI_TerrainViewer.ts | 136 +++++----- B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts | 1 + B05_wf2_Route/B05_wf2_Route_UI_Page.ts | 3 + B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts | 70 +++--- common_util/common_util_http_cache.py | 39 +++ 11 files changed, 480 insertions(+), 107 deletions(-) create mode 100644 A00_Common/b_asset_cache.ts create mode 100644 common_util/common_util_http_cache.py diff --git a/A00_Common/b_asset_cache.ts b/A00_Common/b_asset_cache.ts new file mode 100644 index 00000000..12ff208b --- /dev/null +++ b/A00_Common/b_asset_cache.ts @@ -0,0 +1,238 @@ +/* ============================================================================= + * 3D 자료 브라우저 보관함 (IndexedDB) + * + * 지표면 3D 파일과 등고선은 한 번 만들면 잘 바뀌지 않는데 용량이 크다. 매번 새로 받으면 + * 페이지를 열 때마다 기다려야 하므로, 받은 것을 브라우저에 저장해 두고 다음부터는 그것을 + * 곧바로 화면에 올린다. 저장본을 쓰는 동시에 뒤에서 서버에 "바뀐 것 있나"만 물어보고, + * 바뀌었으면 새로 받아 갱신한다(서버의 ETag 사용). + * + * 프로젝트가 바뀌면 이전 프로젝트 자료는 지운다 — 다른 프로젝트 데이터가 섞이면 안 된다. + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; + +const DB_NAME = "aislo-asset-cache"; +const DB_VERSION = 1; +const STORE = "assets"; + +export interface CachedAsset { + /** `${projectId}|${url}` */ + key: string; + projectId: string; + url: string; + etag: string | null; + savedAt: number; + body: ArrayBuffer; +} + +let dbPromise: Promise | null = null; + +function openDatabase(): Promise { + if (dbPromise) return dbPromise; + dbPromise = new Promise((resolve) => { + if (!("indexedDB" in window)) { + resolve(null); + return; + } + const request = window.indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE)) { + const store = db.createObjectStore(STORE, { keyPath: "key" }); + store.createIndex("projectId", "projectId", { unique: false }); + } + }; + request.onsuccess = () => resolve(request.result); + // 사생활 보호 모드 등으로 열리지 않으면 보관함 없이 동작한다(항상 새로 받는다). + request.onerror = () => resolve(null); + }); + return dbPromise; +} + +function runTransaction( + mode: IDBTransactionMode, + work: (store: IDBObjectStore) => IDBRequest, +): Promise { + return openDatabase().then( + (db) => + new Promise((resolve) => { + if (!db) { + resolve(null); + return; + } + try { + const transaction = db.transaction(STORE, mode); + const request = work(transaction.objectStore(STORE)); + request.onsuccess = () => resolve(request.result ?? null); + request.onerror = () => resolve(null); + } catch { + resolve(null); + } + }), + ); +} + +const cacheKey = (projectId: string, url: string): string => `${projectId}|${url}`; + +async function readAsset(projectId: string, url: string): Promise { + return (await runTransaction("readonly", (store) => + store.get(cacheKey(projectId, url)), + )) as CachedAsset | null; +} + +async function writeAsset(asset: CachedAsset): Promise { + await runTransaction("readwrite", (store) => store.put(asset) as IDBRequest); +} + +/** 다른 프로젝트 자료를 모두 지운다. B그룹 페이지에 들어올 때 호출한다. */ +export async function purgeOtherProjects(projectId: string): Promise { + const db = await openDatabase(); + if (!db) return; + await new Promise((resolve) => { + try { + const transaction = db.transaction(STORE, "readwrite"); + const store = transaction.objectStore(STORE); + const cursorRequest = store.openCursor(); + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result; + if (!cursor) return; + const value = cursor.value as CachedAsset; + if (value.projectId !== projectId) cursor.delete(); + cursor.continue(); + }; + transaction.oncomplete = () => resolve(); + transaction.onerror = () => resolve(); + } catch { + resolve(); + } + }); +} + +export interface CachedFetchOptions { + /** 내려받는 동안 진행률(0~1, 모르면 null)을 알려준다. 저장본을 쓰면 호출되지 않는다. */ + onProgress?: (ratio: number | null) => void; +} + +/** 네트워크에서 받아 보관함에 저장한다. */ +async function downloadAndStore( + projectId: string, + url: string, + options: CachedFetchOptions, +): Promise { + const response = await fetch(url, { credentials: "include" }); + if (!response.ok) throw new Error(`요청 실패: ${response.status}`); + const total = Number(response.headers.get("content-length") ?? 0); + const etag = response.headers.get("etag"); + + let body: ArrayBuffer; + if (response.body && options.onProgress) { + // 진행률을 보여 주기 위해 조각으로 읽는다. + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let received = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + received += value.length; + options.onProgress(total > 0 ? received / total : null); + } + const merged = new Uint8Array(received); + let offset = 0; + chunks.forEach((chunk) => { + merged.set(chunk, offset); + offset += chunk.length; + }); + body = merged.buffer; + } else { + body = await response.arrayBuffer(); + } + + await writeAsset({ + key: cacheKey(projectId, url), + projectId, + url, + etag, + savedAt: Date.now(), + body, + }); + return body; +} + +/** 저장본이 최신인지 뒤에서 확인하고, 바뀌었으면 새로 받아 저장한다. */ +function revalidateInBackground(projectId: string, url: string, etag: string | null): void { + if (!etag) return; + void fetch(url, { credentials: "include", headers: { "If-None-Match": etag } }) + .then(async (response) => { + if (response.status === 304 || !response.ok) return; + const body = await response.arrayBuffer(); + await writeAsset({ + key: cacheKey(projectId, url), + projectId, + url, + etag: response.headers.get("etag"), + savedAt: Date.now(), + body, + }); + }) + .catch(() => { + /* 오프라인 등으로 확인하지 못해도 저장본을 계속 쓴다. */ + }); +} + +/** 저장본이 있으면 즉시 돌려주고 뒤에서 갱신 확인, 없으면 받아서 저장한 뒤 돌려준다. */ +export async function fetchCachedBytes( + projectId: string, + url: string, + options: CachedFetchOptions = {}, +): Promise { + const cached = await readAsset(projectId, url); + if (cached?.body) { + revalidateInBackground(projectId, url, cached.etag); + return cached.body; + } + return downloadAndStore(projectId, url, options); +} + +/** JSON 자료용. 저장본을 쓰면 파싱만 하고 네트워크를 타지 않는다. */ +export async function fetchCachedJson( + projectId: string, + url: string, + options: CachedFetchOptions = {}, +): Promise { + const bytes = await fetchCachedBytes(projectId, url, options); + return JSON.parse(new TextDecoder().decode(bytes)) as T; +} + +/** 화면에 쓰기 전에 미리 받아 둔다(대시보드에서 B그룹으로 들어갈 때). 실패는 무시한다. */ +export function prefetchAsset(projectId: string, url: string): void { + void fetchCachedBytes(projectId, url).catch(() => { + /* 미리 받기 실패는 화면 동작에 영향을 주지 않는다. */ + }); +} + +/** 확정된 지표면의 3D 파일과 등고선을 미리 받아 둔다. + * + * 사용자가 실제로 보는 것은 이 둘이라 이것만 챙긴다(포인트클라우드는 제외 — 2026-08-01 + * 사용자 지시). 이미 보관함에 있으면 아무 것도 하지 않는다. */ +export async function prefetchSurfaceAssets(projectId: string): Promise { + try { + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/surface/models`, { + credentials: "include", + }); + if (!response.ok) return; + const data = (await response.json()) as { + models?: Array<{ id: number; model_type?: string; status?: string }>; + }; + const confirmed = (data.models ?? []).find((model) => model.status === "CONFIRMED"); + if (!confirmed) return; + // 스무딩 지원 방식(dtm·tin)은 화면 기본값이 스무딩 적용본이다. + const method = (confirmed.model_type ?? "").toLowerCase(); + const smooth = method === "dtm" || method === "tin"; + const base = `${API_BASE_URL}/projects/${projectId}/surface/models/${confirmed.id}`; + prefetchAsset(projectId, `${base}/preview?smooth=${smooth}`); + prefetchAsset(projectId, `${base}/contour?interval=1&smooth=${smooth}&recalculate=false`); + } catch { + /* 미리 받기는 실패해도 화면 동작에 영향을 주지 않는다. */ + } +} diff --git a/A00_Common/b_workflow_nav.ts b/A00_Common/b_workflow_nav.ts index fb0ee940..04b88720 100644 --- a/A00_Common/b_workflow_nav.ts +++ b/A00_Common/b_workflow_nav.ts @@ -5,6 +5,7 @@ import { type RoutePath, } from "@config/config_frontend"; import type { WorkflowStage } from "@ui/ui_template_workflow_layout"; +import { prefetchSurfaceAssets, purgeOtherProjects } from "./b_asset_cache"; import { navigateTo } from "./router"; export interface WorkflowState { @@ -36,5 +37,8 @@ export async function fetchWorkflowState(projectId: string): Promise prefetchSurfaceAssets(projectId)); navigateTo(route); } diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router.py b/B04_wf1_Surface/B04_wf1_Surface_Router.py index c00175f4..47ad778b 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Router.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Router.py @@ -9,8 +9,8 @@ from uuid import UUID import aiomysql import numpy as np -from fastapi import APIRouter, Depends -from fastapi.responses import FileResponse, JSONResponse +from fastapi import APIRouter, Depends, Request +from fastapi.responses import JSONResponse, Response from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B04_wf1_Surface.B04_wf1_Surface_Engine import ( @@ -39,6 +39,7 @@ from B04_wf1_Surface.B04_wf1_Surface_Schema import ( ) from B04_wf1_Surface.B04_wf1_Surface_Service import confirm_surface_selection from common_util.common_util_auth import require_system_admin +from common_util.common_util_http_cache import cached_file_response from common_util.common_util_json import atomic_write_json from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_surface_confirmation import surface_confirmation_defaults @@ -512,10 +513,11 @@ async def get_wf1_analysis_status(project_id: UUID) -> dict: @router.get("/{project_id}/surface/models/{model_id}/preview", response_model=None) async def get_surface_model_preview( + request: Request, project_id: UUID, model_id: int, smooth: bool = False, -) -> FileResponse | JSONResponse: +) -> Response | JSONResponse: """지표면 모델의 3D 프리뷰 파일(GLB/PLY)을 반환한다.""" pool = get_db_pool() try: @@ -569,7 +571,8 @@ async def get_surface_model_preview( elif ext == "ply": media_type = "application/ply" - return FileResponse(preview_path, media_type=media_type, filename=preview_filename) + # 브라우저가 이미 같은 파일을 갖고 있으면 304만 돌려준다(새로고침이 빨라진다). + return cached_file_response(request, preview_path, media_type, preview_filename) except Exception: logger.exception( diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router_Contour.py b/B04_wf1_Surface/B04_wf1_Surface_Router_Contour.py index 82b11bdd..333ef4c1 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Router_Contour.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Router_Contour.py @@ -10,8 +10,8 @@ from pathlib import Path from uuid import UUID import numpy as np -from fastapi import APIRouter -from fastapi.responses import FileResponse, JSONResponse +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse, Response from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B04_wf1_Surface.B04_wf1_Surface_Engine_Contour import ( @@ -19,6 +19,7 @@ from B04_wf1_Surface.B04_wf1_Surface_Engine_Contour import ( extract_contours, ) from common_util.common_util_atomic import atomic_write_bytes +from common_util.common_util_http_cache import cached_file_response from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool from config.config_system import SURFACE_CONTOUR_GRID_RESOLUTION_M @@ -50,12 +51,13 @@ def _is_contour_cache_current(contour_path: Path, model_path: Path) -> bool: @router.get("/{project_id}/surface/models/{model_id}/contour", response_model=None) async def get_surface_model_contour( + request: Request, project_id: UUID, model_id: int, interval: float = 1.0, smooth: bool = False, recalculate: bool = False, -) -> FileResponse | JSONResponse: +) -> Response | JSONResponse: """지표면 모델의 등고선 JSON 파일을 반환한다.""" pool = get_db_pool() try: @@ -176,7 +178,8 @@ async def get_surface_model_contour( }, ) - return FileResponse(contour_path, media_type="application/json", filename=contour_filename) + # 브라우저가 이미 같은 등고선 파일을 갖고 있으면 304만 돌려준다(새로고침이 빨라진다). + return cached_file_response(request, contour_path, "application/json", contour_filename) except Exception: logger.exception( diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts index 72902bee..2b5f06b5 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts @@ -58,6 +58,11 @@ export function niceScaleDistance(roughMeters: number): number { const POLAR_EPSILON = 0.02; /** 포인트클라우드 클릭 허용 반경 — 카메라 거리에 비례(멀수록 점이 성기게 보인다). */ const POINT_PICK_RATIO = 0.01; +/** 휠 한 칸당 배율. 휠을 위로 올리면 이 값의 역수만큼 멀어진다(2026-08-01 사용자 지시). */ +const ZOOM_STEP = 0.9; +/** 회전 중심 구슬의 화면상 크기 비율(카메라 거리 대비). 멀어져도 같은 크기로 보인다. */ +const PIVOT_MARKER_RATIO = 0.012; +const PIVOT_MARKER_COLOR = 0xf59e0b; export interface CursorPivotOptions { camera: THREE.PerspectiveCamera; @@ -68,17 +73,46 @@ export interface CursorPivotOptions { pickables: () => THREE.Object3D[]; /** 마커 드래그 등 다른 조작이 잡고 있으면 회전을 넘긴다. */ blocked?: () => boolean; + /** 회전 중심 구슬을 띄울 장면. 주지 않으면 구슬을 만들지 않는다. */ + scene?: THREE.Scene; } /** 커서 기준 회전·줌을 붙이고, 해제 함수를 돌려준다. */ export function bindCursorPivotControls(options: CursorPivotOptions): () => void { const { camera, controls, element } = options; - // 회전은 여기서 직접 처리하므로 OrbitControls 쪽 회전은 끈다(줌은 그대로 둔다). + // 회전·줌 모두 여기서 직접 처리한다(OrbitControls에는 휠 방향을 뒤집는 설정이 없다). controls.enableRotate = false; - controls.zoomToCursor = true; + controls.enableZoom = false; // 가운데 버튼 드래그 = 화면 이동(전역 공통). 기본값(DOLLY)은 휠 줌과 겹쳐 쓸모가 없다. controls.mouseButtons.MIDDLE = THREE.MOUSE.PAN; + // 회전 중심 구슬 — 돌리는 동안에만 보인다. 화면상 크기는 거리와 무관하게 일정하다. + const pivotMarker = options.scene + ? new THREE.Mesh( + new THREE.SphereGeometry(1, 16, 12), + new THREE.MeshBasicMaterial({ + color: PIVOT_MARKER_COLOR, + // 지형에 묻혀 안 보이면 축을 확인할 수 없으므로 항상 위에 그린다. + depthTest: false, + transparent: true, + opacity: 0.9, + }), + ) + : null; + if (pivotMarker && options.scene) { + pivotMarker.visible = false; + pivotMarker.renderOrder = 999; + options.scene.add(pivotMarker); + } + + /** 구슬을 현재 축 위치·크기로 맞춘다. */ + function syncPivotMarker(): void { + if (!pivotMarker || !pivotMarker.visible) return; + pivotMarker.position.copy(pivot); + const distance = camera.position.distanceTo(pivot); + pivotMarker.scale.setScalar(Math.max(distance * PIVOT_MARKER_RATIO, 0.01)); + } + const raycaster = new THREE.Raycaster(); const pointer = new THREE.Vector2(); const pivot = new THREE.Vector3(); @@ -92,7 +126,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void * * 지형을 맞히면 그 점을 쓰고, 하늘·구멍이라 못 맞히면 시선에 수직이고 현재 target을 * 지나는 평면과 광선을 만나게 해 **커서 방향**의 점을 쓴다(화면 중앙으로 돌아가지 않는다). */ - function pickPivot(event: PointerEvent): void { + function pickPivot(event: { clientX: number; clientY: number }): void { pivot.copy(controls.target); const rect = element.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) return; @@ -123,6 +157,28 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void pointerId = event.pointerId; lastX = event.clientX; lastY = event.clientY; + if (pivotMarker) { + pivotMarker.visible = true; + syncPivotMarker(); + } + } + + /** 휠 줌 — 커서가 가리키는 지점을 축으로 삼아 그 점이 화면에 고정된 채 멀어지고 가까워진다. + * 휠을 위로 올리면 멀어진다(사용자 지시). */ + function onWheel(event: WheelEvent): void { + if (!controls.enabled || options.blocked?.()) return; + event.preventDefault(); + pickPivot(event); + const factor = event.deltaY < 0 ? 1 / ZOOM_STEP : ZOOM_STEP; + const cameraOffset = camera.position.clone().sub(pivot).multiplyScalar(factor); + const targetOffset = controls.target.clone().sub(pivot).multiplyScalar(factor); + // 축에 너무 가까워지면 시점이 뒤집히므로 최소 거리를 남긴다. + if (cameraOffset.length() < 0.5 && factor < 1) return; + camera.position.copy(pivot).add(cameraOffset); + controls.target.copy(pivot).add(targetOffset); + camera.lookAt(controls.target); + controls.update(); + syncPivotMarker(); } function onPointerMove(event: PointerEvent): void { @@ -162,10 +218,12 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void controls.target.copy(pivot).add(targetOffset); camera.lookAt(controls.target); controls.update(); + syncPivotMarker(); } function stop(): void { pointerId = null; + if (pivotMarker) pivotMarker.visible = false; } function onPointerEnd(event: PointerEvent): void { @@ -177,6 +235,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void element.addEventListener("pointerup", onPointerEnd); element.addEventListener("pointercancel", onPointerEnd); element.addEventListener("pointerleave", onPointerEnd); + element.addEventListener("wheel", onWheel, { passive: false }); return () => { element.removeEventListener("pointerdown", onPointerDown); @@ -184,6 +243,12 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void element.removeEventListener("pointerup", onPointerEnd); element.removeEventListener("pointercancel", onPointerEnd); element.removeEventListener("pointerleave", onPointerEnd); + element.removeEventListener("wheel", onWheel); + if (pivotMarker) { + pivotMarker.removeFromParent(); + pivotMarker.geometry.dispose(); + (pivotMarker.material as THREE.Material).dispose(); + } }; } diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts index 19137412..06bda3b8 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts @@ -9,6 +9,7 @@ import { } from "@ui/ui_template_elements"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; +import { purgeOtherProjects } from "../A00_Common/b_asset_cache"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { fetchWorkflowState, @@ -85,6 +86,8 @@ function getModelFilter(model: SurfaceModelSummary): string { export async function renderB04Surface(root: HTMLElement): Promise { const guardedProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + // 새로고침으로 바로 들어온 경우에도 다른 프로젝트 자료는 보관함에서 지운다. + if (guardedProjectId) void purgeOtherProjects(guardedProjectId); if (guardedProjectId) { const user = await fetchDashboardMe(); if (user.role !== "SYSTEM_ADMIN") { diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts index 6160fa2f..658c7fe5 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts @@ -3,6 +3,7 @@ import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js"; import { API_BASE_URL } from "@config/config_frontend"; +import { fetchCachedBytes, fetchCachedJson } from "../A00_Common/b_asset_cache"; import { createProgressCircle } from "@ui/ui_template_progress"; import type { SurfaceBounds, SurfaceModelSummary } from "./B04_wf1_Surface_Api_Fetch"; import { @@ -222,12 +223,15 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { let terrainMesh: THREE.Object3D | null = null; const labelElements: HTMLDivElement[] = []; + // 라벨 목록이 바뀌거나 표시 옵션을 껐다 켰을 때는 카메라가 그대로여도 다시 배치해야 한다. + let labelsDirty = true; // 회전·줌 중심을 커서 아래 지형 지점으로 (포인트클라우드 뷰어·B05와 공용 유틸). const releaseCursorPivot = bindCursorPivotControls({ camera, controls, element: renderer.domElement, pickables: () => (terrainMesh ? [terrainMesh] : []), + scene, }); function disposeObject(obj: THREE.Object3D) { @@ -259,6 +263,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { } labelElements.forEach((el) => el.remove()); labelElements.length = 0; + labelsDirty = true; legendBar.style.display = "none"; } @@ -354,47 +359,36 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { showProgress(0, "3D 메쉬 내려받는 중…"); const previewUrl = `${API_BASE_URL}/projects/${currentProjectId}/surface/models/${modelId}/preview?smooth=${isSmooth}`; - /** 로더 진행 이벤트 → 서클. 서버가 길이를 안 주면(gzip) 진행률 없이 회전만 시킨다. */ - const onDownload = (event: ProgressEvent): void => { - if (generation !== loadGeneration) return; - const ratio = event.lengthComputable && event.total > 0 ? event.loaded / event.total : null; - showProgress(ratio, "3D 메쉬 내려받는 중…"); - }; - try { + // 브라우저 보관함에 있으면 그대로 쓰고, 없을 때만 내려받는다(새로고침이 빨라진다). + const buffer = await fetchCachedBytes(currentProjectId, previewUrl, { + onProgress: (ratio) => { + if (generation !== loadGeneration) return; + showProgress(ratio, "3D 메쉬 내려받는 중…"); + }, + }); + if (generation !== loadGeneration) return; + if (activeMethod === "meshfree") { - new PLYLoader().load( - previewUrl, - async (geometry) => { - if (generation !== loadGeneration) { - geometry.dispose(); - return; - } - geometry.computeBoundingSphere(); - const material = new THREE.PointsMaterial({ - size: 0.35, - vertexColors: geometry.hasAttribute("color"), - sizeAttenuation: true, - }); - const points = new THREE.Points(geometry, material); - points.visible = surfCheck.checked; - terrainMesh = points; - scene.add(points); - fitCamera(points); - showProgress(1, "등고선을 그리는 중…"); - await loadSelectedContours(modelId, isSmooth); - showProgress(null, null); - }, - onDownload, - () => { - if (generation !== loadGeneration) return; - statusSpan.textContent = "3D 파일 로드에 실패했습니다."; - showProgress(null, null); - }, - ); + const geometry = new PLYLoader().parse(buffer); + geometry.computeBoundingSphere(); + const material = new THREE.PointsMaterial({ + size: 0.35, + vertexColors: geometry.hasAttribute("color"), + sizeAttenuation: true, + }); + const points = new THREE.Points(geometry, material); + points.visible = surfCheck.checked; + terrainMesh = points; + scene.add(points); + fitCamera(points); + showProgress(1, "등고선을 그리는 중…"); + await loadSelectedContours(modelId, isSmooth); + showProgress(null, null); } else { - new GLTFLoader().load( - previewUrl, + new GLTFLoader().parse( + buffer, + "", async (gltf) => { if (generation !== loadGeneration) { disposeObject(gltf.scene); @@ -414,7 +408,6 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { await loadSelectedContours(modelId, isSmooth); showProgress(null, null); }, - onDownload, () => { if (generation !== loadGeneration) return; statusSpan.textContent = "3D 메쉬 파일이 없거나 로드할 수 없습니다."; @@ -423,7 +416,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { ); } } catch (e) { - statusSpan.textContent = "에러 발생"; + statusSpan.textContent = "3D 파일 로드에 실패했습니다."; showProgress(null, null); } } @@ -438,9 +431,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { const contourUrl = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${isSmooth}&recalculate=${recalculate}`; try { - const res = await fetch(contourUrl, { cache: "no-store" }); - if (!res.ok) throw new Error("등고선 조회 실패"); - const data = await res.json(); + // 등고선도 보관함에서 먼저 찾는다 — 같은 파일을 새로고침마다 다시 내려받지 않는다. + const data = await fetchCachedJson(projectId, contourUrl); if ( currentProjectId !== projectId || currentModelId !== modelId || @@ -470,6 +462,10 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { let minH = Infinity; let maxH = -Infinity; + // 등고선 한 가닥마다 3D 객체를 만들면 수백 개가 되어 그리기가 느려진다. + // 주곡선·보조곡선 두 덩어리로 합쳐 객체 2개만 만든다(2026-08-01). + const majorPoints: THREE.Vector3[] = []; + const minorPoints: THREE.Vector3[] = []; data.contours.forEach((c: any) => { if (c.level < minH) minH = c.level; @@ -478,22 +474,11 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { const points = transform(c.coordinates); if (points.length < 2) return; - const linePoints: THREE.Vector3[] = []; - for (let i = 0; i < points.length - 1; i++) { - linePoints.push(points[i], points[i + 1]); - } - - const geometry = new THREE.BufferGeometry().setFromPoints(linePoints); const isMajor = c.level % (interval * 5) === 0; - const material = new THREE.LineBasicMaterial({ - color: isMajor ? 0xd97706 : 0xf59e0b, - linewidth: isMajor ? 2 : 1, - transparent: true, - opacity: 0.8, - }); - - const segments = new THREE.LineSegments(geometry, material); - contourGroup.add(segments); + const bucket = isMajor ? majorPoints : minorPoints; + for (let i = 0; i < points.length - 1; i++) { + bucket.push(points[i], points[i + 1]); + } if (isMajor && points.length > 4) { const labelPos = points[Math.floor(points.length / 2)]; @@ -532,9 +517,25 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { viewerArea.appendChild(labelDiv); labelElements.push(labelDiv); + labelsDirty = true; } }); + // 합쳐 둔 점들을 주곡선·보조곡선 각각 한 덩어리로 올린다. + [ + { points: minorPoints, color: 0xf59e0b }, + { points: majorPoints, color: 0xd97706 }, + ].forEach(({ points, color }) => { + if (points.length === 0) return; + const geometry = new THREE.BufferGeometry().setFromPoints(points); + const material = new THREE.LineBasicMaterial({ + color, + transparent: true, + opacity: 0.8, + }); + contourGroup.add(new THREE.LineSegments(geometry, material)); + }); + if (minH !== Infinity && maxH !== -Infinity) { const nearestMin10 = Math.round(minH / 10) * 10; const nearestMax10 = Math.round(maxH / 10) * 10; @@ -575,6 +576,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { // Animation render loop let animationFrameId = 0; let hasConnected = false; + // 라벨 재계산 여부 판단용 — 직전 프레임의 카메라 자세. + const cameraMatrixSnapshot = new THREE.Matrix4(); function animate() { if (!root.isConnected) { if (!hasConnected) { @@ -607,12 +610,16 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { scaleBar.hidden = true; } - // Update labels position - labelElements.forEach((label) => { - if (typeof (label as any).__updateLabelPos === "function") { - (label as any).__updateLabelPos(); - } - }); + // 등고 라벨 위치 — 화면이 실제로 움직였을 때만 다시 계산한다(매 프레임 재계산은 낭비). + if (labelsDirty || !cameraMatrixSnapshot.equals(camera.matrixWorldInverse)) { + labelsDirty = false; + cameraMatrixSnapshot.copy(camera.matrixWorldInverse); + labelElements.forEach((label) => { + if (typeof (label as any).__updateLabelPos === "function") { + (label as any).__updateLabelPos(); + } + }); + } renderer.render(scene, camera); animationFrameId = requestAnimationFrame(animate); @@ -640,6 +647,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { labelElements.forEach((el) => { el.style.display = contourCheck.checked ? "block" : "none"; }); + labelsDirty = true; }); intervalForm.addEventListener("submit", async (e) => { diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts index 8f1160cc..f87bbfec 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts @@ -139,6 +139,7 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { controls: orbit, element: canvas, pickables: () => (pointsObject ? [pointsObject] : []), + scene, }); let currentData: SurfacePointCloudSampleResponse | null = null; let animationFrame = 0; diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index d811af61..55ebb520 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -1,5 +1,6 @@ import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; +import { purgeOtherProjects } from "../A00_Common/b_asset_cache"; import { createProgressCircle } from "@ui/ui_template_progress"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; @@ -169,6 +170,8 @@ export async function renderB05Route(root: HTMLElement): Promise { return; } const activeProjectId: string = projectId; + // 새로고침으로 바로 들어온 경우에도 다른 프로젝트 자료는 보관함에서 지운다. + void purgeOtherProjects(activeProjectId); const viewer = createRouteViewer(); const profilePanel = createRouteProfilePanel( diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts b/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts index 06f00775..f98ab03d 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts @@ -3,6 +3,7 @@ import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js"; import { API_BASE_URL } from "@config/config_frontend"; +import { fetchCachedBytes, fetchCachedJson } from "../A00_Common/b_asset_cache"; import { bindCursorPivotControls } from "../B04_wf1_Surface/B04_wf1_Surface_UI_Camera"; import { createRouteMarkers, @@ -121,6 +122,7 @@ export function createRouteViewer(): RouteViewer { element: canvas, pickables: () => (terrain ? [terrain] : []), blocked: () => dragCandidate !== null || draggingMarker || movingSelected, + scene, }); function clearContours(): void { @@ -161,35 +163,42 @@ export function createRouteViewer(): RouteViewer { async function reloadContours(interval: number): Promise { if (!current || !bounds) return; current.interval = interval; - const response = await fetch( - `${API_BASE_URL}/projects/${current.projectId}/surface/models/${current.modelId}/contour?interval=${interval}&smooth=${current.smooth}`, - { credentials: "include", cache: "no-store" }, - ); - if (!response.ok) throw new Error("등고선 조회에 실패했습니다."); - const data = (await response.json()) as { + // 등고선도 보관함에서 먼저 찾는다 — 같은 파일을 새로고침마다 다시 내려받지 않는다. + const data = await fetchCachedJson<{ contours: Array<{ level: number; coordinates: [number, number, number][] }>; - }; + }>( + current.projectId, + `${API_BASE_URL}/projects/${current.projectId}/surface/models/${current.modelId}/contour?interval=${interval}&smooth=${current.smooth}`, + ); clearContours(); + // 등고선 한 가닥마다 3D 객체를 만들면 수백 개가 된다. 주곡선·보조곡선 두 덩어리로 합친다. + const majorPoints: THREE.Vector3[] = []; + const minorPoints: THREE.Vector3[] = []; + const cx = (bounds.x[0] + bounds.x[1]) / 2; + const cy = (bounds.y[0] + bounds.y[1]) / 2; + const cz = (bounds.z[0] + bounds.z[1]) / 2; data.contours.forEach((contour) => { - const points = contour.coordinates.map(([x, y, z]) => { - const cx = (bounds!.x[0] + bounds!.x[1]) / 2; - const cy = (bounds!.y[0] + bounds!.y[1]) / 2; - const cz = (bounds!.z[0] + bounds!.z[1]) / 2; - return new THREE.Vector3(x - cx, z - cz + 0.15, -(y - cy)); - }); - if (points.length > 1) { - contours.add( - new THREE.Line( - new THREE.BufferGeometry().setFromPoints(points), - new THREE.LineBasicMaterial({ - color: contour.level % (interval * 5) === 0 ? 0xd97706 : 0xf59e0b, - transparent: true, - opacity: 0.75, - }), - ), - ); + const points = contour.coordinates.map( + ([x, y, z]) => new THREE.Vector3(x - cx, z - cz + 0.15, -(y - cy)), + ); + if (points.length < 2) return; + const bucket = contour.level % (interval * 5) === 0 ? majorPoints : minorPoints; + for (let index = 0; index < points.length - 1; index += 1) { + bucket.push(points[index], points[index + 1]); } }); + [ + { points: minorPoints, color: 0xf59e0b }, + { points: majorPoints, color: 0xd97706 }, + ].forEach(({ points, color }) => { + if (points.length === 0) return; + contours.add( + new THREE.LineSegments( + new THREE.BufferGeometry().setFromPoints(points), + new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.75 }), + ), + ); + }); } function terrainPoint( @@ -331,17 +340,14 @@ export function createRouteViewer(): RouteViewer { disposeObject(terrain); } const url = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/preview?smooth=${smooth}`; + // 브라우저 보관함에 있으면 그대로 쓰고, 없을 때만 내려받는다(새로고침이 빨라진다). + const buffer = await fetchCachedBytes(projectId, url); terrain = await new Promise((resolve, reject) => { if (method === "meshfree") { - new PLYLoader().load( - url, - (geometry) => - resolve(new THREE.Points(geometry, new THREE.PointsMaterial({ size: 0.35 }))), - undefined, - reject, - ); + const geometry = new PLYLoader().parse(buffer); + resolve(new THREE.Points(geometry, new THREE.PointsMaterial({ size: 0.35 }))); } else { - new GLTFLoader().load(url, (gltf) => resolve(gltf.scene), undefined, reject); + new GLTFLoader().parse(buffer, "", (gltf) => resolve(gltf.scene), reject); } }); terrain.traverse((child) => { diff --git a/common_util/common_util_http_cache.py b/common_util/common_util_http_cache.py new file mode 100644 index 00000000..0e83c4be --- /dev/null +++ b/common_util/common_util_http_cache.py @@ -0,0 +1,39 @@ +"""파일 응답에 브라우저 캐시 검증(ETag)을 붙이는 공통 유틸. + +3D 프리뷰·등고선처럼 한 번 만들면 잘 바뀌지 않는 파일은, 브라우저가 이미 받아 둔 것을 +그대로 쓰게 해야 새로고침이 빠르다. 그렇다고 무조건 캐시를 믿게 두면 모델을 다시 만들었을 때 +옛 파일이 계속 보인다. + +그래서 파일의 수정시각·크기로 ETag를 만들어 보내고, 브라우저가 같은 ETag를 들고 오면 +본문 없이 304만 돌려준다. 파일이 바뀌면 ETag가 저절로 달라져 새 파일을 받는다. +""" + +from __future__ import annotations + +from pathlib import Path + +from fastapi import Request +from fastapi.responses import FileResponse, Response + +# 항상 서버에 물어보되(변경 감지), 안 바뀌었으면 본문을 다시 받지 않는다. +CACHE_CONTROL = "private, max-age=0, must-revalidate" + + +def file_etag(path: Path) -> str: + """파일 수정시각·크기로 만든 ETag. 파일이 바뀌면 값이 달라진다.""" + stat = path.stat() + return f'"{int(stat.st_mtime)}-{stat.st_size}"' + + +def cached_file_response( + request: Request, + path: Path, + media_type: str, + filename: str | None = None, +) -> Response: + """ETag를 붙인 파일 응답. 브라우저가 가진 것과 같으면 304만 돌려준다.""" + etag = file_etag(path) + headers = {"ETag": etag, "Cache-Control": CACHE_CONTROL} + if request.headers.get("if-none-match") == etag: + return Response(status_code=304, headers=headers) + return FileResponse(path, media_type=media_type, filename=filename, headers=headers) From 77a637d7c99fa2330a5c1958f09a37227ec663b1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 1 Aug 2026 10:34:08 +0900 Subject: [PATCH 50/61] =?UTF-8?q?feat(B11):=20=EC=9E=90=EB=A3=8C=20?= =?UTF-8?q?=EC=A4=80=EB=B9=84=20=ED=99=94=EB=A9=B4=20=EC=8B=A0=EC=84=A4=20?= =?UTF-8?q?=E2=80=94=20=EC=9E=91=EC=97=85=20=ED=99=94=EB=A9=B4=20=EC=A7=84?= =?UTF-8?q?=EC=9E=85=20=EC=A0=84=203D=C2=B7=EB=93=B1=EA=B3=A0=EC=84=A0=20?= =?UTF-8?q?=EC=84=A0=EC=A0=81=EC=9E=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B11_Status_UI_Loading + 라우트 b11-loading: 공통 프로그레스 서클로 단계·진행률 표시, 끝나면 원래 가려던 화면으로 자동 이동 - goToWorkflowStage: 아직 준비하지 않은 프로젝트면 준비 화면 경유, 같은 프로젝트로 다시 들어오면 건너뜀(탭 단위 기억). 다른 프로젝트면 보관분 교체 - 선적재 범위: 확정 지표면 3D + 그 등고선만 (종단·횡단은 0.86MB·0.06초로 이미 즉시라 제외, 배수유역·포인트클라우드도 제외) - 실패 시 자동 이동하지 않고 담당자 연락 안내 + [그래도 이동]/[대시보드로] - B04 등고선 간격 시작값을 B05 저장값과 공유(B04 변경은 DB에 쓰지 않음) - 새 문구는 ui_locales에 한국어·영어 등록 --- A00_Common/b_asset_cache.ts | 120 ++++++++++++++---- A00_Common/b_workflow_nav.ts | 13 +- A00_Common/router.ts | 3 + B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts | 8 +- .../B04_wf1_Surface_UI_TerrainViewer.ts | 5 + B11_Status/B11_Status_UI_Loading.ts | 88 +++++++++++++ B11_Status/B11_Status_UI_Style.css | 34 +++++ config/config_frontend.ts | 3 + ui_template/ui_template_locale.ts | 18 +++ 9 files changed, 259 insertions(+), 33 deletions(-) create mode 100644 B11_Status/B11_Status_UI_Loading.ts diff --git a/A00_Common/b_asset_cache.ts b/A00_Common/b_asset_cache.ts index 12ff208b..be365263 100644 --- a/A00_Common/b_asset_cache.ts +++ b/A00_Common/b_asset_cache.ts @@ -204,35 +204,101 @@ export async function fetchCachedJson( return JSON.parse(new TextDecoder().decode(bytes)) as T; } -/** 화면에 쓰기 전에 미리 받아 둔다(대시보드에서 B그룹으로 들어갈 때). 실패는 무시한다. */ -export function prefetchAsset(projectId: string, url: string): void { - void fetchCachedBytes(projectId, url).catch(() => { - /* 미리 받기 실패는 화면 동작에 영향을 주지 않는다. */ - }); -} +/* ── 준비 화면 연동 ──────────────────────────────────────────────────────── + * 어느 프로젝트를 이미 준비했는지, 준비가 끝나면 어느 화면으로 보낼지를 탭 단위로 기억한다. + * 대시보드로 나갔다가 같은 프로젝트로 돌아오면 준비 화면을 건너뛴다(2026-08-01 사용자 지시). */ +const PRELOADED_PROJECT_KEY = "frd_preloaded_project"; +const PRELOAD_TARGET_KEY = "frd_preload_target"; -/** 확정된 지표면의 3D 파일과 등고선을 미리 받아 둔다. - * - * 사용자가 실제로 보는 것은 이 둘이라 이것만 챙긴다(포인트클라우드는 제외 — 2026-08-01 - * 사용자 지시). 이미 보관함에 있으면 아무 것도 하지 않는다. */ -export async function prefetchSurfaceAssets(projectId: string): Promise { +export function isProjectPreloaded(projectId: string): boolean { try { - const response = await fetch(`${API_BASE_URL}/projects/${projectId}/surface/models`, { - credentials: "include", - }); - if (!response.ok) return; - const data = (await response.json()) as { - models?: Array<{ id: number; model_type?: string; status?: string }>; - }; - const confirmed = (data.models ?? []).find((model) => model.status === "CONFIRMED"); - if (!confirmed) return; - // 스무딩 지원 방식(dtm·tin)은 화면 기본값이 스무딩 적용본이다. - const method = (confirmed.model_type ?? "").toLowerCase(); - const smooth = method === "dtm" || method === "tin"; - const base = `${API_BASE_URL}/projects/${projectId}/surface/models/${confirmed.id}`; - prefetchAsset(projectId, `${base}/preview?smooth=${smooth}`); - prefetchAsset(projectId, `${base}/contour?interval=1&smooth=${smooth}&recalculate=false`); + return window.sessionStorage.getItem(PRELOADED_PROJECT_KEY) === projectId; } catch { - /* 미리 받기는 실패해도 화면 동작에 영향을 주지 않는다. */ + return false; } } + +export function markProjectPreloaded(projectId: string): void { + try { + window.sessionStorage.setItem(PRELOADED_PROJECT_KEY, projectId); + } catch { + /* 세션 저장 실패는 준비 화면이 한 번 더 뜨는 정도의 영향뿐이다. */ + } +} + +export function setPreloadTarget(route: string): void { + try { + window.sessionStorage.setItem(PRELOAD_TARGET_KEY, route); + } catch { + /* 저장 실패 시 준비 화면이 기본 화면으로 보낸다. */ + } +} + +export function readPreloadTarget(): string | null { + try { + return window.sessionStorage.getItem(PRELOAD_TARGET_KEY); + } catch { + return null; + } +} + +/** 준비 화면이 표시할 단계 안내. ratio가 null이면 진행률을 모른다는 뜻이다. */ +export type PreloadReporter = (label: string, ratio: number | null) => void; + +/** 사용자가 고른 등고선 간격(B05에서 저장한 값). 없으면 1.0m. + * B04(관리자 확인용 화면)도 이 값을 시작값으로 쓴다 — 사용자가 정한 값이 우선이다. */ +export async function fetchUserContourInterval(projectId: string): Promise { + try { + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/route/latest`, { + credentials: "include", + }); + if (!response.ok) return 1.0; + const data = (await response.json()) as { + surface_params?: { contour_interval_m?: number }; + }; + const interval = data.surface_params?.contour_interval_m; + return typeof interval === "number" && interval > 0 ? interval : 1.0; + } catch { + return 1.0; + } +} + +/** 확정된 지표면의 3D 파일과 그 등고선을 보관함에 채운다(준비 화면에서 호출). + * + * 사용자가 실제로 보는 것은 이 둘이라 이것만 챙긴다 — 포인트클라우드·배수유역은 제외 + * (2026-08-01 사용자 지시). 이미 보관돼 있으면 거의 즉시 끝난다. + * 확정 지표면을 찾지 못하면 오류를 던져 준비 화면이 안내 문구를 띄우게 한다. */ +export async function preloadSurfaceAssets( + projectId: string, + report: PreloadReporter = () => {}, +): Promise { + report("확정된 지표면을 확인하는 중…", null); + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/surface/models`, { + credentials: "include", + }); + if (!response.ok) throw new Error("지표면 목록을 불러오지 못했습니다."); + const data = (await response.json()) as { + models?: Array<{ id: number; model_type?: string; status?: string }>; + }; + const confirmed = (data.models ?? []).find((model) => model.status === "CONFIRMED"); + if (!confirmed) throw new Error("확정된 지표면 모델이 없습니다."); + + // 스무딩을 지원하는 방식(dtm·tin)은 화면 기본값이 스무딩 적용본이다. + const method = (confirmed.model_type ?? "").toLowerCase(); + const smooth = method === "dtm" || method === "tin"; + const base = `${API_BASE_URL}/projects/${projectId}/surface/models/${confirmed.id}`; + const interval = await fetchUserContourInterval(projectId); + + report("3D 지표면을 준비하는 중…", 0); + await fetchCachedBytes(projectId, `${base}/preview?smooth=${smooth}`, { + onProgress: (ratio) => report("3D 지표면을 준비하는 중…", ratio), + }); + + report("등고선을 준비하는 중…", null); + await fetchCachedBytes( + projectId, + `${base}/contour?interval=${interval}&smooth=${smooth}&recalculate=false`, + { onProgress: (ratio) => report("등고선을 준비하는 중…", ratio) }, + ); + report("준비 완료", 1); +} diff --git a/A00_Common/b_workflow_nav.ts b/A00_Common/b_workflow_nav.ts index 04b88720..f8c62270 100644 --- a/A00_Common/b_workflow_nav.ts +++ b/A00_Common/b_workflow_nav.ts @@ -5,7 +5,7 @@ import { type RoutePath, } from "@config/config_frontend"; import type { WorkflowStage } from "@ui/ui_template_workflow_layout"; -import { prefetchSurfaceAssets, purgeOtherProjects } from "./b_asset_cache"; +import { isProjectPreloaded, setPreloadTarget } from "./b_asset_cache"; import { navigateTo } from "./router"; export interface WorkflowState { @@ -37,8 +37,13 @@ export async function fetchWorkflowState(projectId: string): Promise prefetchSurfaceAssets(projectId)); + // 이 프로젝트를 아직 준비하지 않았으면 준비 화면(B11)을 먼저 거친다. 거기서 3D 지표면과 + // 등고선을 브라우저에 담고 원래 가려던 화면으로 넘겨준다. 같은 프로젝트로 다시 들어오면 + // 건너뛴다(2026-08-01 사용자 지시). + if (!isProjectPreloaded(projectId)) { + setPreloadTarget(route); + navigateTo(ROUTES.B11_LOADING); + return; + } navigateTo(route); } diff --git a/A00_Common/router.ts b/A00_Common/router.ts index 3a2c83ef..df75959b 100644 --- a/A00_Common/router.ts +++ b/A00_Common/router.ts @@ -55,6 +55,8 @@ const routeTable: Partial Promise>> = { (await import("../B10_Payment/B10_Payment_UI_Page")).renderB10Payment, [ROUTES.B11_STATUS]: async () => (await import("../B11_Status/B11_Status_UI_Page")).renderB11Status, + [ROUTES.B11_LOADING]: async () => + (await import("../B11_Status/B11_Status_UI_Loading")).renderB11Loading, }; /** 로그인 여부 (토큰 존재 확인) */ @@ -112,6 +114,7 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise { ROUTES.B09_WF6_ESTIMATION, ROUTES.B10_PAYMENT, ROUTES.B11_STATUS, + ROUTES.B11_LOADING, ]; if (workflowRoutes.includes(route)) { diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts index 06bda3b8..64519cb6 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts @@ -9,7 +9,7 @@ import { } from "@ui/ui_template_elements"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; -import { purgeOtherProjects } from "../A00_Common/b_asset_cache"; +import { fetchUserContourInterval, purgeOtherProjects } from "../A00_Common/b_asset_cache"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { fetchWorkflowState, @@ -320,12 +320,16 @@ export async function renderB04Surface(root: HTMLElement): Promise { } async function loadProjectData(projectId: string): Promise { - const [inputs, status, modelResponse] = await Promise.all([ + const [inputs, status, modelResponse, contourInterval] = await Promise.all([ listSurfaceInputFiles(projectId), fetchSurfaceStatus(projectId), listSurfaceModels(projectId), + // 등고선 간격은 사용자가 B05에서 저장한 값을 시작값으로 쓴다. 여기서 바꿔도 DB에는 + // 저장하지 않는다 — 관리자 확인용이라 사용자 설정을 건드리지 않는다(2026-08-01). + fetchUserContourInterval(projectId), ]); models = modelResponse.models; + terrainViewer.setContourInterval(contourInterval); renderInputFiles(inputs.files); renderStatus(status); viewer.setLoading("포인트 데이터 로딩 중…"); diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts index 658c7fe5..aca1d247 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts @@ -27,6 +27,8 @@ export interface SurfaceTerrainViewer { onAxesVisibilityChange: (listener: (visible: boolean) => void) => void; isSmoothingEnabled: () => boolean; getContourInterval: () => number; + /** 등고선 간격 시작값을 정한다(사용자가 B05에서 저장한 값). 다시 그리지는 않는다. */ + setContourInterval: (interval: number) => void; resetOptions: () => void; dispose: () => void; } @@ -701,6 +703,9 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { isSmoothingEnabled() { return !smoothCheck.disabled && smoothCheck.checked; }, + setContourInterval(interval) { + if (Number.isFinite(interval) && interval > 0) intervalInput.value = String(interval); + }, getContourInterval() { return Number.parseFloat(intervalInput.value); }, diff --git a/B11_Status/B11_Status_UI_Loading.ts b/B11_Status/B11_Status_UI_Loading.ts new file mode 100644 index 00000000..72f3a0a7 --- /dev/null +++ b/B11_Status/B11_Status_UI_Loading.ts @@ -0,0 +1,88 @@ +import { CURRENT_PROJECT_ID_KEY, ROUTES, type RoutePath } from "@config/config_frontend"; +import { createButton } from "@ui/ui_template_elements"; +import { createGeneralLayout } from "@ui/ui_template_general_layout"; +import { t } from "@ui/ui_template_locale"; +import { createProgressCircle } from "@ui/ui_template_progress"; +import { + markProjectPreloaded, + preloadSurfaceAssets, + purgeOtherProjects, + readPreloadTarget, +} from "../A00_Common/b_asset_cache"; +import { navigateTo } from "../A00_Common/router"; +import "./B11_Status_UI_Style.css"; + +/* ============================================================================= + * 자료 준비 화면 (B11) + * + * 대시보드에서 프로젝트를 골라 작업 화면으로 들어갈 때, 먼저 이 화면이 3D 지표면과 + * 등고선을 브라우저에 담아 둔다. 담아 두면 B04·B05가 그것을 그대로 쓰므로 화면이 바로 뜬다. + * 이미 담겨 있으면 순식간에 지나간다. + * + * 자료를 준비하지 못하면(분석 실패·저장 경로 문제) 넘어가지 않고 사유를 알린다. + * ========================================================================== */ + +export async function renderB11Loading(root: HTMLElement): Promise { + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const target = (readPreloadTarget() ?? ROUTES.B03_FILE_INPUT) as RoutePath; + + const progress = createProgressCircle({ label: t("B11_Loading_Start"), size: 96 }); + const message = document.createElement("p"); + message.className = "b11-loading__message"; + message.textContent = t("B11_Loading_Message"); + + const actions = document.createElement("div"); + actions.className = "b11-loading__actions"; + + const body = document.createElement("div"); + body.className = "b11-loading__body"; + body.append(progress.root, message, actions); + + const layout = createGeneralLayout({ + pageClass: "b11-status", + title: t("B11_Loading_Title"), + subtitle: t("B11_Loading_Subtitle"), + content: [body], + }); + root.replaceChildren(layout.root); + + if (!projectId) { + showFailure(t("B11_Loading_NoProject")); + return; + } + + try { + // 다른 프로젝트 자료가 남아 있으면 지운다 — 프로젝트끼리 섞이면 안 된다. + await purgeOtherProjects(projectId); + await preloadSurfaceAssets(projectId, (label, ratio) => { + progress.set(ratio, label); + }); + markProjectPreloaded(projectId); + navigateTo(target); + } catch (error) { + const detail = error instanceof Error ? ` (${error.message})` : ""; + showFailure(`${t("B11_Loading_Failed")}${detail}`); + } + + function showFailure(text: string): void { + progress.root.hidden = true; + message.textContent = text; + message.classList.add("b11-loading__message--error"); + actions.replaceChildren( + createButton({ + label: t("B11_Loading_Btn_Continue"), + variant: "ghost", + // 준비를 못 했어도 같은 세션에서 다시 붙잡지 않도록 표시해 둔다. + onClick: () => { + if (projectId) markProjectPreloaded(projectId); + navigateTo(target); + }, + }), + createButton({ + label: t("B11_Loading_Btn_Dashboard"), + variant: "filled", + onClick: () => navigateTo(ROUTES.B01_ACCOUNT), + }), + ); + } +} diff --git a/B11_Status/B11_Status_UI_Style.css b/B11_Status/B11_Status_UI_Style.css index e0862773..9d87977f 100644 --- a/B11_Status/B11_Status_UI_Style.css +++ b/B11_Status/B11_Status_UI_Style.css @@ -26,6 +26,40 @@ color: var(--color-text-body); } +/* 자료 준비 화면 — 서클과 안내 문구를 가운데 모아 둔다. */ +.b11-loading__body { + display: flex; + min-height: 320px; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--spacing-24); + padding: var(--spacing-24); +} + +.b11-loading__message { + max-width: 46ch; + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-body-sm); + line-height: 1.6; + text-align: center; + word-break: keep-all; +} + +.b11-loading__message--error { + color: var(--color-danger); +} + +.b11-loading__actions { + display: flex; + gap: var(--spacing-8); +} + +.b11-loading__actions:empty { + display: none; +} + @media (max-width: 860px) { .b11-status__flow { grid-template-columns: 1fr 1fr; diff --git a/config/config_frontend.ts b/config/config_frontend.ts index 55a4ddde..e6fefe39 100644 --- a/config/config_frontend.ts +++ b/config/config_frontend.ts @@ -92,6 +92,8 @@ export const ROUTES = { B09_WF6_ESTIMATION: "b09-wf6-estimation", B10_PAYMENT: "b10-payment", B11_STATUS: "b11-status", + // 대시보드에서 B그룹으로 처음 들어갈 때 3D·등고선을 미리 받아 두는 준비 화면. + B11_LOADING: "b11-loading", } as const; export type RouteKey = keyof typeof ROUTES; @@ -113,6 +115,7 @@ export const PROTECTED_ROUTES: readonly RoutePath[] = [ ROUTES.B09_WF6_ESTIMATION, ROUTES.B10_PAYMENT, ROUTES.B11_STATUS, + ROUTES.B11_LOADING, ]; /* ----------------------------------------------------------------------------- diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index 8061fda4..76f96a69 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -988,6 +988,24 @@ export const ui_locales = { "결재·문서 생성 상태를 확인하고 결과물을 내려받으세요.", "Check payment and document status, and download results.", ], + // 자료 준비 화면 (대시보드 → 작업 화면 진입 시 3D·등고선 선적재) + B11_Loading_Title: ["자료 준비 중", "Preparing data"], + B11_Loading_Subtitle: ["잠시만 기다려 주세요.", "This will take a moment."], + B11_Loading_Message: [ + "작업 화면에서 바로 쓸 수 있도록 3D 지표면과 등고선을 준비합니다.", + "Loading the 3D surface and contours so the workspace opens instantly.", + ], + B11_Loading_Start: ["자료를 준비하는 중…", "Preparing data…"], + B11_Loading_NoProject: [ + "프로젝트가 선택되지 않았습니다. 대시보드에서 프로젝트를 먼저 고르세요.", + "No project selected. Choose a project on the dashboard first.", + ], + B11_Loading_Failed: [ + "자료를 준비하지 못했습니다. 분석 결과나 저장 경로에 문제가 있을 수 있습니다. 담당자에게 연락해 주세요.", + "Could not prepare the data. The analysis result or storage path may be broken. Please contact support.", + ], + B11_Loading_Btn_Continue: ["그래도 화면으로 이동", "Continue anyway"], + B11_Loading_Btn_Dashboard: ["대시보드로", "Back to dashboard"], B11_Status_Flow_Title: ["결재 진행 상태", "Payment Progress"], B11_Status_Step_Request: ["발행 요청", "Invoice Requested"], B11_Status_Step_Issue: ["세금계산서 발행", "Invoice Issued"], From 96561de1206375f6799f07e5a67d7fadb4dc9422 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 1 Aug 2026 11:34:44 +0900 Subject: [PATCH 51/61] =?UTF-8?q?perf(B04/B05):=20=ED=99=95=EC=A0=95=20?= =?UTF-8?q?=EC=A7=80=ED=91=9C=EB=A9=B4=20=EC=9A=94=EC=95=BD=20API=20?= =?UTF-8?q?=EC=8B=A0=EC=84=A4=20+=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=ED=81=B4?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=EB=93=9C=2024MB=20=EC=88=98=EC=8B=A0=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /surface/confirmed 추가: 확정 구성(모델·필터·표현·평활·등고선간격)과 지형 가장자리만 반환(수 KB, 0.02s). 진입 판정·준비화면·B05의 단일 출처. - B05 진입이 받던 포인트클라우드 JSON 23.8MB 제거 — 실제로 쓰던 값은 bounds뿐. - 준비 표식을 프로젝트 ID에서 확정 signature로 변경: B04에서 다시 확정하면 대시보드 복귀·새 브라우저·B그룹 단계 이동 어느 경로로 들어와도 최신본을 담는다. - preloadSurfaceAssets가 평활 여부를 추측하던 부분 제거(확정 저장값 사용) — 추측이 어긋나면 같은 지형을 두 번 내려받았다. - B04 진입 시 필터·표현·평활·등고선간격을 확정본 값으로 초기화(확정 없으면 기존 기본값). - 모델 확정 직후 준비 표식과 B05 세션 캐시를 비워 옛 지형이 남지 않게 함. Co-Authored-By: Claude Opus 5 (1M context) --- A00_Common/b_asset_cache.ts | 77 +++++++++--------- A00_Common/b_workflow_nav.ts | 30 +++++-- B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts | 31 +++++++ B04_wf1_Surface/B04_wf1_Surface_Router.py | 80 ++++++++++++++++++- B04_wf1_Surface/B04_wf1_Surface_Schema.py | 19 +++++ B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts | 26 ++++-- .../B04_wf1_Surface_UI_TerrainViewer.ts | 6 ++ B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts | 13 +++ B05_wf2_Route/B05_wf2_Route_UI_Page.ts | 14 ++-- B11_Status/B11_Status_UI_Loading.ts | 12 ++- 10 files changed, 243 insertions(+), 65 deletions(-) diff --git a/A00_Common/b_asset_cache.ts b/A00_Common/b_asset_cache.ts index be365263..44905989 100644 --- a/A00_Common/b_asset_cache.ts +++ b/A00_Common/b_asset_cache.ts @@ -10,6 +10,7 @@ * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; +import { fetchConfirmedSurface } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch"; const DB_NAME = "aislo-asset-cache"; const DB_VERSION = 1; @@ -205,27 +206,44 @@ export async function fetchCachedJson( } /* ── 준비 화면 연동 ──────────────────────────────────────────────────────── - * 어느 프로젝트를 이미 준비했는지, 준비가 끝나면 어느 화면으로 보낼지를 탭 단위로 기억한다. - * 대시보드로 나갔다가 같은 프로젝트로 돌아오면 준비 화면을 건너뛴다(2026-08-01 사용자 지시). */ -const PRELOADED_PROJECT_KEY = "frd_preloaded_project"; + * 무엇을 이미 준비했는지, 준비가 끝나면 어느 화면으로 보낼지를 탭 단위로 기억한다. + * 대시보드로 나갔다가 같은 프로젝트로 돌아오면 준비 화면을 건너뛴다(2026-08-01 사용자 지시). + * + * 표식은 프로젝트 번호가 아니라 **확정 구성(signature)** 이다. 관리자가 B04에서 다른 + * 필터·표현으로 다시 확정하면 표식이 달라져 준비 화면이 한 번 더 돌고 새 자료를 담는다. + * 프로젝트 번호만 봤다면 옛 자료를 계속 쓰게 된다(2026-08-01 사용자 지시). */ +const PRELOADED_SIGNATURE_KEY = "frd_preloaded_signature"; const PRELOAD_TARGET_KEY = "frd_preload_target"; -export function isProjectPreloaded(projectId: string): boolean { +const preloadStamp = (projectId: string, signature: string): string => `${projectId}|${signature}`; + +export function isProjectPreloaded(projectId: string, signature: string): boolean { try { - return window.sessionStorage.getItem(PRELOADED_PROJECT_KEY) === projectId; + return ( + window.sessionStorage.getItem(PRELOADED_SIGNATURE_KEY) === preloadStamp(projectId, signature) + ); } catch { return false; } } -export function markProjectPreloaded(projectId: string): void { +export function markProjectPreloaded(projectId: string, signature: string): void { try { - window.sessionStorage.setItem(PRELOADED_PROJECT_KEY, projectId); + window.sessionStorage.setItem(PRELOADED_SIGNATURE_KEY, preloadStamp(projectId, signature)); } catch { /* 세션 저장 실패는 준비 화면이 한 번 더 뜨는 정도의 영향뿐이다. */ } } +/** 담아 둔 표식을 지운다 — 확정이 바뀌어 자료를 다시 담아야 할 때 호출한다. */ +export function clearPreloadMark(): void { + try { + window.sessionStorage.removeItem(PRELOADED_SIGNATURE_KEY); + } catch { + /* 지우지 못해도 다음 표식 비교에서 불일치로 걸러진다. */ + } +} + export function setPreloadTarget(route: string): void { try { window.sessionStorage.setItem(PRELOAD_TARGET_KEY, route); @@ -245,49 +263,25 @@ export function readPreloadTarget(): string | null { /** 준비 화면이 표시할 단계 안내. ratio가 null이면 진행률을 모른다는 뜻이다. */ export type PreloadReporter = (label: string, ratio: number | null) => void; -/** 사용자가 고른 등고선 간격(B05에서 저장한 값). 없으면 1.0m. - * B04(관리자 확인용 화면)도 이 값을 시작값으로 쓴다 — 사용자가 정한 값이 우선이다. */ -export async function fetchUserContourInterval(projectId: string): Promise { - try { - const response = await fetch(`${API_BASE_URL}/projects/${projectId}/route/latest`, { - credentials: "include", - }); - if (!response.ok) return 1.0; - const data = (await response.json()) as { - surface_params?: { contour_interval_m?: number }; - }; - const interval = data.surface_params?.contour_interval_m; - return typeof interval === "number" && interval > 0 ? interval : 1.0; - } catch { - return 1.0; - } -} - /** 확정된 지표면의 3D 파일과 그 등고선을 보관함에 채운다(준비 화면에서 호출). * * 사용자가 실제로 보는 것은 이 둘이라 이것만 챙긴다 — 포인트클라우드·배수유역은 제외 * (2026-08-01 사용자 지시). 이미 보관돼 있으면 거의 즉시 끝난다. - * 확정 지표면을 찾지 못하면 오류를 던져 준비 화면이 안내 문구를 띄우게 한다. */ + * 평활 여부·등고선 간격은 짐작하지 않고 확정 저장값을 그대로 쓴다 — 짐작하면 B04·B05가 + * 서로 다른 파일을 받아 같은 지형을 두 번 내려받게 된다. + * 확정 지표면을 찾지 못하면 오류를 던져 준비 화면이 안내 문구를 띄우게 한다. + * 반환값은 담아 둔 구성의 signature — 호출측이 준비 표식으로 저장한다. */ export async function preloadSurfaceAssets( projectId: string, report: PreloadReporter = () => {}, -): Promise { +): Promise { report("확정된 지표면을 확인하는 중…", null); - const response = await fetch(`${API_BASE_URL}/projects/${projectId}/surface/models`, { - credentials: "include", - }); - if (!response.ok) throw new Error("지표면 목록을 불러오지 못했습니다."); - const data = (await response.json()) as { - models?: Array<{ id: number; model_type?: string; status?: string }>; - }; - const confirmed = (data.models ?? []).find((model) => model.status === "CONFIRMED"); - if (!confirmed) throw new Error("확정된 지표면 모델이 없습니다."); + const confirmed = await fetchConfirmedSurface(projectId); + if (!confirmed.model_id) throw new Error("확정된 지표면 모델이 없습니다."); - // 스무딩을 지원하는 방식(dtm·tin)은 화면 기본값이 스무딩 적용본이다. - const method = (confirmed.model_type ?? "").toLowerCase(); - const smooth = method === "dtm" || method === "tin"; - const base = `${API_BASE_URL}/projects/${projectId}/surface/models/${confirmed.id}`; - const interval = await fetchUserContourInterval(projectId); + const smooth = confirmed.smooth ?? false; + const interval = confirmed.contour_interval_m ?? 1.0; + const base = `${API_BASE_URL}/projects/${projectId}/surface/models/${confirmed.model_id}`; report("3D 지표면을 준비하는 중…", 0); await fetchCachedBytes(projectId, `${base}/preview?smooth=${smooth}`, { @@ -301,4 +295,5 @@ export async function preloadSurfaceAssets( { onProgress: (ratio) => report("등고선을 준비하는 중…", ratio) }, ); report("준비 완료", 1); + return confirmed.signature; } diff --git a/A00_Common/b_workflow_nav.ts b/A00_Common/b_workflow_nav.ts index f8c62270..11c843a5 100644 --- a/A00_Common/b_workflow_nav.ts +++ b/A00_Common/b_workflow_nav.ts @@ -5,6 +5,7 @@ import { type RoutePath, } from "@config/config_frontend"; import type { WorkflowStage } from "@ui/ui_template_workflow_layout"; +import { fetchConfirmedSurface } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch"; import { isProjectPreloaded, setPreloadTarget } from "./b_asset_cache"; import { navigateTo } from "./router"; @@ -37,13 +38,28 @@ export async function fetchWorkflowState(projectId: string): Promise { + let signature: string | null = null; + try { + signature = (await fetchConfirmedSurface(projectId)).signature; + } catch { + signature = null; + } + if (signature && isProjectPreloaded(projectId, signature)) { + navigateTo(route); return; } - navigateTo(route); + setPreloadTarget(route); + navigateTo(ROUTES.B11_LOADING); } diff --git a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts index bfbfb09e..4b23b8f4 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts @@ -111,6 +111,29 @@ export interface SurfaceModelListResponse { models: SurfaceModelSummary[]; } +/** 확정 지표면 요약 (SurfaceConfirmedResponse). + * 포인트 배열 없이 확정 구성과 지형 가장자리만 담는다 — 진입 판정·준비화면·B05 공용. */ +export interface SurfaceConfirmedResponse { + status: string; + project_id: string; + model_id: number | null; + source_filter: string | null; + method: string | null; + smooth: boolean | null; + contour_interval_m: number | null; + /** 확정 구성이 바뀌었는지 한 줄로 비교하기 위한 값. */ + signature: string; + point_count: number | null; + bounds: { + x_min: number; + x_max: number; + y_min: number; + y_max: number; + z_min: number; + z_max: number; + } | null; +} + /** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. * * `timeoutMs`를 주면 그 값으로 끊는다. 배수유역 격자 해석처럼 수십 초가 걸리는 요청은 @@ -199,6 +222,14 @@ export async function fetchSurfacePointCloud( ); } +/** 확정 지표면 구성 + 지형 가장자리만 조회한다(수 KB). + * 포인트클라우드 전체(수십 MB)를 받지 않고도 3D 좌표 환산에 필요한 값을 얻는다. */ +export async function fetchConfirmedSurface(projectId: string): Promise { + return requestJson(`/projects/${projectId}/surface/confirmed`, { + method: "GET", + }); +} + export async function fetchSurfaceGroundStats( projectId: string, ): Promise { diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router.py b/B04_wf1_Surface/B04_wf1_Surface_Router.py index 47ad778b..93568f4f 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Router.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Router.py @@ -28,6 +28,7 @@ from B04_wf1_Surface.B04_wf1_Surface_Repository import ( from B04_wf1_Surface.B04_wf1_Surface_Schema import ( SurfaceAnalyzeRequest, SurfaceAnalyzeResponse, + SurfaceConfirmedResponse, SurfaceConfirmRequest, SurfaceConfirmResponse, SurfaceGroundStatsResponse, @@ -42,7 +43,10 @@ from common_util.common_util_auth import require_system_admin from common_util.common_util_http_cache import cached_file_response from common_util.common_util_json import atomic_write_json from common_util.common_util_storage import resolve_stored_project_path -from common_util.common_util_surface_confirmation import surface_confirmation_defaults +from common_util.common_util_surface_confirmation import ( + get_surface_confirmation_params, + surface_confirmation_defaults, +) from common_util.common_util_workflow_state import ( fail_stage, start_stage, @@ -368,6 +372,80 @@ async def get_surface_point_cloud( ) +@router.get("/{project_id}/surface/confirmed", response_model=SurfaceConfirmedResponse) +async def get_confirmed_surface(project_id: UUID) -> SurfaceConfirmedResponse | JSONResponse: + """확정 지표면 구성과 지형 가장자리만 반환한다(포인트 배열 없음). + + B05 3D 배치·B11 준비화면·진입 판정이 모두 이 응답 하나를 기준으로 삼는다. + 구성이 바뀌면 signature가 달라지므로 프론트가 담아 둔 자료의 갱신 여부를 판단할 수 있다. + """ + pool = get_db_pool() + try: + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + models = await list_surface_models(connection, project_id) + params = await get_surface_confirmation_params(connection, str(project_id)) + + confirmed = next((model for model in models if model["status"] == "CONFIRMED"), None) + source_filter = params.get("source_filter") + + # 가장자리는 B05가 3D 마커 좌표를 환산할 때 쓰므로, 기존 포인트클라우드 응답과 + # 같은 파일(확정 필터의 지면 포인트)에서 읽어 값이 어긋나지 않게 한다. + processed_dir = Path(resolve_stored_project_path(stored_path)) / "B04_wf1_Surface" + processed_dir = processed_dir / "processed" + source_path = processed_dir / "structured.npz" + if source_filter: + filtered = processed_dir / f"ground_points_{source_filter}.npz" + if filtered.is_file(): + source_path = filtered + + bounds_payload: dict[str, float] | None = None + point_count: int | None = None + if source_path.is_file(): + with np.load(source_path) as stored: + bounds = np.asarray(stored["bounds"], dtype=np.float64) + if "point_count" in stored: + point_count = int(stored["point_count"]) + bounds_payload = { + "x_min": float(bounds[0, 0]), + "x_max": float(bounds[0, 1]), + "y_min": float(bounds[1, 0]), + "y_max": float(bounds[1, 1]), + "z_min": float(bounds[2, 0]), + "z_max": float(bounds[2, 1]), + } + + signature = "|".join( + str(value) + for value in ( + confirmed["id"] if confirmed else "none", + source_filter, + params.get("method"), + params.get("smooth"), + params.get("contour_interval_m"), + ) + ) + return SurfaceConfirmedResponse( + project_id=str(project_id), + model_id=int(confirmed["id"]) if confirmed else None, + source_filter=source_filter, + method=params.get("method"), + smooth=params.get("smooth"), + contour_interval_m=params.get("contour_interval_m"), + signature=signature, + point_count=point_count, + bounds=bounds_payload, + ) + except LookupError as exc: + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("B04 확정 지표면 요약 조회 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "확정 지표면 정보를 불러오지 못했습니다."}, + ) + + @router.get("/{project_id}/surface/ground-stats", response_model=SurfaceGroundStatsResponse) async def get_surface_ground_stats(project_id: UUID) -> SurfaceGroundStatsResponse | JSONResponse: """manifest에서 필터별 지면 포인트 통계를 반환한다.""" diff --git a/B04_wf1_Surface/B04_wf1_Surface_Schema.py b/B04_wf1_Surface/B04_wf1_Surface_Schema.py index 13605251..3e0ec724 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Schema.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Schema.py @@ -110,6 +110,25 @@ class SurfacePointCloudSampleResponse(BaseModel): rgb: list[list[int]] | None = None +class SurfaceConfirmedResponse(BaseModel): + """확정 지표면 요약 — 화면 진입 판정·준비화면·B05가 공통으로 쓰는 단일 출처. + + 포인트 배열 없이 확정값과 지형 가장자리만 담아 수 KB로 유지한다. + signature는 확정 구성이 바뀌었는지 프론트가 한 줄로 비교하기 위한 값이다. + """ + + status: str = "success" + project_id: str + model_id: int | None = None + source_filter: str | None = None + method: str | None = None + smooth: bool | None = None + contour_interval_m: float | None = None + signature: str + point_count: int | None = None + bounds: dict[str, float] | None = None + + class SurfaceGroundStatsResponse(BaseModel): """필터별 지면 포인트 통계 응답.""" diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts index 64519cb6..5ea15cd5 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts @@ -9,7 +9,8 @@ import { } from "@ui/ui_template_elements"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; -import { fetchUserContourInterval, purgeOtherProjects } from "../A00_Common/b_asset_cache"; +import { clearPreloadMark, purgeOtherProjects } from "../A00_Common/b_asset_cache"; +import { clearRouteLatestCache } from "../B05_wf2_Route/B05_wf2_Route_Api_Fetch"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { fetchWorkflowState, @@ -19,6 +20,7 @@ import { } from "../A00_Common/b_workflow_nav"; import { confirmSurfaceModel, + fetchConfirmedSurface, fetchSurfacePointCloud, fetchSurfaceStatus, listSurfaceInputFiles, @@ -320,16 +322,24 @@ export async function renderB04Surface(root: HTMLElement): Promise { } async function loadProjectData(projectId: string): Promise { - const [inputs, status, modelResponse, contourInterval] = await Promise.all([ + const [inputs, status, modelResponse, confirmed] = await Promise.all([ listSurfaceInputFiles(projectId), fetchSurfaceStatus(projectId), listSurfaceModels(projectId), - // 등고선 간격은 사용자가 B05에서 저장한 값을 시작값으로 쓴다. 여기서 바꿔도 DB에는 - // 저장하지 않는다 — 관리자 확인용이라 사용자 설정을 건드리지 않는다(2026-08-01). - fetchUserContourInterval(projectId), + // 확정본 구성(필터·표현·평활·등고선 간격)을 그대로 시작값으로 쓴다. 여기서 바꿔도 + // DB에는 저장하지 않는다 — 관리자 확인용이라 사용자 설정을 건드리지 않는다(2026-08-01). + fetchConfirmedSurface(projectId), ]); models = modelResponse.models; - terrainViewer.setContourInterval(contourInterval); + // 확정본과 같은 조합에서 시작해야 B05와 같은 파일을 보고, 보관함도 한 벌만 쓴다. + // 확정 이력이 없을 때만 개발 기본값(csf·dtm)으로 둔다. + if (confirmed.model_id) { + if (confirmed.source_filter) filterGroup.select.value = confirmed.source_filter; + if (confirmed.method) methodGroup.select.value = confirmed.method; + terrainViewer.setSmoothing(confirmed.smooth ?? false); + } + if (confirmed.contour_interval_m) + terrainViewer.setContourInterval(confirmed.contour_interval_m); renderInputFiles(inputs.files); renderStatus(status); viewer.setLoading("포인트 데이터 로딩 중…"); @@ -360,6 +370,10 @@ export async function renderB04Surface(root: HTMLElement): Promise { smooth: terrainViewer.isSmoothingEnabled(), contour_interval_m: terrainViewer.getContourInterval(), }); + // 확정본이 바뀌었으므로 브라우저가 담아 둔 옛 자료를 더 이상 쓰지 않게 한다. + // 준비 표식을 지우면 아래 goToWorkflowStage가 준비 화면을 거쳐 새 자료를 담는다. + clearPreloadMark(); + clearRouteLatestCache(projectId); showToast( L("B04_Surface_Confirm_Success") .replace("{filter}", filterGroup.select.value) diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts index aca1d247..4c79e4f0 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts @@ -26,6 +26,8 @@ export interface SurfaceTerrainViewer { onCameraChange: (listener: (state: SurfaceCameraState) => void) => void; onAxesVisibilityChange: (listener: (visible: boolean) => void) => void; isSmoothingEnabled: () => boolean; + /** 스무딩 시작값을 정한다(확정본 저장값). 다시 그리지는 않는다. */ + setSmoothing: (enabled: boolean) => void; getContourInterval: () => number; /** 등고선 간격 시작값을 정한다(사용자가 B05에서 저장한 값). 다시 그리지는 않는다. */ setContourInterval: (interval: number) => void; @@ -703,6 +705,10 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { isSmoothingEnabled() { return !smoothCheck.disabled && smoothCheck.checked; }, + setSmoothing(enabled) { + smoothPreferred = enabled; + syncSmoothingSupport(); + }, setContourInterval(interval) { if (Number.isFinite(interval) && interval > 0) intervalInput.value = String(interval); }, diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index e06feb17..b6ef26e1 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -261,6 +261,19 @@ export async function fetchLatestRoute(projectId: string): Promise `b05:latest:${projectId}`; + +/** 담아 둔 최신 경로 값을 버린다. B04에서 지표면을 다시 확정하면 옛 확정값이 남아 + * B05가 이전 지형을 그리게 되므로, 확정 직후 이 값을 지운다. */ +export function clearRouteLatestCache(projectId: string): void { + try { + window.sessionStorage.removeItem(routeLatestCacheKey(projectId)); + } catch { + /* 세션 접근 실패 시에는 다음 진입에서 DB를 읽게 되므로 그대로 둔다. */ + } +} + /* ── 배수유역도 (B05_wf2_Route_Router_Drainage.py) ───────────────────────── */ /** 관 매설 구조물 측점 후보 1개. reason: stream=세류 교차, spacing=300m 보충. */ diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index 55ebb520..54d429a0 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -10,13 +10,14 @@ import { WORKFLOW_STEP_ROUTES, } from "../A00_Common/b_workflow_nav"; import { - fetchSurfacePointCloud, + fetchConfirmedSurface, listSurfaceModels, type SurfaceModelSummary, } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch"; import { confirmRoute, fetchLatestRoute, + routeLatestCacheKey, saveDrainageBoundary, solveRoute, updateContourInterval, @@ -246,7 +247,7 @@ export async function renderB05Route(root: HTMLElement): Promise { * 확정 이력이 있으면 매 진입마다 DB(latest) 조회 대신 브라우저 세션 캐시를 * 우선 사용해 응답속도를 높인다. 캐시 미스면 latest를 조회해 적재하고, * solve·확정 성공 시 신선한 값으로 갱신한다(세션 = 탭 단위, 탭 종료 시 소멸). */ - const latestCacheKey = `b05:latest:${activeProjectId}`; + const latestCacheKey = routeLatestCacheKey(activeProjectId); function readLatestCache(): RouteLatestResponse | null { try { @@ -672,17 +673,16 @@ export async function renderB05Route(root: HTMLElement): Promise { if (!confirmedSurface) { showToast("확정된 지표면 모델이 없습니다.", "error"); } else { - const cloud = await fetchSurfacePointCloud( - activeProjectId, - latestResponse.surface_params.source_filter, - ); + // 지형 가장자리만 필요하다 — 포인트클라우드 전체(수십 MB)는 받지 않는다. + const confirmed = await fetchConfirmedSurface(activeProjectId); + if (!confirmed.bounds) throw new Error("지표면 범위 정보를 찾을 수 없습니다."); await viewer.loadSurface( activeProjectId, confirmedSurface.id, latestResponse.surface_params.method, latestResponse.surface_params.smooth, latestResponse.surface_params.contour_interval_m, - toBounds(cloud.bounds), + toBounds(confirmed.bounds), ); // 지형이 올라온 뒤에야 마커·측점선이 지형 위에 놓인다. renderLatest(latestResponse); diff --git a/B11_Status/B11_Status_UI_Loading.ts b/B11_Status/B11_Status_UI_Loading.ts index 72f3a0a7..304faa1a 100644 --- a/B11_Status/B11_Status_UI_Loading.ts +++ b/B11_Status/B11_Status_UI_Loading.ts @@ -22,6 +22,9 @@ import "./B11_Status_UI_Style.css"; * 자료를 준비하지 못하면(분석 실패·저장 경로 문제) 넘어가지 않고 사유를 알린다. * ========================================================================== */ +/** 준비에 실패한 채 그냥 넘어갔을 때 남기는 표식 — 어떤 확정 구성과도 일치하지 않는다. */ +const PRELOAD_SKIPPED_SIGNATURE = "skipped"; + export async function renderB11Loading(root: HTMLElement): Promise { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); const target = (readPreloadTarget() ?? ROUTES.B03_FILE_INPUT) as RoutePath; @@ -54,10 +57,11 @@ export async function renderB11Loading(root: HTMLElement): Promise { try { // 다른 프로젝트 자료가 남아 있으면 지운다 — 프로젝트끼리 섞이면 안 된다. await purgeOtherProjects(projectId); - await preloadSurfaceAssets(projectId, (label, ratio) => { + const signature = await preloadSurfaceAssets(projectId, (label, ratio) => { progress.set(ratio, label); }); - markProjectPreloaded(projectId); + // 표식은 담아 둔 구성 그대로 남긴다 — 확정이 바뀌면 다음 진입에서 다시 준비한다. + markProjectPreloaded(projectId, signature); navigateTo(target); } catch (error) { const detail = error instanceof Error ? ` (${error.message})` : ""; @@ -73,8 +77,10 @@ export async function renderB11Loading(root: HTMLElement): Promise { label: t("B11_Loading_Btn_Continue"), variant: "ghost", // 준비를 못 했어도 같은 세션에서 다시 붙잡지 않도록 표시해 둔다. + // 확정 구성을 모르는 상태이므로 전용 표식을 남긴다 — 확정이 정상화되면 표식이 + // 어긋나 준비 화면이 다시 뜬다. onClick: () => { - if (projectId) markProjectPreloaded(projectId); + if (projectId) markProjectPreloaded(projectId, PRELOAD_SKIPPED_SIGNATURE); navigateTo(target); }, }), From a9caf872b2e53c2cb67cb7a8a676d6931a982dfd Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 1 Aug 2026 11:47:29 +0900 Subject: [PATCH 52/61] =?UTF-8?q?perf(B04/B05):=20=EB=8F=84=EC=97=BD=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=EB=B3=B8=20=EB=8F=84=EC=9E=85=20+=20?= =?UTF-8?q?=EB=B0=B0=EA=B2=BD=20=EB=B2=94=EC=9C=84=C2=B7=EB=8F=84=EC=97=BD?= =?UTF-8?q?=20=EA=B8=B0=EC=A4=80=EC=9D=84=20=EA=B3=84=ED=9A=8D=EB=85=B8?= =?UTF-8?q?=EC=84=A0=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 도엽 병합본의 화면 표시용 사본 생성(B04_wf1_Surface_Engine_SheetView): 프로젝트 + 500m로 잘라내고 좌표를 소수 7자리로 줄여 등고선 37.3MB -> 0.97MB. 분석용 원본은 그대로 둔다(배수유역은 상류까지 넓은 범위가 필요). - /geojson의 도엽 레이어를 파일 그대로 + ETag로 전송 — 매 요청 2.0초 재직렬화 제거(0.008초). - /vworld-map도 ETag 전송으로 바꾸고, 프론트가 붙이던 `&_t=` 시간꼬리표 제거. - 도엽 레이어를 브라우저 보관함 경유로 조회하고 준비화면에서 미리 담는다. - 배경 지도 범위 = 라이다 범위 + 계획노선 범위 + 여유 300m(SURFACE_MAP_MARGIN_M). - 도엽 기준 좌표 = 계획노선 시점·종점(없으면 라이다 중심). 같은 도엽이면 9매, 이웃 도엽에 걸치면 12매. 표본 프로젝트는 기존 9매와 동일(회귀 없음). - 선정에서 빠진 도엽 zip 정리(prune_sheets) — 표본에서 30매 중 21매가 다른 지역 잔재. Co-Authored-By: Claude Opus 5 (1M context) --- A00_Common/b_asset_cache.ts | 26 +++ B04_wf1_Surface/B04_wf1_Surface_Engine.py | 48 ++--- .../B04_wf1_Surface_Engine_Extent.py | 113 ++++++++++++ .../B04_wf1_Surface_Engine_MapSheet.py | 17 ++ .../B04_wf1_Surface_Engine_SheetStore.py | 36 ++++ .../B04_wf1_Surface_Engine_SheetView.py | 165 ++++++++++++++++++ B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py | 32 +++- .../B04_wf1_Surface_UI_MapViewer.ts | 11 +- .../B05_wf2_Route_UI_Drainage_Panel.ts | 10 +- config/config_system.py | 14 ++ 10 files changed, 437 insertions(+), 35 deletions(-) create mode 100644 B04_wf1_Surface/B04_wf1_Surface_Engine_Extent.py create mode 100644 B04_wf1_Surface/B04_wf1_Surface_Engine_SheetView.py diff --git a/A00_Common/b_asset_cache.ts b/A00_Common/b_asset_cache.ts index 44905989..487e6bce 100644 --- a/A00_Common/b_asset_cache.ts +++ b/A00_Common/b_asset_cache.ts @@ -205,6 +205,20 @@ export async function fetchCachedJson( return JSON.parse(new TextDecoder().decode(bytes)) as T; } +/** 배수유역도 배경으로 쓰는 도엽 레이어(유일한 정의처 — 준비화면과 B05 패널이 함께 쓴다). */ +export const DRAINAGE_SHEET_LAYERS = ["도엽_등고선", "도엽_하천중심선"] as const; + +/** 도엽 레이어(GeoJSON)를 보관함에서 먼저 찾는다. + * + * 서버는 프로젝트 주변만 잘라 좌표 자릿수를 줄인 표시용 사본을 ETag와 함께 내보낸다. + * 분석용 원본과는 별개 파일이므로, 담아 두었다가 그대로 다시 써도 화면이 어긋나지 않는다. */ +export async function fetchCachedSheetLayer(projectId: string, layer: string): Promise { + return fetchCachedJson( + projectId, + `${API_BASE_URL}/projects/${projectId}/geojson?layer=${encodeURIComponent(layer)}`, + ); +} + /* ── 준비 화면 연동 ──────────────────────────────────────────────────────── * 무엇을 이미 준비했는지, 준비가 끝나면 어느 화면으로 보낼지를 탭 단위로 기억한다. * 대시보드로 나갔다가 같은 프로젝트로 돌아오면 준비 화면을 건너뛴다(2026-08-01 사용자 지시). @@ -294,6 +308,18 @@ export async function preloadSurfaceAssets( `${base}/contour?interval=${interval}&smooth=${smooth}&recalculate=false`, { onProgress: (ratio) => report("등고선을 준비하는 중…", ratio) }, ); + + // 배수유역도 배경(도엽 표시본·위성사진)도 함께 담는다 — 없으면 B05가 진입할 때마다 받는다. + // 이 자료가 없어도 화면은 뜨므로 실패해도 준비를 멈추지 않는다. + report("배경 지도를 준비하는 중…", null); + await Promise.all( + DRAINAGE_SHEET_LAYERS.map((layer) => fetchCachedSheetLayer(projectId, layer).catch(() => null)), + ); + // 위성사진은 로 표시하므로 보관함이 아니라 브라우저 자체 캐시를 데워 둔다. + await fetch(`${API_BASE_URL}/projects/${projectId}/vworld-map?layer_name=satellite`, { + credentials: "include", + }).catch(() => null); + report("준비 완료", 1); return confirmed.signature; } diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine.py b/B04_wf1_Surface/B04_wf1_Surface_Engine.py index 097ee188..2626e4ec 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine.py @@ -232,14 +232,24 @@ def run_surface_analysis( ) prj_path = prj_candidates[0] if prj_candidates else project_root / "result.prj" - bounds_dict_for_download = { + from B04_wf1_Surface.B04_wf1_Surface_Engine_Extent import download_extent + from B04_wf1_Surface.B04_wf1_Surface_Engine_GisVector import download_all_gis_vectors + from B04_wf1_Surface.B04_wf1_Surface_Engine_VWorld import ( + download_vworld_satellite_map, + get_epsg_from_prj, + ) + + las_bounds_dict = { "x": [float(bounds[0, 0]), float(bounds[0, 1])], "y": [float(bounds[1, 0]), float(bounds[1, 1])], "z": [float(bounds[2, 0]), float(bounds[2, 1])], } - - from B04_wf1_Surface.B04_wf1_Surface_Engine_GisVector import download_all_gis_vectors - from B04_wf1_Surface.B04_wf1_Surface_Engine_VWorld import download_vworld_satellite_map + project_epsg = "EPSG:5186" + if prj_path.exists(): + project_epsg = get_epsg_from_prj(prj_path.read_text(encoding="utf-8", errors="ignore")) + # 배경 지도는 라이다 범위와 계획노선을 합친 범위로 받는다 — 노선이 라이다 범위를 + # 벗어나도 배경이 잘리지 않게 한다(2026-08-01 사용자 지시). + bounds_dict_for_download = download_extent(project_root, las_bounds_dict, project_epsg) # VWorld 지도 및 GIS 데이터 저장 위치는 B04_wf1_Surface/processed에 보관. layers = [ @@ -284,32 +294,26 @@ def run_surface_analysis( except Exception as exc: logger.warning("B04 국가 GIS 벡터 다운로드 실패: %s", exc) - # 3-3. 1:5,000 수치지형도 도엽 3x3(9매) 확보 → 프로젝트 영구저장소 + # 3-3. 1:5,000 수치지형도 도엽 확보 → 프로젝트 영구저장소 + # 기준은 계획노선 시점·종점 (같은 도엽이면 9매, 이웃 도엽에 걸치면 12매). # (실패해도 분석은 계속 — 폴백은 수동 다운로드 + 인제스트) _report(92, "download_maps", "수치지형도 도엽 확보 중") try: - from pyproj import Transformer - - from B04_wf1_Surface.B04_wf1_Surface_Engine_MapSheet import ( - latlon_to_sheet5k, - neighbors_3x3, - ) + from B04_wf1_Surface.B04_wf1_Surface_Engine_Extent import sheet_reference_points_wgs84 + from B04_wf1_Surface.B04_wf1_Surface_Engine_MapSheet import neighbors_for_points from B04_wf1_Surface.B04_wf1_Surface_Engine_SheetStore import ( ensure_sheets, get_project_map_sheets_dir, + prune_sheets, ) - from B04_wf1_Surface.B04_wf1_Surface_Engine_VWorld import get_epsg_from_prj - src_epsg = "EPSG:5186" - if prj_path.exists(): - src_epsg = get_epsg_from_prj(prj_path.read_text(encoding="utf-8", errors="ignore")) - transformer = Transformer.from_crs(src_epsg, "EPSG:4326", always_xy=True) - center_lon, center_lat = transformer.transform( - (bounds_dict_for_download["x"][0] + bounds_dict_for_download["x"][1]) / 2.0, - (bounds_dict_for_download["y"][0] + bounds_dict_for_download["y"][1]) / 2.0, - ) step_started = time.monotonic() - sheet_grid = neighbors_3x3(latlon_to_sheet5k(center_lat, center_lon)) + # 도엽 기준은 계획노선 시점·종점 — 노선이 두 도엽에 걸치면 양쪽 주변까지 확보한다. + # 계획노선이 없으면 라이다 범위 중심으로 되돌아간다(2026-08-01 사용자 지시). + reference_points = sheet_reference_points_wgs84( + project_root, las_bounds_dict, project_epsg + ) + sheet_grid = neighbors_for_points(reference_points) sheet_store = get_project_map_sheets_dir(project_root) sheet_result = ensure_sheets(sheet_store, sheet_grid) if sheet_result["failed"]: @@ -319,6 +323,8 @@ def run_surface_analysis( len(sheet_result["available"]), time.monotonic() - step_started, ) + # 선정에서 빠진 zip은 쓰이지 않으므로 정리한다(다른 지역 잔재 포함). + prune_sheets(sheet_store, sheet_grid) # 확보된 도엽을 레이어별 병합 GeoJSON으로 산출 (기존 산출물 있으면 스킵) if sheet_result["available"] and ( diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_Extent.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Extent.py new file mode 100644 index 00000000..8a5b768b --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Extent.py @@ -0,0 +1,113 @@ +# B04_wf1_Surface_Engine_Extent.py +# 전처리에서 내려받을 범위와 기준 좌표를 정한다. +# +# 배경(2026-08-01 사용자 지시): 배경 지도·수치지형도 도엽의 기준을 라이다 범위 한가운데로 +# 잡으면, 계획노선이 라이다 범위를 벗어날 때 배경과 도엽이 노선을 덮지 못한다. +# 계획노선(B03 CSV)의 시점·종점을 기준으로 삼고, 없을 때만 라이다 범위로 되돌린다. + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +from config.config_system import SURFACE_MAP_MARGIN_M + +logger = logging.getLogger(__name__) + +_ROUTE_CSV_GLOB = "B03_FileInput/input/csv/*.csv" + + +def read_planned_route(project_root: Path) -> dict[str, Any] | None: + """B03 계획노선 CSV를 읽어 좌표계·범위·시점·종점을 돌려준다. 없거나 형식이 어긋나면 None.""" + from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_planned_route_csv + + for csv_path in sorted(project_root.glob(_ROUTE_CSV_GLOB)): + try: + return analyze_planned_route_csv(csv_path) + except (OSError, ValueError) as exc: + logger.warning("B04 계획노선 CSV 해석 실패: %s (%s)", csv_path.name, exc) + return None + + +def _to_target_crs( + points: list[tuple[float, float]], source_epsg: int | None, target_epsg: str +) -> list[tuple[float, float]]: + """계획노선 좌표를 라이다 좌표계로 옮긴다. 좌표계가 같거나 알 수 없으면 그대로 쓴다.""" + if source_epsg is None: + return points + source = f"EPSG:{source_epsg}" + if source.upper() == target_epsg.upper(): + return points + from pyproj import Transformer + + transformer = Transformer.from_crs(source, target_epsg, always_xy=True) + return [transformer.transform(x, y) for x, y in points] + + +def download_extent( + project_root: Path, + las_bounds: dict[str, list[float]], + target_epsg: str, + margin_m: float = SURFACE_MAP_MARGIN_M, +) -> dict[str, list[float]]: + """배경 지도를 내려받을 범위. 라이다 범위와 계획노선을 합친 뒤 여유폭만큼 넓힌다. + + las_bounds/반환값 모두 {"x": [최소, 최대], "y": [...], "z": [...]} 꼴(라이다 좌표계). + """ + x_min, x_max = float(las_bounds["x"][0]), float(las_bounds["x"][1]) + y_min, y_max = float(las_bounds["y"][0]), float(las_bounds["y"][1]) + + route = read_planned_route(project_root) + if route: + bounds = route["bounds"] + corners = [ + (float(bounds["x_min"]), float(bounds["y_min"])), + (float(bounds["x_max"]), float(bounds["y_max"])), + ] + try: + moved = _to_target_crs(corners, route.get("epsg"), target_epsg) + except Exception as exc: + logger.warning("B04 계획노선 좌표 변환 실패 — 라이다 범위만 사용 (%s)", exc) + moved = [] + for x, y in moved: + x_min, x_max = min(x_min, x), max(x_max, x) + y_min, y_max = min(y_min, y), max(y_max, y) + + return { + "x": [x_min - margin_m, x_max + margin_m], + "y": [y_min - margin_m, y_max + margin_m], + "z": list(las_bounds.get("z", [0.0, 0.0])), + } + + +def sheet_reference_points_wgs84( + project_root: Path, + las_bounds: dict[str, list[float]], + target_epsg: str, +) -> list[tuple[float, float]]: + """도엽 선정 기준 좌표(위도, 경도) 목록. + + ① 계획노선 시점·종점 → ② 라이다 범위 중심(폴백). + 시점과 종점이 서로 다른 도엽에 걸치면 호출측이 두 도엽의 주변을 모두 확보한다. + """ + from pyproj import Transformer + + to_wgs84 = Transformer.from_crs(target_epsg, "EPSG:4326", always_xy=True) + + route = read_planned_route(project_root) + if route: + ends = [ + (float(route["start_point"][0]), float(route["start_point"][1])), + (float(route["end_point"][0]), float(route["end_point"][1])), + ] + try: + moved = _to_target_crs(ends, route.get("epsg"), target_epsg) + return [(lat, lon) for lon, lat in (to_wgs84.transform(x, y) for x, y in moved)] + except Exception as exc: + logger.warning("B04 계획노선 기준 좌표 산출 실패 — 라이다 중심 사용 (%s)", exc) + + center_x = (float(las_bounds["x"][0]) + float(las_bounds["x"][1])) / 2.0 + center_y = (float(las_bounds["y"][0]) + float(las_bounds["y"][1])) / 2.0 + lon, lat = to_wgs84.transform(center_x, center_y) + return [(lat, lon)] diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_MapSheet.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_MapSheet.py index e1b0c142..892b25b1 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine_MapSheet.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_MapSheet.py @@ -88,3 +88,20 @@ def neighbors_3x3(sheet_no: str) -> list[str]: latlon_to_sheet5k(lat_c + dr * SHEET5K_SIZE_DEG, lon_c + dc * SHEET5K_SIZE_DEG) ) return result + + +def neighbors_for_points(points: list[tuple[float, float]]) -> list[str]: + """기준 좌표들이 속한 도엽 + 각각의 주변 8매를 합친 목록(중복 제거, 순서 유지). + + 계획노선 시점·종점이 같은 도엽이면 9매, 이웃한 두 도엽에 걸치면 12매가 된다 + (3×3 두 벌이 한 줄을 공유하므로 3×4). 도엽 하나가 늘 때마다 병합 산출물도 늘어나므로 + 기준 좌표는 노선의 양 끝만 쓴다(2026-08-01 사용자 지시). + """ + ordered: list[str] = [] + seen: set[str] = set() + for lat, lon in points: + for sheet_no in neighbors_3x3(latlon_to_sheet5k(lat, lon)): + if sheet_no not in seen: + seen.add(sheet_no) + ordered.append(sheet_no) + return ordered diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_SheetStore.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_SheetStore.py index 8766e65d..f513143e 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine_SheetStore.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_SheetStore.py @@ -18,6 +18,7 @@ import base64 import datetime import http.cookiejar import json +import logging import re import shutil import tempfile @@ -36,6 +37,8 @@ from config.config_system import ( from .B04_wf1_Surface_Engine_MapSheet import latlon_to_sheet5k, sheet5k_to_bounds +logger = logging.getLogger(__name__) + # 도곽선 레이어 코드 (수치지형도 v2.0 도엽본) _SHEET_FRAME_CODE = "A0010000" _SHEET_NO_RE = re.compile(r"(\d{8})") @@ -227,6 +230,39 @@ def get_sheet_path(store_dir: str | Path, sheet_no: str) -> Path | None: return path if path.exists() else None +def prune_sheets(store_dir: str | Path, keep_sheet_nos: list[str]) -> list[str]: + """선정 도엽에 없는 zip을 지우고 지운 도엽번호를 돌려준다. + + 도엽 기준이 바뀌거나 다른 지역 파일이 섞여 들어오면 쓰지 않는 zip이 계속 쌓인다 + (표본 프로젝트에서 30매 중 21매가 다른 지역 잔재였다, 2026-08-01). + 병합에는 선정 도엽만 쓰이므로 산출물은 그대로다. + """ + store = Path(store_dir) + if not store.is_dir(): + return [] + keep = {str(sheet_no) for sheet_no in keep_sheet_nos} + index = _load_index(store) + removed: list[str] = [] + + for zip_path in sorted(store.glob("*.zip")): + match = _SHEET_NO_RE.fullmatch(zip_path.stem) + if not match or match.group(1) in keep: + continue + sheet_no = match.group(1) + try: + zip_path.unlink() + except OSError as exc: + logger.warning("도엽 정리: %s 삭제 실패 (%s)", zip_path.name, exc) + continue + index["sheets"].pop(sheet_no, None) + removed.append(sheet_no) + + if removed: + _save_index(store, index) + logger.info("도엽 정리: 미사용 %d매 삭제 (%s)", len(removed), ", ".join(removed)) + return removed + + def missing_sheets(store_dir: str | Path, sheet_nos: list[str]) -> list[str]: """요청 도엽 중 영구저장소에 없는 번호 목록.""" store = Path(store_dir) diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_SheetView.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_SheetView.py new file mode 100644 index 00000000..2db00f54 --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_SheetView.py @@ -0,0 +1,165 @@ +# B04_wf1_Surface_Engine_SheetView.py +# 도엽 병합본의 화면 표시용 사본 생성. +# +# 배경(2026-08-01 실측): 9매 병합 등고선(도엽_등고선.geojson)은 37.3MB다. 도엽 zip 원본은 +# 9매 합쳐 8.9MB지만, 등고선 레이어만 뽑아 비압축 텍스트로 펼치면서 커진다 +# (정점 815,471개 × 좌표 소수 14자리). 화면은 프로젝트 주변만 보므로 그만큼 보낼 이유가 없다. +# +# 분석용 원본은 건드리지 않는다 — 배수유역 계산은 상류 유역까지 필요해 넓은 범위를 쓴다. +# 여기서는 프로젝트 주변만 잘라 좌표 자릿수를 줄인 별도 사본을 만들어 전송에만 쓴다. + +from __future__ import annotations + +import json +import logging +import math +from pathlib import Path +from typing import Any + +from config.config_system import ( + SHEET_VIEW_COORD_DECIMALS, + SHEET_VIEW_MARGIN_M, + SHEET_VIEW_SUFFIX, +) + +logger = logging.getLogger(__name__) + +_METERS_PER_LAT_DEGREE = 111_320.0 + + +def view_path_for(source_path: Path) -> Path: + """원본 geojson 경로에 대응하는 표시용 사본 경로.""" + return source_path.with_name(f"{source_path.stem}{SHEET_VIEW_SUFFIX}{source_path.suffix}") + + +def _crop_box(processed_dir: Path) -> tuple[float, float, float, float] | None: + """표시 범위(경도/위도 최소·최대). VWorld 메타의 프로젝트 범위 + 여유폭.""" + meta_path = processed_dir / "vworld_white_meta.json" + if not meta_path.is_file(): + meta_path = processed_dir / "vworld_satellite_meta.json" + if not meta_path.is_file(): + return None + try: + meta = json.loads(meta_path.read_text(encoding="utf-8")) + lon_min = float(meta["lon_min"]) + lon_max = float(meta["lon_max"]) + lat_min = float(meta["lat_min"]) + lat_max = float(meta["lat_max"]) + except (OSError, ValueError, KeyError) as exc: + logger.warning("도엽 표시본: 메타 범위 읽기 실패 (%s)", exc) + return None + + lat_margin = SHEET_VIEW_MARGIN_M / _METERS_PER_LAT_DEGREE + center_lat_rad = math.radians((lat_min + lat_max) / 2.0) + lon_scale = max(math.cos(center_lat_rad), 0.1) + lon_margin = SHEET_VIEW_MARGIN_M / (_METERS_PER_LAT_DEGREE * lon_scale) + return ( + lon_min - lon_margin, + lat_min - lat_margin, + lon_max + lon_margin, + lat_max + lat_margin, + ) + + +def _round_coordinates(value: Any) -> Any: + """좌표 배열을 재귀적으로 훑어 소수 자릿수를 줄인다(고도값 포함). + + shapely는 좌표를 튜플로 돌려주므로 list/tuple 둘 다 받는다. + """ + if isinstance(value, (list, tuple)): + if value and isinstance(value[0], (int, float)): + return [round(float(number), SHEET_VIEW_COORD_DECIMALS) for number in value] + return [_round_coordinates(item) for item in value] + return value + + +def _feature_bbox(coordinates: Any) -> tuple[float, float, float, float] | None: + """지오메트리 좌표 전체를 훑어 bbox를 구한다.""" + lons: list[float] = [] + lats: list[float] = [] + + def walk(node: Any) -> None: + if isinstance(node, list): + if node and isinstance(node[0], (int, float)): + lons.append(float(node[0])) + lats.append(float(node[1])) + return + for item in node: + walk(item) + + walk(coordinates) + if not lons: + return None + return (min(lons), min(lats), max(lons), max(lats)) + + +def build_sheet_view(source_path: Path, processed_dir: Path) -> Path | None: + """도엽 병합본에서 표시용 사본을 만들어 경로를 반환한다. + + - 표시 범위(프로젝트 + 여유폭)와 겹치는 지물만 남긴다(경계에서 자르지 않고 통째로 유지). + - 좌표 소수 자릿수를 줄이고 공백 없는 JSON으로 기록한다. + - 이미 최신 사본이 있으면 그대로 돌려준다(원본보다 오래되면 다시 만든다). + - 표시 범위를 알 수 없으면 None — 호출측이 원본을 그대로 보내면 된다. + """ + view_path = view_path_for(source_path) + if view_path.is_file() and view_path.stat().st_mtime >= source_path.stat().st_mtime: + return view_path + + box = _crop_box(processed_dir) + if box is None: + return None + min_lon, min_lat, max_lon, max_lat = box + + try: + data = json.loads(source_path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + logger.warning("도엽 표시본: 원본 읽기 실패 %s (%s)", source_path.name, exc) + return None + + from shapely.geometry import box, mapping, shape + + crop_area = box(min_lon, min_lat, max_lon, max_lat) + kept: list[dict[str, Any]] = [] + for feature in data.get("features") or []: + geometry = feature.get("geometry") or {} + coordinates = geometry.get("coordinates") + if coordinates is None: + continue + bbox = _feature_bbox(coordinates) + if bbox is None: + continue + if bbox[0] > max_lon or bbox[2] < min_lon or bbox[1] > max_lat or bbox[3] < min_lat: + continue + # 등고선 한 가닥은 도엽 끝까지 이어진다. 겹친다고 통째로 남기면 화면 밖 구간까지 + # 보내게 되므로 표시 범위에서 잘라낸다(고도 등 속성은 그대로 유지). + try: + clipped = shape(geometry).intersection(crop_area) + except Exception: + continue + if clipped.is_empty: + continue + clipped_geometry = mapping(clipped) + feature["geometry"] = { + "type": clipped_geometry["type"], + "coordinates": _round_coordinates(clipped_geometry["coordinates"]), + } + kept.append(feature) + + payload = {"type": "FeatureCollection", "features": kept} + if data.get("crs"): + payload["crs"] = data["crs"] + + tmp_path = view_path.with_suffix(".tmp") + tmp_path.write_text( + json.dumps(payload, ensure_ascii=False, separators=(",", ":")), encoding="utf-8" + ) + tmp_path.replace(view_path) + logger.info( + "도엽 표시본 생성: %s %d건 → %s (%.1fMB → %.1fMB)", + source_path.name, + len(kept), + view_path.name, + source_path.stat().st_size / 1e6, + view_path.stat().st_size / 1e6, + ) + return view_path diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py b/B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py index 48b7b73e..60baa091 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py @@ -5,10 +5,12 @@ from pathlib import Path from typing import Any from uuid import UUID -from fastapi import APIRouter, HTTPException, Response -from fastapi.responses import FileResponse, JSONResponse +from fastapi import APIRouter, HTTPException, Request, Response +from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B04_wf1_Surface.B04_wf1_Surface_Engine_SheetView import build_sheet_view +from common_util.common_util_http_cache import cached_file_response from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool @@ -58,9 +60,9 @@ async def get_vworld_meta( # VWorld 맵 API @router.get("/{project_id}/vworld-map", response_model=None) async def get_vworld_map( - project_id: UUID, layer_name: str = "satellite" -) -> FileResponse | JSONResponse: - """배경 지도 레이어 PNG 이미지를 반환합니다.""" + project_id: UUID, request: Request, layer_name: str = "satellite" +) -> Response | JSONResponse: + """배경 지도 레이어 PNG 이미지를 반환합니다(ETag — 바뀌지 않았으면 304).""" pool = get_db_pool() try: async with pool.acquire() as connection: @@ -86,15 +88,21 @@ async def get_vworld_map( "message": f"VWorld {layer_name} 지도가 존재하지 않습니다.", }, ) - return FileResponse(map_path, media_type="image/png") + return cached_file_response(request, map_path, "image/png") except Exception as exc: return JSONResponse(status_code=500, content={"status": "error", "message": str(exc)}) # GeoJSON 조회 API @router.get("/{project_id}/geojson", response_model=None) -async def get_project_geojson(project_id: UUID, layer: str) -> dict[str, Any] | JSONResponse: - """저장된 프로젝트의 특정 GeoJSON 레이어 데이터를 반환합니다.""" +async def get_project_geojson( + project_id: UUID, layer: str, request: Request +) -> dict[str, Any] | Response | JSONResponse: + """저장된 프로젝트의 특정 GeoJSON 레이어 데이터를 반환합니다. + + 도엽 레이어는 분석용 원본 대신 **표시용 사본**(프로젝트 주변만 잘라 좌표를 줄인 것)을 + 파일 그대로 내보낸다. 원본을 매번 읽어 재직렬화하면 등고선 한 장에 2초가 든다. + """ pool = get_db_pool() try: async with pool.acquire() as connection: @@ -140,6 +148,14 @@ async def get_project_geojson(project_id: UUID, layer: str) -> dict[str, Any] | }, ) + if layer in _SHEET_GEOJSON_FILES: + view_path = await asyncio.to_thread(build_sheet_view, filepath, target_dir) + return cached_file_response( + request, + view_path or filepath, + "application/geo+json", + ) + if layer == "등고선": simplified_filepath = target_dir / "등고선_bounds_simplified.geojson" if simplified_filepath.exists(): diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index 30499e4e..4a1788a8 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -1,5 +1,6 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createProgressCircle } from "@ui/ui_template_progress"; +import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache"; import { fetchGisGeoJson, fetchVWorldMeta, @@ -385,7 +386,12 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { const loadedLayers = await Promise.all( GIS_LAYERS.map(async (layer) => { try { - const data = (await fetchGisGeoJson(projectId, layer)) as GeoJsonCollection; + // 도엽 레이어는 표시용 사본이라 보관함에 담아 두고 새로고침 때 그대로 쓴다. + const data = ( + layer.startsWith("도엽_") + ? await fetchCachedSheetLayer(projectId, layer) + : await fetchGisGeoJson(projectId, layer) + ) as GeoJsonCollection; return [layer, data] as const; } catch { return [layer, null] as const; @@ -408,7 +414,8 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { preparedLayers.set(layer, prepareLayer(data, normalizer!, CONTOUR_LABEL_KEYS[layer])); }); BACKGROUND_LAYERS.forEach((layer) => { - backgroundImages.get(layer)!.src = `${getVWorldMapUrl(projectId, layer)}&_t=${Date.now()}`; + // 주소에 시각을 붙이면 브라우저가 매번 다시 받는다. 서버가 ETag를 주므로 그대로 쓴다. + backgroundImages.get(layer)!.src = getVWorldMapUrl(projectId, layer); }); status.textContent = L("B04_Surface_Map_Features").replace( "{count}", diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts index 226e7682..bdaf06b6 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -1,6 +1,6 @@ import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; +import { DRAINAGE_SHEET_LAYERS, fetchCachedSheetLayer } from "../A00_Common/b_asset_cache"; import { - fetchGisGeoJson, fetchVWorldMeta, getVWorldMapUrl, type VWorldMeta, @@ -40,7 +40,7 @@ import { createProgressCircle } from "@ui/ui_template_progress"; /** 배수유역 산정의 근거가 되는 도엽 레이어. 3D는 쓰지 않는다(사용자 지시). * 표고점은 유효 데이터가 적어 산정에서 제외했으므로 배경에도 띄우지 않는다(2026-07-31). */ -const DRAINAGE_LAYERS = ["도엽_등고선", "도엽_하천중심선"] as const; +const DRAINAGE_LAYERS = DRAINAGE_SHEET_LAYERS; type DrainageLayer = (typeof DRAINAGE_LAYERS)[number]; const LAYER_COLORS: Record = { @@ -522,7 +522,8 @@ export function createDrainagePanel(): DrainagePanel { const loaded = await Promise.all( DRAINAGE_LAYERS.map(async (layer) => { try { - const data = (await fetchGisGeoJson(activeProjectId, layer)) as GeoJsonCollection; + // 도엽 레이어는 표시용 사본이라 보관함에 담아 두고 새로고침 때 그대로 쓴다. + const data = await fetchCachedSheetLayer(activeProjectId, layer); return [layer, data] as const; } catch { return [layer, null] as const; @@ -538,7 +539,8 @@ export function createDrainagePanel(): DrainagePanel { featureCount += data.features?.length ?? 0; preparedLayers.set(layer, prepareLayer(data, normalizer!)); }); - backgroundImage.src = `${getVWorldMapUrl(activeProjectId, "satellite")}&_t=${Date.now()}`; + // 주소에 시각을 붙이면 브라우저가 매번 다시 받는다. 서버가 ETag를 주므로 그대로 쓴다. + backgroundImage.src = getVWorldMapUrl(activeProjectId, "satellite"); if (routePoints.length > 1) routeLayer = prepareMetricPolyline(routePoints, nextMeta); pipeEditor.setContext(nextMeta, routePoints); status.hidden = featureCount > 0; diff --git a/config/config_system.py b/config/config_system.py index acba027c..d94fc375 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -516,6 +516,20 @@ STORAGE_BASE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "sto MAP_SHEETS_DIRNAME = "map_sheets" MAP_SHEETS_INDEX_FILENAME = "map_sheets_index.json" +# 도엽 병합본(도엽_*.geojson)의 화면 표시용 사본 — 분석용 원본은 그대로 두고, +# 브라우저로 보낼 때만 프로젝트 주변으로 잘라 좌표 자릿수를 줄인 사본을 쓴다. +# (9매 병합 등고선 원본 37MB → 표시용 1MB 안팎, 2026-08-01) +SHEET_VIEW_SUFFIX = "_view" +SHEET_VIEW_MARGIN_M = 500.0 +# 소수 7자리 ≈ 1cm — 1:5,000 도엽 표시 정밀도에 충분하다. +SHEET_VIEW_COORD_DECIMALS = 7 + +# 배경 지도(위성·하이브리드·백지도) 내려받을 범위의 여유폭. +# 라이다 범위와 계획노선을 합친 뒤 이만큼 넓혀서 받는다 — 계획노선이 라이다 범위를 벗어나도 +# 배경이 잘리지 않게 한다. 브이월드 위성사진은 더 높은 해상도를 주지 않으므로 도엽만큼 +# 넓게 받을 필요는 없다(2026-08-01 사용자 지시). +SURFACE_MAP_MARGIN_M = 300.0 + # 브이월드 지도서비스 도엽 자동 다운로드 — 세션 만료 시 id/pw로 자동 재로그인 (.env) VWORLD_LOGIN_ID = os.getenv("VWORLD_LOGIN_ID", "") VWORLD_LOGIN_PW = os.getenv("VWORLD_LOGIN_PW", "") From 07a2cf1393f46445e131eafa56b7af062892a9f8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 1 Aug 2026 11:54:07 +0900 Subject: [PATCH 53/61] =?UTF-8?q?fix(B04):=20=EB=B0=B0=EC=88=98=EC=9C=A0?= =?UTF-8?q?=EC=97=AD=20=EC=9E=AC=EC=82=B0=EC=A0=95=20=EB=B2=84=ED=8A=BC?= =?UTF-8?q?=EC=9D=B4=20=ED=91=9C=EC=8B=9C=20=ED=86=A0=EA=B8=80=EB=A1=9C=20?= =?UTF-8?q?=EA=B1=B8=EB=A0=A4=20=EB=88=8C=EB=9F=AC=EB=8F=84=20=EB=B0=98?= =?UTF-8?q?=EC=9D=91=20=EC=97=86=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 저장분을 자동 표시하도록 바꾼 뒤로 진입 시 shown=true가 되어, 재산정 버튼을 누르면 분석 요청 없이 오버레이만 숨겨졌다(사용자 눈에는 무응답). 버튼은 언제나 재산정을 실행하도록 하고, 보이기/숨기기는 갈래별 버튼(1차/2차/유역방향/평균흐름)이 맡는다. Co-Authored-By: Claude Opus 5 (1M context) --- B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts index fe074a60..888bf201 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts @@ -484,16 +484,10 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { } } - // 재산정 버튼: 켜져 있으면 끄고, 꺼져 있으면 처음부터 다시 분석한다. + // 재산정 버튼은 언제나 처음부터 다시 분석한다. + // (저장분을 자동으로 띄우게 바꾼 뒤로 이 버튼이 표시 토글로 먼저 걸려, 눌러도 아무 일이 + // 없는 것처럼 보였다 — 2026-08-01. 보이기/숨기기는 갈래별 버튼이 맡는다.) button.addEventListener("click", () => { - if (shown) { - shown = false; - button.classList.remove("is-active"); - button.setAttribute("aria-pressed", "false"); - say(""); - onChange(); - return; - } void loadAnalysis(true); }); From d033e588801c4f37e6f4bc8abf172e3799bdde83 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 1 Aug 2026 11:57:12 +0900 Subject: [PATCH 54/61] =?UTF-8?q?revert(B04):=20=EB=8F=84=EC=97=BD=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=EC=9A=A9=20=EC=82=AC=EB=B3=B8=20=EC=B2=A0?= =?UTF-8?q?=ED=9A=8C=20=E2=80=94=20=EB=8F=84=EC=97=BD=20=EC=82=B0=EC=B6=9C?= =?UTF-8?q?=EB=AC=BC=EC=9D=84=20=EC=9B=90=EB=B3=B8=20=EA=B7=B8=EB=8C=80?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=84=EC=86=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배수유역이 어디까지 뻗을지 알 수 없어 도엽 자료를 잘라 두 벌로 나누지 않는다 (2026-08-01 사용자 지시). 분석 경로는 원래부터 원본을 읽었고 사본은 전송용이었으나, 사본 자체를 없앤다. - B04_wf1_Surface_Engine_SheetView.py 삭제, SHEET_VIEW_* 설정 제거 - /geojson의 도엽 레이어는 원본 파일 그대로 + ETag로 전송 (재직렬화 제거만으로 요청당 2.0초 -> 0.006초, 두 번째 요청부터 304) Co-Authored-By: Claude Opus 5 (1M context) --- .../B04_wf1_Surface_Engine_SheetView.py | 165 ------------------ B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py | 10 +- config/config_system.py | 8 - 3 files changed, 3 insertions(+), 180 deletions(-) delete mode 100644 B04_wf1_Surface/B04_wf1_Surface_Engine_SheetView.py diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_SheetView.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_SheetView.py deleted file mode 100644 index 2db00f54..00000000 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine_SheetView.py +++ /dev/null @@ -1,165 +0,0 @@ -# B04_wf1_Surface_Engine_SheetView.py -# 도엽 병합본의 화면 표시용 사본 생성. -# -# 배경(2026-08-01 실측): 9매 병합 등고선(도엽_등고선.geojson)은 37.3MB다. 도엽 zip 원본은 -# 9매 합쳐 8.9MB지만, 등고선 레이어만 뽑아 비압축 텍스트로 펼치면서 커진다 -# (정점 815,471개 × 좌표 소수 14자리). 화면은 프로젝트 주변만 보므로 그만큼 보낼 이유가 없다. -# -# 분석용 원본은 건드리지 않는다 — 배수유역 계산은 상류 유역까지 필요해 넓은 범위를 쓴다. -# 여기서는 프로젝트 주변만 잘라 좌표 자릿수를 줄인 별도 사본을 만들어 전송에만 쓴다. - -from __future__ import annotations - -import json -import logging -import math -from pathlib import Path -from typing import Any - -from config.config_system import ( - SHEET_VIEW_COORD_DECIMALS, - SHEET_VIEW_MARGIN_M, - SHEET_VIEW_SUFFIX, -) - -logger = logging.getLogger(__name__) - -_METERS_PER_LAT_DEGREE = 111_320.0 - - -def view_path_for(source_path: Path) -> Path: - """원본 geojson 경로에 대응하는 표시용 사본 경로.""" - return source_path.with_name(f"{source_path.stem}{SHEET_VIEW_SUFFIX}{source_path.suffix}") - - -def _crop_box(processed_dir: Path) -> tuple[float, float, float, float] | None: - """표시 범위(경도/위도 최소·최대). VWorld 메타의 프로젝트 범위 + 여유폭.""" - meta_path = processed_dir / "vworld_white_meta.json" - if not meta_path.is_file(): - meta_path = processed_dir / "vworld_satellite_meta.json" - if not meta_path.is_file(): - return None - try: - meta = json.loads(meta_path.read_text(encoding="utf-8")) - lon_min = float(meta["lon_min"]) - lon_max = float(meta["lon_max"]) - lat_min = float(meta["lat_min"]) - lat_max = float(meta["lat_max"]) - except (OSError, ValueError, KeyError) as exc: - logger.warning("도엽 표시본: 메타 범위 읽기 실패 (%s)", exc) - return None - - lat_margin = SHEET_VIEW_MARGIN_M / _METERS_PER_LAT_DEGREE - center_lat_rad = math.radians((lat_min + lat_max) / 2.0) - lon_scale = max(math.cos(center_lat_rad), 0.1) - lon_margin = SHEET_VIEW_MARGIN_M / (_METERS_PER_LAT_DEGREE * lon_scale) - return ( - lon_min - lon_margin, - lat_min - lat_margin, - lon_max + lon_margin, - lat_max + lat_margin, - ) - - -def _round_coordinates(value: Any) -> Any: - """좌표 배열을 재귀적으로 훑어 소수 자릿수를 줄인다(고도값 포함). - - shapely는 좌표를 튜플로 돌려주므로 list/tuple 둘 다 받는다. - """ - if isinstance(value, (list, tuple)): - if value and isinstance(value[0], (int, float)): - return [round(float(number), SHEET_VIEW_COORD_DECIMALS) for number in value] - return [_round_coordinates(item) for item in value] - return value - - -def _feature_bbox(coordinates: Any) -> tuple[float, float, float, float] | None: - """지오메트리 좌표 전체를 훑어 bbox를 구한다.""" - lons: list[float] = [] - lats: list[float] = [] - - def walk(node: Any) -> None: - if isinstance(node, list): - if node and isinstance(node[0], (int, float)): - lons.append(float(node[0])) - lats.append(float(node[1])) - return - for item in node: - walk(item) - - walk(coordinates) - if not lons: - return None - return (min(lons), min(lats), max(lons), max(lats)) - - -def build_sheet_view(source_path: Path, processed_dir: Path) -> Path | None: - """도엽 병합본에서 표시용 사본을 만들어 경로를 반환한다. - - - 표시 범위(프로젝트 + 여유폭)와 겹치는 지물만 남긴다(경계에서 자르지 않고 통째로 유지). - - 좌표 소수 자릿수를 줄이고 공백 없는 JSON으로 기록한다. - - 이미 최신 사본이 있으면 그대로 돌려준다(원본보다 오래되면 다시 만든다). - - 표시 범위를 알 수 없으면 None — 호출측이 원본을 그대로 보내면 된다. - """ - view_path = view_path_for(source_path) - if view_path.is_file() and view_path.stat().st_mtime >= source_path.stat().st_mtime: - return view_path - - box = _crop_box(processed_dir) - if box is None: - return None - min_lon, min_lat, max_lon, max_lat = box - - try: - data = json.loads(source_path.read_text(encoding="utf-8")) - except (OSError, ValueError) as exc: - logger.warning("도엽 표시본: 원본 읽기 실패 %s (%s)", source_path.name, exc) - return None - - from shapely.geometry import box, mapping, shape - - crop_area = box(min_lon, min_lat, max_lon, max_lat) - kept: list[dict[str, Any]] = [] - for feature in data.get("features") or []: - geometry = feature.get("geometry") or {} - coordinates = geometry.get("coordinates") - if coordinates is None: - continue - bbox = _feature_bbox(coordinates) - if bbox is None: - continue - if bbox[0] > max_lon or bbox[2] < min_lon or bbox[1] > max_lat or bbox[3] < min_lat: - continue - # 등고선 한 가닥은 도엽 끝까지 이어진다. 겹친다고 통째로 남기면 화면 밖 구간까지 - # 보내게 되므로 표시 범위에서 잘라낸다(고도 등 속성은 그대로 유지). - try: - clipped = shape(geometry).intersection(crop_area) - except Exception: - continue - if clipped.is_empty: - continue - clipped_geometry = mapping(clipped) - feature["geometry"] = { - "type": clipped_geometry["type"], - "coordinates": _round_coordinates(clipped_geometry["coordinates"]), - } - kept.append(feature) - - payload = {"type": "FeatureCollection", "features": kept} - if data.get("crs"): - payload["crs"] = data["crs"] - - tmp_path = view_path.with_suffix(".tmp") - tmp_path.write_text( - json.dumps(payload, ensure_ascii=False, separators=(",", ":")), encoding="utf-8" - ) - tmp_path.replace(view_path) - logger.info( - "도엽 표시본 생성: %s %d건 → %s (%.1fMB → %.1fMB)", - source_path.name, - len(kept), - view_path.name, - source_path.stat().st_size / 1e6, - view_path.stat().st_size / 1e6, - ) - return view_path diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py b/B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py index 60baa091..58b70861 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py @@ -9,7 +9,6 @@ from fastapi import APIRouter, HTTPException, Request, Response from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path -from B04_wf1_Surface.B04_wf1_Surface_Engine_SheetView import build_sheet_view from common_util.common_util_http_cache import cached_file_response from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool @@ -149,12 +148,9 @@ async def get_project_geojson( ) if layer in _SHEET_GEOJSON_FILES: - view_path = await asyncio.to_thread(build_sheet_view, filepath, target_dir) - return cached_file_response( - request, - view_path or filepath, - "application/geo+json", - ) + # 도엽 산출물은 자르거나 줄이지 않고 파일 그대로 보낸다(2026-08-01 사용자 지시). + # 재직렬화만 건너뛰어도 요청당 2초가 사라지고, ETag로 두 번째부터는 304가 된다. + return cached_file_response(request, filepath, "application/geo+json") if layer == "등고선": simplified_filepath = target_dir / "등고선_bounds_simplified.geojson" diff --git a/config/config_system.py b/config/config_system.py index d94fc375..c3839ef2 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -516,14 +516,6 @@ STORAGE_BASE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "sto MAP_SHEETS_DIRNAME = "map_sheets" MAP_SHEETS_INDEX_FILENAME = "map_sheets_index.json" -# 도엽 병합본(도엽_*.geojson)의 화면 표시용 사본 — 분석용 원본은 그대로 두고, -# 브라우저로 보낼 때만 프로젝트 주변으로 잘라 좌표 자릿수를 줄인 사본을 쓴다. -# (9매 병합 등고선 원본 37MB → 표시용 1MB 안팎, 2026-08-01) -SHEET_VIEW_SUFFIX = "_view" -SHEET_VIEW_MARGIN_M = 500.0 -# 소수 7자리 ≈ 1cm — 1:5,000 도엽 표시 정밀도에 충분하다. -SHEET_VIEW_COORD_DECIMALS = 7 - # 배경 지도(위성·하이브리드·백지도) 내려받을 범위의 여유폭. # 라이다 범위와 계획노선을 합친 뒤 이만큼 넓혀서 받는다 — 계획노선이 라이다 범위를 벗어나도 # 배경이 잘리지 않게 한다. 브이월드 위성사진은 더 높은 해상도를 주지 않으므로 도엽만큼 From 5073916739111bfacb92eacc123f9306365b6029 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 1 Aug 2026 12:06:23 +0900 Subject: [PATCH 55/61] =?UTF-8?q?fix(cache):=20=EB=B8=8C=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=EC=A0=80=20=EB=B3=B4=EA=B4=80=ED=95=A8=20=EB=B2=84=EC=A0=84?= =?UTF-8?q?=EC=9D=84=20=EC=98=AC=EB=A0=A4=20=EC=B2=A0=ED=9A=8C=EB=90=9C=20?= =?UTF-8?q?=EB=8F=84=EC=97=BD=20=ED=91=9C=EC=8B=9C=EB=B3=B8=EC=9D=84=20?= =?UTF-8?q?=EB=B9=84=EC=9A=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 도엽 표시용 사본을 철회했지만, 이미 그 사본을 받아 둔 브라우저는 계속 그것을 쓴다 (보관함 키가 주소 기준이라 서버 파일을 지워도 남는다). DB_VERSION 1 -> 2로 올리고 업그레이드 시 기존 저장소를 버리도록 해 다음 접속에서 원본을 새로 받게 한다. Co-Authored-By: Claude Opus 5 (1M context) --- A00_Common/b_asset_cache.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/A00_Common/b_asset_cache.ts b/A00_Common/b_asset_cache.ts index 487e6bce..2b9cc584 100644 --- a/A00_Common/b_asset_cache.ts +++ b/A00_Common/b_asset_cache.ts @@ -13,7 +13,9 @@ import { API_BASE_URL } from "@config/config_frontend"; import { fetchConfirmedSurface } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch"; const DB_NAME = "aislo-asset-cache"; -const DB_VERSION = 1; +// 담아 둔 자료의 형식이나 내용이 바뀌면 이 번호를 올린다 — 올리면 기존 보관분을 통째로 버린다. +// v2: 도엽 표시용 사본(잘라낸 자료)을 철회했다. 그 사본을 담고 있던 브라우저는 비워야 한다. +const DB_VERSION = 2; const STORE = "assets"; export interface CachedAsset { @@ -38,10 +40,10 @@ function openDatabase(): Promise { const request = window.indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded = () => { const db = request.result; - if (!db.objectStoreNames.contains(STORE)) { - const store = db.createObjectStore(STORE, { keyPath: "key" }); - store.createIndex("projectId", "projectId", { unique: false }); - } + // 번호가 올라가면 옛 보관분은 형식이나 내용이 다를 수 있으므로 통째로 버리고 새로 만든다. + if (db.objectStoreNames.contains(STORE)) db.deleteObjectStore(STORE); + const store = db.createObjectStore(STORE, { keyPath: "key" }); + store.createIndex("projectId", "projectId", { unique: false }); }; request.onsuccess = () => resolve(request.result); // 사생활 보호 모드 등으로 열리지 않으면 보관함 없이 동작한다(항상 새로 받는다). From d3b443e0770c021dec9f7872a16da5e03fdc4a32 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 1 Aug 2026 12:11:39 +0900 Subject: [PATCH 56/61] =?UTF-8?q?feat(B04):=20=EB=B0=B0=EA=B2=BD=20?= =?UTF-8?q?=EC=A7=80=EB=8F=84=20=EC=97=AC=EC=9C=A0=ED=8F=AD=EC=9D=84=20?= =?UTF-8?q?=EB=AF=B8=ED=84=B0=EA=B0=80=20=EC=95=84=EB=8B=8C=20=EC=A3=BC?= =?UTF-8?q?=EB=B3=80=20=EC=85=80=20=EA=B2=B9=EC=88=98=EB=A1=9C=20=EC=A7=80?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 요청 방향(2026-08-01): 해상도(zoom 18)는 그대로 두고, 계획노선과 기준 좌표를 덮는 타일 바깥으로 주변 셀을 더 확보한다. - SURFACE_MAP_MARGIN_M(300m) 제거, SURFACE_MAP_MARGIN_TILES(3겹) 신설. 기존 하드코딩 1겹 -> 설정값. zoom 18에서 1겹 약 122m(위도 37deg). - download_extent는 덮어야 할 범위(라이다 U 계획노선)만 돌려주고 여유는 타일 쪽에서 준다. - 표본 실측: 덮을 범위 372x401m -> 10x11 타일 = 1220x1341m, 계획노선 사방 여유 474~588m (이전 731x853m). Co-Authored-By: Claude Opus 5 (1M context) --- B04_wf1_Surface/B04_wf1_Surface_Engine_Extent.py | 12 ++++++------ B04_wf1_Surface/B04_wf1_Surface_Engine_VWorld.py | 13 ++++++++----- config/config_system.py | 10 +++++----- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_Extent.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Extent.py index 8a5b768b..a35a20b3 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine_Extent.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Extent.py @@ -11,8 +11,6 @@ import logging from pathlib import Path from typing import Any -from config.config_system import SURFACE_MAP_MARGIN_M - logger = logging.getLogger(__name__) _ROUTE_CSV_GLOB = "B03_FileInput/input/csv/*.csv" @@ -49,9 +47,11 @@ def download_extent( project_root: Path, las_bounds: dict[str, list[float]], target_epsg: str, - margin_m: float = SURFACE_MAP_MARGIN_M, ) -> dict[str, list[float]]: - """배경 지도를 내려받을 범위. 라이다 범위와 계획노선을 합친 뒤 여유폭만큼 넓힌다. + """배경 지도가 반드시 덮어야 할 범위 = 라이다 범위 ∪ 계획노선 범위. + + 여유폭은 여기서 미터로 더하지 않는다. 내려받기 쪽에서 이 범위를 덮는 타일을 정한 뒤 + 바깥으로 `SURFACE_MAP_MARGIN_TILES` 겹만큼 주변 셀을 더 받는다(2026-08-01 사용자 지시). las_bounds/반환값 모두 {"x": [최소, 최대], "y": [...], "z": [...]} 꼴(라이다 좌표계). """ @@ -75,8 +75,8 @@ def download_extent( y_min, y_max = min(y_min, y), max(y_max, y) return { - "x": [x_min - margin_m, x_max + margin_m], - "y": [y_min - margin_m, y_max + margin_m], + "x": [x_min, x_max], + "y": [y_min, y_max], "z": list(las_bounds.get("z", [0.0, 0.0])), } diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_VWorld.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_VWorld.py index bfba30f6..d4fad938 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine_VWorld.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_VWorld.py @@ -19,8 +19,10 @@ try: VWORLD_API_KEY = getattr( config_system, "VWORLD_API_KEY", "3DBD7306-7DBD-38BB-B292-267C5ED7AC6B" ) + SURFACE_MAP_MARGIN_TILES = getattr(config_system, "SURFACE_MAP_MARGIN_TILES", 3) except ImportError: VWORLD_API_KEY = "3DBD7306-7DBD-38BB-B292-267C5ED7AC6B" + SURFACE_MAP_MARGIN_TILES = 3 def get_epsg_from_prj(prj_content: str) -> str: @@ -103,11 +105,12 @@ def download_vworld_satellite_map( x1, y1 = latlon_to_tile(lat_max, lon_min, zoom) x2, y2 = latlon_to_tile(lat_min, lon_max, zoom) - # 타일 경계 마진 패딩 - x_start = min(x1, x2) - 1 - x_end = max(x1, x2) + 1 - y_start = min(y1, y2) - 1 - y_end = max(y1, y2) + 1 + # 범위를 덮는 타일 바깥으로 주변 셀을 더 받는다(해상도는 그대로, 셀만 확장). + margin = SURFACE_MAP_MARGIN_TILES + x_start = min(x1, x2) - margin + x_end = max(x1, x2) + margin + y_start = min(y1, y2) - margin + y_end = max(y1, y2) + margin tile_w = x_end - x_start + 1 tile_h = y_end - y_start + 1 diff --git a/config/config_system.py b/config/config_system.py index c3839ef2..456f314f 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -516,11 +516,11 @@ STORAGE_BASE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "sto MAP_SHEETS_DIRNAME = "map_sheets" MAP_SHEETS_INDEX_FILENAME = "map_sheets_index.json" -# 배경 지도(위성·하이브리드·백지도) 내려받을 범위의 여유폭. -# 라이다 범위와 계획노선을 합친 뒤 이만큼 넓혀서 받는다 — 계획노선이 라이다 범위를 벗어나도 -# 배경이 잘리지 않게 한다. 브이월드 위성사진은 더 높은 해상도를 주지 않으므로 도엽만큼 -# 넓게 받을 필요는 없다(2026-08-01 사용자 지시). -SURFACE_MAP_MARGIN_M = 300.0 +# 배경 지도(위성·하이브리드·백지도)를 내려받을 때 덧붙일 주변 셀(타일) 겹 수. +# 계획노선과 기준 좌표(라이다 범위)를 덮는 타일을 먼저 정하고, 그 바깥으로 이만큼 더 받는다. +# 해상도(zoom)는 그대로 두고 주변 셀만 늘린다 — 브이월드가 더 높은 해상도를 주지 않는다 +# (2026-08-01 사용자 지시). zoom 18에서 셀 1겹 ≈ 122m(위도 37° 기준), 3겹 ≈ 366m. +SURFACE_MAP_MARGIN_TILES = 3 # 브이월드 지도서비스 도엽 자동 다운로드 — 세션 만료 시 id/pw로 자동 재로그인 (.env) VWORLD_LOGIN_ID = os.getenv("VWORLD_LOGIN_ID", "") From 6af50811b60d58d1333fcd8fa053527d89441ea3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 1 Aug 2026 12:18:31 +0900 Subject: [PATCH 57/61] =?UTF-8?q?fix(B04):=20=EB=B0=B0=EA=B2=BD=20?= =?UTF-8?q?=EC=A7=80=EB=8F=84=20=EC=9E=AC=EB=8B=A4=EC=9A=B4=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=ED=8C=90=EC=A0=95=EC=9D=84=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=A1=B4=EC=9E=AC=EC=97=90=EC=84=9C=20=EB=B2=94=EC=9C=84=20?= =?UTF-8?q?=ED=8F=AC=ED=95=A8=20=EC=97=AC=EB=B6=80=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존에는 vworld_*_meta.json이 있으면 무조건 건너뛰었다. B03 업로드가 부르는 전처리 경로는 rebuild=False라, 계획노선이 바뀌거나 여유 셀 설정을 바꿔도 옛 사진을 계속 썼다 (2026-08-01 사용자 지적: 트리거 시점 문제). map_meta_covers()로 저장된 사진이 필요한 범위(라이다 U 계획노선)를 실제로 덮는지 확인하고, 못 덮으면 다시 받는다. Co-Authored-By: Claude Opus 5 (1M context) --- B04_wf1_Surface/B04_wf1_Surface_Engine.py | 9 ++++++-- .../B04_wf1_Surface_Engine_Extent.py | 21 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine.py b/B04_wf1_Surface/B04_wf1_Surface_Engine.py index 2626e4ec..0139e66d 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine.py @@ -232,7 +232,10 @@ def run_surface_analysis( ) prj_path = prj_candidates[0] if prj_candidates else project_root / "result.prj" - from B04_wf1_Surface.B04_wf1_Surface_Engine_Extent import download_extent + from B04_wf1_Surface.B04_wf1_Surface_Engine_Extent import ( + download_extent, + map_meta_covers, + ) from B04_wf1_Surface.B04_wf1_Surface_Engine_GisVector import download_all_gis_vectors from B04_wf1_Surface.B04_wf1_Surface_Engine_VWorld import ( download_vworld_satellite_map, @@ -259,7 +262,9 @@ def run_surface_analysis( ] for item in layers: meta_path = processed_dir / f"vworld_{item['layer'].lower()}_meta.json" - if not rebuild and meta_path.is_file(): + # 파일이 있어도 계획노선·여유 셀이 바뀌어 범위를 못 덮으면 다시 받는다. + # (B03 업로드가 부르는 경로는 rebuild=False라, 존재 여부만 보면 영영 갱신되지 않는다.) + if not rebuild and map_meta_covers(meta_path, bounds_dict_for_download): continue try: step_started = time.monotonic() diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_Extent.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Extent.py index a35a20b3..de370354 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine_Extent.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Extent.py @@ -7,6 +7,7 @@ from __future__ import annotations +import json import logging from pathlib import Path from typing import Any @@ -81,6 +82,26 @@ def download_extent( } +def map_meta_covers(meta_path: Path, extent: dict[str, list[float]]) -> bool: + """저장된 배경 지도가 필요한 범위를 이미 덮고 있는가. + + 파일 존재 여부만 보면 계획노선이 바뀌거나 여유 셀 설정을 바꿔도 옛 사진을 계속 쓴다 + (B03 업로드 → 전처리 경로는 `rebuild=False`라 더더욱 다시 받지 않는다, 2026-08-01). + """ + if not meta_path.is_file(): + return False + try: + meta = json.loads(meta_path.read_text(encoding="utf-8")) + return ( + float(meta["x_min"]) <= extent["x"][0] + and float(meta["x_max"]) >= extent["x"][1] + and float(meta["y_min"]) <= extent["y"][0] + and float(meta["y_max"]) >= extent["y"][1] + ) + except (OSError, ValueError, KeyError, TypeError): + return False + + def sheet_reference_points_wgs84( project_root: Path, las_bounds: dict[str, list[float]], From a17b95f6210d212697fa127eb65f3c91569d5bb5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 1 Aug 2026 12:35:51 +0900 Subject: [PATCH 58/61] =?UTF-8?q?fix(B04/B05):=202D=20=EC=A7=80=EB=8F=84?= =?UTF-8?q?=20=EA=B8=B0=EC=A4=80=EC=9D=84=20=EB=9D=BC=EC=9D=B4=EB=8B=A4?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B8=8C=EC=9D=B4=EC=9B=94=EB=93=9C=20?= =?UTF-8?q?=EB=B2=94=EC=9C=84=EB=A1=9C=20+=20=EB=B0=B0=EA=B2=BD=20?= =?UTF-8?q?=ED=99=95=EB=B3=B4=EB=A5=BC=20=EA=B8=B0=EC=A4=80=20=EB=B0=95?= =?UTF-8?q?=EC=8A=A4=203x3=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3D(라이다)와 2D(브이월드)는 다루는 범위가 다르다. 그런데 2D 지도가 열릴 때 라이다 범위에 맞춰 확대하고 있어서, 배경을 넓게 받아도 화면에 보이는 범위가 늘 같았다 (2026-08-01 사용자 지적). - 확보 범위: 계획노선·기준 좌표를 덮는 기준 박스 + 주변 박스(SURFACE_MAP_NEIGHBOR_RINGS=1) = 3x3 배치. 미터/타일 여유폭 방식(SURFACE_MAP_MARGIN_TILES) 폐기. 해상도(zoom 18)는 브이월드 기본 스케일 그대로 — 화소를 키우는 것이 아니다. - 타일 상한 15는 양쪽에서 균등하게 줄여 기준 박스가 가장자리로 밀리지 않게 한다. - B04 하단 지도·B05 배수유역도는 확보한 배경 전체가 보이도록 열린다. - 표본 실측: 기준 박스 372x401m -> 12x15 타일 = 1463x1828m (이전 731x853m). Co-Authored-By: Claude Opus 5 (1M context) --- .../B04_wf1_Surface_Engine_VWorld.py | 40 +++++++++++-------- .../B04_wf1_Surface_UI_MapViewer.ts | 36 ++++++----------- .../B05_wf2_Route_UI_Drainage_Panel.ts | 29 ++------------ config/config_system.py | 11 ++--- 4 files changed, 47 insertions(+), 69 deletions(-) diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_VWorld.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_VWorld.py index d4fad938..23f98c64 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine_VWorld.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_VWorld.py @@ -19,10 +19,10 @@ try: VWORLD_API_KEY = getattr( config_system, "VWORLD_API_KEY", "3DBD7306-7DBD-38BB-B292-267C5ED7AC6B" ) - SURFACE_MAP_MARGIN_TILES = getattr(config_system, "SURFACE_MAP_MARGIN_TILES", 3) + SURFACE_MAP_NEIGHBOR_RINGS = getattr(config_system, "SURFACE_MAP_NEIGHBOR_RINGS", 1) except ImportError: VWORLD_API_KEY = "3DBD7306-7DBD-38BB-B292-267C5ED7AC6B" - SURFACE_MAP_MARGIN_TILES = 3 + SURFACE_MAP_NEIGHBOR_RINGS = 1 def get_epsg_from_prj(prj_content: str) -> str: @@ -105,24 +105,32 @@ def download_vworld_satellite_map( x1, y1 = latlon_to_tile(lat_max, lon_min, zoom) x2, y2 = latlon_to_tile(lat_min, lon_max, zoom) - # 범위를 덮는 타일 바깥으로 주변 셀을 더 받는다(해상도는 그대로, 셀만 확장). - margin = SURFACE_MAP_MARGIN_TILES - x_start = min(x1, x2) - margin - x_end = max(x1, x2) + margin - y_start = min(y1, y2) - margin - y_end = max(y1, y2) + margin + # 기준 박스 = 계획노선·기준 좌표를 덮는 한 장. 그 주위로 같은 크기의 박스를 더 받는다. + # rings=1이면 주변 8장이 붙어 3×3 배치가 된다(2026-08-01 사용자 지시). + base_x_start, base_x_end = min(x1, x2), max(x1, x2) + base_y_start, base_y_end = min(y1, y2), max(y1, y2) + box_w = base_x_end - base_x_start + 1 + box_h = base_y_end - base_y_start + 1 + rings = SURFACE_MAP_NEIGHBOR_RINGS + x_start = base_x_start - box_w * rings + x_end = base_x_end + box_w * rings + y_start = base_y_start - box_h * rings + y_end = base_y_end + box_h * rings + # 너무 많은 타일을 내려받아 IP가 막히는 것을 막는다. 한쪽만 자르면 기준 박스가 화면 + # 가장자리로 밀리므로 양쪽에서 균등하게 줄인다. + def _clip(start: int, end: int, limit: int = 15) -> tuple[int, int]: + count = end - start + 1 + if count <= limit: + return start, end + over = count - limit + return start + over // 2, end - (over - over // 2) + + x_start, x_end = _clip(x_start, x_end) + y_start, y_end = _clip(y_start, y_end) tile_w = x_end - x_start + 1 tile_h = y_end - y_start + 1 - # 너무 많은 타일을 다운로드하여 IP 차단되는 것을 방지 - if tile_w > 15: - tile_w = 15 - x_end = x_start + 14 - if tile_h > 15: - tile_h = 15 - y_end = y_start + 14 - # 4. 개별 타일 다운로드 및 이미지 병합 map_img = Image.new("RGBA", (tile_w * 256, tile_h * 256)) headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"} diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index 4a1788a8..cb05e2ce 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -147,7 +147,6 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { root.append(header, viewport); let currentProjectId: string | null = null; - let referenceBounds: SurfaceBounds | null = null; let meta: VWorldMeta | null = null; // 배수유역 오버레이가 lon/lat을 화면 좌표로 옮길 때 쓴다. 레이어 로드 시 1회 만든다. let normalizer: Normalizer | null = null; @@ -268,29 +267,19 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { scheduleDraw(); } - function fitReferenceBounds(): void { - if (!meta || !referenceBounds) return; - const rect = viewport.getBoundingClientRect(); - const width = Math.max(rect.width, 1); - const height = Math.max(rect.height, 1); - const mapRect = computeMapRect(meta, width, height); - const referenceWidth = Math.max(referenceBounds.x_max - referenceBounds.x_min, 1); - const referenceHeight = Math.max(referenceBounds.y_max - referenceBounds.y_min, 1); - scale = - Math.min(meta.width_meters / referenceWidth, meta.height_meters / referenceHeight) * 0.9; - const centerX = (referenceBounds.x_min + referenceBounds.x_max) / 2; - const centerY = (referenceBounds.y_min + referenceBounds.y_max) / 2; - const baseX = mapRect.x + ((centerX - meta.x_min) / meta.width_meters) * mapRect.width; - const baseY = mapRect.y + (1 - (centerY - meta.y_min) / meta.height_meters) * mapRect.height; - offsetX = -(baseX - width / 2) * scale; - offsetY = -(baseY - height / 2) * scale; - } - - function resetView(): void { + /** 확보한 배경 지도(브이월드) 전체가 보이도록 맞춘다. + * + * 3D(라이다)와 2D(브이월드)는 다루는 범위가 다르다 — 라이다는 노선 주변의 좁은 구역, + * 2D 지도는 그보다 훨씬 넓은 주변까지 담는다. 이전에는 2D 지도를 라이다 범위에 맞춰 + * 확대해서, 배경을 넓게 받아도 화면에 보이는 범위가 늘 같았다(2026-08-01 사용자 지적). */ + function fitMapExtent(): void { scale = 1; offsetX = 0; offsetY = 0; - fitReferenceBounds(); + } + + function resetView(): void { + fitMapExtent(); updateImageTransform(); scheduleDraw(); } @@ -475,11 +464,12 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { return { root, - render(projectId, nextReferenceBounds) { + // 라이다 범위는 더 이상 화면 배율에 쓰지 않는다(2D는 브이월드 범위 기준). + // 인자는 호출측 호환을 위해 유지한다. + render(projectId) { currentProjectId = projectId; watershed.reset(); watershed.setProject(projectId); - referenceBounds = nextReferenceBounds ?? null; void loadLayers(); }, dispose() { diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts index bdaf06b6..80a75dde 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -475,35 +475,14 @@ export function createDrainagePanel(): DrainagePanel { boundaryEditor.setEditMode(boundaryMode); }); - /** 노선 전체가 보이도록 배율·중심을 맞춘다. 노선이 없으면 도엽 전체를 그대로 보여준다. */ + /** 확보한 배경 지도(브이월드) 전체가 보이도록 맞춘다. + * + * 노선 범위에 맞춰 확대하면 배경을 넓게 받아도 화면에 보이는 범위가 늘 같았다 + * (2026-08-01 사용자 지적). 노선 주변 지형까지 함께 보고 판단하도록 배경 전체를 띄운다. */ function fitToRoute(): void { scale = 1; offsetX = 0; offsetY = 0; - if (!meta || routePoints.length < 2) return; - const rect = viewport.getBoundingClientRect(); - const width = Math.max(rect.width, 1); - const height = Math.max(rect.height, 1); - const mapRect = computeMapRect(meta, width, height); - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - routePoints.forEach((point) => { - if (point.x < minX) minX = point.x; - if (point.x > maxX) maxX = point.x; - if (point.y < minY) minY = point.y; - if (point.y > maxY) maxY = point.y; - }); - const routeWidth = Math.max(maxX - minX, 1); - const routeHeight = Math.max(maxY - minY, 1); - scale = Math.min(meta.width_meters / routeWidth, meta.height_meters / routeHeight) * 0.85; - const centerX = (minX + maxX) / 2; - const centerY = (minY + maxY) / 2; - const baseX = mapRect.x + ((centerX - meta.x_min) / meta.width_meters) * mapRect.width; - const baseY = mapRect.y + (1 - (centerY - meta.y_min) / meta.height_meters) * mapRect.height; - offsetX = -(baseX - width / 2) * scale; - offsetY = -(baseY - height / 2) * scale; } async function loadLayers(): Promise { diff --git a/config/config_system.py b/config/config_system.py index 456f314f..a2e356e1 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -516,11 +516,12 @@ STORAGE_BASE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "sto MAP_SHEETS_DIRNAME = "map_sheets" MAP_SHEETS_INDEX_FILENAME = "map_sheets_index.json" -# 배경 지도(위성·하이브리드·백지도)를 내려받을 때 덧붙일 주변 셀(타일) 겹 수. -# 계획노선과 기준 좌표(라이다 범위)를 덮는 타일을 먼저 정하고, 그 바깥으로 이만큼 더 받는다. -# 해상도(zoom)는 그대로 두고 주변 셀만 늘린다 — 브이월드가 더 높은 해상도를 주지 않는다 -# (2026-08-01 사용자 지시). zoom 18에서 셀 1겹 ≈ 122m(위도 37° 기준), 3겹 ≈ 366m. -SURFACE_MAP_MARGIN_TILES = 3 +# 배경 지도(위성·하이브리드·백지도) 확보 범위 = 기준 박스 + 주변 박스. +# +# 기준 박스 = 계획노선과 기준 좌표를 덮는 한 장. 그 주위로 같은 크기의 박스를 몇 겹 더 받는다. +# 1이면 주변 8장을 더해 3×3 배치가 된다(2026-08-01 사용자 지시). +# 해상도(zoom)는 브이월드 기본 스케일 그대로 두고 범위만 넓힌다 — 화소를 키우는 것이 아니다. +SURFACE_MAP_NEIGHBOR_RINGS = 1 # 브이월드 지도서비스 도엽 자동 다운로드 — 세션 만료 시 id/pw로 자동 재로그인 (.env) VWORLD_LOGIN_ID = os.getenv("VWORLD_LOGIN_ID", "") From 5da0fb1cd66b8044f9c50339689e900c1a21db2b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 1 Aug 2026 12:47:20 +0900 Subject: [PATCH 59/61] =?UTF-8?q?feat(B04):=20=EB=B0=B0=EA=B2=BD=20?= =?UTF-8?q?=EC=A7=80=EB=8F=84=20=ED=99=95=EB=B3=B4=20=EB=B2=94=EC=9C=84?= =?UTF-8?q?=EB=A5=BC=20=EA=B8=B0=EC=A4=80=20=EB=8F=84=EC=97=BD=20=EB=8F=84?= =?UTF-8?q?=EA=B3=BD=EA=B3=BC=20=EB=8F=99=EC=9D=BC=ED=95=98=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지시(2026-08-01): 위성사진은 수치지형도 도엽과 같은 눈금으로 확보한다. 계획노선이 걸치는 기준 도엽만 받고(1매 또는 2~3매), 주변 도엽은 받지 않는다. - sheets_for_points(): 기준 좌표가 속한 도엽번호만 반환(주변 확장 없음) - satellite_extent(): 기준 도엽들의 도곽 합집합을 프로젝트 좌표계로 반환. 도엽을 못 구하면 라이다∪계획노선 범위로 폴백 - 다운로더: 주변 확장 로직 제거. 요청 범위를 덮는 타일만 받되, 한 변 타일 수가 한도를 넘으면 zoom을 낮춘다(도엽 1매를 zoom 18로 받으면 한 변 19타일 = 4,864px) - 국가 GIS 벡터는 종전대로 라이다∪계획노선 범위 사용 - 표본 실측: 기준 도엽 1매(37816093) -> 10x13 타일, 2438x3169m, 0.95m/px, 15.1MB, 실패 타일 0개 (이전 731x853m) Co-Authored-By: Claude Opus 5 (1M context) --- B04_wf1_Surface/B04_wf1_Surface_Engine.py | 11 ++-- .../B04_wf1_Surface_Engine_Extent.py | 38 +++++++++++++ .../B04_wf1_Surface_Engine_MapSheet.py | 16 ++++++ .../B04_wf1_Surface_Engine_VWorld.py | 56 ++++++++----------- config/config_system.py | 12 ++-- 5 files changed, 90 insertions(+), 43 deletions(-) diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine.py b/B04_wf1_Surface/B04_wf1_Surface_Engine.py index 0139e66d..83bcd7b1 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine.py @@ -235,6 +235,7 @@ def run_surface_analysis( from B04_wf1_Surface.B04_wf1_Surface_Engine_Extent import ( download_extent, map_meta_covers, + satellite_extent, ) from B04_wf1_Surface.B04_wf1_Surface_Engine_GisVector import download_all_gis_vectors from B04_wf1_Surface.B04_wf1_Surface_Engine_VWorld import ( @@ -250,9 +251,11 @@ def run_surface_analysis( project_epsg = "EPSG:5186" if prj_path.exists(): project_epsg = get_epsg_from_prj(prj_path.read_text(encoding="utf-8", errors="ignore")) - # 배경 지도는 라이다 범위와 계획노선을 합친 범위로 받는다 — 노선이 라이다 범위를 - # 벗어나도 배경이 잘리지 않게 한다(2026-08-01 사용자 지시). + # 국가 GIS 벡터는 라이다∪계획노선 범위로 받는다. bounds_dict_for_download = download_extent(project_root, las_bounds_dict, project_epsg) + # 배경 지도(위성·하이브리드·백지도)는 계획노선이 걸치는 기준 도엽의 도곽 범위로 받는다 + # — 수치지형도 도엽과 같은 눈금. 주변 도엽은 받지 않는다(2026-08-01 사용자 지시). + map_bounds_for_download = satellite_extent(project_root, las_bounds_dict, project_epsg) # VWorld 지도 및 GIS 데이터 저장 위치는 B04_wf1_Surface/processed에 보관. layers = [ @@ -264,13 +267,13 @@ def run_surface_analysis( meta_path = processed_dir / f"vworld_{item['layer'].lower()}_meta.json" # 파일이 있어도 계획노선·여유 셀이 바뀌어 범위를 못 덮으면 다시 받는다. # (B03 업로드가 부르는 경로는 rebuild=False라, 존재 여부만 보면 영영 갱신되지 않는다.) - if not rebuild and map_meta_covers(meta_path, bounds_dict_for_download): + if not rebuild and map_meta_covers(meta_path, map_bounds_for_download): continue try: step_started = time.monotonic() download_vworld_satellite_map( prj_path, - bounds_dict_for_download, + map_bounds_for_download, processed_dir, layer_name=item["layer"], ext=item["ext"], diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_Extent.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Extent.py index de370354..ca95774f 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine_Extent.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Extent.py @@ -82,6 +82,44 @@ def download_extent( } +def satellite_extent( + project_root: Path, + las_bounds: dict[str, list[float]], + target_epsg: str, +) -> dict[str, list[float]]: + """배경 지도를 확보할 범위 = 계획노선이 걸치는 **기준 도엽**의 도곽 범위. + + 수치지형도 도엽과 같은 눈금으로 맞춘다 — 도엽 1매면 1매 크기, 2~3매에 걸치면 그만큼. + 주변 도엽은 받지 않는다(2026-08-01 사용자 지시). + 도엽 번호를 얻지 못하면 라이다∪계획노선 범위로 되돌아간다. + """ + from pyproj import Transformer + + from .B04_wf1_Surface_Engine_MapSheet import sheet5k_to_bounds, sheets_for_points + + points = sheet_reference_points_wgs84(project_root, las_bounds, target_epsg) + sheets = sheets_for_points(points) + if not sheets: + return download_extent(project_root, las_bounds, target_epsg) + + lon_min = lat_min = float("inf") + lon_max = lat_max = float("-inf") + for sheet_no in sheets: + s_lon_min, s_lat_min, s_lon_max, s_lat_max = sheet5k_to_bounds(sheet_no) + lon_min, lon_max = min(lon_min, s_lon_min), max(lon_max, s_lon_max) + lat_min, lat_max = min(lat_min, s_lat_min), max(lat_max, s_lat_max) + + to_target = Transformer.from_crs("EPSG:4326", target_epsg, always_xy=True) + x0, y0 = to_target.transform(lon_min, lat_min) + x1, y1 = to_target.transform(lon_max, lat_max) + logger.info("B04 배경 지도 기준 도엽 %d매: %s", len(sheets), ", ".join(sheets)) + return { + "x": [min(x0, x1), max(x0, x1)], + "y": [min(y0, y1), max(y0, y1)], + "z": list(las_bounds.get("z", [0.0, 0.0])), + } + + def map_meta_covers(meta_path: Path, extent: dict[str, list[float]]) -> bool: """저장된 배경 지도가 필요한 범위를 이미 덮고 있는가. diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_MapSheet.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_MapSheet.py index 892b25b1..aae948ad 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine_MapSheet.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_MapSheet.py @@ -90,6 +90,22 @@ def neighbors_3x3(sheet_no: str) -> list[str]: return result +def sheets_for_points(points: list[tuple[float, float]]) -> list[str]: + """기준 좌표들이 속한 도엽번호만(주변 도엽 없음, 중복 제거). + + 계획노선 시점·종점이 같은 도엽이면 1매, 걸치면 2~3매가 된다. + 배경 지도(위성사진)는 이 도엽 범위만 확보한다(2026-08-01 사용자 지시). + """ + ordered: list[str] = [] + seen: set[str] = set() + for lat, lon in points: + sheet_no = latlon_to_sheet5k(lat, lon) + if sheet_no not in seen: + seen.add(sheet_no) + ordered.append(sheet_no) + return ordered + + def neighbors_for_points(points: list[tuple[float, float]]) -> list[str]: """기준 좌표들이 속한 도엽 + 각각의 주변 8매를 합친 목록(중복 제거, 순서 유지). diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_VWorld.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_VWorld.py index 23f98c64..2746cb87 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine_VWorld.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_VWorld.py @@ -19,10 +19,14 @@ try: VWORLD_API_KEY = getattr( config_system, "VWORLD_API_KEY", "3DBD7306-7DBD-38BB-B292-267C5ED7AC6B" ) - SURFACE_MAP_NEIGHBOR_RINGS = getattr(config_system, "SURFACE_MAP_NEIGHBOR_RINGS", 1) + SURFACE_MAP_MAX_TILES_PER_SIDE = getattr(config_system, "SURFACE_MAP_MAX_TILES_PER_SIDE", 12) + SURFACE_MAP_MAX_ZOOM = getattr(config_system, "SURFACE_MAP_MAX_ZOOM", 18) + SURFACE_MAP_MIN_ZOOM = getattr(config_system, "SURFACE_MAP_MIN_ZOOM", 14) except ImportError: VWORLD_API_KEY = "3DBD7306-7DBD-38BB-B292-267C5ED7AC6B" - SURFACE_MAP_NEIGHBOR_RINGS = 1 + SURFACE_MAP_MAX_TILES_PER_SIDE = 12 + SURFACE_MAP_MAX_ZOOM = 18 + SURFACE_MAP_MIN_ZOOM = 14 def get_epsg_from_prj(prj_content: str) -> str: @@ -98,38 +102,22 @@ def download_vworld_satellite_map( lon_min, lat_min = transformer.transform(x_min, y_min) lon_max, lat_max = transformer.transform(x_max, y_max) - # 3. 지도 타일 크기 결정 (ZOOM 18 초고해상도 적용) - zoom = 18 - - # 영역을 포괄하는 좌상단 타일, 우하단 타일 인덱스 산출 - x1, y1 = latlon_to_tile(lat_max, lon_min, zoom) - x2, y2 = latlon_to_tile(lat_min, lon_max, zoom) - - # 기준 박스 = 계획노선·기준 좌표를 덮는 한 장. 그 주위로 같은 크기의 박스를 더 받는다. - # rings=1이면 주변 8장이 붙어 3×3 배치가 된다(2026-08-01 사용자 지시). - base_x_start, base_x_end = min(x1, x2), max(x1, x2) - base_y_start, base_y_end = min(y1, y2), max(y1, y2) - box_w = base_x_end - base_x_start + 1 - box_h = base_y_end - base_y_start + 1 - rings = SURFACE_MAP_NEIGHBOR_RINGS - x_start = base_x_start - box_w * rings - x_end = base_x_end + box_w * rings - y_start = base_y_start - box_h * rings - y_end = base_y_end + box_h * rings - - # 너무 많은 타일을 내려받아 IP가 막히는 것을 막는다. 한쪽만 자르면 기준 박스가 화면 - # 가장자리로 밀리므로 양쪽에서 균등하게 줄인다. - def _clip(start: int, end: int, limit: int = 15) -> tuple[int, int]: - count = end - start + 1 - if count <= limit: - return start, end - over = count - limit - return start + over // 2, end - (over - over // 2) - - x_start, x_end = _clip(x_start, x_end) - y_start, y_end = _clip(y_start, y_end) - tile_w = x_end - x_start + 1 - tile_h = y_end - y_start + 1 + # 3. 요청 범위를 그대로 덮는 타일 범위를 잡는다(주변으로 넓히지 않는다). + # 한 변 타일 수가 한도를 넘으면 zoom을 한 단계씩 낮춘다 — 도엽 1매를 zoom 18로 받으면 + # 한 변이 19타일(4,864px)이라 파일이 지나치게 커진다(2026-08-01 사용자 지시). + zoom = SURFACE_MAP_MAX_ZOOM + while True: + x1, y1 = latlon_to_tile(lat_max, lon_min, zoom) + x2, y2 = latlon_to_tile(lat_min, lon_max, zoom) + x_start, x_end = min(x1, x2), max(x1, x2) + y_start, y_end = min(y1, y2), max(y1, y2) + tile_w = x_end - x_start + 1 + tile_h = y_end - y_start + 1 + if zoom <= SURFACE_MAP_MIN_ZOOM: + break + if max(tile_w, tile_h) <= SURFACE_MAP_MAX_TILES_PER_SIDE: + break + zoom -= 1 # 4. 개별 타일 다운로드 및 이미지 병합 map_img = Image.new("RGBA", (tile_w * 256, tile_h * 256)) diff --git a/config/config_system.py b/config/config_system.py index a2e356e1..90a58a4a 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -516,12 +516,14 @@ STORAGE_BASE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "sto MAP_SHEETS_DIRNAME = "map_sheets" MAP_SHEETS_INDEX_FILENAME = "map_sheets_index.json" -# 배경 지도(위성·하이브리드·백지도) 확보 범위 = 기준 박스 + 주변 박스. +# 배경 지도(위성·하이브리드·백지도) 확보 범위 = 계획노선이 걸치는 **기준 도엽**의 도곽. +# 1:5,000 도엽 1매는 약 2.2km × 2.3km다. 주변 도엽은 받지 않는다(2026-08-01 사용자 지시). # -# 기준 박스 = 계획노선과 기준 좌표를 덮는 한 장. 그 주위로 같은 크기의 박스를 몇 겹 더 받는다. -# 1이면 주변 8장을 더해 3×3 배치가 된다(2026-08-01 사용자 지시). -# 해상도(zoom)는 브이월드 기본 스케일 그대로 두고 범위만 넓힌다 — 화소를 키우는 것이 아니다. -SURFACE_MAP_NEIGHBOR_RINGS = 1 +# 도엽 1매를 zoom 18로 받으면 한 변이 19타일(4,864px)이라 파일이 지나치게 커진다. +# 아래 한도 안에 들어오는 가장 선명한 zoom을 자동으로 고른다(브이월드 타일 눈금 그대로). +SURFACE_MAP_MAX_TILES_PER_SIDE = 16 +SURFACE_MAP_MAX_ZOOM = 18 +SURFACE_MAP_MIN_ZOOM = 14 # 브이월드 지도서비스 도엽 자동 다운로드 — 세션 만료 시 id/pw로 자동 재로그인 (.env) VWORLD_LOGIN_ID = os.getenv("VWORLD_LOGIN_ID", "") From 306268b61602f15172d1ae551d7fd48a7a3da9b6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 1 Aug 2026 13:01:02 +0900 Subject: [PATCH 60/61] =?UTF-8?q?feat(B04/B05):=20=EC=A7=80=EB=8F=84=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=96=B4=20=EA=B8=B0=EB=B3=B8=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=EA=B0=92=20=EC=A0=95=EC=9D=98=20+=20=EB=AA=85?= =?UTF-8?q?=EC=B9=AD=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-08-01 사용자 지정 기본값을 한곳(BACKGROUND_DEFAULT_ON / GIS_DEFAULT_ON / CONTOUR_LABEL_DEFAULT_ON)에 모아 정의한다. - 켜짐: 백지도, 위성, 시군구, 읍면동, 세류(전체) - 꺼짐: 하이브리드, 연속지적도, 등고선, 도엽등고선, 표고점, 성절토, 옹벽석축, 등고라벨 - 유수방향 레이어는 화면에서 제외(목록에서 삭제) - 명칭: 세류(하천중심선) -> 세류(전체), 배수유역 재산정 -> 유역 분석 - 유역 갈래 기본값(1차 꺼짐 / 2차 켜짐 / 유역방향 꺼짐 / 평균흐름 켜짐)은 기존과 동일 - B05 배수유역도 초기 보기는 도로 기준 줌인으로 되돌림(B04 하단 지도는 배경 전체 보기 유지) Co-Authored-By: Claude Opus 5 (1M context) --- .../B04_wf1_Surface_UI_MapViewer.ts | 44 ++++++++++++++----- .../B04_wf1_Surface_UI_Watershed.ts | 8 ++-- .../B05_wf2_Route_UI_Drainage_Panel.ts | 31 +++++++++++-- ui_template/ui_template_locale.ts | 2 +- 4 files changed, 67 insertions(+), 18 deletions(-) diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index cb05e2ce..8c271641 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -30,6 +30,7 @@ export interface SurfaceMapViewer { } const BACKGROUND_LAYERS = ["white", "satellite", "hybrid"] as const; +// 유수방향은 화면에 쓰지 않기로 해 목록에서 뺐다(2026-08-01 사용자 지시). const GIS_LAYERS = [ "지적도", "행정구역_시군구", @@ -40,11 +41,34 @@ const GIS_LAYERS = [ "도엽_표고점", "도엽_성절토", "도엽_옹벽석축", - "도엽_유수방향", ] as const; type BackgroundLayer = (typeof BACKGROUND_LAYERS)[number]; type GisLayer = (typeof GIS_LAYERS)[number]; +/* ── 처음 열었을 때 켜져 있을 레이어 (2026-08-01 사용자 지정) ────────────── + * 지도가 복잡해지지 않도록 실제로 자주 보는 것만 켠 채로 시작한다. + * 나머지는 버튼으로 그때그때 켠다. */ +const BACKGROUND_DEFAULT_ON: Record = { + white: true, + satellite: true, + hybrid: false, +}; + +const GIS_DEFAULT_ON: Record = { + 지적도: false, + 행정구역_시군구: true, + 행정구역_읍면동: true, + 등고선: false, + 도엽_등고선: false, + 도엽_하천중심선: true, + 도엽_표고점: false, + 도엽_성절토: false, + 도엽_옹벽석축: false, +}; + +/** 등고 라벨(계곡선 수치) 기본 표시 여부. */ +const CONTOUR_LABEL_DEFAULT_ON = false; + const GIS_LAYER_COLORS: Record = { 지적도: "#f97316", 행정구역_시군구: "#7c3aed", @@ -55,7 +79,6 @@ const GIS_LAYER_COLORS: Record = { 도엽_표고점: "#f9a8d4", 도엽_성절토: "#f43f5e", 도엽_옹벽석축: "#0f766e", - 도엽_유수방향: "#0891b2", }; // 등고 라벨 표기 대상 레이어와 표고 속성 키 (gpkg=CTRLN_HG, 도엽=등고수치) @@ -152,10 +175,11 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { let normalizer: Normalizer | null = null; // 사전 투영된 렌더용 레이어. 원본 GeoJSON은 변형하지 않으며 투영 후에는 참조를 잡아두지 않는다. const preparedLayers = new Map(); - const activeBackgrounds = new Set(BACKGROUND_LAYERS); - // gpkg 등고선은 기본 꺼짐(도엽 등고선이 기본 표기), 등고 라벨은 기본 켜짐 (2026-07-26 사용자 지시) - const activeGisLayers = new Set(GIS_LAYERS.filter((layer) => layer !== "등고선")); - let showContourLabels = true; + const activeBackgrounds = new Set( + BACKGROUND_LAYERS.filter((layer) => BACKGROUND_DEFAULT_ON[layer]), + ); + const activeGisLayers = new Set(GIS_LAYERS.filter((layer) => GIS_DEFAULT_ON[layer])); + let showContourLabels = CONTOUR_LABEL_DEFAULT_ON; let scale = 1; let offsetX = 0; let offsetY = 0; @@ -215,7 +239,6 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { 도엽_표고점: L("B04_Surface_Map_SheetElevPoint"), 도엽_성절토: L("B04_Surface_Map_SheetCutFill"), 도엽_옹벽석축: L("B04_Surface_Map_SheetWall"), - 도엽_유수방향: L("B04_Surface_Map_SheetFlowDir"), }; GIS_LAYERS.forEach((layer) => { gisButtons.append( @@ -223,12 +246,13 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { ); }); - // 등고 라벨 보기/숨기기 (기본 켜짐 — 등고선·도엽 등고선의 계곡선 수치 표기) + // 등고 라벨 보기/숨기기 (등고선·도엽 등고선의 계곡선 수치 표기) const contourLabelButton = document.createElement("button"); contourLabelButton.type = "button"; - contourLabelButton.className = "b04-map__layer-button is-active"; + contourLabelButton.className = + "b04-map__layer-button" + (CONTOUR_LABEL_DEFAULT_ON ? " is-active" : ""); contourLabelButton.textContent = L("B04_Surface_Map_ContourLabel"); - contourLabelButton.setAttribute("aria-pressed", "true"); + contourLabelButton.setAttribute("aria-pressed", String(CONTOUR_LABEL_DEFAULT_ON)); contourLabelButton.addEventListener("click", () => { showContourLabels = !showContourLabels; contourLabelButton.classList.toggle("is-active", showContourLabels); diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts index 888bf201..57dee145 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts @@ -82,7 +82,7 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { const button = document.createElement("button"); button.type = "button"; button.className = "b04-map__layer-button b04-map__layer-button--gis"; - button.textContent = "배수유역 재산정"; + button.textContent = "유역 분석"; button.style.setProperty("--b04-layer-color", "#dc2626"); button.setAttribute("aria-pressed", "false"); button.title = @@ -473,13 +473,13 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { // 저장분이 아직 없는 것은 오류가 아니다 — 무엇을 눌러야 하는지 알려 준다. say( refresh - ? `배수유역 재산정 실패: ${message}` - : "저장된 배수유역 분석이 없습니다. [배수유역 재산정]을 누르세요.", + ? `유역 분석 실패: ${message}` + : "저장된 배수유역 분석이 없습니다. [유역 분석]을 누르세요.", ); } finally { busy = false; button.disabled = false; - button.textContent = "배수유역 재산정"; + button.textContent = "유역 분석"; onChange(); } } diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts index 80a75dde..c6537f9c 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -475,14 +475,39 @@ export function createDrainagePanel(): DrainagePanel { boundaryEditor.setEditMode(boundaryMode); }); - /** 확보한 배경 지도(브이월드) 전체가 보이도록 맞춘다. + /** 노선 전체가 보이도록 배율·중심을 맞춘다(초기 보기 = 도로 기준 줌인). * - * 노선 범위에 맞춰 확대하면 배경을 넓게 받아도 화면에 보이는 범위가 늘 같았다 - * (2026-08-01 사용자 지적). 노선 주변 지형까지 함께 보고 판단하도록 배경 전체를 띄운다. */ + * B05는 노선 주변 배수유역을 보는 화면이라 도로에 맞춰 확대한 상태로 연다 + * (2026-08-01 사용자 지시). 배경 전체를 보려면 휠로 축소하면 된다. + * 노선이 없으면 배경 전체를 그대로 보여준다. */ function fitToRoute(): void { scale = 1; offsetX = 0; offsetY = 0; + if (!meta || routePoints.length < 2) return; + const rect = viewport.getBoundingClientRect(); + const width = Math.max(rect.width, 1); + const height = Math.max(rect.height, 1); + const mapRect = computeMapRect(meta, width, height); + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + routePoints.forEach((point) => { + if (point.x < minX) minX = point.x; + if (point.x > maxX) maxX = point.x; + if (point.y < minY) minY = point.y; + if (point.y > maxY) maxY = point.y; + }); + const routeWidth = Math.max(maxX - minX, 1); + const routeHeight = Math.max(maxY - minY, 1); + scale = Math.min(meta.width_meters / routeWidth, meta.height_meters / routeHeight) * 0.85; + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + const baseX = mapRect.x + ((centerX - meta.x_min) / meta.width_meters) * mapRect.width; + const baseY = mapRect.y + (1 - (centerY - meta.y_min) / meta.height_meters) * mapRect.height; + offsetX = -(baseX - width / 2) * scale; + offsetY = -(baseY - height / 2) * scale; } async function loadLayers(): Promise { diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index 76f96a69..253e8b66 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -662,7 +662,7 @@ export const ui_locales = { B04_Surface_Map_Contour: ["등고선", "Contour lines"], B04_Surface_Map_SheetContour: ["도엽 등고선", "Sheet contours"], B04_Surface_Map_ContourLabel: ["등고 라벨", "Contour labels"], - B04_Surface_Map_SheetStream: ["세류(하천중심선)", "Stream centerline"], + B04_Surface_Map_SheetStream: ["세류(전체)", "Streams (all)"], B04_Surface_Map_SheetElevPoint: ["표고점", "Spot elevation"], B04_Surface_Map_SheetCutFill: ["성절토", "Cut/fill slope"], B04_Surface_Map_SheetWall: ["옹벽석축", "Retaining wall"], From 85eda04a8e73b6ba8cbfd4edceb7d6e0f95bce6d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 1 Aug 2026 13:08:11 +0900 Subject: [PATCH 61/61] =?UTF-8?q?feat(B04/B05):=20=EC=A7=80=EB=8F=84=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=20=ED=99=94=EB=A9=B4=EC=9D=84=20=EB=8F=84?= =?UTF-8?q?=EB=A1=9C=20=EC=A4=91=EC=8B=AC=20+=20=EC=97=AC=EC=9C=A0=20200m?= =?UTF-8?q?=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B04 하단 지도와 B05 배수유역도가 같은 규칙으로 열린다 — 계획도로 중심을 화면 중앙에 두고 도로 전체 + 사방 200m가 보이는 배율(2026-08-01 사용자 지시). - computeRouteView()를 공용 렌더 엔진(B04_wf1_Surface_UI_MapRender)에 두어 두 화면이 같은 정의를 쓴다. 여유 거리는 ROUTE_VIEW_MARGIN_M 한 곳에서 정의. - B04는 도로 범위를 알 방법이 없어 GET /surface/confirmed 응답에 route_bounds를 추가 (B03 계획노선 CSV 범위를 프로젝트 좌표계로 변환). 노선이 없으면 배경 전체 보기로 폴백. - B05는 기존 상대 배율(0.85배) 대신 같은 함수를 쓴다. - 표본 실측: 노선 239x166m -> 여유 포함 639x566m가 화면에 들어온다. Co-Authored-By: Claude Opus 5 (1M context) --- B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts | 2 + .../B04_wf1_Surface_Engine_Extent.py | 32 ++++++++++++++ B04_wf1_Surface/B04_wf1_Surface_Router.py | 9 ++++ B04_wf1_Surface/B04_wf1_Surface_Schema.py | 2 + .../B04_wf1_Surface_UI_MapRender.ts | 44 +++++++++++++++++++ .../B04_wf1_Surface_UI_MapViewer.ts | 39 ++++++++++------ B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts | 5 ++- .../B05_wf2_Route_UI_Drainage_Panel.ts | 24 +++++----- 8 files changed, 128 insertions(+), 29 deletions(-) diff --git a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts index 4b23b8f4..52037edb 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts @@ -132,6 +132,8 @@ export interface SurfaceConfirmedResponse { z_min: number; z_max: number; } | null; + /** 계획노선(B03 CSV)의 평면 범위. 지도 초기 화면을 도로 중심으로 맞출 때 쓴다. */ + route_bounds: { x_min: number; x_max: number; y_min: number; y_max: number } | null; } /** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_Extent.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Extent.py index ca95774f..e84fc474 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine_Extent.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Extent.py @@ -120,6 +120,38 @@ def satellite_extent( } +def planned_route_bounds(project_root: Path, target_epsg: str) -> dict[str, float] | None: + """계획노선(B03 CSV)의 평면 범위를 프로젝트 좌표계로 돌려준다. 없으면 None. + + 지도(2D) 초기 화면을 도로 기준으로 맞출 때 쓴다 — 화면 쪽은 도로 범위만 알면 된다. + """ + route = read_planned_route(project_root) + if not route: + return None + bounds = route["bounds"] + corners = [ + (float(bounds["x_min"]), float(bounds["y_min"])), + (float(bounds["x_max"]), float(bounds["y_max"])), + ] + try: + moved = _to_target_crs(corners, route.get("epsg"), target_epsg) + except Exception as exc: + logger.warning("B04 계획노선 범위 변환 실패 (%s)", exc) + return None + xs = [point[0] for point in moved] + ys = [point[1] for point in moved] + return {"x_min": min(xs), "x_max": max(xs), "y_min": min(ys), "y_max": max(ys)} + + +def project_epsg_from_prj(project_root: Path) -> str: + """프로젝트 PRJ에서 좌표계를 읽는다. 없으면 중부원점(EPSG:5186).""" + from .B04_wf1_Surface_Engine_VWorld import get_epsg_from_prj + + for prj_path in sorted(project_root.glob("B03_FileInput/**/*.prj")): + return get_epsg_from_prj(prj_path.read_text(encoding="utf-8", errors="ignore")) + return "EPSG:5186" + + def map_meta_covers(meta_path: Path, extent: dict[str, list[float]]) -> bool: """저장된 배경 지도가 필요한 범위를 이미 덮고 있는가. diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router.py b/B04_wf1_Surface/B04_wf1_Surface_Router.py index 93568f4f..c9cfe5a2 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Router.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Router.py @@ -18,6 +18,10 @@ from B04_wf1_Surface.B04_wf1_Surface_Engine import ( cache_ground_points, run_surface_analysis, ) +from B04_wf1_Surface.B04_wf1_Surface_Engine_Extent import ( + planned_route_bounds, + project_epsg_from_prj, +) from B04_wf1_Surface.B04_wf1_Surface_Repository import ( clear_confirmed_surface_models, get_input_file, @@ -415,6 +419,10 @@ async def get_confirmed_surface(project_id: UUID) -> SurfaceConfirmedResponse | "z_max": float(bounds[2, 1]), } + # 지도(2D) 초기 화면을 도로 기준으로 맞추기 위한 계획노선 범위(없으면 None). + project_root = processed_dir.parent.parent + route_bounds = planned_route_bounds(project_root, project_epsg_from_prj(project_root)) + signature = "|".join( str(value) for value in ( @@ -435,6 +443,7 @@ async def get_confirmed_surface(project_id: UUID) -> SurfaceConfirmedResponse | signature=signature, point_count=point_count, bounds=bounds_payload, + route_bounds=route_bounds, ) except LookupError as exc: return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) diff --git a/B04_wf1_Surface/B04_wf1_Surface_Schema.py b/B04_wf1_Surface/B04_wf1_Surface_Schema.py index 3e0ec724..cf6520ad 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Schema.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Schema.py @@ -127,6 +127,8 @@ class SurfaceConfirmedResponse(BaseModel): signature: str point_count: int | None = None bounds: dict[str, float] | None = None + # 계획노선(B03 CSV)의 평면 범위. 지도 초기 화면을 도로 기준으로 맞출 때 쓴다. + route_bounds: dict[str, float] | None = None class SurfaceGroundStatsResponse(BaseModel): diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts index 077f95de..05b5afc1 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts @@ -105,6 +105,50 @@ export function computeMapRect(meta: VWorldMeta | null, width: number, height: n }; } +/** 계획도로 주변으로 보여줄 여유 거리(m). B04 하단 지도와 B05 배수유역도가 같은 값을 쓴다 + * (2026-08-01 사용자 지시: 도로 중심을 화면 중앙에, 도로 전체 + 200m까지). */ +export const ROUTE_VIEW_MARGIN_M = 200; + +/** 평면 좌표(m) 범위. */ +export interface PlanBounds { + x_min: number; + x_max: number; + y_min: number; + y_max: number; +} + +/** + * 도로 전체 + 여유 거리가 화면에 들어오도록 배율·이동량을 구한다(도로 중심이 화면 중앙). + * + * 지도 초기 화면의 유일한 정의처 — B04 하단 지도와 B05 배수유역도가 함께 쓴다. + * 배경 지도보다 넓은 범위를 요구하면 배경 크기에 맞춰 멈춘다(빈 여백을 만들지 않는다). + */ +export function computeRouteView( + meta: VWorldMeta | null, + route: PlanBounds | null, + viewportWidth: number, + viewportHeight: number, + marginM: number = ROUTE_VIEW_MARGIN_M, +): { scale: number; offsetX: number; offsetY: number } { + if (!meta || !route) return { scale: 1, offsetX: 0, offsetY: 0 }; + const mapRect = computeMapRect(meta, viewportWidth, viewportHeight); + const wantWidth = Math.max(route.x_max - route.x_min, 1) + marginM * 2; + const wantHeight = Math.max(route.y_max - route.y_min, 1) + marginM * 2; + const scale = Math.max( + Math.min(meta.width_meters / wantWidth, meta.height_meters / wantHeight), + 1, + ); + const centerX = (route.x_min + route.x_max) / 2; + const centerY = (route.y_min + route.y_max) / 2; + const baseX = mapRect.x + ((centerX - meta.x_min) / meta.width_meters) * mapRect.width; + const baseY = mapRect.y + (1 - (centerY - meta.y_min) / meta.height_meters) * mapRect.height; + return { + scale, + offsetX: -(baseX - viewportWidth / 2) * scale, + offsetY: -(baseY - viewportHeight / 2) * scale, + }; +} + function isPoint(value: unknown): value is [number, number] { return Array.isArray(value) && typeof value[0] === "number" && typeof value[1] === "number"; } diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index 8c271641..791ee668 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -5,13 +5,13 @@ import { fetchGisGeoJson, fetchVWorldMeta, getVWorldMapUrl, - type SurfaceBounds, type VWorldMeta, } from "./B04_wf1_Surface_Api_Fetch"; import { niceScaleDistance } from "./B04_wf1_Surface_UI_Camera"; import { createWatershedOverlay } from "./B04_wf1_Surface_UI_Watershed"; import { computeMapRect, + computeRouteView, createNormalizer, drawPreparedLabels, drawPreparedLayer, @@ -19,13 +19,15 @@ import { type GeoJsonCollection, type MapRect, type Normalizer, + type PlanBounds, type PreparedLayer, type ViewState, } from "./B04_wf1_Surface_UI_MapRender"; export interface SurfaceMapViewer { root: HTMLElement; - render: (projectId: string, referenceBounds?: SurfaceBounds) => void; + /** routeBounds: 계획노선 평면 범위 — 초기 화면을 도로 중심으로 맞추는 데 쓴다. */ + render: (projectId: string, routeBounds?: PlanBounds | null) => void; dispose: () => void; } @@ -171,6 +173,8 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { let currentProjectId: string | null = null; let meta: VWorldMeta | null = null; + // 초기 화면 기준이 되는 계획노선 범위(B03 CSV). 없으면 배경 전체를 보여준다. + let routeBounds: PlanBounds | null = null; // 배수유역 오버레이가 lon/lat을 화면 좌표로 옮길 때 쓴다. 레이어 로드 시 1회 만든다. let normalizer: Normalizer | null = null; // 사전 투영된 렌더용 레이어. 원본 GeoJSON은 변형하지 않으며 투영 후에는 참조를 잡아두지 않는다. @@ -291,19 +295,26 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { scheduleDraw(); } - /** 확보한 배경 지도(브이월드) 전체가 보이도록 맞춘다. + /** 계획도로가 화면 중앙에 오고 도로 전체 + 여유 200m가 보이도록 맞춘다(B05와 같은 규칙). * - * 3D(라이다)와 2D(브이월드)는 다루는 범위가 다르다 — 라이다는 노선 주변의 좁은 구역, - * 2D 지도는 그보다 훨씬 넓은 주변까지 담는다. 이전에는 2D 지도를 라이다 범위에 맞춰 - * 확대해서, 배경을 넓게 받아도 화면에 보이는 범위가 늘 같았다(2026-08-01 사용자 지적). */ - function fitMapExtent(): void { - scale = 1; - offsetX = 0; - offsetY = 0; + * 라이다 범위에 맞추던 것을 도로 기준으로 바꿨다 — 3D(라이다)와 2D(지도)는 다루는 범위가 + * 달라, 라이다에 맞추면 배경을 넓게 받아도 보이는 범위가 늘 같았다(2026-08-01 사용자 지시). + * 계획노선이 없으면 배경 전체를 그대로 보여준다. */ + function fitRouteView(): void { + const rect = viewport.getBoundingClientRect(); + const view = computeRouteView( + meta, + routeBounds, + Math.max(rect.width, 1), + Math.max(rect.height, 1), + ); + scale = view.scale; + offsetX = view.offsetX; + offsetY = view.offsetY; } function resetView(): void { - fitMapExtent(); + fitRouteView(); updateImageTransform(); scheduleDraw(); } @@ -488,10 +499,10 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { return { root, - // 라이다 범위는 더 이상 화면 배율에 쓰지 않는다(2D는 브이월드 범위 기준). - // 인자는 호출측 호환을 위해 유지한다. - render(projectId) { + // 초기 화면은 계획노선 기준이다(라이다 범위는 쓰지 않는다). + render(projectId, nextRouteBounds) { currentProjectId = projectId; + routeBounds = nextRouteBounds ?? null; watershed.reset(); watershed.setProject(projectId); void loadLayers(); diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts index 5ea15cd5..2017353d 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts @@ -347,11 +347,12 @@ export async function renderB04Surface(root: HTMLElement): Promise { pointCloud = await fetchSurfacePointCloud(projectId, filterGroup.select.value); terrainViewer.setReferenceBounds(pointCloud.bounds); viewer.render(pointCloud); - mapViewer.render(projectId, pointCloud.bounds); + // 지도(2D)는 계획노선 기준으로 연다 — 라이다 범위와 다루는 범위가 다르다. + mapViewer.render(projectId, confirmed.route_bounds); } catch { pointCloud = null; viewer.render(null); - mapViewer.render(projectId); + mapViewer.render(projectId, confirmed.route_bounds); } renderInputInfo(); updateSelectedModel(); diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts index c6537f9c..15006d2c 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -7,6 +7,7 @@ import { } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch"; import { computeMapRect, + computeRouteView, createNormalizer, drawFilledRing, drawPreparedLayer, @@ -485,10 +486,6 @@ export function createDrainagePanel(): DrainagePanel { offsetX = 0; offsetY = 0; if (!meta || routePoints.length < 2) return; - const rect = viewport.getBoundingClientRect(); - const width = Math.max(rect.width, 1); - const height = Math.max(rect.height, 1); - const mapRect = computeMapRect(meta, width, height); let minX = Infinity; let minY = Infinity; let maxX = -Infinity; @@ -499,15 +496,16 @@ export function createDrainagePanel(): DrainagePanel { if (point.y < minY) minY = point.y; if (point.y > maxY) maxY = point.y; }); - const routeWidth = Math.max(maxX - minX, 1); - const routeHeight = Math.max(maxY - minY, 1); - scale = Math.min(meta.width_meters / routeWidth, meta.height_meters / routeHeight) * 0.85; - const centerX = (minX + maxX) / 2; - const centerY = (minY + maxY) / 2; - const baseX = mapRect.x + ((centerX - meta.x_min) / meta.width_meters) * mapRect.width; - const baseY = mapRect.y + (1 - (centerY - meta.y_min) / meta.height_meters) * mapRect.height; - offsetX = -(baseX - width / 2) * scale; - offsetY = -(baseY - height / 2) * scale; + const rect = viewport.getBoundingClientRect(); + const view = computeRouteView( + meta, + { x_min: minX, x_max: maxX, y_min: minY, y_max: maxY }, + Math.max(rect.width, 1), + Math.max(rect.height, 1), + ); + scale = view.scale; + offsetX = view.offsetX; + offsetY = view.offsetY; } async function loadLayers(): Promise {