diff --git a/B05_Profile/B05_Profile_UI_Corridor_Split.ts b/B05_Profile/B05_Profile_UI_Corridor_Split.ts index a51503b01..dc031d5b5 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Split.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Split.ts @@ -20,6 +20,23 @@ export interface SceneBox { maxZ: number; } +/** + * 정점색 원본 — 색 버퍼 열쇠. 흑백 지형(뷰어 `applyGrayscaleTo`)이 덮기 전 값을 담는다. + * 메시가 아니라 버퍼에 묶어 같은 버퍼를 새 메시가 감싸도 원본은 한 번만 기억한다. + */ +export const ORIGINAL_COLORS = new WeakMap(); + +/** 사본 색을 지형 원래 색으로 — 흑백이 먼저 덮었어도 분할본은 원본 색에서 시작한다. */ +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) }; } diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index 78ddd2885..64591f2cc 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -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; diff --git a/resources/tester/helper_135_2_grayscale.cjs b/resources/tester/helper_135_2_grayscale.cjs new file mode 100644 index 000000000..69b3ad7e2 --- /dev/null +++ b/resources/tester/helper_135_2_grayscale.cjs @@ -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)); diff --git a/resources/tester/test_135_2_grayscale.py b/resources/tester/test_135_2_grayscale.py new file mode 100644 index 000000000..74ac5b033 --- /dev/null +++ b/resources/tester/test_135_2_grayscale.py @@ -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