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:
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user