Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1

This commit is contained in:
2026-09-04 18:44:00 +09:00
4 changed files with 62 additions and 65 deletions
+45 -12
View File
@@ -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;
},
+4 -4
View File
@@ -423,10 +423,10 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
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,
};
+13 -7
View File
@@ -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();
}
@@ -363,6 +367,8 @@ export function createRouteViewer(): RouteViewer {
} else {
compass.setVisible(false);
}
// 측점 라벨 솎기 — 가까울수록 촘촘히 보인다(단계가 안 바뀌면 모듈 안에서 걸러낸다).
markers.updateLabelDetail(camera.position.distanceTo(controls.target));
renderer.render(scene, camera);
}
animate();
@@ -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();
},
};
}