From 6ff86cdd2059b760fd179614993a7d2390c40f7a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 18:26:26 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(B05):=203D=20=EB=B7=B0=EC=96=B4=20?= =?UTF-8?q?=EC=9B=90=EA=B7=BC=20=EC=B9=B4=EB=A9=94=EB=9D=BC=20=EB=B3=B5?= =?UTF-8?q?=EA=B7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지시(2026-09-04) — 「이전 방식이 좋았음」. 2026-08-25 `f6296fa7` 에서 직교로 바꿨던 것을 시야각 45° 원근으로 되돌림. - 직교 전용 모듈 `B05_Profile_UI_Viewer_Camera.ts` 삭제, 뷰어 4자리 복원 (카메라 생성 · 리사이즈 종횡비 · 화면맞춤 · 투영행렬). - 근평면 상한 0.4m 신설 — 휠 확대가 커서 아래 지점 0.5m 앞에서 멈추는데 근평면을 맞춤거리의 1/1000 로만 두면 긴 노선에서 그 0.5m 를 넘어 지형이 잘림 (용화_LAS 맞춤거리 1,208m → 상한 없으면 근평면 1.21m). 자체검증(공용 브라우저 5173, 용화_LAS) — 네 방향 모두 원근 45°, 확대 한계 0.516m > 근평면 0.4m 로 잘림 없음, 구조물 클릭 선택·해제 그대로. Co-Authored-By: Claude Opus 5 --- B05_Profile/B05_Profile_UI_Viewer.ts | 18 +++++---- B05_Profile/B05_Profile_UI_Viewer_Camera.ts | 42 --------------------- 2 files changed, 11 insertions(+), 49 deletions(-) delete mode 100644 B05_Profile/B05_Profile_UI_Viewer_Camera.ts diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index 04bcd643..8d067309 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -14,7 +14,6 @@ import { type RouteMarkers, type SectionStationMarker, } from "./B05_Profile_UI_Markers"; -import { createOrthoCameraRig } from "./B05_Profile_UI_Viewer_Camera"; import { bindMarkerPointerControls } from "./B05_Profile_UI_Viewer_Marker_Input"; import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; import { @@ -165,8 +164,10 @@ export function createRouteViewer(): RouteViewer { }); systemDarkTheme.addEventListener("change", updateSceneBackground); updateSceneBackground(); - const cameraRig = createOrthoCameraRig(); - const camera = cameraRig.camera; + // 원근 카메라(시야각 45°) — 2026-09-04 사용자 지시로 직교에서 되돌렸다. 직교가 + // 필요했던 탑뷰 구조물 투영 윤곽선은 2026-09-02에 숨김 처리되어 화면에 없다. + const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100000); + camera.position.set(100, 120, 100); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); const controls = new OrbitControls(camera, canvas); @@ -275,7 +276,8 @@ export function createRouteViewer(): RouteViewer { const width = Math.max(1, root.clientWidth); const height = Math.max(1, root.clientHeight); renderer.setSize(width, height, false); - cameraRig.setAspect(width / height); + camera.aspect = width / height; + camera.updateProjectionMatrix(); } const resizeObserver = new ResizeObserver(resize); resizeObserver.observe(root); @@ -291,10 +293,12 @@ export function createRouteViewer(): RouteViewer { } as const; const [x, y, z] = positions[view]; camera.position.set(target.x + x, target.y + y, target.z + z); - camera.near = Math.max(0.1, distance / 1000); + // 근평면 상한 0.4m — 휠 확대는 커서 아래 지점 0.5m 앞에서 멈춘다(커서 피벗 유틸). + // 거리에만 비례시키면 긴 노선(맞춤 거리 1km 이상)에서 근평면이 그 0.5m를 넘어 + // 최대 확대 시 지형이 잘린다(2026-09-04 원근 복귀 실측: 400m 노선 여유 13mm). + camera.near = Math.max(0.1, Math.min(0.4, distance / 1000)); camera.far = distance * 10; - // 원근 45°(반각 tan ≈ 0.414)와 비슷한 화면 배율 — 뷰 전환 시 크기감이 유지된다. - cameraRig.setHalfHeight(distance * 0.42); + camera.updateProjectionMatrix(); controls.update(); } diff --git a/B05_Profile/B05_Profile_UI_Viewer_Camera.ts b/B05_Profile/B05_Profile_UI_Viewer_Camera.ts deleted file mode 100644 index 6e36d63b..00000000 --- a/B05_Profile/B05_Profile_UI_Viewer_Camera.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Viewer_Camera.ts - * B05 뷰어의 **직교(원근 없음) 카메라**(2026-08-25 사용자 확정) — 탑뷰에서 구조물· - * 절단 경계가 원근으로 일그러지지 않는다. 화면 배율은 camera.zoom이 지고(커서 피벗 - * 유틸이 조작), 절두체 반높이는 fit()이 정한다. Viewer 700줄 제한으로 분리. - * ========================================================================== */ - -import * as THREE from "three"; - -export interface OrthoCameraRig { - camera: THREE.OrthographicCamera; - /** 뷰포트 종횡비 반영(리사이즈 시). */ - setAspect(aspect: number): void; - /** 절두체 반높이(월드 m) 지정 — fit()이 화면 배율을 잡을 때 쓴다. zoom은 1로 되돌린다. */ - setHalfHeight(halfHeight: number): void; -} - -export function createOrthoCameraRig(): OrthoCameraRig { - const camera = new THREE.OrthographicCamera(-100, 100, 100, -100, 0.1, 100000); - camera.position.set(100, 120, 100); - let halfHeight = 100; - let aspect = 1; - const apply = (): void => { - camera.left = -halfHeight * aspect; - camera.right = halfHeight * aspect; - camera.top = halfHeight; - camera.bottom = -halfHeight; - camera.updateProjectionMatrix(); - }; - return { - camera, - setAspect(value: number): void { - aspect = value; - apply(); - }, - setHalfHeight(value: number): void { - halfHeight = value; - camera.zoom = 1; - apply(); - }, - }; -} From 84042fe010ed4cbf602b96255a52991d45ea4998 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 18:34:05 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(B05):=203D=20=EC=A0=88=ED=86=A0=20?= =?UTF-8?q?=EA=B5=AC=EA=B0=84=20=EC=B8=A1=EC=A0=90=20=ED=91=9C=EA=B3=A0=20?= =?UTF-8?q?=C2=B7=20=EC=A0=84=20=EC=B8=A1=EC=A0=90=20=EB=9D=BC=EB=B2=A8=20?= =?UTF-8?q?=EA=B1=B0=EB=A6=AC=20=EC=86=8E=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지적(2026-09-04) — 「절토가 된 경우 측점 표시가 안 따라감」, 「측점 라벨이 5측점 기준으로만 붙음, 전체 라벨이 있으면 좋겠음」. - 측점 막대 표고에서 `max(지반, 계획고)` 제거. 코리도가 켜져 있으면 계획고를 그대로 씀 — 절토는 계획고가 지반보다 아래라 max 로는 막대만 원지반에 떠 있었음 (실측 0측점 3.2m 공중). - 라벨을 규칙 측점 전부에 만들고(`STATION_LABEL_STEP` 제거), 카메라 거리로 솎음 (`LABEL_LOD` 150m 미만 전부 · 400m 미만 2칸 · 그 밖 5칸). 구조물·BP·EP 는 항상. - 뷰어 렌더 루프에서 카메라~시점 거리로 단계를 갱신(단계 불변이면 즉시 반환). 자체검증(공용 브라우저 5173, 용화_LAS) — 절토 3측점·성토 10측점 모두 막대와 코리도 표면 높이차 0.80m(띄움값). 라벨 66개 중 화면맞춤 23 · 중간 39 · 근접 66. Co-Authored-By: Claude Opus 5 --- B05_Profile/B05_Profile_UI_Markers.ts | 57 +++++++++++++++++++++------ B05_Profile/B05_Profile_UI_Page.ts | 8 ++-- B05_Profile/B05_Profile_UI_Viewer.ts | 2 + 3 files changed, 51 insertions(+), 16 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Markers.ts b/B05_Profile/B05_Profile_UI_Markers.ts index 371a9842..7e304fa5 100644 --- a/B05_Profile/B05_Profile_UI_Markers.ts +++ b/B05_Profile/B05_Profile_UI_Markers.ts @@ -43,8 +43,16 @@ export interface SectionStationMarker { structure?: string; } -/** 규칙 측점 라벨을 몇 칸마다 달지. 전부 달면 글자가 겹쳐 도면을 못 읽는다. */ -const STATION_LABEL_STEP = 5; +/** + * 규칙 측점 라벨 솎기 — 라벨은 **전 측점에 만들어 두고** 카메라 거리로 골라 보인다 + * (2026-09-04 사용자 지시 「전체 라벨이 있으면 좋겠음」). 멀면 글자가 겹치므로 + * 5칸 → 2칸 → 전부로 단계를 올린다. 경계는 카메라~시점거리(m). + */ +const LABEL_LOD: ReadonlyArray<{ within: number; step: number }> = [ + { within: 150, step: 1 }, + { within: 400, step: 2 }, + { within: Infinity, step: 5 }, +]; // 측점 바 양 끝 원형 램프 색: 상단(등고 높은 쪽) 예상측=주황, 반대측=회색. const UPHILL_LAMP_COLOR = 0xf97316; @@ -293,28 +301,42 @@ export function createRouteMarkers( * * BP·EP — 시·종점은 항상. 이름을 앞에 붙여 어느 끝인지 바로 읽히게 한다. * 구조물(비정규) — 측점번호 + 구조물 이름(배관 등). - * 5측점 배수 — 규칙 측점은 5칸마다만. 전부 달면 글자가 겹쳐 도면을 못 읽는다. + * 규칙 측점 — 전부 만든다. 몇 개를 보일지는 카메라 거리가 정한다(`LABEL_LOD`). * * 측점번호는 라벨 표기(`측점번호+잔여거리`)에서 되짚는다 — 측점간격은 렌더러가 모른다. - * 잔여거리가 남은 측점(예: `4+12.3`)은 규칙 격자가 아니므로 배수 판정에서 뺀다. + * 잔여거리가 남은 측점(예: `4+12.3`)은 규칙 격자가 아니므로 솎기 판정에서 뺀다 + * (`number: null` = 거리와 무관하게 항상 보임). */ - function stationLabelText(station: SectionStationMarker, intervalM: number): string | null { + function stationLabelText( + station: SectionStationMarker, + intervalM: number, + ): { text: string; number: number | null } | null { const chainage = station.chainage_m; if (!Number.isFinite(chainage)) return null; // 표기는 종단 그래프·도면 테이블과 **같은 규칙**(`측점번호+잔여거리`)을 쓴다. // 서버가 내려주는 `label`(`STA.0+000.000`)을 그대로 쓰면 화면마다 표기가 갈린다. const text = stationLabel(chainage as number, intervalM); - if (station.kind === "bp") return `BP ${text}`; - if (station.kind === "ep") return `EP ${text}`; + if (station.kind === "bp") return { text: `BP ${text}`, number: null }; + if (station.kind === "ep") return { text: `EP ${text}`, number: null }; if (station.kind === "irregular") { const structure = station.structure?.trim(); - return structure ? `${text} ${structure}` : text; + return { text: structure ? `${text} ${structure}` : text, number: null }; } const safeInterval = intervalM > 0 ? intervalM : 1; const stationNumber = Math.round((chainage as number) / safeInterval); const remainder = (chainage as number) - stationNumber * safeInterval; if (Math.abs(remainder) > 0.05) return null; - return stationNumber % STATION_LABEL_STEP === 0 ? text : null; + return { text, number: stationNumber }; + } + + /** 지금 솎기 단계(몇 칸마다 보일지). 카메라 거리로 바뀐다. */ + let labelStep = LABEL_LOD[LABEL_LOD.length - 1].step; + + function applyLabelStep(): void { + stationLabelGroup.children.forEach((child) => { + const number = (child.userData as { stationNumber?: number | null }).stationNumber; + child.visible = typeof number !== "number" || number % labelStep === 0; + }); } /** @@ -405,9 +427,11 @@ export function createRouteMarkers( // 측점 바 양 끝 원형 램프: 상단(등고 높은 쪽) 예상측 컬러, 반대측 회색. // 클릭하면 그 측을 상단측(=측구 방향)으로 지정한다(onUphillPick). - const labelText = stationLabelText(station, stationIntervalM); - if (labelText) { - stationLabelGroup.add(stationLabelSprite(labelText, modelToScene(center, bounds))); + const label = stationLabelText(station, stationIntervalM); + if (label) { + const sprite = stationLabelSprite(label.text, modelToScene(center, bounds)); + sprite.userData.stationNumber = label.number; + stationLabelGroup.add(sprite); } (["left", "right"] as const).forEach((side, endIndex) => { @@ -435,6 +459,8 @@ export function createRouteMarkers( stationGroup.add(lampHit); }); }); + // 새로 만든 라벨에도 지금 솎기 단계를 그대로 먹인다. + applyLabelStep(); // 재렌더로 좌표가 갱신됐으니 선택 핀도 그 자리로 다시 놓는다. syncSelectionPin(); } @@ -537,6 +563,13 @@ export function createRouteMarkers( setStationLabelsVisible(visible: boolean) { stationLabelGroup.visible = visible; }, + /** 카메라~시점 거리(m)로 규칙 측점 라벨을 솎는다. 구조물·BP·EP 는 늘 보인다. */ + updateLabelDetail(distanceM: number) { + const step = (LABEL_LOD.find((lod) => distanceM < lod.within) ?? LABEL_LOD[0]).step; + if (step === labelStep) return; + labelStep = step; + applyLabelStep(); + }, onChange(listener: (next: RouteDesignPoints) => void) { changeListener = listener; }, diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index ba1a5267..10bee531 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -423,10 +423,10 @@ export async function renderB05Route(root: HTMLElement): Promise { const design = designAt(station.chainage_m); return { ...station, - center_z: - design !== null && station.center_z !== null - ? Math.max(station.center_z, design) - : station.center_z, + // 절토 구간에서는 계획고가 지반보다 **아래**다(2026-09-04 사용자 지적). + // max 로 잡으면 코리도가 절취해 내려간 노면을 두고 막대만 원지반에 떠 있다. + // 코리도가 켜져 있으면(designAt 이 값을 줌) 계획고를 그대로 쓴다. + center_z: design !== null && station.center_z !== null ? design : station.center_z, uphill_side: uphillOverrides.get(uphillKey(station.chainage_m)) ?? station.uphill_side ?? null, }; diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index 8d067309..c09b1096 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -367,6 +367,8 @@ export function createRouteViewer(): RouteViewer { } else { compass.setVisible(false); } + // 측점 라벨 솎기 — 가까울수록 촘촘히 보인다(단계가 안 바뀌면 모듈 안에서 걸러낸다). + markers.updateLabelDetail(camera.position.distanceTo(controls.target)); renderer.render(scene, camera); } animate();