diff --git a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts index a8b00529..94f672e5 100644 --- a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts +++ b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts @@ -43,6 +43,9 @@ export interface ProjectItem { road_type?: string | null; project_year?: number | null; estimated_length_m?: number | null; + /** 계획노선 사용 범위 (2026-09-04 사용자 지시) — 비우면 전 구간. */ + route_start_m?: number | null; + route_end_m?: number | null; memo?: string | null; status?: string | null; /** 도면 표제란·표지에 실리는 값 — 프로그램이 지어낼 수 없어 사람이 넣는다 */ @@ -154,6 +157,9 @@ export interface UpdateProjectRequest { road_type?: string | null; project_year?: number | null; estimated_length_m?: number | null; + /** 계획노선 사용 범위 (2026-09-04 사용자 지시) — 비우면 전 구간. */ + route_start_m?: number | null; + route_end_m?: number | null; memo?: string | null; status?: string | null; client_org?: string | null; diff --git a/B01_Dashboard/B01_Dashboard_Repository.py b/B01_Dashboard/B01_Dashboard_Repository.py index ec13d932..5f284ae0 100644 --- a/B01_Dashboard/B01_Dashboard_Repository.py +++ b/B01_Dashboard/B01_Dashboard_Repository.py @@ -158,7 +158,8 @@ async def list_user_projects(user_id: int) -> list[dict[str, Any]]: async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute( """SELECT id, company_id, name, region, road_type, project_year, - estimated_length_m, memo, status, updated_at, created_at, + estimated_length_m, route_start_m, route_end_m, + memo, status, updated_at, created_at, client_org, project_number, work_amount, design_date, pm_user_id, field_lead_user_id, designer_user_id, logo_asset_id, signature_asset_id @@ -174,7 +175,8 @@ async def list_company_projects(company_id: int) -> list[dict[str, Any]]: async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute( """SELECT p.id, p.company_id, p.name, p.region, p.road_type, p.project_year, - p.estimated_length_m, p.memo, p.status, p.updated_at, p.created_at, + p.estimated_length_m, p.route_start_m, p.route_end_m, + p.memo, p.status, p.updated_at, p.created_at, p.client_org, p.project_number, p.work_amount, p.design_date, p.pm_user_id, p.field_lead_user_id, p.designer_user_id, p.logo_asset_id, p.signature_asset_id, @@ -192,7 +194,8 @@ async def list_all_projects() -> list[dict[str, Any]]: async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute( """SELECT p.id, p.company_id, p.name, p.region, p.road_type, p.project_year, - p.estimated_length_m, p.memo, p.status, p.updated_at, p.created_at, + p.estimated_length_m, p.route_start_m, p.route_end_m, + p.memo, p.status, p.updated_at, p.created_at, p.client_org, p.project_number, p.work_amount, p.design_date, p.pm_user_id, p.field_lead_user_id, p.designer_user_id, p.logo_asset_id, p.signature_asset_id, @@ -209,7 +212,7 @@ async def get_project(project_id: str) -> dict[str, Any] | None: async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute( """SELECT id, user_id, company_id, name, region, road_type, project_year, - estimated_length_m, memo, status, + estimated_length_m, route_start_m, route_end_m, memo, status, client_org, project_number, work_amount, design_date, pm_user_id, field_lead_user_id, designer_user_id, logo_asset_id, signature_asset_id @@ -226,7 +229,8 @@ async def update_project(project_id: str, data: dict[str, Any], actor_id: int) - await cursor.execute( """UPDATE projects SET name = %s, region = %s, road_type = %s, project_year = %s, - estimated_length_m = %s, memo = %s, status = COALESCE(%s, status), + estimated_length_m = %s, route_start_m = %s, route_end_m = %s, + memo = %s, status = COALESCE(%s, status), client_org = %s, project_number = %s, work_amount = %s, design_date = %s, pm_user_id = %s, field_lead_user_id = %s, designer_user_id = %s, logo_asset_id = %s, signature_asset_id = %s @@ -237,6 +241,8 @@ async def update_project(project_id: str, data: dict[str, Any], actor_id: int) - data.get("road_type"), data.get("project_year"), data.get("estimated_length_m"), + data.get("route_start_m"), + data.get("route_end_m"), data.get("memo"), data.get("status"), data.get("client_org"), diff --git a/B01_Dashboard/B01_Dashboard_Router.py b/B01_Dashboard/B01_Dashboard_Router.py index a0ba6723..a0227f68 100644 --- a/B01_Dashboard/B01_Dashboard_Router.py +++ b/B01_Dashboard/B01_Dashboard_Router.py @@ -264,6 +264,13 @@ async def dashboard_update_project( if not _can_edit_project(session, project): raise HTTPException(status_code=403, detail="프로젝트 수정 권한이 없습니다.") data = payload.model_dump() + # 시작이 종료보다 뒤면 남는 구간이 없다 — B02 등록과 같은 규칙 (2026-09-04 사용자 지시). + start_m, end_m = data.get("route_start_m"), data.get("route_end_m") + if start_m is not None and end_m is not None and start_m >= end_m: + raise HTTPException( + status_code=400, + detail="노선 시작 누가거리는 종료 누가거리보다 작아야 합니다.", + ) await check_project_refs(int(project["company_id"]), data) if not await update_project(project_id, data, int(session["user_id"])): raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") diff --git a/B01_Dashboard/B01_Dashboard_Schema.py b/B01_Dashboard/B01_Dashboard_Schema.py index 3e69affb..b9d791e0 100644 --- a/B01_Dashboard/B01_Dashboard_Schema.py +++ b/B01_Dashboard/B01_Dashboard_Schema.py @@ -56,6 +56,9 @@ class UpdateProjectRequest(BaseModel): road_type: str | None = Field(default=None, max_length=100) project_year: int | None = Field(default=None, ge=1900, le=2100) estimated_length_m: float | None = Field(default=None, ge=0) + # 계획노선 사용 범위 (2026-09-04 사용자 지시) — 비우면 전 구간. + route_start_m: float | None = Field(default=None, ge=0) + route_end_m: float | None = Field(default=None, ge=0) memo: str | None = Field(default=None, max_length=5000) status: str | None = Field(default=None, max_length=50) # 도면 표제란·표지에 실리는 값 (2026-09-02). 프로그램이 지어낼 수 없어 사람이 넣는다. diff --git a/B01_Dashboard/B01_Dashboard_UI_Modals.ts b/B01_Dashboard/B01_Dashboard_UI_Modals.ts index a4141316..59845ba2 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Modals.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Modals.ts @@ -124,6 +124,19 @@ export async function openEditProjectModal( type: "number", value: String(project.estimated_length_m ?? ""), }); + // 계획노선 사용 범위 — 등록(B02)에서 받은 값을 여기서도 고친다 (2026-09-04 사용자 지시). + const routeStart = createInputField({ + label: "노선 시작 누가거리 (m)", + type: "number", + value: project.route_start_m == null ? "" : String(project.route_start_m), + placeholder: "비우면 처음부터", + }); + const routeEnd = createInputField({ + label: "노선 종료 누가거리 (m)", + type: "number", + value: project.route_end_m == null ? "" : String(project.route_end_m), + placeholder: "비우면 끝까지", + }); const memo = createInputField({ label: "비고", value: project.memo ?? "" }); // 도면 표제란·표지에 그대로 실리는 값 — 프로그램이 지어낼 수 없어 여기서 받는다. const clientOrg = createInputField({ @@ -209,6 +222,8 @@ export async function openEditProjectModal( roadType.input.disabled = true; year.input.disabled = true; length.input.disabled = true; + routeStart.input.disabled = true; + routeEnd.input.disabled = true; memo.input.disabled = true; clientOrg.input.disabled = true; projectNumber.input.disabled = true; @@ -228,6 +243,8 @@ export async function openEditProjectModal( roadType.root, year.root, length.root, + routeStart.root, + routeEnd.root, memo.root, clientOrg.root, projectNumber.root, @@ -253,6 +270,8 @@ export async function openEditProjectModal( road_type: roadType.input.value.trim() || null, project_year: year.input.value ? Number(year.input.value) : null, estimated_length_m: length.input.value ? Number(length.input.value) : null, + route_start_m: routeStart.input.value ? Number(routeStart.input.value) : null, + route_end_m: routeEnd.input.value ? Number(routeEnd.input.value) : null, memo: memo.input.value.trim() || null, status: project.status, client_org: clientOrg.input.value.trim() || null, diff --git a/B02_ProjRegister/B02_ProjRegister_Repository.py b/B02_ProjRegister/B02_ProjRegister_Repository.py index 06a1958f..8a184ec7 100644 --- a/B02_ProjRegister/B02_ProjRegister_Repository.py +++ b/B02_ProjRegister/B02_ProjRegister_Repository.py @@ -68,12 +68,13 @@ async def create_project( """ INSERT INTO projects ( id, user_id, company_id, name, region, road_type, - project_year, estimated_length_m, memo, status, + project_year, estimated_length_m, route_start_m, route_end_m, + memo, status, crs_epsg, storage_path, created_at, updated_at, client_org, project_number, work_amount, design_date, pm_user_id, field_lead_user_id, designer_user_id, logo_asset_id ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'NEW', 5178, %s, %s, %s, + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'NEW', 5178, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """, ( @@ -85,6 +86,8 @@ async def create_project( road_type, project_year, estimated_length_m, + fields.get("route_start_m"), + fields.get("route_end_m"), memo, storage_path, now, @@ -116,7 +119,8 @@ async def create_project( await cursor.execute( """ SELECT id AS project_id, name, region, road_type, project_year, - estimated_length_m, memo, status, storage_path, + estimated_length_m, route_start_m, route_end_m, + memo, status, storage_path, DATE_FORMAT(created_at, '%%Y-%%m-%%dT%%H:%%i:%%s') AS created_at FROM projects WHERE id = %s @@ -138,7 +142,8 @@ async def get_project_by_id(project_id: str) -> dict[str, Any] | None: await cursor.execute( """ SELECT id AS project_id, user_id, company_id, name, region, road_type, - project_year, estimated_length_m, memo, status, storage_path, + project_year, estimated_length_m, route_start_m, route_end_m, + memo, status, storage_path, DATE_FORMAT(created_at, '%%Y-%%m-%%dT%%H:%%i:%%s') AS created_at FROM projects WHERE id = %s AND deleted_at IS NULL diff --git a/B02_ProjRegister/B02_ProjRegister_Router.py b/B02_ProjRegister/B02_ProjRegister_Router.py index ec160b60..f8127d0c 100644 --- a/B02_ProjRegister/B02_ProjRegister_Router.py +++ b/B02_ProjRegister/B02_ProjRegister_Router.py @@ -34,8 +34,17 @@ async def post_project( "field_lead_user_id", "designer_user_id", "logo_asset_id", + "route_start_m", + "route_end_m", } ) + # 시작이 종료보다 뒤면 남는 구간이 없다 — 저장 전에 막는다 (2026-09-04 사용자 지시). + start_m, end_m = payload.route_start_m, payload.route_end_m + if start_m is not None and end_m is not None and start_m >= end_m: + raise HTTPException( + status_code=400, + detail="노선 시작 누가거리는 종료 누가거리보다 작아야 합니다.", + ) # 담당자·로고는 같은 회사 것만 (B01 프로젝트 수정과 같은 규칙). await check_project_refs(int(company_id), title_block) diff --git a/B02_ProjRegister/B02_ProjRegister_Schema.py b/B02_ProjRegister/B02_ProjRegister_Schema.py index 362ace0d..55b6a047 100644 --- a/B02_ProjRegister/B02_ProjRegister_Schema.py +++ b/B02_ProjRegister/B02_ProjRegister_Schema.py @@ -15,6 +15,10 @@ class CreateProjectRequest(BaseModel): road_type: str = Field(..., pattern="^(main|fire|work)$") project_year: int = Field(..., ge=2000, le=2100) estimated_length_m: float | None = Field(default=None, ge=0) + # 계획노선 자료가 공사지 전체일 수 있어 쓸 구간을 받는다 (2026-09-04 사용자 지시). + # 둘 다 비우면 전 구간. 시작 >= 종료 는 라우터에서 막는다. + route_start_m: float | None = Field(default=None, ge=0) + route_end_m: float | None = Field(default=None, ge=0) memo: str | None = Field(default=None, max_length=1000) # 도면 표제란·표지 값 — 등록 때부터 받는다 (2026-09-02 사용자 지시). # 프로젝트 수정 모달(B01)과 같은 칸이며, 비워 두면 도면에 빈칸으로 나간다. @@ -37,6 +41,8 @@ class CreateProjectResponse(BaseModel): road_type: str | None project_year: int | None estimated_length_m: float | None + route_start_m: float | None = None + route_end_m: float | None = None memo: str | None status: str storage_path: str diff --git a/B02_ProjRegister/B02_ProjRegister_UI_Page.ts b/B02_ProjRegister/B02_ProjRegister_UI_Page.ts index cc697a71..5609d184 100644 --- a/B02_ProjRegister/B02_ProjRegister_UI_Page.ts +++ b/B02_ProjRegister/B02_ProjRegister_UI_Page.ts @@ -90,6 +90,20 @@ export function renderB02ProjRegister(root: HTMLElement): void { type: "number", min: 0, }); + // 계획노선 자료가 공사지 전체일 수 있어 쓸 구간을 받는다 (2026-09-04 사용자 지시). + // 둘 다 비우면 전 구간을 쓴다. + const routeStartField = createInputField({ + label: "노선 시작 누가거리 (m)", + placeholder: "비우면 처음부터", + type: "number", + min: 0, + }); + const routeEndField = createInputField({ + label: "노선 종료 누가거리 (m)", + placeholder: "비우면 끝까지", + type: "number", + min: 0, + }); const memoField = createInputField({ label: L("B02_Proj_Field_Memo"), placeholder: L("B02_Proj_Field_Memo_Placeholder"), @@ -189,6 +203,8 @@ export function renderB02ProjRegister(root: HTMLElement): void { regionField.setError(); yearField.setError(); lengthField.setError(); + routeStartField.setError(); + routeEndField.setError(); // 1차 유효성: 필수값 검사 let hasError = false; @@ -212,6 +228,25 @@ export function renderB02ProjRegister(root: HTMLElement): void { lengthField.setError(L("Common_Validation_NumberRange")); hasError = true; } + const routeStart = isBlank(routeStartField.input.value) + ? null + : Number.parseFloat(routeStartField.input.value); + const routeEnd = isBlank(routeEndField.input.value) + ? null + : Number.parseFloat(routeEndField.input.value); + if (routeStart !== null && (!Number.isFinite(routeStart) || routeStart < 0)) { + routeStartField.setError(L("Common_Validation_NumberRange")); + hasError = true; + } + if (routeEnd !== null && (!Number.isFinite(routeEnd) || routeEnd < 0)) { + routeEndField.setError(L("Common_Validation_NumberRange")); + hasError = true; + } + // 시작이 종료보다 뒤면 남는 구간이 없다 — 서버도 같은 규칙으로 막는다. + if (routeStart !== null && routeEnd !== null && routeStart >= routeEnd) { + routeEndField.setError("종료 누가거리는 시작보다 커야 합니다."); + hasError = true; + } if (hasError) return; showLoadingOverlay(); @@ -226,6 +261,8 @@ export function renderB02ProjRegister(root: HTMLElement): void { road_type: roadTypeField.select.value, project_year: projectYear, estimated_length_m: estimatedLength, + route_start_m: routeStart, + route_end_m: routeEnd, memo: memoField.input.value.trim() || null, client_org: clientOrgField.input.value.trim() || null, project_number: projectNumberField.input.value.trim() || null, @@ -269,6 +306,8 @@ export function renderB02ProjRegister(root: HTMLElement): void { roadTypeField.root, yearField.root, lengthField.root, + routeStartField.root, + routeEndField.root, memoField.root, clientOrgField.root, projectNumberField.root, diff --git a/B03_FileInput/B03_FileInput_Service_Chain.py b/B03_FileInput/B03_FileInput_Service_Chain.py index d23842b4..cc37af0d 100644 --- a/B03_FileInput/B03_FileInput_Service_Chain.py +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -26,16 +26,19 @@ logger = logging.getLogger(__name__) def _planned_route_points_in_project_crs( - project_root: Path, surface: dict[str, Any] | None = None + project_root: Path, + surface: dict[str, Any] | None = None, + route_range: tuple[float | None, float | None] | None = None, ) -> list[dict[str, float]] | None: """설계용 계획노선을 프로젝트 좌표계(m) 점 목록으로 돌려준다. 없으면 None. 읽기·좌표계 변환·트림·조밀화는 `load_design_route()` 한 곳에서 한다 — 배수유역·유입도 - 같은 함수를 쓰므로 여기만 트림되는 일이 없다. + 같은 함수를 쓰므로 여기만 트림되는 일이 없다. 사용자가 정한 사용 범위(`route_range`)는 + 서피스 트림보다 먼저 적용된다 (2026-09-04 사용자 지시). """ from common_util.common_util_route_geometry import load_design_route - planned = load_design_route(project_root, surface) + planned = load_design_route(project_root, surface, route_range) if planned is None: if surface: logger.warning( @@ -176,8 +179,17 @@ async def run_auto_design_chain( # 끊긴다(2026-08-30 실사고). 노선 트림도 이 지표면을 기준으로 한다. async with pool.acquire() as connection: defaults = await get_surface_confirmation_params(connection, str(project_id)) + # 사용자가 B02·B01 에서 정한 계획노선 사용 범위 (2026-09-04 사용자 지시). + # 비어 있으면 전 구간 — 지금까지와 같다. + async with connection.cursor() as cursor: + await cursor.execute( + "SELECT route_start_m, route_end_m FROM projects WHERE id = %s", + (str(project_id),), + ) + range_row = await cursor.fetchone() + route_range = (range_row[0], range_row[1]) if range_row else None - points = _planned_route_points_in_project_crs(project_root, defaults) + points = _planned_route_points_in_project_crs(project_root, defaults, route_range) if not points: logger.warning("자동 설계 체인 중단(계획노선 없음): project_id=%s", project_id) mark_design_failed(project_root, "계획노선이 없어 초기 노선을 세울 수 없습니다.") diff --git a/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts b/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts index 3fa47f41..4ef0f6a6 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts @@ -7,6 +7,7 @@ * ========================================================================== */ import { themeColor } from "@ui/ui_template_palette"; +import { stationLabel } from "@util/common_util_svg"; /** 상류 세류망 강조선 색. 정의처는 `ui_template_theme.css`(`--map-upstream`). */ const upstreamLineColor = (): string => themeColor("--map-upstream", "rgba(29, 78, 216, 0.95)"); @@ -184,3 +185,100 @@ export function drawRidgeRing( context.stroke(); context.restore(); } + +/* ----------------------------------------------------------------------------- + * 계획선 위 측점 눈금·번호 (2026-09-04 사용자 지시) + * + * 종단·3D와 같은 `측점번호+잔여거리` 표기다. 배율이 낮으면 글자가 붙으므로 3D 라벨과 같은 + * 단계 규칙으로 솎는다(5칸 → 2칸 → 전부). 관 마커가 있는 측점은 라벨을 계획선 **반대쪽** + * 으로 밀어 마커를 가리지 않게 한다. B04 지도와 B05 배수유역도가 이 한 곳을 함께 쓴다. + * -------------------------------------------------------------------------- */ + +export interface StationTickOptions { + /** 규칙 측점 간격(m). */ + intervalM: number; + /** 화면 1m 당 픽셀 — 라벨 솎기 단계를 여기서 정한다. */ + pxPerMeter: number; + toScreen: (x: number, y: number) => [number, number]; + /** 관 마커가 놓인 누가거리 목록 — 겹치면 라벨을 반대쪽으로 민다. */ + avoidChainages?: ReadonlyArray; +} + +export function drawStationTicks( + context: CanvasRenderingContext2D, + points: ReadonlyArray<{ x: number; y: number }>, + options: StationTickOptions, +): void { + if (points.length < 2) return; + const interval = options.intervalM > 0 ? options.intervalM : 20; + // 라벨 사이가 좁아지면 솎는다 — 화면에서 잰 간격(px)으로 정한다. + const gapPx = interval * options.pxPerMeter; + const step = gapPx >= 90 ? 1 : gapPx >= 40 ? 2 : 5; + const avoid = options.avoidChainages ?? []; + + // 정점 누가거리 — 측점 자리는 정점 사이에 떨어지므로 보간해서 찍는다. + const cumulative: number[] = [0]; + for (let index = 1; index < points.length; index += 1) { + cumulative.push( + cumulative[index - 1] + + Math.hypot(points[index].x - points[index - 1].x, points[index].y - points[index - 1].y), + ); + } + const total = cumulative[cumulative.length - 1]; + if (total <= 0) return; + + context.save(); + context.font = "11px system-ui, sans-serif"; + context.textAlign = "center"; + context.textBaseline = "middle"; + // 노선이 되꺾이면 멀쩡한 배율에서도 두 측점이 화면에서 붙는다 — 이미 그린 라벨과 + // 겹치는 자리는 건너뛴다(2026-09-04 실측에서 4px 간격까지 붙었다). + const drawn: Array<{ x: number; y: number; half: number }> = []; + let cursor = 1; + for (let chainage = 0; chainage <= total; chainage += interval) { + const stationNo = Math.round(chainage / interval); + if (stationNo % step !== 0) continue; + while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1; + const back = points[cursor - 1]; + const front = points[cursor]; + const segment = cumulative[cursor] - cumulative[cursor - 1] || 1; + const ratio = Math.min(1, Math.max(0, (chainage - cumulative[cursor - 1]) / segment)); + const px = back.x + (front.x - back.x) * ratio; + const py = back.y + (front.y - back.y) * ratio; + const [sx, sy] = options.toScreen(px, py); + const [bx, by] = options.toScreen(back.x, back.y); + const [fx, fy] = options.toScreen(front.x, front.y); + const dx = fx - bx; + const dy = fy - by; + const length = Math.hypot(dx, dy) || 1; + // 계획선에 직각인 방향 — 눈금과 라벨을 이 방향으로 놓는다. + const ux = -dy / length; + const uy = dx / length; + const nearPipe = avoid.some((pipe) => Math.abs(pipe - chainage) < interval / 2); + const side = nearPipe ? -1 : 1; + + context.beginPath(); + context.moveTo(sx - ux * 6, sy - uy * 6); + context.lineTo(sx + ux * 6, sy + uy * 6); + context.lineWidth = 1.2; + context.strokeStyle = "rgba(40, 40, 40, 0.85)"; + context.stroke(); + + const label = stationLabel(chainage, interval); + const lx = sx + ux * side * 16; + const ly = sy + uy * side * 16; + const width = context.measureText(label).width + 6; + const half = width / 2; + const collides = drawn.some( + (item) => Math.abs(item.x - lx) < item.half + half && Math.abs(item.y - ly) < 16, + ); + if (collides) continue; + drawn.push({ x: lx, y: ly, half }); + // 배경을 깔아 등고선 위에서도 읽히게 한다. + context.fillStyle = "rgba(255, 255, 255, 0.78)"; + context.fillRect(lx - half, ly - 8, width, 16); + context.fillStyle = "#222222"; + context.fillText(label, lx, ly); + } + context.restore(); +} diff --git a/B04_PreProcess/B04_PreProcess_UI_MapRender.ts b/B04_PreProcess/B04_PreProcess_UI_MapRender.ts index 9a56bf87..258b9723 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapRender.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapRender.ts @@ -121,6 +121,59 @@ export function computeMapRect(meta: VWorldMeta | null, width: number, height: n * (2026-08-01 사용자 지시: 도로 중심을 화면 중앙에, 도로 전체 + 200m까지). */ export const ROUTE_VIEW_MARGIN_M = 200; +/** + * 사업지 미터 좌표를 화면 좌표로 옮기는 변환기 (B04 지도·B05 배수유역도 공용). + * + * 배수유역도에서 쓰던 것을 여기로 옮겼다 — 두 화면이 같은 자리에 측점 눈금을 찍어야 한다 + * (2026-09-04). `pxPerMeter` 는 라벨 솎기·축척 계산에 쓴다. + */ +export function createMetricProjector( + meta: VWorldMeta, + view: ViewState, +): { toScreen: (x: number, y: number) => [number, number]; pxPerMeter: number } { + const spanX = meta.width_meters || 1; + const spanY = 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 { + toScreen: (x, y) => [ + ((x - meta.x_min) / spanX) * ax + bx, + (1 - (y - meta.y_min) / spanY) * ay + by, + ], + pxPerMeter: ax / spanX, + }; +} + +/** 규칙 측점 간격(m) — 종단 패널과 같은 20m 고정. 지도·배수유역도 눈금 표기 기준(2026-09-04). */ +export const MAP_STATION_INTERVAL_M = 20; + +/** 최대 확대에서 화면 폭에 들어올 실거리(m) — 규칙 측점 20m 기준 1~2측점 + * (2026-09-04 사용자 지시). 고정 배율(8배·16배)로는 도엽 크기마다 체감이 달라진다. */ +export const MAX_ZOOM_VIEW_WIDTH_M = 20; + +/** 배율 상한의 안전장치 — 도엽 메타가 이상해도 여기서 멈춘다. */ +export const ZOOM_SCALE_HARD_CAP = 2000; + +/** + * 「화면 폭이 `MAX_ZOOM_VIEW_WIDTH_M` 가 될 때까지」에 해당하는 배율 상한을 구한다. + * + * 배율 1에서 도엽 실폭(`meta.width_meters`)이 지도 사각형 폭(px)을 채우므로, + * 화면 폭(px)에 들어오는 실거리 = width_meters × viewportWidth / (mapRect.width × scale) 이다. + * 이것을 20m 로 놓고 scale 을 푼다. 메타가 없으면 종전 고정값으로 되돌아간다. + */ +export function computeMaxScale( + meta: VWorldMeta | null, + mapRectWidth: number, + viewportWidth: number, + fallback: number, +): number { + if (!meta || mapRectWidth <= 0 || viewportWidth <= 0) return fallback; + const scale = (meta.width_meters * viewportWidth) / (mapRectWidth * MAX_ZOOM_VIEW_WIDTH_M); + return Math.min(ZOOM_SCALE_HARD_CAP, Math.max(fallback, scale)); +} + /** 평면 좌표(m) 범위. */ export interface PlanBounds { x_min: number; diff --git a/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts b/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts index e7403d3f..61782992 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts @@ -26,7 +26,10 @@ import { createFlowStrengthOverlay } from "./B04_PreProcess_UI_FlowStrength"; import { createWatershedOverlay } from "./B04_PreProcess_UI_Watershed"; import { computeMapRect, + computeMaxScale, computeRouteView, + createMetricProjector, + MAP_STATION_INTERVAL_M, createNormalizer, drawPreparedLabels, drawPreparedLayer, @@ -41,6 +44,7 @@ import { type PreparedLayer, type ViewState, } from "./B04_PreProcess_UI_MapRender"; +import { drawStationTicks } from "./B04_PreProcess_UI_MapOverlays"; import type { WatershedAnalysis } from "./B04_PreProcess_Api_Fetch"; export interface SurfaceMapViewer { @@ -160,8 +164,10 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { ); const activeGisLayers = new Set(GIS_LAYERS.filter((layer) => GIS_DEFAULT_ON[layer])); let showContourLabels = CONTOUR_LABEL_DEFAULT_ON; - // 계획선(B03 업로드 계획노선) — 사업지 좌표계(m) 폴리라인을 배경 지도 위에 겹친다. + // 계획선(B03 계획노선 정본) — 사업지 좌표계(m) 폴리라인을 배경 지도 위에 겹친다. let routeLayer: PreparedLayer | null = null; + // 측점 눈금·번호를 찍기 위한 원본 점 목록 (2026-09-04 사용자 지시). + let routePoints: ReadonlyArray<{ x: number; y: number }> = []; let showRoute = true; let scale = 1; let offsetX = 0; @@ -314,6 +320,9 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { function updateImageTransform(): void { backgroundImages.forEach((image) => { image.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`; + // 크게 당기면 배경 그림이 뭉개진다 — 흐림 보간을 끄고 픽셀을 그대로 보인다 + // (2026-09-04 사용자 지시). 실제 크기는 축척 막대로 읽는다. + image.style.imageRendering = scale > 4 ? "pixelated" : "auto"; }); } @@ -416,6 +425,15 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { context.strokeStyle = routeLineColor(); drawPreparedLayer(context, routeLayer, view, "dot"); } + // 측점 눈금·번호 — 계획선 위, 유역 오버레이 아래. B05 배수유역도와 같은 규칙이다. + if (showRoute && meta && routePoints.length > 1) { + const projector = createMetricProjector(meta, view); + drawStationTicks(context, routePoints, { + intervalM: MAP_STATION_INTERVAL_M, + pxPerMeter: projector.pxPerMeter, + toScreen: projector.toScreen, + }); + } // 흐름 강도(도로 색·유입 집중점 마커)는 계획선 위에 얹는다. flowStrength.draw(context, normalizer, view); // 세부유역 채움과 관 마커는 그 위 — 편집 대상이라 다른 레이어에 가려지면 집을 수 없다. @@ -443,6 +461,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { meta = null; preparedLayers.clear(); routeLayer = null; + routePoints = []; resetView(); status.textContent = L("B04_Surface_Map_Loading"); showProgress(0, L("B04_Surface_Map_Loading")); @@ -480,6 +499,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { normalizer = createNormalizer(nextMeta); routeLayer = planned.points.length > 1 ? prepareMetricPolyline(planned.points, nextMeta) : null; + routePoints = planned.points; // 흐름 강도는 계획선 위에 칠하므로 같은 점 목록·같은 메타를 쓴다(어긋나면 색이 밀린다). flowStrength.setRoute(planned.points, nextMeta); // 관 마커도 같은 계획선 위에 스냅한다 — 목록이 다르면 마커가 노선을 벗어난다. @@ -518,7 +538,16 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { event.preventDefault(); const prevScale = scale; // 휠을 **당기면 확대**, 밀면 축소한다(2026-08-02 사용자 지시). 일반 스크롤과 반대 방향이다. - scale = Math.min(8, Math.max(0.5, scale * (event.deltaY > 0 ? 1.15 : 0.87))); + // 상한은 「화면 폭 20m」로 계산한다 — 도엽 크기가 달라도 체감이 같다(2026-09-04 사용자 지시). + const wheelRect = viewport.getBoundingClientRect(); + const wheelWidth = Math.max(1, Math.floor(wheelRect.width)); + const maxScale = computeMaxScale( + meta, + computeMapRect(meta, wheelWidth, Math.max(1, Math.floor(wheelRect.height))).width, + wheelWidth, + 8, + ); + scale = Math.min(maxScale, 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). diff --git a/B05_Profile/B05_Profile_UI_Drainage_Interact.ts b/B05_Profile/B05_Profile_UI_Drainage_Interact.ts index 6212e2d1..a5674238 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Interact.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Interact.ts @@ -9,10 +9,12 @@ * ========================================================================== */ import { + computeMaxScale, lonLatToScreen, type Normalizer, type ViewState, } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; +import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { pointInRings } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays"; import type { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes"; @@ -29,6 +31,8 @@ export interface DrainageInteractParams { currentView: () => ViewState; getScale: () => number; setScale: (value: number) => void; + /** 배율 상한을 도엽 실폭으로 계산하기 위한 메타 (2026-09-04). 없으면 종전 고정 상한. */ + getMeta: () => VWorldMeta | null; getOffset: () => { x: number; y: number }; setOffset: (x: number, y: number) => void; scheduleDraw: () => void; @@ -55,7 +59,10 @@ export function bindDrainageInteractions(params: DrainageInteractParams): void { event.preventDefault(); const prevScale = params.getScale(); // 휠을 **당기면 확대**, 밀면 축소한다(2026-08-02 사용자 지시). B04 2D 지도와 같은 방향이다. - const scale = Math.min(16, Math.max(0.5, prevScale * (event.deltaY > 0 ? 1.15 : 0.87))); + // 상한은 「화면 폭 20m」로 계산한다 — B04 지도와 같은 규칙(2026-09-04 사용자 지시). + const view = currentView(); + const maxScale = computeMaxScale(params.getMeta(), view.mapRect.width, view.width, 16); + const scale = Math.min(maxScale, Math.max(0.5, prevScale * (event.deltaY > 0 ? 1.15 : 0.87))); params.setScale(scale); // 커서 아래 지점을 고정한 채 확대/축소 (B04 지도와 동일 동작). const ratio = scale / prevScale; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index dcce6cb4..75f865cb 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -12,6 +12,7 @@ import { } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { computeMapRect, + MAP_STATION_INTERVAL_M, createNormalizer, prepareLayer, prepareMetricPolyline, @@ -200,6 +201,9 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra function updateImageTransform(): void { backgroundImage.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`; + // 크게 당기면 도엽 그림이 뭉개진다 — 흐림 보간을 끄고 픽셀을 그대로 보인다 + // (2026-09-04 사용자 지시). 축척 막대가 실제 크기를 알려 준다. + backgroundImage.style.imageRendering = scale > 4 ? "pixelated" : "auto"; } function draw(): void { @@ -244,6 +248,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra pipeEditor, pipeColor, markedChainage, + stationIntervalM: MAP_STATION_INTERVAL_M, }); updateImageTransform(); } @@ -488,6 +493,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra setScale: (value) => { scale = value; }, + getMeta: () => meta, getOffset: () => ({ x: offsetX, y: offsetY }), setOffset: (x, y) => { offsetX = x; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts index 189e8713..e64ed302 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts @@ -171,24 +171,8 @@ export function renderBasinRows( /** 사업지 좌표계(m) → 화면 px 변환기. 흐름 화살표와 강도 색칠이 같은 식을 쓴다 — * 둘이 어긋나면 색은 계획선 위인데 화살표만 밀린 것처럼 보인다. */ -export function createMetricProjector( - meta: VWorldMeta, - view: ViewState, -): { toScreen: (x: number, y: number) => [number, number]; pxPerMeter: number } { - const spanX = meta.width_meters || 1; - const spanY = 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 { - toScreen: (x, y) => [ - ((x - meta.x_min) / spanX) * ax + bx, - (1 - (y - meta.y_min) / spanY) * ay + by, - ], - pxPerMeter: ax / spanX, - }; -} +// 미터 좌표 → 화면 좌표 변환기는 B04 지도와 공용이다(정의처: MapRender). +export { createMetricProjector } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; /** 배관 우클릭 메뉴를 지도 뷰포트에 붙인다 — 마커 위면 삭제, 계획선 위면 추가. diff --git a/B05_Profile/B05_Profile_UI_Drainage_Render.ts b/B05_Profile/B05_Profile_UI_Drainage_Render.ts index 015898e2..847909e3 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Render.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Render.ts @@ -22,6 +22,7 @@ import { drawFilledRing, drawRidgeRing, drawRingBadge, + drawStationTicks, drawUpstreamLines, ringCenterOnScreen, } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays"; @@ -64,6 +65,8 @@ export interface DrainageScene { pipeColor: (chainage: number, position: number) => string; /** 선택된 측점의 누가거리(m). 계획선 위 그 자리에 선택 표식을 그린다(null=없음). */ markedChainage: number | null; + /** 규칙 측점 간격(m) — 눈금·번호 표기 기준 (2026-09-04 사용자 지시). */ + stationIntervalM: number; } export function drawDrainageScene( @@ -182,6 +185,13 @@ export function drawDrainageScene( context.stroke(); context.restore(); } + // 측점 눈금·번호 — 관 마커 위, 유역 번호 아래 (2026-09-04 사용자 지시). B04 지도와 공용. + drawStationTicks(context, scene.strengthSamples, { + intervalM: scene.stationIntervalM, + pxPerMeter: projector.pxPerMeter, + toScreen: projector.toScreen, + avoidChainages: scene.pipeEditor.chainages(), + }); // 유역 번호 — 무엇에도 가리지 않게 맨 마지막. drawBadges(context, badges); } diff --git a/common_util/common_util_route_geometry.py b/common_util/common_util_route_geometry.py index b467425e..aa1a297c 100644 --- a/common_util/common_util_route_geometry.py +++ b/common_util/common_util_route_geometry.py @@ -213,7 +213,9 @@ def find_planned_route_file(input_dir: Path) -> Path | None: def load_design_route( - project_root: Path, surface_params: dict[str, Any] | None = None + project_root: Path, + surface_params: dict[str, Any] | None = None, + route_range: tuple[float | None, float | None] | None = None, ) -> PlannedRoute | None: """설계가 쓸 계획노선 한 벌을 만든다 — 읽기·좌표계 변환·트림·조밀화를 여기서 끝낸다. @@ -225,6 +227,9 @@ def load_design_route( `surface_params`(확정 필터·방식·스무딩)를 주면 지표면이 덮지 못하는 구간을 잘라 내고, B05 격자 탐색이 계획노선을 바꾸지 않도록 정점 간격을 직결 문턱 아래로 좁힌다. 주지 않으면 읽어서 좌표계만 맞춘 원본을 돌려준다. + + `route_range`(시작·종료 누가거리 m)를 주면 **서피스 트림보다 먼저** 그 구간만 남긴다 + (2026-09-04 사용자 지시). 순서가 바뀌면 사용자가 정한 시점이 서피스 트림에 밀린다. """ from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj from common_util.common_util_initial_snapshot import design_route_csv_path @@ -264,6 +269,10 @@ def load_design_route( transformer = Transformer.from_crs(source_crs, target_crs, always_xy=True) points = [transformer.transform(x, y) for x, y in points] + # 사용자가 정한 범위가 먼저다 — 그 다음에 서피스 밖을 깎는다. + if route_range: + points = clip_route_by_chainage(points, route_range[0], route_range[1]) + if surface_params: from common_util.common_util_surface_sampler import build_surface_sampler @@ -311,6 +320,57 @@ def replace_vertices( ) +def clip_route_by_chainage( + points: list[tuple[float, float]], + start_m: float | None, + end_m: float | None, +) -> list[tuple[float, float]]: + """사용자가 정한 누가거리 구간만 남긴다 (2026-09-04 사용자 지시). + + 계획노선 자료가 공사지 전체일 수 있어 **쓸 구간을 사용자가 정한다**(B02 등록 화면). + 경계는 정점 사이에 떨어질 수 있으므로 그 자리에 점을 하나 만들어 끼운다. + 둘 다 없으면 원본 그대로. 남는 구간이 2점 미만이면 원본을 돌려준다 — 범위가 자료를 + 벗어난 경우까지 여기서 노선을 지우면 원인을 못 찾는다(판정·안내는 화면·라우터 몫). + """ + if len(points) < 2 or (start_m is None and end_m is None): + return list(points) + low = max(0.0, float(start_m)) if start_m is not None else 0.0 + high = float(end_m) if end_m is not None else float("inf") + if high <= low: + return list(points) + + clipped: list[tuple[float, float]] = [] + travelled = 0.0 + for index in range(1, len(points)): + ax, ay = points[index - 1] + bx, by = points[index] + length = math.dist((ax, ay), (bx, by)) + if length <= 0.0: + continue + seg_start, seg_end = travelled, travelled + length + travelled = seg_end + if seg_end < low or seg_start > high: + continue + # 이 구간에서 남길 부분의 시작·끝 비율. + t0 = max(0.0, (low - seg_start) / length) + t1 = min(1.0, (high - seg_start) / length) + if t1 <= t0: + continue + first = (ax + (bx - ax) * t0, ay + (by - ay) * t0) + last = (ax + (bx - ax) * t1, ay + (by - ay) * t1) + if not clipped: + clipped.append(first) + clipped.append(last) + if len(clipped) < 2: + logger.warning( + "계획노선 범위 절단: 남는 구간이 없어 전 구간을 씁니다 — 범위 %s~%s m", + start_m, + end_m, + ) + return list(points) + return clipped + + def _log_trim_wipeout(points: list[tuple[float, float]], sampler: Any, target_crs: str) -> None: """트림이 노선을 통째로 지운 이유를 **수치로** 남긴다. diff --git a/config/config_system_terrain.py b/config/config_system_terrain.py index a44511b1..2d9c66bd 100644 --- a/config/config_system_terrain.py +++ b/config/config_system_terrain.py @@ -55,7 +55,8 @@ SURFACE_GROUND_RATIO_WARN = float(os.getenv("SURFACE_GROUND_RATIO_WARN", "0.01") # 계획노선이 지표면 밖으로 나가 잘릴 때, 잘린 쪽 끝에서 더 깎을 길이(m). # 서피스 가장자리는 점 밀도가 떨어져 외곽선이 불규칙하다 — 경계에 딱 붙여 자르면 # 그 구간 지반고가 못 미덥다 (2026-09-01 사용자 확정). -SURFACE_ROUTE_EDGE_TRIM_M = float(os.getenv("SURFACE_ROUTE_EDGE_TRIM_M", "30.0")) +# 30m 는 너무 많이 깎는다는 지적으로 3m 로 낮춤 (2026-09-04 사용자 지시). +SURFACE_ROUTE_EDGE_TRIM_M = float(os.getenv("SURFACE_ROUTE_EDGE_TRIM_M", "3.0")) # ───────────────────────────────────────────────────────────────────────── # 5-2. 지표면 모델 생성 파라미터 (TIN/DTM/NURBS/implicit/meshfree) diff --git a/db_management/015_route_range.sql b/db_management/015_route_range.sql new file mode 100644 index 00000000..b1d48b4c --- /dev/null +++ b/db_management/015_route_range.sql @@ -0,0 +1,16 @@ +-- 015_route_range.sql +-- 계획노선 사용 범위 (2026-09-04 사용자 지시) +-- +-- 계획노선 자료가 공사지 전체일 수 있어 **어느 구간을 쓸지 사용자가 정한다**. +-- B02 등록 화면에서 시작·종료 누가거리(m)를 받고, B03 이 계획노선을 세울 때 이 범위로 +-- 먼저 자른 뒤 서피스 밖을 잘라 낸다(순서가 바뀌면 사용자가 정한 시점이 밀린다). +-- +-- 둘 다 NULL 이면 지금처럼 **전 구간**을 쓴다 — 기존 행·기존 동작은 그대로다. + +USE aislo_db; + +ALTER TABLE projects + ADD COLUMN IF NOT EXISTS route_start_m DOUBLE NULL + COMMENT '계획노선 시작 누가거리(m) — NULL 이면 처음부터' AFTER estimated_length_m, + ADD COLUMN IF NOT EXISTS route_end_m DOUBLE NULL + COMMENT '계획노선 종료 누가거리(m) — NULL 이면 끝까지' AFTER route_start_m;