43.3 KB · 수정 2026-10-12 21:59
목차

PNCAD 실사 렌더링 설계 (design-photoreal)


1. 목표 · 현황 (사실 5줄)

  1. 목표 = 가구 3D 화면의 «실사 느낌»을 돈 0원·서버 GPU 없이 최대화. 렌더는 전부 클라이언트(사무실 PC 내장 GPU · 미니PC N100급).
  2. 메인 렌더러(app.html 1130)는 톤매핑 없음(NoToneMapping)·그림자 꺼짐·환경맵 없음·AO 없음·텍스처 0개. 조명은 Hemisphere 1.15 + Directional 1.5(1149-1151). three@0.160.0(importmap 879-884).
  3. 재질은 colorMat(hex,door)(1185-1190) 단색 MeshStandardMaterial roughness .85. 색은 cmHex(code)(수동 hex 또는 CM_PAL 무채색 8단 순환). 칠하는 곳 = rebuild() 1628행 m.material = colorMat(cmHex(code),door).
  4. 렌더 루프(5578-5583)는 유휴에도 매 프레임 renderer.render. 썸네일은 별도 GL 컨텍스트 tRen(1880), 편집기(editor.js)도 자체 렌더러 → 메인 실사 모드가 둘에 영향 없음.
  5. 헤드리스 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. 모듈 구조

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;

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);

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 });

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++;
}

3-8. 재질 · 텍스처 (코드 조각은 4장 데이터와 연결)

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
}

4-3. 수동 hex 와 공존 규칙 (추천대로)

  1. SCENE.colorMap.map[코드] 가 hex 문자열이면 그 단색이 이긴다(텍스처 끔, 옛 작업본 그대로). 이중 틴트 방지.
  2. 수동값이 없고 finish-map 이 판별하면 아키타입 텍스처/색.
  3. 판별 불가면 cmHex(code)(CM_PAL) 단색 PBR.
  4. cmOnOf() 가 «색 구분 꺼짐» 상태면 finish 적용 안 함(흰색 단색 PBR). 구현자는 1175-1190·1626-1628 을 읽어 의미를 확인하고 U3 에서 확정.
  5. 아키타입 수동 선택 UI({a:'oak'} 객체값, renderColorMap 4563 확장)는 이번 범위 제외 — web-designer 상담·직렬화(4676 keep·4579 cmSet·hexOk 1175) 변경이 필요해 후속. 이번엔 문자열 hex 만 존재하므로 저장 포맷 변경 없음.
  6. 기본 «실사» 꺼짐 = 안전 롤아웃. 켠 사용자만 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

4-5. tools/fetch_textures.py 사양 (C:\dev\pncad\tools\fetch_textures.py)


5. B 사전 설계 (경로추적 스틸) — 1쪽


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 는 테스트 배포 한정(운영 승인 게이트는 그대로).


7. E2E 계획 — C:\dev\pncad\tools\_e2e_photo.py [local|test|prod]

골격은 _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

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줄