PNCAD 실사 렌더링 설계 (design-photoreal)
- 작성: [코디] 26.10.12(월) · 근거 = 조사 R1(렌더러·훅·위험) · R2(환경맵·톤맵·그림자·AO·경로추적 실측) · R3(칼라→재질·텍스처)
- 범위: A 설계 상세 + A 작업단위. B 는 사전설계, C 는 후보 비교만. 운영 배포 없음(테스트 배포까지).
- 행번호 =
C:\dev\pncad\app.html 5657행(401,680B) 기준. 편집은 문자열 앵커로 한다.
1. 목표 · 현황 (사실 5줄)
- 목표 = 가구 3D 화면의 «실사 느낌»을 돈 0원·서버 GPU 없이 최대화. 렌더는 전부 클라이언트(사무실 PC 내장 GPU · 미니PC N100급).
- 메인 렌더러(
app.html 1130)는 톤매핑 없음(NoToneMapping)·그림자 꺼짐·환경맵 없음·AO 없음·텍스처 0개. 조명은 Hemisphere 1.15 + Directional 1.5(1149-1151). three@0.160.0(importmap 879-884).
- 재질은
colorMat(hex,door)(1185-1190) 단색 MeshStandardMaterial roughness .85. 색은 cmHex(code)(수동 hex 또는 CM_PAL 무채색 8단 순환). 칠하는 곳 = rebuild() 1628행 m.material = colorMat(cmHex(code),door).
- 렌더 루프(5578-5583)는 유휴에도 매 프레임
renderer.render. 썸네일은 별도 GL 컨텍스트 tRen(1880), 편집기(editor.js)도 자체 렌더러 → 메인 실사 모드가 둘에 영향 없음.
- 헤드리스 E2E = SwiftShader 소프트웨어 WebGL2, 비보안 컨텍스트 → fps·GPU 전용 버그 검증 불가. 측정은 이 PC(Radeon 780M)만 있고 N100 은 미측정.
2. 3단계 안 비교
|
A 실시간 PBR 업그레이드 |
B 브라우저 경로추적 스틸 |
C AI 후처리 · 서버 렌더 |
| 내용 |
환경맵(IBL)·ACES 톤매핑·그림자맵·GTAO·목재 텍스처·접촉그림자. 실시간 토글 «실사» |
three-gpu-pathtracer@0.0.17 로 «렌더» 버튼 → 수십~수백 샘플 누적 → PNG |
(C1) 스틸 캡처 → 나노바나나 img2img (C2) 집PC RTX4070 Blender Cycles |
| 비용(원) |
0원. CC0 텍스처 7장(≈1MB). 외부 API 없음 |
0원. CDN 모듈 2개(190KB+185KB) 동적 로드 |
C1 0원(웹UI 무료 쿼터 소모·수동). C2 0원 + 전기료 + 파이프라인 구축 |
| 비용(시간) |
개발 ≈8h(병렬 시 5~6h) |
개발 ≈6~8h |
C1 ≈2h 스크립트 + 건당 수 분. C2 ≈2~3일(glTF/DXF export·전송·큐) |
| 체감 효과 |
큼. 그림자·AO·재질감이 «도면 → 실물» 인상을 만든다. 실시간 조작 유지 |
가장 큼(GI·부드러운 그림자). 단 정지 이미지 |
C1 큼 but 형상 왜곡(환각) 위험. C2 최고 품질 but 느림 |
| 위험 |
내장 GPU/N100 에서 느림 → 유휴 0fps·조작 중 AO off·품질 2단으로 방어. 룩 변경은 토글 ON 때만 |
실측: D3D11(크롬 기본 ANGLE) + MIS ON = 전면 순백 깨짐, 컴파일 32~45초 메인스레드 정지. 방어책 필요(5장) |
C1 치수·부재 형상이 바뀔 수 있음(고객 제출물 부적합). C2 운영 복잡·집PC 켜야 함 |
| 전제 |
없음(현 구조 위에 모듈 추가) |
A 의 재질 정리 + 별도 오프스크린 렌더러 + 보안/EXT_color_buffer_float |
스틸 캡처 기능(B 의 PNG) |
추천 = A 지금 → B 다음 → C 선택.
- 이유1: A 는 비용 0·위험 가장 낮고(토글 OFF = 종전 룩 불변, 지연 로드) 매일 쓰는 화면 전체에 효과가 간다. B·C 의 재질·텍스처 자산도 A 에서 만든 것을 재사용한다.
- 이유2: B 는 GPU 백엔드별 깨짐(D3D11)이 실측돼 사무실 PC 실기 확인이 선행돼야 하고, C 는 정확도(치수) 문제로 «참고용 연출 이미지» 용도로만 의미가 있다. 근거가 달라지면(예: 사무실 PC 가 Vulkan/GL 안정, 고객이 연출 이미지를 원함) B 를 먼저 하고 C1 은 B 결과 PNG 위에 얹는다.
3. A 상세 설계
3-1. 모듈 구조
- 새 파일
C:\dev\pncad\photo.js (ES module). 정적 import 하지 않는다 → app.html 이 첫 «실사 ON» 때만 await import('./photo.js'). 효과: ① OFF 일 때 네트워크·메모리·셰이더 비용 0 ② photo.js 가 배포에서 빠지거나 CDN 이 막혀도 화면 전체가 빈 채로 죽지 않고 catch 로 «실사 불가» 처리(jointui.js 선례의 «모듈 링크 실패 = 화면 빔» 회피).
- importmap 추가 없음. photo.js 는 기존 매핑 그대로
three · three/addons/... 를 import (동적 import 한 모듈에도 importmap 적용):
js
import * as THREE from 'three';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { GTAOPass } from 'three/addons/postprocessing/GTAOPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
- 전역명 충돌 금지: photo.js 안에서
Environment·Texture·Image·Option·CSS·Node·Event 등을 최상위 이름으로 쓰지 않는다. 접두 PH_/ph. app.html 쪽 신규 이름은 PH(모듈 핸들)·showReal·setReal 만.
- 공개 API:
createPhoto(ctx) → { on, set(v), paint(mesh,code,door,hexFallback), afterRebuild(), shadowDirty(), dirty(), frame(), resize(w,h), info() }.
ctx (app.html 이 넘김): { renderer, scene, camera, hemi, sun, grid, gBody, gDoor, gZone, gWall, gDim, gMark, gSel, cmHexOf(code), manualOf(code), cmOn(), WALL_MAT, lineMats:[EDGE,DIM_MAT,GAP_MAT,MARK_MAT,WALL_EDGE,...SEL_M], selMats, hiMats, rebuild, resize, dprCap }. manualOf(code) = SCENE.colorMap.on && SCENE.colorMap.map[code] 원값.
3-2. app.html 최소 삽입점 (R1 hooks 그대로)
| # |
위치(행) |
삽입 내용 |
| H1 |
573-581 #vbar HTML |
bElev 뒤에 <button id="bReal" title="실사 (Ctrl+7)" aria-keyshortcuts="Control+7">실사<kbd aria-hidden="true">7</kbd></button>. 13px·높이 28·min-width 52 규칙. web-designer 상담 선행. 오른쪽 64px 비움(QA 헬퍼 44×44 회피) |
| H2 |
1128-1131 renderer/camera 생성 직후 |
변경 없음(모듈 초기화 때 톤매핑 켜지 않는다 — 켜면 CM_PAL 룩이 바뀜) |
| H3 |
1149-1151 조명 |
익명 add 를 변수 hemi·sun·grid 로 잡는다(동작 불변). ctx 로 전달 |
| H4 |
1203-1210 SEL_M 선택 선 재질 |
toneMapped:false 추가(OFF 에서도 무해: 톤매핑 없음). SEL_DOT(ShaderMaterial)은 그대로 |
| H5 |
1628 for (const [m,code,door] of painted) … |
PH && PH.on ? PH.paint(m,code,door,cmHex(code)) : (m.material = colorMat(cmHex(code),door)). 부재 메시 생성(1609)·painted push(1611)는 불변 |
| H6 |
rebuild() 끝(1658 직전, renderInst/save 앞) |
PH && PH.on && PH.afterRebuild() — 그림자 범위 재계산·needsUpdate·dirty |
| H7 |
1663-1671 hiMats |
변경 없음. PH.set() 이 토글 때 hiMats.clear() 후 rebuild() 호출(선택 재적용은 1645-1655 가 한다) |
| H8 |
3171-3190 이동 드래그 / 3280 moveDrag |
드래그 중 PH && PH.on && PH.shadowDirty(), 드래그 종료(pointerup) 시 PH.afterRebuild() 로 그림자 범위 재맞춤 |
| H9 |
4386-4391 tg 아래 / 4511-4518 개별 토글 |
let showReal = FOLD['app.real'] === true; tg('bReal', () => showReal, v => setReal(v)); — setReal 은 아래 3-9 |
| H10 |
4521-4528 VBAR_KEYS·keydown |
배열에 'bReal' 추가, 정규식 /^Digit([1-7])$/ |
| H11 |
4932-4939 resize() |
끝에 PH && PH.on && PH.resize(w,h) |
| H12 |
5578-5583 렌더 루프 |
renderer.render(scene,camera) → (PH && PH.on) ? PH.frame() : renderer.render(scene,camera) |
| H13 |
5586 window.PC2 = {…} |
E2E 훅: three:{scene,camera,sun,hemi,grid}, real:()=>PH?PH.info():{on:false}, setReal:v=>setReal(v), drawInfo:()=>renderer.info.render |
| H14 |
부팅 끝 |
if (showReal) setReal(true) (기본 꺼짐이라 대부분 건너뜀) |
setReal (H9):
async function setReal(v){
try {
if (v && !PH) PH = (await import('./photo.js')).createPhoto(ctx);
} catch (e) { console.error('photo.js load failed', e); showReal = false; syncRealBtn(); return; } // 화면은 종전 그대로
showReal = v; foldSet('app.real', v); // true 만 저장, 통째 setItem 금지
if (PH) PH.set(v); // 내부에서 hiMats.clear() + 환경/그림자/픽셀비 토글 + rebuild() + ctx.resize()
}
3-3. 실사 ON/OFF 일괄 상태 (photo.js set(v))
| 항목 |
ON |
OFF (원복 = 종전 룩 불변) |
renderer.toneMapping / toneMappingExposure |
ACESFilmicToneMapping / 1.15 (U5 에서 튜닝) |
NoToneMapping / 1 |
scene.environment |
PMREM(RoomEnvironment) |
null |
hemi.intensity |
0.12 |
1.15 |
sun.intensity / 위치 |
2.2 / bbox 맞춤(3-5) |
1.5 / (2000,3200,2600), target 원점, scene.remove(sun.target) |
sun.castShadow |
true |
false |
grid.visible |
false |
true |
바닥 접촉그림자 그룹 gFloor |
visible |
visible=false |
| 픽셀비 |
min(dpr, 1.5) (low 티어 1.0) |
min(dpr, 2) |
선 재질 EDGE.opacity |
0.28 |
0.5 |
선 재질 toneMapped |
false(합성 경로 실패 대비 직접 렌더 폴백용) |
true |
| composer 렌더타깃 |
사용 |
renderTarget1/2.dispose() (패스·셰이더는 유지 → 재ON 컴파일 0) |
hiMats |
clear() |
clear() |
재질 envMapIntensity |
finMats 0.45 · WALL_MAT 0.2 |
WALL_MAT 1.0(원값) |
renderer.shadowMap.enabled/type 은 첫 ON 때 한 번만 true / PCFSoftShadowMap 으로 세팅하고 그대로 둔다(그림자 광원이 없으면 셰이더에 그림자 코드가 안 들어감 → OFF 비용 0). 그때 cmMats·WALL_MAT 등 기존 캐시 재질에 needsUpdate=true 1회. 이후 ON/OFF 는 sun.castShadow 토글만(three 가 조명 해시로 프로그램 자동 재선택, 두 번째부터 컴파일 0).
3-4. 환경맵 · 톤매핑 (코드 + 수치)
const pm = new THREE.PMREMGenerator(renderer);
const room = new RoomEnvironment(renderer); // renderer 인자 필수(없으면 강도 5 → 암흑, 있으면 900)
envTex = pm.fromScene(room, 0.04).texture; // sigma 0.04
room.dispose(); pm.dispose();
scene.background = null; // alpha 캔버스 유지(#view CSS 그라디언트가 배경)
renderer.toneMapping = THREE.ACESFilmicToneMapping; // r160 에 Neutral 없음. AgX(=6)는 대안
renderer.toneMappingExposure = 1.15;
- r160 에는
scene.environmentIntensity 없음 → 재질별 envMapIntensity (finMats 0.45, 벽 0.2).
- 합격 수치(R2 육안): sun 2.2 · envMapIntensity 0.45 · hemi 0.12. 노출 0.9 에서
#F2F2F2 가 200~222 로 탁해짐 → 1.15 시작, U5 에서 «흰 부재 평균 픽셀이 OFF 의 85% 이상» 이 되게 1.1~1.3 안에서 조정. 안 되면 AgX.
- composer 경로에서는 톤매핑·sRGB 변환이 마지막
OutputPass 가 renderer.toneMapping 을 읽어 적용한다. 즉 renderer 에 설정 + OutputPass 가 마지막.
useLegacyLights 건드리지 않는다(deprecated 경고). 조명 단위는 이미 물리 단위.
3-5. 그림자 · 바닥 접촉그림자
const SUN_DIR = new THREE.Vector3(2000,3200,2600).normalize(); // 종전 sun 방향 유지
function fitShadow(){
const b = new THREE.Box3().setFromObject(gBody);
if (b.isEmpty()) { sun.castShadow = false; return; }
const s = b.getBoundingSphere(new THREE.Sphere()), R = Math.max(s.radius, 500), C = s.center; // mm
sun.position.copy(C).addScaledVector(SUN_DIR, R*1.5);
sun.target.position.copy(C); sun.target.updateMatrixWorld();
const c = sun.shadow.camera;
c.left=-R; c.right=R; c.top=R; c.bottom=-R; c.near=R*0.4; c.far=R*2.6; c.updateProjectionMatrix(); // 변경 후 필수
sun.castShadow = true;
floor.position.set(C.x, 1, C.z); const f = Math.max(9000, R*6); floor.scale.set(f,1,f);
renderer.shadowMap.needsUpdate = true; dirty();
}
// 초기화 1회
sun.shadow.mapSize.set(tier.shadow, tier.shadow); // high 2048 / low 1024
sun.shadow.bias = -0.0002; sun.shadow.normalBias = 1.5; // mm 단위
renderer.shadowMap.autoUpdate = false; // 정지 장면: 리빌드·이동 때만 needsUpdate
// 바닥 접촉그림자: gBody/gDoor/gZone 밖, scene 직속 새 그룹
const g = new THREE.PlaneGeometry(1,1); g.rotateX(-Math.PI/2);
const floor = new THREE.Mesh(g, new THREE.ShadowMaterial({ opacity: 0.28 }));
floor.receiveShadow = true; floor.raycast = () => {}; // 픽킹·cursorPoint 에 안 걸림
const gFloor = new THREE.Group(); gFloor.name = 'gFloor'; gFloor.add(floor); scene.add(gFloor);
PCFSoftShadowMap 은 shadow.radius 무시(고정 커널). 부드럽기 조절 필요 시 PCFShadowMap + radius. VSM 은 쓰지 않음.
- 부재:
paint() 안에서 비도어만 castShadow = receiveShadow = true. 도어(투명 .82)·존 박스·벽·선은 false(castShadow 가 알파를 무시해 불투명 그림자가 생김).
- 그림자 메모리: 2048² ≈16MB, 1024² ≈4MB(추정).
- 10m 규모 전체 장면은 미실측 → U5 에서
live_scene_33 으로 acne/뜬그림자 육안 확인, bias 조정.
3-6. AO (GTAOPass) · 합성 · 안티앨리어싱
const rt = new THREE.WebGLRenderTarget(cssW, cssH, { type: THREE.HalfFloatType, samples: tier.msaa }); // MSAA 는 RT 에만
composer = new EffectComposer(renderer, rt);
composer.setPixelRatio(renderer.getPixelRatio()); composer.setSize(cssW, cssH); // CSS 크기 기준(이중 스케일 방지)
composer.addPass(new RenderPass(scene, camera));
gtao = new GTAOPass(scene, camera, cssW, cssH); composer.addPass(gtao);
composer.addPass(new OutputPass()); // 톤매핑+sRGB 는 여기서
gtao.updateGtaoMaterial({ radius: 200, distanceExponent: 1.5, thickness: 250, scale: 1.4,
samples: tier.aoSamples, distanceFallOff: 1, screenSpaceRadius: false });
gtao.updatePdMaterial({ lumaPhi: 10, depthPhi: 2, normalPhi: 3, radius: 6, rings: 2, samples: tier.pdSamples });
- 단위 함정: radius/thickness 는 mm. 기본값(thickness 1)이면 AO 가 전 화소 순백(에러 없음). 위 값은 R2 에서 동작 확인.
- G-버퍼 패스가 투명·오버레이를 불투명으로 그려 AO 를 더럽힘 → 패치(R2 코드 + 그룹 단위 숨김):
js
const _ov = gtao.overrideVisibility.bind(gtao);
gtao.overrideVisibility = function(){
_ov(); // 전 객체 visible 을 캐시에 저장한 뒤
[gZone, gWall, gDim, gMark, gSel, gFloor].forEach(g => g.visible = false);
this.scene.traverse(o => { const m = o.material;
if (o.isMesh && (o.isLineSegments2 || (m && m.transparent))) o.visible = false; }); // 도어 포함
}; // restoreVisibility 가 캐시로 복구
(Raycaster 용 gZone 은 패스 동안만 숨기고 복구 — 규칙 «gZone visible 유지» 준수.)
- 한계 수용: 벽 평면이 투명이라 AO 에서 빠져 «벽-장 접합부» 벽 쪽은 안 어두워짐. 접촉그림자 + 장 내부 AO 로 충분.
- 안티앨리어싱: renderer
antialias:true 는 composer RT 에 효과 없음 → 위 samples MSAA(high 4 / low 2). HalfFloat+MSAA 는 EXT_color_buffer_float 필요 — 없거나 composer 생성 실패 시 직접 렌더 폴백(try/catch → mode='direct': renderer.render + 선 재질 toneMapped:false 로 색 보호).
- 알파:
alpha:true 캔버스 + RGBA HalfFloat → 코너 픽셀 rgba 0,0,0,0 유지(R2 검증).
- 선택 오버레이 분리:
gSel(주황 선·점)이 RT 안에서 그려지면 OutputPass 의 ACES 를 한 번 더 맞아 변색 → 합성 패스에서는 gSel.visible=false, 합성 후 오버레이 패스:
js
gSel.visible = selVis;
const vis = scene.children.map(o => o.visible);
scene.children.forEach(o => { if (o !== gSel && !o.isLight) o.visible = false; });
renderer.autoClear = false; renderer.render(scene, camera); renderer.autoClear = true;
scene.children.forEach((o,i) => o.visible = vis[i]);
SEL_M 은 H4 로 toneMapped:false(화면 직접 렌더라 색공간 변환은 종전과 동일), SEL_DOT 은 청크가 없어 sRGB 그대로. gDim/gMark/엣지 선은 깊이 가림을 지켜야 해서 합성 패스에 두고 ACES 영향(약한 명암·채도 변화)은 수용 — U5 육안 확인, 거슬리면 선 색을 ON 때만 보정.
3-7. 유휴 비용 0 · 조작 중 경량화 (photo.frame())
function frame(){
const now = performance.now();
if (camKeyChanged()) { lastMove = now; need = true; } // 카메라 위치·회전·fov·aspect·near/far 지문. Shift+휠 직접 회전도 잡힘
const settled = now - lastMove >= 150;
if (!need && !(settled && !aoDone) && now - lastRender < 250) return; // 하트비트 4fps: 누락된 dirty 소스도 250ms 내 자가치유
gtao.enabled = settled && tier.ao; aoDone = settled; // 조작 중 AO off, 멈추고 150ms 뒤 1프레임 AO
renderComposerAndOverlay(); need = false; lastRender = now; stats.composerFrames++;
}
- 정지 시 렌더 4fps 이하(종전 60fps 상시 대비 대폭 절감). 명시 dirty 소스: rebuild·resize·토글·드래그·선택·텍스처 로드 완료.
- 티어:
pickTier(gl) — UNMASKED_RENDERER 가 SwiftShader|Intel.*(UHD|HD Graphics)|Basic Render 면 low(shadow 1024 · pr 1.0 · AO 샘플 8 · MSAA 2), 아니면 high(2048 · 1.5 · 16 · 4). ?realq=low|high URL 파라미터로 강제(E2E 용).
- 측정(780M, 900×560): composer ≈1.4~1.9ms, +GTAO ≈4~6ms. 1080p 는 ×4.1. N100 은 미측정(10배+ 추정) → low 티어에서 AO 는 «정지 1프레임»만.
photo.info() 는 frameMsAvg(최근 60프레임 평균)를 노출 → 실기에서 PC2 로 읽어 측정.
3-8. 재질 · 텍스처 (코드 조각은 4장 데이터와 연결)
- 캐시 키 변경:
colorMat/cmMats 는 불변. 실사 재질은 별도 finMats Map(photo.js 내부), 키 = `${archetype||'-'}|${hex}|${rough}|${door?1:0}`. 같은 색인데 아키타입/광택이 다르면 별 재질. OFF 로 돌아가면 cmMats 그대로 쓰므로 종전 룩 보장.
- 부재 UV(BoxGeometry 면별 0~1 → 부재 크기에 늘어남) 해결 —
paint() 에서 지오메트리에 mm 비례 UV 를 굽는다(R2 검증 코드):
js
const FACE_UV_AXES = [[2,1],[2,1],[0,2],[0,2],[0,1],[0,1]]; // 축 0=x폭 1=y높이 2=z깊이 (px,nx,py,ny,pz,nz)
function bakeWoodUV(geo, dims, grainAxis, tile, offU, offV){
const uv = geo.attributes.uv;
for (let f=0; f<6; f++){ const [au,av] = FACE_UV_AXES[f];
for (let i=0; i<4; i++){ const k=f*4+i, u=uv.getX(k), v=uv.getY(k); let U,V;
if (grainAxis === av) { U = v*dims[av]/tile[0]; V = u*dims[au]/tile[1]; } // 결 축이 v → 90° 회전
else { U = u*dims[au]/tile[0]; V = v*dims[av]/tile[1]; }
uv.setXY(k, U+offU, V+offV); } }
uv.needsUpdate = true; geo.userData.baked = true; return geo;
}
dims = geo.parameters(width,height,depth) 또는 boundingBox 크기, grainAxis = 가장 긴 변. offU/offV = 부재 위치 해시(반복 무늬 방지, 재빌드해도 안정). ExtrudeGeometry(라운드) 는 UV 가 이미 mm → 전체를 1/tile 로 스케일만. miterMesh 도 Box 로 취급(꼭짓점 이동분 UV 오차는 수용).
- 결 방향이 세로인 이미지(oak_veneer_01·walnut_veneer)는 텍스처 객체에
center.set(.5,.5); rotation=Math.PI/2 를 줘서 «U 축 = 결»로 통일.
- 텍스처:
colorSpace=SRGBColorSpace, RepeatWrapping, 밉맵, anisotropy=min(4, max). 로드는 TextureLoader(상대경로 tex/…), 아키타입당 1회 캐시. 로드 전·실패 시 아키타입 hex 단색, 성공 시 material.map=tex; color.set(0xffffff); needsUpdate=true; dirty().
- 재질 종류:
MeshStandardMaterial{ color, map, roughness, metalness:0, envMapIntensity:.45 }. 도어: 텍스처 끄고 lighten(hex,.12) 단색, transparent:true, opacity:.82, depthWrite:false, castShadow false.
- 텍스처는 메인 GL 컨텍스트에만 올라감(≤7장, 1K 밉 포함 ≈5.6MB/장).
tRen·편집기와 무관.
hiMat() 은 base.clone() 시점 스냅샷이라 토글 시 hiMats.clear() + rebuild() 로 낡은 클론 제거.
3-9. R1 risks 전부 대응
| # |
R1 위험 |
대응 |
| 1 |
gSel 에 LineSegments2+Points 혼재, SEL_DOT 은 sRGB 직접 |
합성 후 오버레이 패스에서만 그림. SEL_M.toneMapped=false, SEL_DOT 불변. photo.js 는 gSel 에 자식을 추가/제거하지 않음 |
| 2 |
선택선 굵기·펄스 복귀값 |
굵기·후광 코드 미접촉 |
| 3 |
도어 기본 꺼짐, 숨김 도어 픽킹 |
showDoor 상태 그대로 존중. 도어 재질 단색 투명, 그림자·AO 제외. E2E 는 «상태 맞춤»(on 이면 click) |
| 4 |
카메라/fov 변경 금지(이동 E2E 런6) |
photo.js 는 camera 를 읽기만 한다(쓰기 0). E2E 가 ON 전후 camera.position·fov 동일 검사 |
| 5 |
우상단 64px 에 버튼 금지(test 에서만 QA 헬퍼 겹침) |
bReal 은 vbar 왼쪽 흐름(bElev 뒤). test E2E 에서 bReal 의 right 좌표 ≤ viewport-64 검사 |
| 6 |
colorMat 캐시 키=색, 색 외 요소 섞이면 stale |
colorMat 불변, 실사는 별 Map finMats(키에 아키타입·광택·도어 포함) |
| 7 |
라벨 배치는 카메라·캔버스 크기 기준 |
캔버스 CSS 크기·카메라 불변. setPixelRatio 후 resize() 로 setSize 동일 값 재적용 |
| 8 |
최상위 상수가 전역과 충돌 |
photo.js 모듈 스코프 + PH_/ph 접두. U2 에서 브라우저 import 로 검증 |
| 9 |
새 .js 는 FILES 3곳 · worktree · pfs 직접수정 금지 |
U6 에서 photo.js·data/finish-map.json·tex/*.jpg 3곳 등록, 개발은 _wt_photo worktree, pfs 는 sync 로만 |
| 10 |
pncad.fold 공용키, 3D 뷰 안 오버레이는 pointerdown 차단 |
foldSet('app.real') 만 사용. #view 안에 새 DOM 없음(버튼은 vbar) |
| 11 |
유휴 상시 렌더 |
3-7 dirty+하트비트+조작 중 AO off |
| 12 |
environmentIntensity 없음, RoomEnvironment 인자 |
재질별 envMapIntensity, new RoomEnvironment(renderer) |
| 13 |
톤매핑이 확정 표시색과 어긋남 |
ON 일 때만 ACES, OFF 원복. 기본 OFF |
| 14 |
투명·알파: castShadow/AO 오염 |
도어·존·벽 castShadow false, AO 패치로 숨김 |
| 15 |
composer: MSAA·알파·해상도 |
RT samples, RGBA HalfFloat, CSS 크기+setPixelRatio, resize() 훅(H11) |
| 16 |
그림자 mm 스케일·near/far |
fitShadow(): bbox 구(C,R)에 카메라 범위·bias(mm) 맞춤, target=C |
| 17 |
텍스처 UV 늘어남·결 방향 |
bakeWoodUV(mm/tile) + 긴 변 결 + 회전 텍스처 |
| 18 |
재질 dispose 없음·hiMats 낡은 클론 |
텍스처 ≤7 모듈 캐시 상주(수용), hiMats.clear(), composer RT 는 OFF 에서 dispose |
| 19 |
새 객체가 pickBody/fit() 에 먹힘 |
gFloor 는 scene 직속 별도 그룹 + raycast no-op. fit 은 gBody 만 |
| 20 |
썸네일 tRen 불변 |
matOf·tScene·tRen 미접촉. E2E 가 썸네일 dataURL 해시 ON/OFF 동일 확인 |
| 21 |
PC2.scene() 은 데이터 |
H13 로 PC2.three·PC2.real 신설 |
| 22 |
SwiftShader·비보안 컨텍스트 |
fps 검증 포기, 플래그·픽셀 차이로 검증 + frameMsAvg 실기 측정은 별도 |
| 23 |
로컬 /api/* 404, test DB 작업본 0건 |
live_scene_33 fixture route. 운영 배포 없음 |
| 24 |
web-designer·Playwright close·rules append |
U3 착수 전 web-designer, 모든 스크립트 try/finally br.close(), 확립된 함정은 seedchain\projects\pncad\rules.md 🆕 신규에 1줄 |
4. 재질 매핑
4-1. 아키타입 12종 (R3)
| 이름 |
hex |
텍스처 |
tileMm |
결 |
rough |
판별 키워드(요약) |
| white |
#F2F2F2 |
없음 |
- |
- |
0.6 |
화이트·WHITE·CHALK·백색·^WH\d |
| ivory |
#F1EBDD |
없음 |
- |
- |
0.6 |
아이보리·IVORY·크림·베이지·^IV\d |
| light_oak |
#E2CBB4 |
oak_veneer_03_1k.jpg |
1000 |
u |
0.55 |
라이트오크·WHITE OAK·메이플·\bASH\b |
| oak |
#A27F58 |
oak_veneer_01_1k.jpg |
1830 |
v |
0.55 |
오크·OAK·티크·체리·훈증 |
| walnut |
#8A6A51 |
walnut_veneer_1k.jpg |
1800 |
v |
0.5 |
월넛·WALNUT·다크브라운·^BR\d |
| grey_wood |
#9C9085 |
grey_oak_veneer_02_1k.jpg |
1000 |
u |
0.6 |
그레이 우드·GREY OAK·애쉬그레이 |
| dark_wood |
#6B6258 |
black_oak_veneer_1k.jpg |
1000 |
u |
0.55 |
블랙오크·다크오크·SMOKE |
| gray |
#A6A6A6 |
없음 |
- |
- |
0.65 |
그레이·GRAY·PLATINUM·도브그레이·실버·니켈·^GY\d |
| charcoal |
#404040 |
없음 |
- |
- |
0.6 |
차콜·CHARCOAL·진그레이·다크그레이·^CH\d |
| black |
#2B2B2B |
없음 |
- |
- |
0.5 |
블랙·BLACK·^BL\d |
| concrete |
#B9B9B9 |
Concrete034_1k.jpg |
[2000,1000] |
u |
0.8 |
콘크리트·CONCRETE·시멘트 |
| marble |
#EAEAEA |
Marble019_1k.jpg |
1500 |
u |
0.2 |
대리석·MARBLE |
4-2. data/finish-map.json 스키마 (C:\dev\pncad\data\finish-map.json)
{
"v": 1,
"archetypes": {
"oak": { "label": "오크", "kind": "wood", "hex": "#A27F58", "tex": "oak_veneer_01_1k.jpg", "tileMm": 1830, "grain": "v", "rough": 0.55 },
"white": { "label": "화이트", "kind": "solid", "hex": "#F2F2F2", "tex": null, "rough": 0.6 }
},
"exact": {
"N501": { "a": "oak", "hex": "#AB9B72", "src": "colorchip" },
"N505": { "a": "dark_wood","hex": "#33261F", "src": "colorchip" },
"N781": { "a": "walnut", "hex": "#806755", "src": "colorchip" },
"N209": { "a": "gray", "hex": "#6A6C6B", "src": "colorchip" },
"N228": { "a": "white", "hex": "#F7F9F6", "src": "colorchip" },
"N239": { "a": "ivory", "hex": "#A4A291", "src": "colorchip" },
"N420": { "a": "gray", "hex": "#D5D6D0", "src": "colorchip" },
"N421": { "a": "white", "hex": "#DDDED2", "src": "colorchip" },
"N445": { "a": "charcoal", "hex": "#5B5751", "src": "colorchip" },
"N448": { "a": "gray", "hex": "#C4C3B9", "src": "colorchip" },
"효산N401": { "a": "concrete", "hex": "#828380", "src": "colorchip" }
},
"rules": [
{ "re": "블랙오크|다크오크|SMOKE", "a": "dark_wood" },
{ "re": "그레이\\s*우드|GREY\\s*OAK|애쉬\\s*그레이", "a": "grey_wood" },
{ "re": "라이트오크|WHITE\\s*OAK|메이플|MAPLE|\\bASH\\b", "a": "light_oak" },
{ "re": "월넛|WALNUT|다크브라운|^BR\\d", "a": "walnut" },
{ "re": "오크|OAK|티크|TEAK|체리|훈증", "a": "oak" },
{ "re": "콘크리트|CONCRETE|시멘트", "a": "concrete" },
{ "re": "대리석|MARBLE", "a": "marble" },
{ "re": "차콜|CHARCOAL|진그레이|다크그레이|다크니켈|다크실버|^CH\\d", "a": "charcoal" },
{ "re": "블랙|BLACK|^BL\\d", "a": "black" },
{ "re": "그레이|GRAY|GREY|PLATINUM|실버|니켈|^GY\\d", "a": "gray" },
{ "re": "아이보리|IVORY|크림|베이지|^IV\\d", "a": "ivory" },
{ "re": "화이트|WHITE|CHALK|백색|^WH\\d", "a": "white" }
],
"rough": [
{ "re": "HG|유광|GLOSS", "r": 0.18 },
{ "re": "반광", "r": 0.4 },
{ "re": "무광|MATT|MT\\b|M$", "r": 0.7 }
],
"fallback": null
}
- 모든
re 는 iu 플래그. 규칙은 위에서 아래 첫 일치(나무·구체 규칙이 색상어보다 먼저: «WHITE OAK» 가 white 로 새는 것 방지).
- 조회 순서: 키 정규화(trim · 대문자 · 공백 접기) →
exact 에서 ① 전체 ② \s*\(.*\)$ 꼬리 제거(N239(SC)→N239) ③ / 앞 첫 칼라 → 없으면 rules 를 같은 3키 순으로 → 없으면 null.
null = 미판별 → 종전 cmHex(code) 색(CM_PAL 순환)으로 단색 PBR. 근거 없는 목재결을 붙이지 않는다(마스터 869코드 중 이름으로 판별 가능한 건 ≈16%, 페페 26.10.9 «마감재별 색 구분» 요구 유지).
- 1단계 제외:
@chip 규칙(N2xx·S###=SOLID 인데 색 불명 → 미판별), 상위 사용 코드 수동 지정(예: HS0868GP) — 근거 없는 추정 매핑 금지, 후속(8장).
rough 규칙은 판별된 모든 코드에 덮어쓰기(아키타입 rough 보다 우선).
4-3. 수동 hex 와 공존 규칙 (추천대로)
SCENE.colorMap.map[코드] 가 hex 문자열이면 그 단색이 이긴다(텍스처 끔, 옛 작업본 그대로). 이중 틴트 방지.
- 수동값이 없고
finish-map 이 판별하면 아키타입 텍스처/색.
- 판별 불가면
cmHex(code)(CM_PAL) 단색 PBR.
cmOnOf() 가 «색 구분 꺼짐» 상태면 finish 적용 안 함(흰색 단색 PBR). 구현자는 1175-1190·1626-1628 을 읽어 의미를 확인하고 U3 에서 확정.
- 아키타입 수동 선택 UI(
{a:'oak'} 객체값, renderColorMap 4563 확장)는 이번 범위 제외 — web-designer 상담·직렬화(4676 keep·4579 cmSet·hexOk 1175) 변경이 필요해 후속. 이번엔 문자열 hex 만 존재하므로 저장 포맷 변경 없음.
- 기본 «실사» 꺼짐 = 안전 롤아웃. 켠 사용자만 finish 적용.
4-4. 텍스처 파일 목록 (전부 CC0 · 1K · JPEG q80 재인코딩 ≈ 합 1MB)
파일(C:\dev\pncad\tex\) |
원본 URL |
라이선스 |
| oak_veneer_03_1k.jpg |
https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/oak_veneer_03/oak_veneer_03_diff_1k.jpg |
CC0 Poly Haven |
| oak_veneer_01_1k.jpg |
https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/oak_veneer_01/oak_veneer_01_diff_1k.jpg |
CC0 Poly Haven |
| walnut_veneer_1k.jpg |
https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/walnut_veneer/walnut_veneer_diff_1k.jpg |
CC0 Poly Haven |
| grey_oak_veneer_02_1k.jpg |
https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/grey_oak_veneer_02/grey_oak_veneer_02_diff_1k.jpg |
CC0 Poly Haven |
| black_oak_veneer_1k.jpg |
https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/black_oak_veneer/black_oak_veneer_diff_1k.jpg |
CC0 Poly Haven |
| Concrete034_1k.jpg |
https://ambientcg.com/get?file=Concrete034_1K-JPG.zip → Concrete034_1K-JPG_Color.jpg |
CC0 ambientCG |
| Marble019_1k.jpg |
https://ambientcg.com/get?file=Marble019_1K-JPG.zip → Marble019_1K-JPG_Color.jpg |
CC0 ambientCG |
(예비, --all 때만) white_maple_veneer / black_walnut_veneer_02 / painted_concrete_02 |
같은 Poly Haven 패턴 …/<name>/<name>_diff_1k.jpg |
CC0 |
- 노멀맵은 1단계 제외(IBL+albedo 만으로 충분, 용량 절약).
- 배포 대상은 위 7장만(
--all 예비 3장은 로컬 보관, FILES 미등록).
4-5. tools/fetch_textures.py 사양 (C:\dev\pncad\tools\fetch_textures.py)
- 실행: 미니PC 에서
cd C:\dev\pncad && PYTHONUTF8=1 python tools/fetch_textures.py [--all] [--force] [--check].
- 저장 위치 =
C:\dev\pncad\tex\ (정본). C:\dev\pfs\web\pncad\tex\ 로는 sync_to_pfs.py 가 복사한다 — pfs web/pncad 직접 수정 금지 규칙(정본 → sync → 배포) 때문. 서빙은 기존 라우트 /production/pncad/<asset>(default_controller.py L92, 로그인 뒤). /static/ 에는 두지 않는다(로그인 없이 열림).
- 입력 = 스크립트 내부 SOURCES 표(4-4). 의존 = 표준 라이브러리(
urllib, zipfile, hashlib, json) + Pillow.
- 동작:
1. 요청마다
User-Agent: Mozilla/5.0 (pncad-fetch) (ambientCG 는 기본 UA 403). 재시도 3회, 타임아웃 30초.
2. Poly Haven: JPG 직접 다운로드. ambientCG: zip 받아 *_Color.jpg 만 추출.
3. Pillow 로 열어 최대 변 1024 유지, quality=80, optimize=True 재인코딩 → tex/<name>_1k.jpg.
4. 이미 있으면 건너뜀(--force 로 재다운로드).
5. 검증: 첫 바이트 FF D8, 크기 20~400KB, 이미지 평균색이 finish-map.json 아키타입 hex 와 채널별 ±14 이내, 7장 합계 ≤ 1.2MB. 하나라도 실패 시 종료코드 1.
6. tex/SOURCES.json 기록: {file, url, license:"CC0-1.0", author, sha256, bytes, meanHex, fetchedAt}.
7. --check = 다운로드 없이 위 5번 검증만.
- 출력: 한국어 한 줄 요약(성공 n/실패 n, 합계 KB). 로그 UTF-8(
sys.stdout.reconfigure).
- sync FILES 3곳 등록은 U6:
tools/sync_to_pfs.py L20-31 · tools/_deploy_hash.py L7 · tools/_verify_deploy.py L16 에 photo.js · data/finish-map.json · tex/<7장>.jpg 를 같은 순서로 추가(서브디렉터리 tex/ 복사 시 makedirs 여부는 U6 에서 확인·보강).
- 캐시: 서버가 자산에
Cache-Control: no-cache(ETag) → 로드마다 304 왕복. 이번엔 pfs 코드 변경 없이 수용(8장).
5. B 사전 설계 (경로추적 스틸) — 1쪽
- 핀(실행 검증됨, r160):
three-gpu-pathtracer@0.0.17 + three-mesh-bvh@0.7.0(peer 정확히 0.7.0). 0.0.24 이상은 three>=0.180 이라 불가, 0.0.18~0.0.23 은 API 상이·미검증.
- 로드 방식: importmap 에 3줄 추가 —
"three/examples/jsm/"(=기존 addons 와 병행), "three-mesh-bvh"(…@0.7.0/build/index.module.js), "three-gpu-pathtracer"(…@0.0.17/build/index.module.js). 새 모듈 pathtrace.js 를 «렌더» 버튼 클릭 때만 await import() → 평소 비용 0. 본 PNG 용 별도 WebGLRenderer({preserveDrawingBuffer:true}) 오프스크린(메인 renderer 상태와 분리).
- 씬 변환: 경로추적은 mm 원본 직투입이 미검증 → 미터 스케일 복제 씬(
S=0.001)에 부재 메시만 복제(재질·텍스처 공유), 카메라 near 0.05 / far 60, DirectionalLight 3 + GradientEquirectTexture(top #fff / bottom #9a9a9a) 환경, bounces 5, environmentIntensity 0.6. 1차는 도어 제외(투명 .82 미검증).
- 흐름: ① «렌더» 클릭 → 모달(해상도 1600×1000 기본 / 2x, 목표 샘플 128·256) ② «준비 중» 표시 후 rAF 2회 대기(첫 update 의 셰이더 컴파일이 메인스레드를 막음) ③
ptRenderer.update() 를 rAF 마다 호출, 카메라 변경 없음 → 샘플 누적 진행률 표시 + «중지» ④ 목표 샘플 도달 → canvas.toBlob('image/png') 같은 태스크에서 → pncad-<작업본번호>-<YYYYMMDD-HHmm>.png 다운로드.
- D3D11 방어(실측 근거):
UNMASKED_RENDERER 가 Direct3D11 이면 setDefine('FEATURE_RUSSIAN_ROULETTE',0)(컴파일 45→10초), 첫 8샘플 뒤 target 평균 휘도 > 0.98 이면 FEATURE_MIS=0 + reset() 폴백(직접광 약해지나 정상 화면). 첫 update 전에 «첫 컴파일은 10~40초 걸립니다» 안내.
- 예상 시간(780M, 선형 외삽, 미실측): Vulkan 12샘플/s@900×560 → 1600×1000 에서 ≈3.8샘플/s → 256샘플 ≈70초. D3D11+MIS off ≈16샘플/s. N100 은 10배+ 느릴 것으로 추정(분 단위) → 목표 샘플 기본 128.
- 전제 확인 순서: A 테스트 배포 후 사무실 PC 에서
chrome://gpu 로 ANGLE 백엔드 확인 → 필요 시 chrome://flags/#use-angle 안내. 서버 GPU 없음·비용 0 유지.
6. 작업단위 표 (A)
worktree: cd C:\dev\pncad && git worktree add -b photo _wt_photo HEAD → /pncad/_wt_photo/app.html 로 서빙(8799). 같은 날 다른 세션(pncad 보완·기능 제안)이 C:\dev\pncad 를 만지므로 app.html 을 건드리는 단위는 모두 worktree.
| 번호 |
파일 |
할 일 |
검증 명령 |
예상 |
병렬 |
worktree |
| U1 |
C:\dev\pncad\tools\fetch_textures.py · C:\dev\pncad\data\finish-map.json · C:\dev\pncad\tex\* · C:\dev\pncad\tex\SOURCES.json |
4-5 사양대로 받기 스크립트 + finish-map.json(12 아키타입·exact 11·rules 12) 작성, 미니PC 실행 |
cd C:\dev\pncad && PYTHONUTF8=1 python tools/fetch_textures.py && PYTHONUTF8=1 python tools/fetch_textures.py --check · python -c "import json;json.load(open('data/finish-map.json',encoding='utf-8'))" |
45분 |
U2 와 병렬 |
불필요(신규 파일만) |
| U2 |
C:\dev\pncad\photo.js |
3장 전부 구현: 환경맵·톤매핑·그림자·gFloor·GTAO 패치·composer+오버레이·frame()·티어·finishMat/bakeWoodUV/resolveFinish·info() |
copy photo.js %TEMP%\photo_chk.mjs && node --check %TEMP%\photo_chk.mjs (문법) · 브라우저 import 는 U3 에서 |
2시간 |
U1 과 병렬(finish-map 형식은 4-2 고정) |
불필요(신규 파일만) |
| U3 |
C:\dev\pncad\_wt_photo\app.html |
web-designer 상담 후 H1~H14 삽입(vbar 7버튼·Ctrl+7·setReal 동적 import·paint 훅·루프/resize/드래그 훅·PC2 훅·SEL_M toneMapped). 착수 첫 단계에 기준 E2E 결과 저장 |
cd C:\dev\pncad\_wt_photo && PYTHONUTF8=1 python tools/_e2e_3dui.py local (기준선 대비 동일 PASS, vbar 7개 기대값만 U4 와 함께 갱신) |
1.5시간 |
U1·U2 완료 후. U4 와 병렬 |
필요 |
| U4 |
C:\dev\pncad\tools\_e2e_photo.py · C:\dev\pncad\tools\_e2e_3dui.py(9-1~9-4·런6 Ctrl 키 6→7 기대값) |
7장 E2E 작성(live_scene_33 fixture: 색 HS0868GP→N781, HD-50-010→N501 치환으로 텍스처 경로 확보) + 기존 E2E vbar 기대값 갱신 |
cd C:\dev\pncad\_wt_photo && PYTHONUTF8=1 python tools/_e2e_photo.py local |
1시간 |
U3 와 병렬(PC2 훅 계약은 H13 고정) |
필요 |
| U5 |
C:\dev\pncad\_wt_photo\photo.js (+ 필요 시 app.html 수치) |
룩 튜닝: 노출(흰 부재 평균 ≥ OFF 의 85%)·envMapIntensity·sun·AO radius·bias/normalBias·엣지 선 색. ON/OFF 스크린샷(local, high/low 티어, 도어 on/off, 선택 상태) 육안+cody-vision 확인 |
PYTHONUTF8=1 python tools/_e2e_photo.py local 전 항목 PASS + PYTHONUTF8=1 python tools/_e2e_3dui.py local all 회귀 PASS |
1.5시간 |
U3·U4 이후 |
필요 |
| U6 |
C:\dev\pncad\tools\sync_to_pfs.py · tools\_deploy_hash.py · tools\_verify_deploy.py |
worktree 브랜치 master 병합 후 FILES 3곳에 photo.js·data/finish-map.json·tex/<7장>.jpg 등록, tex/ 서브디렉터리 복사 보강 |
cd C:\dev\pncad && PYTHONUTF8=1 python tools/sync_to_pfs.py --check (누락 0) |
30분 |
불가(순차) |
병합 후 master(worktree 제거) |
| U7 |
C:\dev\pfs\web\pncad\* (sync 산출) |
테스트 배포: sync_to_pfs.py → pfs git fetch + git log -3 -- web/pncad/ 재확인 → 변경 파일 명시 git add(-A 금지) → 커밋 → git push origin master(테스트 배포) → _deploy_hash.py test → E2E test. deploy-live·운영 반영 없음 |
cd C:\dev\pncad && PYTHONUTF8=1 python tools/_deploy_hash.py test (해시 일치) · PYTHONUTF8=1 python tools/_e2e_photo.py test · PYTHONUTF8=1 python tools/_e2e_3dui.py test all · 사이트맵: cd C:\dev\pfs && python tools/sitemap/build_sitemap.py --dump |
40분 |
불가(순차) |
불필요 |
의존: U1∥U2 → U3∥U4 → U5 → U6 → U7. 합계 ≈8시간, 병렬 시 ≈5.5시간. U7 전에 pfs 쪽 push 는 테스트 배포 한정(운영 승인 게이트는 그대로).
골격은 _e2e_3dui.py 1-60행·보조(chk·handler·boot·open_scene)·메인 블록 복사. 서버 쓰기 0(writes) 유지, Playwright try/finally br.close(). 스크린샷 → C:\dev\_work\pncad-photo-1012\*.png(접미 ''|_test|_prod). prod 는 읽기 전용이고 이번엔 실행 대상 아님(photo.js 미배포).
| ID |
검사 |
| P1 |
부팅 pageerror 0 · 콘솔 에러 0(404·Failed to load resource·ERR_FAILED 제외) |
| P2 |
vbar 버튼 id 7개(…,bElev,bReal), bReal aria-pressed=false 기본, 가로 넘침 0, test 에서 bReal 우측 ≤ viewport-64 |
| P3 |
OFF 비용 0 증거: 부팅~씬 로드 동안 photo.js·GTAOPass·tex/ 요청 0건, PC2.real().on===false, 렌더 호출 = renderer.render 만 |
| P4 |
live_scene_33(fixture 치환) 열고 PC2.fit() → 캔버스 스크린샷 off1.png, 썸네일 img src 해시(앞 3개) 저장 |
| P5 |
bReal 클릭(상태 맞춤: on 이면 click) → PC2.real(): on true · tm===4(ACES) · env===true · shadow===true(renderer.shadowMap.enabled && sun.castShadow) · floor===true · composerFrames>0 · ao 플래그 |
| P6 |
텍스처: tex/*.jpg 응답 ≥1건, 전부 200·content-type: image/jpeg·첫 바이트 FFD8(비로그인 login.html 200 오탐 방지, test 는 로그인 뒤). photo.js 200 |
| P7 |
토글 on/off 픽셀 차이: off1.png vs on.png 캔버스 영역 평균 절대차 > 임계(초기 3/255) · 흰 부재 평균 밝기가 OFF 의 85% 이상(노출 가드) |
| P8 |
OFF 복귀: on===false · tm===0 · env===false · shadow===false · grid.visible===true · hemi.intensity===1.15 · sun.intensity===1.5 · 500ms 후 composerFrames 증가 0(composer 미사용) · 스크린샷 off2.png 가 off1.png 와 평균차 < 0.5 |
| P9 |
유휴 렌더: ON 정지 2초간 composerFrames 증가 ≤ 10(하트비트 4fps), 마우스 드래그(page.mouse) 중에는 증가 > 20 |
| P10 |
불변 검사: ON 전후 camera.position·fov·near/far 동일, PC2.fit() 결과 동일(gFloor 가 fit 에 안 먹음), 썸네일 src 해시 동일 |
| P11 |
선택·픽킹: ON 에서 부재 클릭 → 선택 성립, 바닥 클릭이 gFloor 에 가로채이지 않음. 선택 선 색 보존: 스크린샷에서 주황(r>230, 80<g<140, b<40) 화소 > 0 |
| P12 |
fold: ON 후 localStorage pncad.fold 에 app.real:true, 기존 키 보존 → 새로고침 후 자동 ON(동적 import) → OFF 시 키 삭제. Ctrl+7 로 토글 |
| P13 |
티어: 헤드리스(SwiftShader)는 자동 low 경로, ?realq=high 로 high 경로도 한 번(둘 다 pageerror 0, GL 에러 0) |
| P14 |
도어 켠 상태 + 실사 ON 에서 pageerror 0, 스크린샷 육안 |
| P15 |
실패 격리: photo.js 요청을 route.abort → 클릭해도 pageerror 0, showReal=false 로 복귀, 화면 종전 그대로 |
| Z1/Z2 |
pageerror·콘솔 에러 0 / 서버 쓰기 0 |
- 회귀:
_e2e_3dui.py [local|test] all(런1~6) 기준선과 동일 PASS. vbar 7개로 바뀌는 9-1~9-4·런6 Ctrl+1~6 기대값만 갱신(U4).
- 한계(고정 기재): 헤드리스 = SwiftShader·비보안 컨텍스트라 fps/GPU 버그 검증 불가. 실기(사무실 PC·미니PC)는
PC2.real().frameMsAvg 를 «OFF · ON 정지 · ON 조작 중» 3값으로 수동 측정해 이 문서 8장 후속에 기록.
- test 실행 조건: U7 배포 후,
C:\dev\.secrets\pfs_login.env 의 PFS_TEST_* 로그인(값 출력 금지), 작업본은 운영 GET 사본 C:\dev\_work\pncad-spec-1008\live_scene_33.json route.
8. 미결 · 확인 항목 (결정은 전부 «추천으로 진행»)
페페 지시 «선택은 추천대로», 운영배포는 추후 → 아래는 질문이 아니라 추천 확정 사항이다.
| # |
항목 |
결정(추천으로 진행) |
| 1 |
기본값 |
실사 꺼짐(안전 롤아웃). 켠 사용자만 영향 |
| 2 |
코드 구조 |
새 photo.js + 첫 ON 때 동적 import(OFF 비용 0, 배포 누락 시 격리) |
| 3 |
AO |
1단계 GTAOPass(추가 의존 0). 품질 불만이면 N8AO 는 후속 |
| 4 |
안티앨리어싱 |
composer RT MSAA(high 4/low 2). SMAA 는 미사용 |
| 5 |
도어 |
텍스처 없이 단색 투명, 그림자·AO 제외 |
| 6 |
아키타입 수동 선택 UI · 객체형 colorMap 저장 |
이번 범위 제외(후속, web-designer 상담 필요) |
| 7 |
노멀맵 · 예비 텍스처 3장 |
제외 / 로컬 보관만(--all), 미배포 |
| 8 |
텍스처 캐시 헤더 |
pfs 코드 변경 없이 no-cache(304) 수용 |
| 9 |
상위 사용 코드 수동 지정(예: HS0868GP) |
근거 없는 추정 금지 → 후속: 읽기전용 SELECT(poin_colors JOIN board_usage_history 집계) 1회로 상위 30개 확보 후 exact 추가. 이번엔 colorchip 11개만 |
| 10 |
cmOnOf() 의미 |
U3 에서 코드 확인 후 4-3 항 4번대로 확정 |
| 11 |
B 시작 조건 |
A 테스트 배포 + 사무실 PC chrome://gpu 확인 후 |
| 12 |
배포 |
테스트까지. 운영 deploy-live·운영 반영 없음(추후 검토) |
| 13 |
후속 기록 |
U7 후 총괄이 시드(projects\pncad\handoff.md) · devplan 갱신. 새 함정(예: GTAO mm 단위, D3D11 MIS 순백)은 rules.md 🆕 신규에 1줄 |