feat(B05): 3D 새 ① 브레이크라인 국소 재삼각분할 · 구조물 둘레 드레이프 · 밴드 좁힘 · 3D 방식 고르기 한 줄(115-5 P1 · P2)
- 노면 끝 · 비탈 끝 · 단면 꺾임 · 비탈 마구리 · 구조물 윗면 외곽 · 관 입구 선을 지형에 끼워 걸친 삼각형만 다시 나눔(변 교점 꼭짓점 번호 순 식 · 일직선 마디 되살림) · 색은 면 단위 - 구조물 구멍은 윗면 외곽만 · 풋프린트 안 · 둘레 0.75 m 띠는 솔리드 윗면 아래로 · 관 입구만 구멍 - 손대는 자리 = 행별 비탈 끝 + 5 m 띠(밖 지형 그대로) - 3D 보기 줄 「3D 방식: 지금 · 옛 A · 새 ① · 제안 ②」 — 방식 등록부(B05_Profile_UI_Corridor_Modes.ts) · 설정 키 corridor-mode 하나(옛 두 키 읽어서 옮김) · 기본 새 ① · 제안 ② 는 빈자리(단추 꺼짐) - 226 실측: T자 균열(1 cm 넘음) 298 → 0 · 구조물 둘레 0.1 m 넘음 57 % → 12 % · 색 번짐 p90 3.1 → 0 m · 발끝 어긋남 p90 2.0 → 0.15 m Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
This commit is contained in:
@@ -44,8 +44,8 @@ const r4 = (v) => (v === null || v === undefined ? v : Math.round(v * 10000) / 1
|
||||
const HALF = 3; // 노면부 반폭(차도 2.5 + 노견 0.5)
|
||||
const ROAD_Z = 100;
|
||||
|
||||
/** 직선 노선(x 축) 위 측점 하나 — 지반 평탄, 양측 같은 비탈(경사 1:n). */
|
||||
function station(chainage, mode, groundZ, ratio) {
|
||||
/** 직선 노선 위 측점 하나 — 지반 평탄, 양측 같은 비탈(경사 1:n). 노선은 x 축에서 angle 만큼 돈다. */
|
||||
function station(chainage, mode, groundZ, ratio, angle = 0) {
|
||||
const w = Math.abs(groundZ - ROAD_Z) * ratio;
|
||||
const pts = [
|
||||
[-30, groundZ],
|
||||
@@ -64,10 +64,10 @@ function station(chainage, mode, groundZ, ratio) {
|
||||
chainage_m: chainage,
|
||||
label: "",
|
||||
kind: "regular",
|
||||
center_x: chainage,
|
||||
center_y: 0,
|
||||
center_x: chainage * Math.cos(angle),
|
||||
center_y: chainage * Math.sin(angle),
|
||||
center_z: groundZ,
|
||||
frame: { left_xy: [0, 1] },
|
||||
frame: { left_xy: [-Math.sin(angle), Math.cos(angle)] },
|
||||
samples,
|
||||
design: {
|
||||
section_mode: mode,
|
||||
@@ -187,4 +187,221 @@ const results = {};
|
||||
results.rowPlace = { alongMax, lateralMax, folds };
|
||||
}
|
||||
|
||||
const { deformTerrain } = loadTs(B05("B05_Profile_UI_Corridor_Deform_Terrain"));
|
||||
const { TerrainBandSplit } = loadTs(B05("B05_Profile_UI_Corridor_Split"));
|
||||
const { TerrainHeightIndex } = loadTs(B05("B05_Profile_UI_Corridor_Terrain"));
|
||||
|
||||
/** 격자 지형(모델 좌표 [x0,x1]×[y0,y1] · 칸 step) — 높이 함수 · 정점색 200. */
|
||||
function gridTerrain(x0, x1, y0, y1, step, heightAt, bounds) {
|
||||
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;
|
||||
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 - cx;
|
||||
positions[v * 3 + 1] = heightAt(x, y) - cz;
|
||||
positions[v * 3 + 2] = -(y - cy);
|
||||
}
|
||||
}
|
||||
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;
|
||||
index.push(a, a + 1, a + nx, a + 1, a + nx + 1, a + nx);
|
||||
}
|
||||
}
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
const colors = new Uint8Array(nx * ny * 4).fill(200);
|
||||
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 4, true));
|
||||
geometry.setIndex(index);
|
||||
return new THREE.Mesh(geometry, new THREE.MeshLambertMaterial({ vertexColors: true }));
|
||||
}
|
||||
|
||||
function shape(build, bounds, terrain) {
|
||||
const split = new TerrainBandSplit(terrain, { minX: -200, maxX: 200, minZ: -200, maxZ: 200 });
|
||||
return deformTerrain(split, build, bounds, new TerrainHeightIndex(terrain));
|
||||
}
|
||||
|
||||
/** 변형 지형 삼각형들 — 모델 좌표 [x,y,z]×3 · 정점 칠 표식. */
|
||||
function trianglesOf(out, bounds) {
|
||||
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;
|
||||
const tris = [];
|
||||
out.root.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh) || !child.userData.owned) return;
|
||||
const p = child.geometry.getAttribute("position");
|
||||
const idx = child.geometry.getIndex();
|
||||
const keep = child.userData.keepColor;
|
||||
for (let t = 0; t < idx.count; t += 3) {
|
||||
const vs = [0, 1, 2].map((k) => idx.getX(t + k));
|
||||
tris.push({
|
||||
pts: vs.map((v) => [p.getX(v) + cx, -p.getZ(v) + cy, p.getY(v) + cz]),
|
||||
keep: vs.map((v) => keep[v]),
|
||||
});
|
||||
}
|
||||
});
|
||||
return tris;
|
||||
}
|
||||
|
||||
/** 점을 덮는 삼각형들의 높이 — 넓이 0 은 뺀다. */
|
||||
function heightsAt(tris, x, y) {
|
||||
const out = [];
|
||||
for (const { pts } of tris) {
|
||||
const [a, b, c] = pts;
|
||||
const den = (b[1] - c[1]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[1] - c[1]);
|
||||
if (Math.abs(den) < 1e-12) continue;
|
||||
const w0 = ((b[1] - c[1]) * (x - c[0]) + (c[0] - b[0]) * (y - c[1])) / den;
|
||||
const w1 = ((c[1] - a[1]) * (x - c[0]) + (a[0] - c[0]) * (y - c[1])) / den;
|
||||
const w2 = 1 - w0 - w1;
|
||||
if (w0 < -1e-7 || w1 < -1e-7 || w2 < -1e-7) continue;
|
||||
out.push(w0 * a[2] + w1 * b[2] + w2 * c[2]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ④ 브레이크라인(P1-3) — 격자에 비스듬한 노선(31°) · 성토 1:1.5 · 울퉁불퉁 지형. 모든 정점에서
|
||||
// 덮는 삼각형 높이가 같다(T자 균열 0) · 색은 면 단위 · 비탈 끝 선 위 높이 = 원지반.
|
||||
{
|
||||
const angle = (31 * Math.PI) / 180;
|
||||
const sections = [0, 15, 30, 45].map((c) => station(c, "both_fill", 96, 1.5, angle));
|
||||
const route = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 45 * Math.cos(angle), y: 45 * Math.sin(angle) },
|
||||
];
|
||||
const build = buildCorridor(sections, route);
|
||||
build.sections = sections;
|
||||
const bounds = { x: [-40, 80], y: [-40, 70], z: [80, 110] };
|
||||
const ground = (x, y) => 96 + 0.3 * Math.sin(x * 0.7) + 0.2 * Math.cos(y * 0.9);
|
||||
const out = shape(build, bounds, gridTerrain(-20, 60, -20, 50, 0.97, ground, bounds));
|
||||
const tris = trianglesOf(out, bounds);
|
||||
let worst = 0;
|
||||
let checked = 0;
|
||||
const seen = new Set();
|
||||
for (const { pts } of tris) {
|
||||
for (const [x, y] of pts) {
|
||||
const key = `${Math.round(x * 1e4)},${Math.round(y * 1e4)}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const hs = heightsAt(tris, x, y);
|
||||
if (hs.length < 2) continue;
|
||||
checked += 1;
|
||||
worst = Math.max(worst, Math.max(...hs) - Math.min(...hs));
|
||||
}
|
||||
}
|
||||
const mixed = tris.filter(({ keep }) => keep.some((k) => k) && keep.some((k) => !k)).length;
|
||||
// 비탈 끝 선(가운데 행 좌측) 위 높이 = 원지반.
|
||||
const field = deform.buildDeformField(build, deform.stationProfileSource(sections), ground);
|
||||
const row = Math.floor(field.rows.length / 2);
|
||||
const [tx, ty] = field.pointAt(row, 0, field.catchAt(row, 0, 0));
|
||||
const toe = heightsAt(tris, tx, ty);
|
||||
results.breakline = {
|
||||
lines: out.stats.lines,
|
||||
split: out.stats.split,
|
||||
fallback: out.stats.fallback,
|
||||
checked,
|
||||
worst,
|
||||
mixed,
|
||||
toeGap: toe.length ? Math.max(...toe.map((h) => Math.abs(h - ground(tx, ty)))) : null,
|
||||
};
|
||||
}
|
||||
|
||||
// ⑤ 구조물 둘레(P1-4) — 20 측점 좌측 옹벽(기초 5~7 · 높이 95.5 / 벽체 5~5.6 · 상단 99.5).
|
||||
// 지형(100)이 벽 상단보다 높다. 구멍은 벽체 윗면만 · 기초 위 · 둘레 띠는 상단(99.5)까지 내림 ·
|
||||
// 띠 밖은 그대로.
|
||||
{
|
||||
const sections = [0, 20, 40].map((c) => station(c, "both_fill", 100, 1.5));
|
||||
const build = buildCorridor(sections, routeOf(sections));
|
||||
build.sections = sections;
|
||||
const ring = (cx) => ({ cx, cy: 0, leftX: 0, leftY: 1, dz: 0 });
|
||||
build.structures = [
|
||||
{
|
||||
chainage_m: 20,
|
||||
kind: "revet",
|
||||
polygon: [
|
||||
[5, 95],
|
||||
[7, 95],
|
||||
[7, 95.5],
|
||||
[5.6, 95.5],
|
||||
[5.6, 99.5],
|
||||
[5, 99.5],
|
||||
],
|
||||
rings: [ring(18), ring(20), ring(22)],
|
||||
},
|
||||
];
|
||||
const bounds = { x: [-10, 50], y: [-20, 20], z: [90, 110] };
|
||||
const out = shape(
|
||||
build,
|
||||
bounds,
|
||||
gridTerrain(-10, 50, -20, 20, 0.5, () => 100, bounds),
|
||||
);
|
||||
const tris = trianglesOf(out, bounds);
|
||||
const one = (x, y) => {
|
||||
const hs = heightsAt(tris, x, y);
|
||||
return hs.length ? Math.round(Math.max(...hs) * 1e4) / 1e4 : null;
|
||||
};
|
||||
results.drape = {
|
||||
holes: out.stats.holes,
|
||||
stemTop: one(20, 5.3), // 벽체 윗면 = 구멍
|
||||
footing: one(20, 6.5), // 기초 위 = 묻음 · 상단까지
|
||||
band: one(20, 7.4), // 둘레 띠(0.75 m) 안
|
||||
outside: one(20, 9), // 띠 밖 = 그대로
|
||||
endBand: one(22.5, 5.3), // 구조물 끝 너머 띠
|
||||
};
|
||||
}
|
||||
|
||||
// ⑥ 3D 방식 등록부(고르기 한 줄) — 자리 넷 · 등록 셋 · 제안 ② 빈자리 · 옛 두 키 옮겨 읽기.
|
||||
{
|
||||
const store = () => {
|
||||
const proto = {
|
||||
getItem(k) {
|
||||
return Object.prototype.hasOwnProperty.call(this, k) ? this[k] : null;
|
||||
},
|
||||
setItem(k, v) {
|
||||
this[k] = String(v);
|
||||
},
|
||||
removeItem(k) {
|
||||
delete this[k];
|
||||
},
|
||||
key(i) {
|
||||
return Object.keys(this)[i] ?? null;
|
||||
},
|
||||
};
|
||||
Object.defineProperty(proto, "length", {
|
||||
get() {
|
||||
return Object.keys(this).length;
|
||||
},
|
||||
});
|
||||
return Object.create(proto);
|
||||
};
|
||||
globalThis.window = { localStorage: store(), sessionStorage: store() };
|
||||
const modes = loadTs(B05("B05_Profile_UI_Corridor_Modes"));
|
||||
const state = loadTs(path.join(ROOT, "A00_Common", "b_page_state.ts"));
|
||||
const picks = {};
|
||||
picks.fresh = modes.corridorModeId();
|
||||
state.writeStateRaw("corridor-deform", "0");
|
||||
picks.oldDeformOff = modes.corridorModeId();
|
||||
state.writeStateRaw("corridor-legacy", "1");
|
||||
picks.oldLegacyOn = modes.corridorModeId();
|
||||
modes.setCorridorMode("proposal2");
|
||||
picks.proposal = modes.corridorModeId();
|
||||
picks.proposalDraws = modes.activeCorridorMode().id;
|
||||
modes.setCorridorMode("new1");
|
||||
picks.set = modes.corridorModeId();
|
||||
results.modes = {
|
||||
slots: modes.MODE_SLOTS.map(([id, label]) => `${id}:${label}`),
|
||||
registered: modes.CORRIDOR_MODES.map((mode) => mode.id),
|
||||
defaultMode: modes.DEFAULT_MODE,
|
||||
newHasTerrain: typeof modes.modeOf("new1").terrain === "function",
|
||||
...picks,
|
||||
};
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(results));
|
||||
|
||||
@@ -62,6 +62,7 @@ def test_legacy_state_key_registered():
|
||||
registry = (ROOT / "A00_Common" / "b_page_state.ts").read_text(encoding="utf-8")
|
||||
assert '"corridor-legacy": { bucket: "pref", scope: "global" }' in registry
|
||||
assert '"corridor-deform": { bucket: "pref", scope: "global" }' in registry
|
||||
panel = (ROOT / "B05_Profile" / "B05_Profile_UI_Panel_View.ts").read_text(encoding="utf-8")
|
||||
assert 'toggleButton("옛 방식 A(실루엣)", corridorLegacyOn(), setCorridorLegacy)' in panel
|
||||
assert 'toggleButton("새 방식", corridorDeformOn(), setCorridorDeform)' in panel
|
||||
# 115-5 — 단추 둘은 고르기 한 줄 「3D 방식」로 옮겼다(옛 키 값은 읽어서 옮김).
|
||||
modes = (ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Modes.ts").read_text(encoding="utf-8")
|
||||
assert '["legacy-a", "옛 A"]' in modes
|
||||
assert 'readStateRaw("corridor-legacy") === "1"' in modes
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
"""PLAN 115-5 — B05 3D 제안 ① 새 방식 개선 합성 검사.
|
||||
|
||||
Node 도우미(helper_115_5_corridor_improve.cjs)가 TS 를 트랜스파일해 돌리고, 여기서는
|
||||
결과(JSON)를 단언한다. 비탈 폭주 0 · 조각 규칙 · 행 자리 0 m.
|
||||
결과(JSON)를 단언한다. 비탈 폭주 0 · 조각 규칙 · 행 자리 0 m · 브레이크라인 T자 균열 0 ·
|
||||
구조물 둘레(윗면 구멍 · 드레이프) · 3D 방식 등록부(고르기 한 줄).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -44,3 +46,43 @@ def test_row_place_follows_route_length(results):
|
||||
assert r["alongMax"] < 1e-6 # 행 자리 = 누가거리(0 m)
|
||||
assert r["lateralMax"] < 1e-6 # 노선 위
|
||||
assert r["folds"] == 0
|
||||
|
||||
|
||||
def test_breakline_no_t_cracks_and_face_colours(results):
|
||||
r = results["breakline"]
|
||||
assert r["split"] > 0 and r["fallback"] == 0
|
||||
assert r["checked"] > 1000
|
||||
assert r["worst"] < 1e-4 # 같은 자리 덮는 삼각형 높이 같음(T자 균열 0)
|
||||
assert r["mixed"] == 0 # 색은 면 단위 — 한 삼각형에 칠 · 원지반 섞임 없음
|
||||
assert r["toeGap"] < 0.02 # 비탈 끝 선 위 = 원지반
|
||||
|
||||
|
||||
def test_structure_top_hole_and_drape(results):
|
||||
r = results["drape"]
|
||||
assert r["holes"] == 1
|
||||
assert r["stemTop"] is None # 벽체 윗면만 구멍
|
||||
assert r["footing"] == 99.5 # 기초 위는 묻고 벽 상단까지 내림
|
||||
assert r["band"] == 99.5 # 둘레 띠 안
|
||||
assert r["endBand"] == 99.5 # 끝 너머 띠
|
||||
assert r["outside"] == 100 # 띠 밖은 그대로
|
||||
|
||||
|
||||
def test_mode_registry_and_migration(results):
|
||||
r = results["modes"]
|
||||
assert r["slots"] == ["current:지금", "legacy-a:옛 A", "new1:새 ①", "proposal2:제안 ②"]
|
||||
assert r["registered"] == ["current", "legacy-a", "new1"] # 제안 ② 는 다른 창 몫(빈자리)
|
||||
assert r["defaultMode"] == "new1" and r["fresh"] == "new1"
|
||||
assert r["newHasTerrain"] is True
|
||||
assert r["oldDeformOff"] == "current" # 옛 「새 방식」 끔 → 지금
|
||||
assert r["oldLegacyOn"] == "legacy-a" # 옛 「옛 방식 A」 켬 → 옛 A
|
||||
assert r["proposal"] == "proposal2" and r["proposalDraws"] == "new1" # 미등록 → 기본으로 그림
|
||||
assert r["set"] == "new1"
|
||||
|
||||
|
||||
def test_mode_row_replaces_old_toggles():
|
||||
root = Path(HERE).parents[1]
|
||||
registry = (root / "A00_Common" / "b_page_state.ts").read_text(encoding="utf-8")
|
||||
assert '"corridor-mode": { bucket: "pref", scope: "global" }' in registry
|
||||
panel = (root / "B05_Profile" / "B05_Profile_UI_Panel_View.ts").read_text(encoding="utf-8")
|
||||
assert "MODE_SLOTS.map" in panel and '"3D 방식"' in panel
|
||||
assert 'toggleButton("새 방식"' not in panel and "옛 방식 A(실루엣)" not in panel
|
||||
|
||||
@@ -22,7 +22,8 @@ if str(PROJECT_ROOT) not in sys.path:
|
||||
# 저장 형식(serialize/deserialize)은 브라우저·서버 공용 모듈로 옮겼다(2026-09-04 분리).
|
||||
CORRIDOR = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Envelope.ts"
|
||||
# 지형 스냅 가드는 뷰어에서 코리도 조각으로 옮겼다(계획서 24-3a · 700줄 나눔).
|
||||
VIEWER = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Viewer_Corridor.ts"
|
||||
# 115-5 — 심 보정(snapCorridorEdges)은 방식 등록부로 옮겼다.
|
||||
VIEWER = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Modes.ts"
|
||||
CARVE_WING = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Carve_Wing.ts"
|
||||
CARVE = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Carve.ts"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user