fix(b05): 3D 흑백 지형 단추가 예상형상 지형에도 먹게 — 분할본 원래 색 · far 색 버퍼 따로 · 원래 색 = 색 버퍼 열쇠(135-2)

- _Split: 사본 색을 지형 원래 색(ORIGINAL_COLORS)으로 · far 색 버퍼 clone
- 뷰어 applyGrayscaleTo: 원래 색 기억을 메시 userData → 색 버퍼 열쇠(WeakMap) · ③ 사면 keepColor 그대로
- 새 시험 test_135_2_grayscale(+ 도우미)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
This commit is contained in:
2026-10-09 14:59:29 +09:00
co-authored by Claude Opus 5.5
parent 87ffe800ed
commit 895c6d4d3a
4 changed files with 158 additions and 9 deletions
+30 -6
View File
@@ -20,6 +20,23 @@ export interface SceneBox {
maxZ: number;
}
/**
* 정점색 원본 — 색 버퍼 열쇠. 흑백 지형(뷰어 `applyGrayscaleTo`)이 덮기 전 값을 담는다.
* 메시가 아니라 버퍼에 묶어 같은 버퍼를 새 메시가 감싸도 원본은 한 번만 기억한다.
*/
export const ORIGINAL_COLORS = new WeakMap<THREE.BufferAttribute, Float32Array>();
/** 사본 색을 지형 원래 색으로 — 흑백이 먼저 덮었어도 분할본은 원본 색에서 시작한다. */
function withOriginalColors(
copy: THREE.BufferGeometry,
from: THREE.BufferGeometry,
): THREE.BufferGeometry {
const original = ORIGINAL_COLORS.get(from.getAttribute("color") as THREE.BufferAttribute);
const color = copy.getAttribute("color") as THREE.BufferAttribute | undefined;
if (original && color) (color.array as Float32Array).set(original);
return copy;
}
/** 지형 조각 하나 — 씬 좌표로 구운 geometry와 그릴 재질. */
export interface TerrainPart {
geometry: THREE.BufferGeometry;
@@ -39,14 +56,20 @@ export class TerrainBandSplit {
if (child instanceof THREE.Points) {
// 포인트클라우드(meshfree)는 통째로 near에 둔다 — 클리핑이 점 단위라 가볍다.
this.near.push({
geometry: child.geometry.clone().applyMatrix4(child.matrixWorld),
geometry: withOriginalColors(
child.geometry.clone().applyMatrix4(child.matrixWorld),
child.geometry,
),
material: (child.material as THREE.Material).clone(),
points: true,
});
return;
}
if (!(child instanceof THREE.Mesh)) return;
const source = child.geometry.clone().applyMatrix4(child.matrixWorld);
const source = withOriginalColors(
child.geometry.clone().applyMatrix4(child.matrixWorld),
child.geometry,
);
const split = splitGeometryByBand(source, band);
const material = Array.isArray(child.material)
? child.material.map((m) => m.clone())
@@ -82,7 +105,8 @@ export class TerrainBandSplit {
/**
* 삼각형 소속을 밴드로 갈라 인덱스만 나눈다 — 정점 버퍼는 두 조각이 **공유**하므로
* 쪼개는 비용이 인덱스 복사뿐이다(정점 복사·재배치 없음).
* 쪼개는 비용이 인덱스 복사뿐이다(정점 복사·재배치 없음). 색 버퍼만 far 가 따로 —
* 화면에 그대로 얹히는 far 를 흑백으로 덮어도 near(③ 망 색 원천)는 원래 색 그대로.
*/
function splitGeometryByBand(
geometry: THREE.BufferGeometry,
@@ -106,16 +130,16 @@ function splitGeometryByBand(
const outside = maxX < band.minX || minX > band.maxX || maxZ < band.minZ || minZ > band.maxZ;
(outside ? farIndices : nearIndices).push(ia, ib, ic);
}
const make = (indices: number[]): THREE.BufferGeometry | null => {
const make = (indices: number[], ownColor: boolean): THREE.BufferGeometry | null => {
if (!indices.length) return null;
const part = new THREE.BufferGeometry();
part.setAttribute("position", position);
const color = geometry.getAttribute("color");
if (color) part.setAttribute("color", color);
if (color) part.setAttribute("color", ownColor ? color.clone() : color);
const normal = geometry.getAttribute("normal");
if (normal) part.setAttribute("normal", normal);
part.setIndex(indices);
return part;
};
return { near: make(nearIndices), far: make(farIndices) };
return { near: make(nearIndices, false), far: make(farIndices, true) };
}
+8 -3
View File
@@ -23,6 +23,7 @@ import {
type StructurePickControls,
} from "./B05_Profile_UI_Viewer_Structure_Pick";
import { TerrainHeightIndex } from "./B05_Profile_UI_Corridor_Terrain";
import { ORIGINAL_COLORS } from "./B05_Profile_UI_Corridor_Split";
type ViewKind = "iso" | "top" | "front" | "side";
@@ -384,7 +385,7 @@ export function createRouteViewer(): RouteViewer {
(window as unknown as { __routeViewer?: typeof probe }).__routeViewer = probe;
// 지표면 흑백 표시(2026-08-05 사용자 요청). 정점색 배열을 한 번 바꿔치기하는 것뿐이라
// (원본은 userData에 보관, 재질·셰이더 교체 없음) 로딩·회전 속도에 영향이 없다.
// (원본은 색 버퍼 열쇠 `ORIGINAL_COLORS`에 보관, 재질·셰이더 교체 없음) 로딩·회전 속도에 영향이 없다.
// 기본 흑백 지형(2026-08-23 사용자 확정) — 패널 토글 초기값과 맞춘다.
let surfaceGrayscale = true;
function applySurfaceGrayscale(): void {
@@ -396,8 +397,12 @@ export function createRouteViewer(): RouteViewer {
const color = geometry?.getAttribute("color") as THREE.BufferAttribute | undefined;
if (!geometry || !color) return;
const target = color.array as Float32Array;
if (!child.userData.originalColors) child.userData.originalColors = target.slice();
const original = child.userData.originalColors as Float32Array;
// 원래 색은 색 버퍼 열쇠로 한 번만 기억한다 — 같은 far 조각을 새 메시가 감싸도 회색을 원본으로 안 잡는다.
let original = ORIGINAL_COLORS.get(color);
if (!original) {
original = target.slice();
ORIGINAL_COLORS.set(color, original);
}
const stride = color.itemSize;
// 새 방식 절성토 색(115-1)은 흑백에서도 남긴다.
const keep = child.userData.keepColor as Uint8Array | undefined;
@@ -0,0 +1,96 @@
/* PLAN 135-2 — 흑백 지형 단추: 분할본 색 = 지형 원래 색 · far 색 버퍼 따로 · 원래 색 기억 = 색 버퍼 열쇠.
* TS 를 트랜스파일해 돌리고 결과(JSON)를 낸다(helper_115_8 과 같은 방식). */
const fs = require("fs");
const path = require("path");
const ROOT = path.resolve(__dirname, "..", "..");
const NM = path.join(ROOT, "config", "node_modules");
const ts = require(path.join(NM, "typescript"));
const THREE = require(path.join(NM, "three"));
function loadTs(file) {
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, (r) =>
r === "three" ? THREE : require(r),
);
return box.exports;
}
const B05 = (name) => path.join(ROOT, "B05_Profile", `${name}.ts`);
const { TerrainBandSplit, ORIGINAL_COLORS } = loadTs(B05("B05_Profile_UI_Corridor_Split"));
// 뷰어 applyGrayscaleTo 와 같은 규칙(색 버퍼 열쇠로 원본 한 번만 기억).
function gray(object, on) {
object.traverse((child) => {
const color = child.geometry?.getAttribute("color");
if (!color) return;
const target = color.array;
let original = ORIGINAL_COLORS.get(color);
if (!original) {
original = target.slice();
ORIGINAL_COLORS.set(color, original);
}
if (on) {
for (let i = 0; i + 2 < original.length; i += 3) {
const g = 0.299 * original[i] + 0.587 * original[i + 1] + 0.114 * original[i + 2];
target[i] = target[i + 1] = target[i + 2] = g;
}
} else target.set(original);
});
}
// 지형 — 40 × 40 m 격자 · 색 = 빨강 → 초록(회색과 다름).
const N = 21;
const pos = [];
const col = [];
const idx = [];
for (let j = 0; j < N; j += 1)
for (let i = 0; i < N; i += 1) {
pos.push(i * 2 - 20, 0, j * 2 - 20);
col.push(1, i / (N - 1), 0);
}
for (let j = 0; j + 1 < N; j += 1)
for (let i = 0; i + 1 < N; i += 1) {
const a = j * N + i;
idx.push(a, a + N, a + 1, a + 1, a + N, a + N + 1);
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3));
geometry.setAttribute("color", new THREE.Float32BufferAttribute(col, 3));
geometry.setIndex(idx);
const terrain = new THREE.Group();
terrain.add(new THREE.Mesh(geometry, new THREE.MeshLambertMaterial({ vertexColors: true })));
const original = Float32Array.from(col);
gray(terrain, true); // 기본 흑백 — 지형 적재 직후 회색 먼저.
const split = new TerrainBandSplit(terrain, { minX: -5, maxX: 5, minZ: -5, maxZ: 5 });
const near = split.near[0].geometry.getAttribute("color");
const far = split.far[0].geometry.getAttribute("color");
const same = (a, b) => a.length === b.length && a.every((v, k) => Math.abs(v - b[k]) < 1e-6);
const r = {};
r.nearOriginal = same(near.array, original);
r.farOriginal = same(far.array, original);
r.farOwnBuffer = near !== far && near.array !== far.array;
// far 를 감싼 메시 두 번(③ 다시 짬) — 흑백 → 끔이 원래 색으로.
const wrap = () => {
const root = new THREE.Group();
root.add(new THREE.Mesh(split.far[0].geometry, split.far[0].material));
return root;
};
const first = wrap();
gray(first, true);
r.nearUntouchedByFarGray = same(near.array, original);
const second = wrap();
gray(second, true); // 새 메시 — 회색을 원본으로 잡으면 안 됨.
gray(second, false);
r.farBackToOriginal = same(far.array, original);
gray(terrain, false);
r.terrainBackToOriginal = same(geometry.getAttribute("color").array, original);
// 뷰어가 같은 열쇠를 쓰는가.
const viewer = fs.readFileSync(B05("B05_Profile_UI_Viewer"), "utf8");
r.viewerUsesKey =
viewer.includes("ORIGINAL_COLORS.get(color)") && !viewer.includes("userData.originalColors");
process.stdout.write(JSON.stringify(r));
+24
View File
@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
"""PLAN 135-2 — B05 3D 흑백 지형 단추 검사.
도우미(helper_135_2_grayscale.cjs)가 분할본을 만들고 뷰어 흑백 규칙을 흉내 내어 결과(JSON)를 낸다.
분할본 색 = 지형 원래 색(흑백이 먼저 덮었어도) · far 색 버퍼 따로 · 원래 색 기억 = 색 버퍼 열쇠.
"""
import json
import os
import subprocess
HERE = os.path.dirname(os.path.abspath(__file__))
def test_grayscale_toggle_restores_original_colors():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_135_2_grayscale.cjs")],
capture_output=True,
text=True,
timeout=120,
)
assert proc.returncode == 0, proc.stderr
r = json.loads(proc.stdout)
assert all(r.values()), r