Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1
This commit is contained in:
@@ -206,10 +206,9 @@ export async function mountProfileView(
|
||||
let selectionSyncing = false;
|
||||
|
||||
/* ── 측점 상단측(=측구 방향) 사용자 변경분 ─────────────────────────────
|
||||
* solve가 자동 판정한 uphill_side를 3D 램프 클릭으로 바꾼 값. 세션에 보관했다가
|
||||
* 저장 시 uphill_overrides로 백엔드/DB(종단 정본)에 병합한다. */
|
||||
* solve가 자동 판정한 uphill_side를 사용자가 바꾼 값. 세션에 보관했다가
|
||||
* 저장 시 uphill_overrides로 백엔드/DB(종단 정본)에 병합한다(3D 구슬 입력은 176-3 에서 지움). */
|
||||
const uphillOverrides = loadUphillOverrides(activeProjectId);
|
||||
const uphillKey = (chainage: number): string => chainage.toFixed(3);
|
||||
|
||||
const { syncIrregularSelection, syncBasinHighlight, selectStationOfPipe } = createSelectionSync({
|
||||
stations: () => bridge.irregularStations(),
|
||||
@@ -356,25 +355,6 @@ export async function mountProfileView(
|
||||
profilePanel.setSelectedStation(stationId);
|
||||
syncIrregularSelection(stationId);
|
||||
});
|
||||
// 3D 램프 클릭 → 그 측을 상단측(측구 방향)으로 지정하고 세션 보관 + 램프 재렌더.
|
||||
viewer.markers.onUphillPick((stationId, side) => {
|
||||
if (!currentSectionDetail) return;
|
||||
const base = currentSectionDetail.longitudinal.stations.find(
|
||||
(station) => station.station_id === stationId,
|
||||
);
|
||||
let chainage = base?.chainage_m;
|
||||
if (chainage === undefined) {
|
||||
const prefix = irregularStationId("");
|
||||
if (stationId.startsWith(prefix)) {
|
||||
const id = stationId.slice(prefix.length);
|
||||
chainage = bridge.irregularStations().find((entry) => entry.id === id)?.chainage_m;
|
||||
}
|
||||
}
|
||||
if (chainage === undefined) return;
|
||||
uphillOverrides.set(uphillKey(chainage), side);
|
||||
saveUphillOverrides(activeProjectId, uphillOverrides);
|
||||
renderStationLines(currentSectionDetail);
|
||||
});
|
||||
viewer.root.append(panel.viewControls);
|
||||
|
||||
function restorePanel(next: RouteLatestResponse): void {
|
||||
@@ -389,7 +369,6 @@ export async function mountProfileView(
|
||||
routePoints: latest?.route_points ?? [],
|
||||
liveSamples: profilePanel.alignmentSamples(),
|
||||
corridorVisible,
|
||||
uphillOverrides,
|
||||
});
|
||||
viewer.renderStationLines(lines, roadWidths[panel.values().gradeClass] / 2);
|
||||
}
|
||||
|
||||
@@ -69,6 +69,8 @@ const POINT_GAP_M = 0.3;
|
||||
// 늘리면 더 매끈 · 더 뜸.
|
||||
const LIFT_SIGMA_M = 3;
|
||||
const LIFT_CAP_M = 0.5;
|
||||
/** 성토 덩이 끝(성토 아닌 행)에서 이 누가거리 안은 뜸을 0 까지 줄인다(smoothstep · 176-1 · 절토와 톱니 없게). */
|
||||
const LIFT_TAPER_M = 3;
|
||||
/** 구조물 구멍 둘레 이만큼 안 격자 점은 뺀다(구멍 둘레 실오라기 막음 · m). */
|
||||
const HOLE_CLEAR_M = 0.3;
|
||||
/** 구조물 구멍 둘레 높이 찾기 — 노면부 끝에서 이 너머는 원지반(m). */
|
||||
@@ -348,9 +350,21 @@ export const loftDesign: TinDesigner = (build, groundAt, bounds) => {
|
||||
(r) => fill(r) && stationAt(chainages[r]) >= 0,
|
||||
fill,
|
||||
);
|
||||
// 가장 가까운 성토 아닌 행까지 누가거리(앞 · 뒤) — 덩이 끝 줄이기.
|
||||
const gap = new Float64Array(n).fill(Infinity);
|
||||
for (let r = 0, last = -Infinity; r < n; r += 1) {
|
||||
if (!fill(r)) last = chainages[r];
|
||||
else gap[r] = chainages[r] - last;
|
||||
}
|
||||
for (let r = n - 1, next = Infinity; r >= 0; r -= 1) {
|
||||
if (!fill(r)) next = chainages[r];
|
||||
else gap[r] = Math.min(gap[r], next - chainages[r]);
|
||||
}
|
||||
for (let r = 0; r < n; r += 1) {
|
||||
if (!fill(r)) continue;
|
||||
toeOff[si][r] = Math.max(-LIFT_CAP_M, Math.min(LIFT_CAP_M, smooth[r] - raw[r]));
|
||||
const t = Math.min(1, gap[r] / LIFT_TAPER_M);
|
||||
const off = Math.max(-LIFT_CAP_M, Math.min(LIFT_CAP_M, smooth[r] - raw[r]));
|
||||
toeOff[si][r] = off * t * t * (3 - 2 * t);
|
||||
raw[r] += toeOff[si][r];
|
||||
}
|
||||
});
|
||||
|
||||
@@ -38,8 +38,9 @@ const FALLBACK_RGB = [0.6, 0.6, 0.6];
|
||||
/** 꼬리표 없는 설계 삼각형을 절성토로 칠할 높이 차(m). */
|
||||
const TINT_MIN_M = 0.01;
|
||||
const GRID_M = 2;
|
||||
/** 설계 구역 · 구멍 둘레에 이만큼 붙은 지형 정점은 안으로 본다(m). */
|
||||
const ON_BOUNDARY_M = 1e-3;
|
||||
/** 설계 구역 · 구멍 둘레에서 이만큼 안 지형 정점은 빼고 잇는다(m · 176-1) — 비탈 끝 뜸이 짧은 턱 · 홈이 되지 않게
|
||||
* 잇는 띠를 1.5~2.5 m 로 늘림. 변 판정(boundary.touches)은 그대로. */
|
||||
export const SEAM_M = 1.5;
|
||||
|
||||
/** 노면부 띠 — 행마다 좌우 노면 끝 사이 사각형(리본이 덮는 자리 · 망에서 뺀다). */
|
||||
class RoadStrip extends CellRegion {
|
||||
@@ -88,8 +89,8 @@ function harvestPart(
|
||||
if (!state[v]) {
|
||||
const x = position.getX(v);
|
||||
const z = position.getZ(v);
|
||||
// 경계 선 위 정점도 안으로(제약이 점을 관통하지 않게).
|
||||
state[v] = inRegion(x, z) || boundary.passesNear(x, z, ON_BOUNDARY_M) ? 1 : 2;
|
||||
// 경계 선 위 · 둘레 SEAM_M 안 정점도 안으로(제약이 점을 관통하지 않게 · 잇는 띠 완만).
|
||||
state[v] = inRegion(x, z) || boundary.passesNear(x, z, SEAM_M) ? 1 : 2;
|
||||
}
|
||||
return state[v] === 1;
|
||||
};
|
||||
@@ -209,11 +210,12 @@ export function tinTerrain(
|
||||
segment += 1;
|
||||
}
|
||||
});
|
||||
// SEAM_M 만큼 넓힘 — 둘레 띠 안 정점을 쓰는 삼각형이 상자 밖이라 남아 틈이 되지 않게.
|
||||
const box = {
|
||||
minX: Math.min(outer.minX, holes.minX),
|
||||
maxX: Math.max(outer.maxX, holes.maxX),
|
||||
minZ: Math.min(outer.minZ, holes.minZ),
|
||||
maxZ: Math.max(outer.maxZ, holes.maxZ),
|
||||
minX: Math.min(outer.minX, holes.minX) - SEAM_M,
|
||||
maxX: Math.max(outer.maxX, holes.maxX) + SEAM_M,
|
||||
minZ: Math.min(outer.minZ, holes.minZ) - SEAM_M,
|
||||
maxZ: Math.max(outer.maxZ, holes.maxZ) + SEAM_M,
|
||||
};
|
||||
|
||||
const root = new THREE.Group();
|
||||
|
||||
@@ -32,8 +32,6 @@ export interface SectionStationMarker {
|
||||
center_x: number;
|
||||
center_y: number;
|
||||
center_z: number | null;
|
||||
/** 상단측(등고 높은 쪽) — 램프 컬러 표시용. Page가 사용자 변경분을 반영해 넘긴다. */
|
||||
uphill_side?: "left" | "right" | null;
|
||||
frame: { left_xy: [number, number] };
|
||||
/** 측점 종류(`bp`/`ep`/`regular`/`irregular`) — 어느 측점에 라벨을 달지 가르는 기준. */
|
||||
kind?: string;
|
||||
@@ -54,10 +52,6 @@ const LABEL_LOD: ReadonlyArray<{ within: number; step: number }> = [
|
||||
{ within: Infinity, step: 5 },
|
||||
];
|
||||
|
||||
// 측점 바 양 끝 원형 램프 색: 상단(등고 높은 쪽) 예상측=주황, 반대측=회색.
|
||||
const UPHILL_LAMP_COLOR = 0xf97316;
|
||||
const UPHILL_LAMP_INACTIVE_COLOR = 0x9ca3af;
|
||||
|
||||
const COLORS: Record<RoutePointKind, number> = {
|
||||
bp: 0x10b981,
|
||||
ep: 0xef4444,
|
||||
@@ -124,7 +118,6 @@ export function createRouteMarkers(
|
||||
let changeListener: ((points: RouteDesignPoints) => void) | undefined;
|
||||
let selectionListener: ((point: PlacedRoutePoint | null) => void) | undefined;
|
||||
let stationSelectionListener: ((stationId: string | null) => void) | undefined;
|
||||
let uphillPickListener: ((stationId: string, side: "left" | "right") => void) | undefined;
|
||||
let selectedStationId: string | null = null;
|
||||
|
||||
function allPoints(): PlacedRoutePoint[] {
|
||||
@@ -423,7 +416,7 @@ export function createRouteMarkers(
|
||||
.map((entry) => ({ chainage_m: entry.chainage_m as number })),
|
||||
);
|
||||
// 측점선(노란 띠)은 도로 반폭의 1.5배로 그린다 — 지형에 묻혀 짧아 보이는 문제
|
||||
// (2026-08-19 사용자 지시). 측구 방향 램프는 늘어난 선의 **끝단**에 따라붙는다.
|
||||
// (2026-08-19 사용자 지시).
|
||||
const barHalfWidth = halfWidth * 1.5;
|
||||
stations.forEach((station) => {
|
||||
if (station.center_z === null) return;
|
||||
@@ -456,39 +449,12 @@ export function createRouteMarkers(
|
||||
bar.userData.stationId = station.station_id;
|
||||
stationGroup.add(bar);
|
||||
|
||||
// 측점 바 양 끝 원형 램프: 상단(등고 높은 쪽) 예상측 컬러, 반대측 회색.
|
||||
// 클릭하면 그 측을 상단측(=측구 방향)으로 지정한다(onUphillPick).
|
||||
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) => {
|
||||
const active = station.uphill_side === side;
|
||||
// 램프 절반 축소(1.1→0.55) — 측점 고를 때 가리는 문제(2026-08-06 보고).
|
||||
const lamp = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(0.55, 14, 10),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: active ? UPHILL_LAMP_COLOR : UPHILL_LAMP_INACTIVE_COLOR,
|
||||
}),
|
||||
);
|
||||
lamp.position.copy(points[endIndex]);
|
||||
lamp.position.y += 0.3;
|
||||
lamp.userData.uphillStationId = station.station_id;
|
||||
lamp.userData.uphillSide = side;
|
||||
stationGroup.add(lamp);
|
||||
// 클릭 판정은 예전 크기 그대로 — 투명 히트 구를 겹쳐 "작게 보여도 잘 눌리게".
|
||||
const lampHit = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(1.1, 8, 6),
|
||||
new THREE.MeshBasicMaterial({ transparent: true, opacity: 0, depthWrite: false }),
|
||||
);
|
||||
lampHit.position.copy(lamp.position);
|
||||
lampHit.userData.uphillStationId = station.station_id;
|
||||
lampHit.userData.uphillSide = side;
|
||||
stationGroup.add(lampHit);
|
||||
});
|
||||
});
|
||||
// 새로 만든 라벨에도 지금 솎기 단계를 그대로 먹인다.
|
||||
applyLabelStep();
|
||||
@@ -527,7 +493,7 @@ export function createRouteMarkers(
|
||||
function selectStation(stationId: string | null): void {
|
||||
selectedStationId = stationId;
|
||||
stationGroup.children.forEach((object) => {
|
||||
// 측점 띠(stationId 보유 Mesh)만 색을 바꾼다 — 램프(uphill*)는 제외.
|
||||
// 측점 띠(stationId 보유 Mesh)만 색을 바꾼다.
|
||||
if (!(object instanceof THREE.Mesh) || typeof object.userData.stationId !== "string") return;
|
||||
const selected = object.userData.stationId === selectedStationId;
|
||||
(object.material as THREE.MeshBasicMaterial).color.set(selected ? 0xef4444 : 0xfacc15);
|
||||
@@ -553,14 +519,6 @@ export function createRouteMarkers(
|
||||
updateSelected,
|
||||
deleteSelected,
|
||||
selectObject(object: THREE.Object3D | undefined) {
|
||||
// 상단측 램프 클릭: 해당 측을 측구 방향으로 지정(측점 선택보다 우선 판정).
|
||||
if (typeof object?.userData.uphillStationId === "string") {
|
||||
uphillPickListener?.(
|
||||
object.userData.uphillStationId,
|
||||
object.userData.uphillSide as "left" | "right",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (typeof object?.userData.stationId === "string") {
|
||||
selectStation(object.userData.stationId);
|
||||
selectionListener?.(null);
|
||||
@@ -610,9 +568,6 @@ export function createRouteMarkers(
|
||||
onStationSelectionChange(listener: (stationId: string | null) => void) {
|
||||
stationSelectionListener = listener;
|
||||
},
|
||||
onUphillPick(listener: (stationId: string, side: "left" | "right") => void) {
|
||||
uphillPickListener = listener;
|
||||
},
|
||||
dispose() {
|
||||
disposeGroup(interactionGroup);
|
||||
disposeGroup(routeGroup);
|
||||
|
||||
@@ -17,8 +17,6 @@ export interface StationLinesInput {
|
||||
liveSamples: ReadonlyArray<{ chainage_m: number; elevation_m: number }> | null;
|
||||
/** [예상형상] 켜짐 — 측점 바를 계획고 위로 올린다. */
|
||||
corridorVisible: boolean;
|
||||
/** 상단측(측구 방향) 사용자 변경분 — 키는 누가거리 3자리. */
|
||||
uphillOverrides: Map<string, "left" | "right">;
|
||||
}
|
||||
|
||||
export function stationLinesOf(input: StationLinesInput) {
|
||||
@@ -60,8 +58,6 @@ export function stationLinesOf(input: StationLinesInput) {
|
||||
// max 로 잡으면 코리도가 절취해 내려간 노면을 두고 막대만 원지반에 떠 있다.
|
||||
// 코리도가 켜져 있으면(designAt 이 값을 줌) 계획고를 그대로 쓴다.
|
||||
center_z: design !== null && station.center_z !== null ? design : station.center_z,
|
||||
uphill_side:
|
||||
input.uphillOverrides.get(station.chainage_m.toFixed(3)) ?? station.uphill_side ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,13 +62,13 @@ export function createViewControls(callbacks: ViewControlCallbacks): {
|
||||
toggleButton("지표면", true, callbacks.onSurfaceVisible),
|
||||
// 예상형상(2026-08-23): ON=공사 후 형상(기본), OFF=원지반 완전체.
|
||||
toggleButton("예상형상", true, callbacks.onCorridorVisible),
|
||||
toggleButton("등고선", true, callbacks.onContoursVisible),
|
||||
// 등고선 · 흑백 지형 기본 꺼짐(176-2) — 뷰어 처음 값과 맞춘다.
|
||||
toggleButton("등고선", false, callbacks.onContoursVisible),
|
||||
toggleButton("축 표시", false, callbacks.onAxesVisible),
|
||||
toggleButton(L("B05_Route_Field_StationLines"), true, callbacks.onStationLinesVisible),
|
||||
toggleButton(L("B05_Route_Field_StationLabels"), true, callbacks.onStationLabelsVisible),
|
||||
// 무지개 고도색이 헷갈릴 때 명도만 남기는 흑백 표시(2026-08-05 사용자 요청).
|
||||
// 기본 켜짐(2026-08-23 사용자 확정) — 예상형상 색이 지형 고도색에 묻히지 않는다.
|
||||
toggleButton("흑백 지형", true, callbacks.onSurfaceGrayscale),
|
||||
toggleButton("흑백 지형", false, callbacks.onSurfaceGrayscale),
|
||||
);
|
||||
// [3D 업데이트](2026-09-01 사용자 지시) — 계획선을 고칠 때마다 3D가 따라오면 조작이
|
||||
// 무거워, 갱신을 이 버튼으로 모았다. 편집이 쌓이면 is-pending으로 알리고, 누르는 동안은
|
||||
|
||||
@@ -182,6 +182,7 @@ export function createRouteViewer(): RouteViewer {
|
||||
return heightIndex.triangleCount > 0 ? heightIndex : null;
|
||||
}
|
||||
const contours = new THREE.Group();
|
||||
contours.visible = false; // 기본 꺼짐(176-2) — 패널 토글 처음 값과 맞춘다.
|
||||
scene.add(contours);
|
||||
let bounds: ModelBounds | null = null;
|
||||
let current: { projectId: string; modelId: number; smooth: boolean; interval: number } | null =
|
||||
@@ -386,8 +387,8 @@ export function createRouteViewer(): RouteViewer {
|
||||
|
||||
// 지표면 흑백 표시(2026-08-05 사용자 요청). 정점색 배열을 한 번 바꿔치기하는 것뿐이라
|
||||
// (원본은 색 버퍼 열쇠 `ORIGINAL_COLORS`에 보관, 재질·셰이더 교체 없음) 로딩·회전 속도에 영향이 없다.
|
||||
// 기본 흑백 지형(2026-08-23 사용자 확정) — 패널 토글 초기값과 맞춘다.
|
||||
let surfaceGrayscale = true;
|
||||
// 기본 꺼짐(176-2) — 패널 토글 초기값과 맞춘다.
|
||||
let surfaceGrayscale = false;
|
||||
function applySurfaceGrayscale(): void {
|
||||
[terrain, corridor.clipped()].forEach((target) => applyGrayscaleTo(target));
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ const { taubin } = loadTs(B05("B05_Profile_UI_Corridor_Loft_Grid"));
|
||||
const { TerrainBandSplit } = loadTs(B05("B05_Profile_UI_Corridor_Split"));
|
||||
const { TerrainHeightIndex } = loadTs(B05("B05_Profile_UI_Corridor_Terrain"));
|
||||
const PSLG = loadTs(B05("B05_Profile_UI_Corridor_Tin_Pslg"));
|
||||
const { SEAM_M } = loadTs(B05("B05_Profile_UI_Corridor_Tin"));
|
||||
|
||||
const HALF = 3;
|
||||
const ROAD_Z = 100;
|
||||
@@ -257,7 +258,7 @@ function gapEdges(tris, out, original) {
|
||||
return { gaps, maxGapLen };
|
||||
}
|
||||
|
||||
/** 공사 밖(설계 구역 · 구멍 밖 · 둘레에서 1 mm 넘게 떨어진) 원래 정점 중 결과 망에서 사라진 수. */
|
||||
/** 공사 밖(설계 구역 · 구멍 밖 · 둘레에서 SEAM_M 넘게 떨어진) 원래 정점 중 결과 망에서 사라진 수. */
|
||||
function lostVertices(tris, before, out) {
|
||||
const region = new PSLG.LoopIndex([out.design.outer, ...out.design.holes]);
|
||||
const border = new PSLG.SegGrid(2);
|
||||
@@ -274,7 +275,7 @@ function lostVertices(tris, before, out) {
|
||||
for (let v = 0; v < before.length / 3; v += 1) {
|
||||
const x = before[v * 3];
|
||||
const z = before[v * 3 + 2];
|
||||
if (region.contains(x, z) || border.passesNear(x, z, 1e-3)) continue;
|
||||
if (region.contains(x, z) || border.passesNear(x, z, SEAM_M)) continue;
|
||||
if (!present.has(key(x, before[v * 3 + 1], z))) lost += 1;
|
||||
}
|
||||
return lost;
|
||||
@@ -450,6 +451,8 @@ function daylightPoints(out, bounds) {
|
||||
let sectionErr = 0;
|
||||
for (const side of [1, -1]) {
|
||||
for (let s = 0.05; s <= 12; s += 0.05) {
|
||||
// 비탈 끝(s 9) 바깥 이음 띠(SEAM_M + 격자 0.5 m)는 지형 굴곡을 긴 삼각형으로 펴므로 뺀다(176-1).
|
||||
if (s > 9 && s < 9 + SEAM_M + 0.5) continue;
|
||||
const z = tin(0, -side * (HALF + s)); // 씬 x = 20 − 20
|
||||
if (z === null) continue;
|
||||
sectionErr = Math.max(sectionErr, Math.abs(z + 100 - Math.max(ROAD_Z - s / 1.5, 94)));
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
/* PLAN 176-1 단위검증 헬퍼 — B05 3D 성토 비탈 끝 이음 띠(SEAM_M) · 성토 덩이 끝 뜸 줄이기.
|
||||
* 직선 노선 80 m · 측점 0 · 20 · 40 성토(노면 100 · 지반 94 · 1:1.5) · 60 · 80 절토(지반 104 · 1:1).
|
||||
* 지반에 노선 방향 사인 물결(진폭 1 m · 파장 10 m · 측점 자리 0) → 성토 비탈 끝이 한도까지 뜸.
|
||||
* 트랜스파일 틀 · 격자 지형 · 틈 셈은 helper_172_1_loft_wrinkle.cjs 와 같다. 결과는 표준출력 JSON.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const NM = path.join(ROOT, "config", "node_modules");
|
||||
const ts = require(path.join(NM, "typescript"));
|
||||
const THREE = require(path.join(NM, "three"));
|
||||
const LIBS = {
|
||||
three: THREE,
|
||||
delaunator: require(path.join(NM, "delaunator", "index.js")),
|
||||
"three/examples/jsm/utils/BufferGeometryUtils.js": require(path.join(NM, "three", "examples", "jsm", "utils", "BufferGeometryUtils.js")),
|
||||
"@kninnug/constrainautor": {
|
||||
__esModule: true,
|
||||
default: require(path.join(NM, "@kninnug", "constrainautor", "lib", "Constrainautor.cjs")),
|
||||
},
|
||||
};
|
||||
|
||||
const cache = new Map();
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (LIBS[request]) return LIBS[request];
|
||||
if (request.startsWith("@ui/") || request.startsWith("@config/")) {
|
||||
return new Proxy({}, { get: () => () => undefined });
|
||||
}
|
||||
const target = request.startsWith("@util/")
|
||||
? path.join(ROOT, "common_util", request.slice(6))
|
||||
: path.join(path.dirname(full), request);
|
||||
if (request.endsWith(".json")) return JSON.parse(fs.readFileSync(target, "utf8"));
|
||||
return loadTs(fs.existsSync(`${target}.ts`) ? `${target}.ts` : path.join(target, "index.ts"));
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const B05 = (name) => path.join(ROOT, "B05_Profile", `${name}.ts`);
|
||||
const { buildCorridor } = loadTs(B05("B05_Profile_UI_Corridor_Build"));
|
||||
const { loftTerrain } = loadTs(B05("B05_Profile_UI_Corridor_Loft"));
|
||||
const { TerrainBandSplit } = loadTs(B05("B05_Profile_UI_Corridor_Split"));
|
||||
const { TerrainHeightIndex } = loadTs(B05("B05_Profile_UI_Corridor_Terrain"));
|
||||
const PSLG = loadTs(B05("B05_Profile_UI_Corridor_Tin_Pslg"));
|
||||
|
||||
const { SEAM_M } = loadTs(B05("B05_Profile_UI_Corridor_Tin"));
|
||||
|
||||
const HALF = 3;
|
||||
const ROAD_Z = 100;
|
||||
const LENGTH = 80;
|
||||
const STATIONS = [0, 20, 40, 60, 80];
|
||||
const AMP = 1;
|
||||
const WAVE_M = 10;
|
||||
const r6 = (v) => Math.round(v * 1e6) / 1e6;
|
||||
|
||||
/** 측점 단면 — 성토(지반 94 · 1:1.5) 또는 절토(지반 104 · 1:1). */
|
||||
function station(chainage) {
|
||||
const fill = chainage <= 40;
|
||||
const ground = fill ? 94 : 104;
|
||||
const toe = HALF + Math.abs(ROAD_Z - ground) * (fill ? 1.5 : 1);
|
||||
const pts = [
|
||||
[-40, ground],
|
||||
[-toe, ground],
|
||||
[-HALF, ROAD_Z],
|
||||
[-2.5, ROAD_Z],
|
||||
[2.5, ROAD_Z],
|
||||
[HALF, ROAD_Z],
|
||||
[toe, ground],
|
||||
[40, ground],
|
||||
];
|
||||
const samples = [];
|
||||
for (let o = -40; o <= 40; o += 1) samples.push({ offset_m: o, elevation_m: ground });
|
||||
return {
|
||||
station_id: `S${chainage}`,
|
||||
chainage_m: chainage,
|
||||
label: "",
|
||||
kind: "regular",
|
||||
center_x: chainage,
|
||||
center_y: 0,
|
||||
center_z: ground,
|
||||
frame: { left_xy: [0, 1] },
|
||||
samples,
|
||||
design: {
|
||||
section_mode: fill ? "both_fill" : "both_cut",
|
||||
road_edges: {
|
||||
left: { offset_m: HALF, elevation_m: ROAD_Z },
|
||||
right: { offset_m: -HALF, elevation_m: ROAD_Z },
|
||||
},
|
||||
carriageway_edges: {
|
||||
left: { offset_m: 2.5, elevation_m: ROAD_Z },
|
||||
right: { offset_m: -2.5, elevation_m: ROAD_Z },
|
||||
},
|
||||
design_line: pts.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })),
|
||||
ditch: { type: "none" },
|
||||
ditch_enabled: false,
|
||||
ditch_side: "left",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const BOUNDS = { x: [-10, 90], y: [-30, 30], z: [85, 115] };
|
||||
const OX = (BOUNDS.x[0] + BOUNDS.x[1]) / 2;
|
||||
const OY = (BOUNDS.y[0] + BOUNDS.y[1]) / 2;
|
||||
const OZ = (BOUNDS.z[0] + BOUNDS.z[1]) / 2;
|
||||
const X0 = -10;
|
||||
const X1 = 90;
|
||||
const Y0 = -30;
|
||||
const Y1 = 30;
|
||||
const STEP = 0.5;
|
||||
|
||||
function gridTerrain(heightAt) {
|
||||
const nx = Math.round((X1 - X0) / STEP) + 1;
|
||||
const ny = Math.round((Y1 - Y0) / STEP) + 1;
|
||||
const positions = new Float32Array(nx * ny * 3);
|
||||
for (let j = 0; j < ny; j += 1) {
|
||||
for (let i = 0; i < nx; i += 1) {
|
||||
const v = j * nx + i;
|
||||
const x = X0 + i * STEP;
|
||||
const y = Y0 + j * STEP;
|
||||
positions[v * 3] = x - OX;
|
||||
positions[v * 3 + 1] = heightAt(x, y) - OZ;
|
||||
positions[v * 3 + 2] = -(y - OY);
|
||||
}
|
||||
}
|
||||
const index = [];
|
||||
for (let j = 0; j < ny - 1; j += 1) {
|
||||
for (let i = 0; i < nx - 1; i += 1) {
|
||||
const a = j * nx + i;
|
||||
if ((i + j) % 2) index.push(a, a + 1, a + nx, a + 1, a + nx + 1, a + nx);
|
||||
else index.push(a, a + nx + 1, a + nx, a, a + 1, a + nx + 1);
|
||||
}
|
||||
}
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setAttribute("color", new THREE.BufferAttribute(new Uint8Array(nx * ny * 4).fill(200), 4, true));
|
||||
geometry.setIndex(index);
|
||||
return new THREE.Mesh(geometry, new THREE.MeshLambertMaterial({ vertexColors: true }));
|
||||
}
|
||||
|
||||
const key = (x, y, z) => `${Math.round(x * 1e4)},${Math.round(y * 1e4)},${Math.round(z * 1e4)}`;
|
||||
const pkey = (x, z) => `${Math.round(x * 1e4)},${Math.round(z * 1e4)}`;
|
||||
const fkey = (x, z) => pkey(Math.fround(x), Math.fround(z));
|
||||
|
||||
/** 한 번만 쓰인 변 중 지형 바깥 테두리 · 노면 끝 · 구멍 둘레가 아닌 것(틈). */
|
||||
function gapCount(root, out) {
|
||||
const pslg = out.design.pslg;
|
||||
const tagAt = new Map();
|
||||
for (let i = 0; i < pslg.count; i += 1) tagAt.set(fkey(pslg.x[i], pslg.z[i]), pslg.tag[i]);
|
||||
const onFrame = (p) => {
|
||||
const x = p[0] + OX;
|
||||
const y = -p[2] + OY;
|
||||
return Math.abs(x - X0) < 1e-3 || Math.abs(x - X1) < 1e-3 || Math.abs(y - Y0) < 1e-3 || Math.abs(y - Y1) < 1e-3;
|
||||
};
|
||||
const uses = new Map();
|
||||
root.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh)) return;
|
||||
const p = child.geometry.getAttribute("position");
|
||||
const idx = child.geometry.getIndex();
|
||||
const count = idx ? idx.count : p.count;
|
||||
for (let t = 0; t < count; t += 3) {
|
||||
const tri = [0, 1, 2].map((k) => {
|
||||
const v = idx ? idx.getX(t + k) : t + k;
|
||||
return [p.getX(v), p.getY(v), p.getZ(v)];
|
||||
});
|
||||
const [a, b, c] = tri;
|
||||
if (Math.abs((b[0] - a[0]) * (c[2] - a[2]) - (c[0] - a[0]) * (b[2] - a[2])) < 1e-9) continue;
|
||||
for (let k = 0; k < 3; k += 1) {
|
||||
const ka = key(...tri[k]);
|
||||
const kb = key(...tri[(k + 1) % 3]);
|
||||
const e = ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`;
|
||||
const entry = uses.get(e) ?? { n: 0, a: tri[k], b: tri[(k + 1) % 3] };
|
||||
entry.n += 1;
|
||||
uses.set(e, entry);
|
||||
}
|
||||
}
|
||||
});
|
||||
let gaps = 0;
|
||||
for (const { n, a, b } of uses.values()) {
|
||||
if (n !== 1) continue;
|
||||
const ta = tagAt.get(pkey(a[0], a[2]));
|
||||
const tb = tagAt.get(pkey(b[0], b[2]));
|
||||
if (ta === PSLG.TAG_ANCHOR && tb === PSLG.TAG_ANCHOR) continue;
|
||||
if (ta === PSLG.TAG_HOLE && tb === PSLG.TAG_HOLE) continue;
|
||||
if (onFrame(a) && onFrame(b)) continue;
|
||||
gaps += 1;
|
||||
}
|
||||
return gaps;
|
||||
}
|
||||
|
||||
/** 성토 비탈 끝 둘레 점 · 이음 띠 경사 · 덩이 끝 뜸 · 틈. */
|
||||
function run() {
|
||||
const base = (x) => (x <= 40 ? 94 : x >= 60 ? 104 : 94 + (x - 40) / 2);
|
||||
const terrain = gridTerrain((x) => base(x) + AMP * Math.sin((2 * Math.PI * x) / WAVE_M));
|
||||
const sections = STATIONS.map(station);
|
||||
const build = buildCorridor(sections, [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: LENGTH, y: 0 },
|
||||
]);
|
||||
build.sections = sections;
|
||||
build.structures = [];
|
||||
const split = new TerrainBandSplit(terrain, { minX: -1e4, maxX: 1e4, minZ: -1e4, maxZ: 1e4 });
|
||||
const heightIndex = new TerrainHeightIndex(terrain);
|
||||
const out = loftTerrain(split, build, BOUNDS, heightIndex);
|
||||
const pslg = out.design.pslg;
|
||||
const groundAt = (x, y) => heightIndex.heightAt(x - OX, -(y - OY)) + OZ;
|
||||
|
||||
// 성토 비탈 끝(설계 둘레 · 성토 꼬리표) — 측마다 높이 − 지형 · 덩이 끝(가장 큰 x) 점.
|
||||
const outer = new Set(out.design.outer.map(([x, z]) => pkey(x, z)));
|
||||
const rim = new Set();
|
||||
const ends = { 1: { x: -Infinity, off: 0 }, [-1]: { x: -Infinity, off: 0 } };
|
||||
let midOff = 0;
|
||||
for (let i = 0; i < pslg.count; i += 1) {
|
||||
if (pslg.tag[i] !== PSLG.TAG_FILL || !outer.has(pkey(pslg.x[i], pslg.z[i]))) continue;
|
||||
rim.add(pkey(pslg.x[i], pslg.z[i]));
|
||||
const x = pslg.x[i] + OX;
|
||||
const y = -pslg.z[i] + OY;
|
||||
const off = Math.abs(pslg.y[i] + OZ - groundAt(x, y));
|
||||
if (x > 5 && x < 35) midOff = Math.max(midOff, off);
|
||||
const end = ends[y > 0 ? 1 : -1];
|
||||
if (x > end.x) Object.assign(end, { x, off });
|
||||
}
|
||||
|
||||
// 이음 띠 — 성토 비탈 끝 점을 쓰는 원지반 색 삼각형의 경사(°).
|
||||
const mesh = out.root.getObjectByName("terrain-tin-mesh");
|
||||
const p = mesh.geometry.getAttribute("position");
|
||||
const keep = mesh.userData.keepColor;
|
||||
const slopes = [];
|
||||
for (let t = 0; t < p.count / 3; t += 1) {
|
||||
if (keep[t * 3]) continue;
|
||||
const v = [0, 1, 2].map((k) => [p.getX(t * 3 + k), p.getY(t * 3 + k), p.getZ(t * 3 + k)]);
|
||||
if (!v.some(([x, , z]) => rim.has(pkey(x, z)))) continue;
|
||||
// 지반 바탕이 평탄한 성토 구간만(지형 자체 경사 ≤ 32°) — 40~60 m 오르막은 지형이 45° 넘음.
|
||||
const cx = (v[0][0] + v[1][0] + v[2][0]) / 3 + OX;
|
||||
if (cx < 2 || cx > 38) continue;
|
||||
const u = [0, 1, 2].map((k) => v[1][k] - v[0][k]);
|
||||
const w = [0, 1, 2].map((k) => v[2][k] - v[0][k]);
|
||||
const n = [u[1] * w[2] - u[2] * w[1], u[2] * w[0] - u[0] * w[2], u[0] * w[1] - u[1] * w[0]];
|
||||
const len = Math.hypot(...n);
|
||||
if (len < 1e-9) continue;
|
||||
slopes.push((Math.acos(Math.abs(n[1]) / len) * 180) / Math.PI);
|
||||
}
|
||||
slopes.sort((a, b) => a - b);
|
||||
return {
|
||||
seam: SEAM_M,
|
||||
stats: out.stats,
|
||||
gaps: gapCount(out.root, out),
|
||||
rimPoints: rim.size,
|
||||
seamTris: slopes.length,
|
||||
seamP99: r6(slopes[Math.floor(0.99 * (slopes.length - 1))] ?? 0),
|
||||
midOff: r6(midOff),
|
||||
endX: [r6(ends[1].x), r6(ends[-1].x)],
|
||||
endOff: r6(Math.max(ends[1].off, ends[-1].off)),
|
||||
};
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(run()));
|
||||
@@ -0,0 +1,45 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PLAN 176-1 — B05 3D 성토 비탈 끝 깨짐(172 뒤) 고침 검사.
|
||||
|
||||
도우미(helper_176_1_seam.cjs) — 직선 80 m · 성토(0~40 m) → 절토(60~80 m) · 지반 사인 물결(진폭 1 m).
|
||||
비탈 끝 바깥 SEAM_M(1.5 m) 안 지형 점 빼서 잇는 띠 완만 · 성토 덩이 끝 3 m 안 뜸 0 까지 줄임.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def r():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_176_1_seam.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=240,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_seam_band_gentle(r):
|
||||
# 성토 비탈 끝을 쓰는 원지반 삼각형 경사 p99 < 45°(1 mm 띠 = 54.7°)
|
||||
assert r["seam"] == 1.5
|
||||
assert r["seamTris"] > 100, r
|
||||
assert r["seamP99"] < 45, r
|
||||
|
||||
|
||||
def test_run_end_lift_tapered(r):
|
||||
# 성토 덩이 가운데는 한도까지 뜸(물결이 실제로 재어짐) · 절토로 바뀌는 끝은 0 근처
|
||||
assert r["midOff"] > 0.4, r
|
||||
assert r["endX"][0] > 45 and r["endX"][1] > 45, r
|
||||
assert r["endOff"] < 0.02, r
|
||||
|
||||
|
||||
def test_no_gap_no_failed_constraint(r):
|
||||
assert r["gaps"] == 0, r
|
||||
assert r["stats"]["failed"] == 0, r
|
||||
@@ -0,0 +1,27 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PLAN 176-2 — B05 3D 흑백 지형 · 등고선 처음 값 false(단추 · 뷰어 두 곳 다)."""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")
|
||||
|
||||
|
||||
def _read(name):
|
||||
with open(os.path.join(ROOT, "B05_Profile", name), encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def test_panel_buttons_start_off():
|
||||
src = _read("B05_Profile_UI_Panel_View.ts")
|
||||
assert re.search(r'toggleButton\("등고선", false,', src)
|
||||
assert re.search(r'toggleButton\("흑백 지형", false,', src)
|
||||
|
||||
|
||||
def test_viewer_starts_off():
|
||||
src = _read("B05_Profile_UI_Viewer.ts")
|
||||
assert "let surfaceGrayscale = false;" in src
|
||||
# 등고선 그룹은 만들자마자 숨김(적재 때 선은 그대로 만들고 단추로 켬)
|
||||
assert re.search(
|
||||
r"const contours = new THREE\.Group\(\);\s*\n\s*contours\.visible = false;", src
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PLAN 176-3 — B05 3D 측점 노란 띠 끝 주황 구슬(측구 방향 고르기) · 클릭 · 처리기 지움.
|
||||
|
||||
저장 길(세션 uphill → uphill_overrides)과 B06 이 쓰는 종단 병합 함수는 남김.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")
|
||||
|
||||
|
||||
def _read(*parts):
|
||||
with open(os.path.join(ROOT, *parts), encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def test_markers_have_no_lamp():
|
||||
src = _read("B05_Profile", "B05_Profile_UI_Markers.ts")
|
||||
for gone in ("uphillStationId", "onUphillPick", "0xf97316", "UPHILL_LAMP", "lampHit"):
|
||||
assert gone not in src, gone
|
||||
|
||||
|
||||
def test_page_has_no_lamp_handler():
|
||||
src = _read("A00_Common", "design", "design_view_profile.ts")
|
||||
assert "onUphillPick" not in src
|
||||
assert "uphillOverrides" not in _read("B05_Profile", "B05_Profile_UI_Page_Stations.ts")
|
||||
|
||||
|
||||
def test_save_path_and_merge_kept():
|
||||
# 저장 길(세션 → 저장 몸통)과 B06 이 부르는 종단 병합 함수는 그대로
|
||||
page = _read("A00_Common", "design", "design_view_profile.ts")
|
||||
assert "persistUphillOverrides" in page
|
||||
assert "uphill_overrides" in _read("B05_Profile", "B05_Profile_UI_Page_Actions.ts")
|
||||
assert "def _merge_uphill_overrides_into_longitudinal" in _read(
|
||||
"B05_Profile", "B05_Profile_Router_Confirm.py"
|
||||
)
|
||||
Reference in New Issue
Block a user