feat: 프로젝트 공통 지반모델·교대 지반 통합·재료표/수량 진입·옥성교 랜딩 히어로 (11-06~11-09 작업분)

공용 파일(main.ts·index.html·style.css)에서 서로 얽혀 있어 한 커밋으로 묶음.
- 지반: ① 형상 프로젝트 지반모델 관리창(생성·JSON 불러오기·교각별 비교·선택 적용),
  프로젝트 JSON 내장 groundModel 추출, 교대 8행 통합 적용·공통지형 중복격자 제외
- 재료표·수량: 상단 ⑤ 메뉴·배근 바로가기, 결과 교각/탭 기억, 수량 워크북·정합 보정
- 랜딩: 옥성교 6교각 히어로 이미지·투영 강조점·좌→우 광선·지형 라운드/도로 연결
- 검증 스크립트(verify-project-ground/ground-abutments/okseong-user-files/quantity-*/landing-hero 등)
정본: DOC/04_실행/실행_*_2026-11-09_v0.1.00.md

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-09 18:01:02 +09:00
1 parent b2bebe47eb
commit fc520ec03d
47 files changed
+2848 -149

No files matched your search

+85
View File
@@ -0,0 +1,85 @@
// Run with Windows Node and an isolated Chrome on CDP 9444; Vite must be on 5173.
const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict');
const out = path.resolve(__dirname, '../.verify/landing-horizontal-20261109/applied-browser');
fs.mkdirSync(out, { recursive: true });
const report = { checks: [], errors: [] };
let ws, send;
const delay = ms => new Promise(r => setTimeout(r, ms));
(async () => {
const target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json();
ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
let seq = 0; const pending = new Map();
ws.onmessage = ({ data }) => {
const m = JSON.parse(data), p = pending.get(m.id);
if (p) { clearTimeout(p.timer); pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); }
if (m.method === 'Runtime.exceptionThrown') report.errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
};
send = (method, params = {}) => new Promise((resolve, reject) => {
const id = ++seq, timer = setTimeout(() => reject(new Error('Timeout ' + method)), 30000);
pending.set(id, { resolve, reject, timer }); ws.send(JSON.stringify({ id, method, params }));
});
const ev = async expression => {
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
return r.result.value;
};
const until = async expression => {
for (let i = 0; i < 120; i++) { if (await ev(expression)) return; await delay(150); }
throw new Error('Wait ' + expression);
};
const check = (name, value) => { assert.ok(value, name); report.checks.push(name); console.log('PASS', name); };
const click = expression => ev(`(()=>{const e=${expression};if(!e||e.disabled)throw new Error('Missing target');e.click();})()`);
const shot = async name => {
const r = await send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64'));
};
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
await send('Page.navigate', { url: process.env.PIERZAINER_URL || 'http://127.0.0.1:5173/' });
await until(`typeof window.openSampleProject==='function'`);
await until(`document.querySelector('.landing-promo-image')?.complete&&document.querySelector('.landing-promo-image').naturalWidth>0`);
await delay(1800);
check('landing uses the new high-resolution WebP',await ev(`document.querySelector('.landing-promo-image').src.includes('okseong-model-hero.webp')&&document.querySelector('.landing-promo-image').naturalWidth===4344`));
check('eight projected markers include both abutments',await ev(`document.querySelectorAll('.landing-pier-beacon').length===8&&!!document.querySelector('[data-structure="A1"]')&&!!document.querySelector('[data-structure="A2"]')`));
check('30m end margin is active',await ev(`(async()=>{const {heroCapture}=await import('/'+encodeURIComponent('랜딩페이지')+'/assets/okseong-hero-capture.ts');return heroCapture.endMarginM===30;})()`));
check('taller frame and rounded terrain are active',await ev(`(async()=>{const {heroCapture}=await import('/'+encodeURIComponent('랜딩페이지')+'/assets/okseong-hero-capture.ts');return heroCapture.height===870&&heroCapture.cornerRadiusM===18&&document.querySelector('.landing-promo-image').naturalHeight===1740;})()`));
check('horizontal view and smooth inward outline are applied',await ev(`(async()=>{const {heroCapture}=await import('/'+encodeURIComponent('랜딩페이지')+'/assets/okseong-hero-capture.ts');return heroCapture.along===0&&heroCapture.outline.method==='inward-alpha-envelope-quadratic'&&heroCapture.outline.addedOutsideSourcePixels===0&&heroCapture.outline.retainedTerrainRatio>.9;})()`));
const expectedHash=require('node:crypto').createHash('sha256').update(fs.readFileSync(path.resolve(__dirname,'../.verify/landing-horizontal-20261109/horizontal-smooth.webp'))).digest('hex');
check('served landing image matches the reviewed horizontal capture',await ev(`(async()=>{const bytes=await(await fetch(document.querySelector('.landing-promo-image').src)).arrayBuffer();const hash=[...new Uint8Array(await crypto.subtle.digest('SHA-256',bytes))].map(n=>n.toString(16).padStart(2,'0')).join('');return hash===${JSON.stringify(expectedHash)};})()`));
check('both road extensions follow valid terrain sections',await ev(`(async()=>{const {heroCapture}=await import('/'+encodeURIComponent('랜딩페이지')+'/assets/okseong-hero-capture.ts');return heroCapture.roads.length===2&&heroCapture.roads.every(r=>r.missingSections===0&&r.validSections===81&&r.visualizationOnly);})()`));
check('decorative frame includes both abutments',await ev(`(()=>{const box=document.querySelector('.landing-hud-bracket').getBBox(),a=document.querySelector('[data-structure="A1"]').transform.baseVal.getItem(0).matrix,b=document.querySelector('[data-structure="A2"]').transform.baseVal.getItem(0).matrix;return box.x<a.e&&box.x+box.width>b.e;})()`));
check('restored light follows projected structures from left to right',await ev(`(()=>{const path=document.getElementById('landing-alignment-path'),first=path.getPointAtLength(0),last=path.getPointAtLength(path.getTotalLength()),a=document.querySelector('[data-structure="A1"]').transform.baseVal.getItem(0).matrix,b=document.querySelector('[data-structure="A2"]').transform.baseVal.getItem(0).matrix;return first.x<last.x&&Math.abs(first.x-a.e)<.1&&Math.abs(last.x-b.e)<.1;})()`));
check('light follows dense terrain samples rather than coping elevations',await ev(`(async()=>{const {heroCapture}=await import('/'+encodeURIComponent('랜딩페이지')+'/assets/okseong-hero-capture.ts');const path=document.getElementById('landing-alignment-path'),samples=heroCapture.terrainFlow.filter(Boolean),first=path.getPointAtLength(0),a=document.querySelector('[data-structure="A1"]').transform.baseVal.getItem(0).matrix;return samples.length>100&&(path.getAttribute('d').match(/L/g)||[]).length>100&&Math.abs(first.y-samples[0].y)<.1&&Math.abs(first.y-a.f)>10;})()`));
check('vertical scan, moving grid, and rising sparks are removed',await ev(`!document.querySelector('.landing-scan-mask,.landing-scan-beam,.landing-terrain-grid,.landing-pier-spark')&&![...document.querySelectorAll('.landing-hero *')].some(e=>e.getAnimations().some(a=>['landing-scan-pass','landing-beacon-spark','landing-grid-breathe'].includes(a.animationName)))`));
check('traveling light is white',await ev(`[...document.querySelectorAll('#landing-flow-fade stop')].every(e=>getComputedStyle(e).stopColor==='rgb(255, 255, 255)')&&getComputedStyle(document.querySelector('.landing-energy-track')).stroke==='rgb(255, 255, 255)'`));
const offset=async time=>ev(`(()=>{const e=document.querySelector('.landing-energy-flow'),a=e.getAnimations()[0];if(!a)throw new Error('Missing flow animation');a.pause();a.currentTime=${time};return parseFloat(getComputedStyle(e).strokeDashoffset);})()`);
const left=await offset(3500),right=await offset(5000);
check('light actually advances along the path over time',right<left);
await shot('light-moving');
await ev(`document.querySelector('.landing-energy-flow').getAnimations()[0].play()`);
check('asset has transparent background and opaque terrain',await ev(`(()=>{const c=document.createElement('canvas');c.width=100;c.height=34;const ctx=c.getContext('2d');ctx.drawImage(document.querySelector('.landing-promo-image'),0,0,100,34);const data=ctx.getImageData(0,0,100,34).data;let lo=255,hi=0;for(let i=3;i<data.length;i+=4){lo=Math.min(lo,data[i]);hi=Math.max(hi,data[i]);}return lo===0&&hi===255;})()`));
await send('Emulation.setEmulatedMedia',{features:[{name:'prefers-reduced-motion',value:'reduce'}]});
for(const [width,height] of [[1920,1080],[1600,900],[1280,800],[390,844]]){
await send('Emulation.setDeviceMetricsOverride',{width,height,deviceScaleFactor:1,mobile:width<760});
await delay(200);
await shot(`${width}-landing`);
report.layout=await ev(`[...document.querySelectorAll('.landing-cards .landing-primary')].map(e=>({text:e.textContent,rect:e.getBoundingClientRect().toJSON()}))`);
check(`no horizontal overflow at ${width}`,await ev(`document.documentElement.scrollWidth<=innerWidth`));
check(`all entry actions remain visible at ${width}`,await ev(`[...document.querySelectorAll('.landing-cards .landing-primary')].every(e=>{const r=e.getBoundingClientRect(),hit=document.elementFromPoint(r.x+r.width/2,r.y+r.height/2);return r.width>0&&r.height>0&&r.left>=0&&r.right<=innerWidth+1&&r.top>=0&&r.bottom<=innerHeight&&!!hit&&(hit===e||e.contains(hit));})`));
check(`image and projected markers align at ${width}`,await ev(`(()=>{const img=document.querySelector('.landing-promo-image').getBoundingClientRect(),svg=document.querySelector('.landing-model-motion'),view=svg.viewBox.baseVal;return [...document.querySelectorAll('.landing-pier-beacon')].every(g=>{const t=g.transform.baseVal.getItem(0).matrix,p=svg.createSVGPoint();p.x=t.e;p.y=t.f;const actual=p.matrixTransform(svg.getScreenCTM());return Math.abs(actual.x-(img.left+t.e/view.width*img.width))<2&&Math.abs(actual.y-(img.top+t.f/view.height*img.height))<2;});})()`));
check(`first-visit guide does not cover structures at ${width}`,await ev(`(()=>{const hint=document.querySelector('.landing-guide-hint');if(hint.hidden)return true;const r=hint.getBoundingClientRect(),svg=document.querySelector('.landing-model-motion');return [...document.querySelectorAll('.landing-pier-beacon')].every(g=>{const m=g.transform.baseVal.getItem(0).matrix,p=svg.createSVGPoint();p.x=m.e;p.y=m.f;const q=p.matrixTransform(svg.getScreenCTM());return q.x<r.left||q.x>r.right||q.y<r.top||q.y>r.bottom;});})()`));
await shot(`${width}-landing`);
}
check('reduced motion stops beacon animation',await ev(`getComputedStyle(document.querySelector('.landing-pier-pulse')).animationName==='none'`));
check('reduced motion stops light travel',await ev(`getComputedStyle(document.querySelector('.landing-energy-flow')).animationName==='none'`));
await send('Emulation.setDeviceMetricsOverride',{width:1600,height:1000,deviceScaleFactor:1,mobile:false});
await click(`document.querySelector('[data-card="sample"] .landing-primary')`);
check('design library still opens',await ev(`document.querySelector('.sample-library-popup').open`));
await click(`document.querySelector('.sample-library-close')`);
await click(`document.querySelector('[data-card="excel"] .landing-primary')`);
check('Excel entry still opens',await ev(`document.getElementById('import-dialog').open`));
check('no runtime exceptions',report.errors.length===0);report.result='PASS';
})().catch(e=>{report.result='FAIL';report.failure=e.stack;process.exitCode=1;}).finally(()=>{
fs.writeFileSync(path.join(out,'report.json'),JSON.stringify(report,null,2));console.log(JSON.stringify(report));ws?.close();
});