Compare commits
25
Commits
f51865c57b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50bdc3d691 | ||
|
|
fc3cc3b018 | ||
|
|
ac646d554c | ||
|
|
ed84761449 | ||
|
|
33aa2d9a61 | ||
|
|
8f808f453e | ||
|
|
6a15a13fc7 | ||
|
|
51209cccf9 | ||
|
|
0b3087e752 | ||
|
|
7340f8c1a1 | ||
|
|
9f336e2be4 | ||
|
|
a39a1ba8d2 | ||
|
|
9777938aa8 | ||
|
|
bd84ac6176 | ||
|
|
0fb39076b8 | ||
|
|
b84288c48a | ||
|
|
d7a61df7a9 | ||
|
|
8aedc9b969 | ||
|
|
4ef8412d89 | ||
|
|
38854cbe4c | ||
|
|
7d36c4b64a | ||
|
|
ef596650cc | ||
|
|
fc520ec03d | ||
|
|
b2bebe47eb | ||
|
|
d740406a8c |
No files matched your search
+30
-4
@@ -18,6 +18,7 @@
|
|||||||
<button id="nav-plan" class="step" data-label="② 형상계획" aria-label="② 형상계획" type="button">② 계획 <em id="step2-status"></em></button>
|
<button id="nav-plan" class="step" data-label="② 형상계획" aria-label="② 형상계획" type="button">② 계획 <em id="step2-status"></em></button>
|
||||||
<button id="nav-rebar" class="step" data-label="③ 철근배근" aria-label="③ 철근배근" type="button">③ 배근 <em id="step3-status"></em></button>
|
<button id="nav-rebar" class="step" data-label="③ 철근배근" aria-label="③ 철근배근" type="button">③ 배근 <em id="step3-status"></em></button>
|
||||||
<button id="nav-drawing" class="step" data-label="④ 도면작성" aria-label="④ 도면작성" type="button">④ 도면 <em id="step-drawing-status"></em></button>
|
<button id="nav-drawing" class="step" data-label="④ 도면작성" aria-label="④ 도면작성" type="button">④ 도면 <em id="step-drawing-status"></em></button>
|
||||||
|
<button id="nav-quantity" class="step" aria-label="⑤ 재료표·수량산출" type="button">⑤ 재료표·수량</button>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<span id="save-status" class="savest">저장 전</span>
|
<span id="save-status" class="savest">저장 전</span>
|
||||||
@@ -47,6 +48,8 @@
|
|||||||
|
|
||||||
<main class="grid" id="view-shape">
|
<main class="grid" id="view-shape">
|
||||||
<aside class="panel piers" aria-labelledby="pier-title">
|
<aside class="panel piers" aria-labelledby="pier-title">
|
||||||
|
<button id="project-ground-open" class="btn-o" type="button">프로젝트 지반모델</button>
|
||||||
|
<p id="project-ground-status" class="pnote">모델 없음</p>
|
||||||
<p id="pier-title" class="lbl">교각·교대 목록</p>
|
<p id="pier-title" class="lbl">교각·교대 목록</p>
|
||||||
<div class="pier-tools">
|
<div class="pier-tools">
|
||||||
<label>형식
|
<label>형식
|
||||||
@@ -193,8 +196,22 @@
|
|||||||
<ul id="members" class="members"></ul>
|
<ul id="members" class="members"></ul>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="center-col">
|
<div class="center-col workspace" id="plan-workspace" data-stage="plan">
|
||||||
|
<div class="workspace-tools" aria-label="계획 화면 배치 조절">
|
||||||
|
<div class="layout-switch" role="group" aria-label="모델과 입력창 배치">
|
||||||
|
<button type="button" class="btn small" data-layout="stack" aria-pressed="true">상하</button>
|
||||||
|
<button type="button" class="btn small" data-layout="side" aria-pressed="false">좌우</button>
|
||||||
|
</div>
|
||||||
|
<label class="model-height-control" for="plan-model-height"><span data-size-label>모델 높이</span>
|
||||||
|
<input id="plan-model-height" type="range" min="180" max="560" step="20" value="400" />
|
||||||
|
<output id="plan-model-height-value" for="plan-model-height">400 px</output>
|
||||||
|
</label>
|
||||||
|
<button id="plan-model-toggle" class="btn small" type="button" aria-expanded="true" aria-controls="slot-plan">모델 접기</button>
|
||||||
|
<span class="layout-note muted small" aria-live="polite"></span>
|
||||||
|
</div>
|
||||||
<div class="viewer-slot" id="slot-plan"></div>
|
<div class="viewer-slot" id="slot-plan"></div>
|
||||||
|
<div class="workspace-splitter" role="separator" tabindex="0" aria-controls="slot-plan" aria-label="모델 크기 조절"></div>
|
||||||
|
<div class="workspace-pane">
|
||||||
<!-- ② 교대 부재 카드 (시안, 2026-11-03): the card of the row picked in 부재 · 교대, under the 3D view. -->
|
<!-- ② 교대 부재 카드 (시안, 2026-11-03): the card of the row picked in 부재 · 교대, under the 3D view. -->
|
||||||
<section id="abut-panel-plan" class="panel viewer abut-card" hidden aria-labelledby="abut-panel-plan-title">
|
<section id="abut-panel-plan" class="panel viewer abut-card" hidden aria-labelledby="abut-panel-plan-title">
|
||||||
<div class="viewer-head">
|
<div class="viewer-head">
|
||||||
@@ -258,6 +275,7 @@
|
|||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<aside class="panel results" aria-labelledby="changes-title">
|
<aside class="panel results" aria-labelledby="changes-title">
|
||||||
<h2 id="changes-title">기본값 대비 변경</h2>
|
<h2 id="changes-title">기본값 대비 변경</h2>
|
||||||
@@ -295,23 +313,31 @@
|
|||||||
<ul id="rebar-tabs" class="members"></ul>
|
<ul id="rebar-tabs" class="members"></ul>
|
||||||
<p class="pnote">형상은 ② 형상계획 기준입니다. 형상이 바뀌면 "형상 바뀜"으로 표시합니다.</p>
|
<p class="pnote">형상은 ② 형상계획 기준입니다. 형상이 바뀌면 "형상 바뀜"으로 표시합니다.</p>
|
||||||
</aside>
|
</aside>
|
||||||
<div class="center-col review-workspace" id="review-workspace">
|
<div class="center-col workspace review-workspace" id="review-workspace" data-stage="rebar">
|
||||||
<div class="workspace-tools" aria-label="검토 공간 조절">
|
<div class="workspace-tools" aria-label="검토 공간 조절">
|
||||||
<label class="model-height-control" for="review-model-height">모델 높이
|
<div class="layout-switch" role="group" aria-label="모델과 철근표 배치">
|
||||||
|
<button type="button" class="btn small" data-layout="stack" aria-pressed="true">상하</button>
|
||||||
|
<button type="button" class="btn small" data-layout="side" aria-pressed="false">좌우</button>
|
||||||
|
</div>
|
||||||
|
<label class="model-height-control" for="review-model-height"><span data-size-label>모델 높이</span>
|
||||||
<input id="review-model-height" type="range" min="180" max="560" step="20" value="240" />
|
<input id="review-model-height" type="range" min="180" max="560" step="20" value="240" />
|
||||||
<output id="review-model-height-value" for="review-model-height">240 px</output>
|
<output id="review-model-height-value" for="review-model-height">240 px</output>
|
||||||
</label>
|
</label>
|
||||||
<button id="review-model-toggle" class="btn small" type="button" aria-expanded="true" aria-controls="slot-rebar">모델 접기</button>
|
<button id="review-model-toggle" class="btn small" type="button" aria-expanded="true" aria-controls="slot-rebar">모델 접기</button>
|
||||||
<button id="review-inputs" class="btn small" type="button">입력 보기</button>
|
<button id="review-inputs" class="btn small" type="button">입력 보기</button>
|
||||||
<button id="review-table" class="btn small" type="button">철근표 보기</button>
|
<button id="review-table" class="btn small" type="button">철근표 보기</button>
|
||||||
|
<span class="layout-note muted small" aria-live="polite"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="viewer-slot" id="slot-rebar"></div>
|
<div class="viewer-slot" id="slot-rebar"></div>
|
||||||
|
<div class="workspace-splitter" role="separator" tabindex="0" aria-controls="slot-rebar" aria-label="모델 크기 조절"></div>
|
||||||
|
<div class="workspace-pane">
|
||||||
<section class="panel viewer" aria-labelledby="rebar-title">
|
<section class="panel viewer" aria-labelledby="rebar-title">
|
||||||
<div class="viewer-head"><h2 id="rebar-title">철근표</h2></div>
|
<div class="viewer-head"><h2 id="rebar-title">철근표</h2><button id="rebar-materials" class="btn-o" type="button">재료표·수량산출 보기</button></div>
|
||||||
<p id="rebar-note" class="num"></p>
|
<p id="rebar-note" class="num"></p>
|
||||||
<div id="rebar-table"></div>
|
<div id="rebar-table"></div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<aside class="panel results" aria-labelledby="rebar-issues-title">
|
<aside class="panel results" aria-labelledby="rebar-issues-title">
|
||||||
<h2 id="rebar-issues-title">알림</h2>
|
<h2 id="rebar-issues-title">알림</h2>
|
||||||
<ul id="rebar-issues" class="issues"></ul>
|
<ul id="rebar-issues" class="issues"></ul>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { existsSync } from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Plugin } from 'vite';
|
import type { Plugin } from 'vite';
|
||||||
|
import { resolvePython,describeSpawnError } from './python-runtime.ts';
|
||||||
export const GROUND_LINK_API='/__pierzainer/ground-link';
|
export const GROUND_LINK_API='/__pierzainer/ground-link';
|
||||||
export function startGroundBat(bat:string,context:string,cwd:string){
|
export function startGroundBat(bat:string,context:string,cwd:string){
|
||||||
return spawn(process.env.ComSpec??'cmd.exe',['/d','/s','/c',`""${bat}""`],{cwd,windowsHide:true,windowsVerbatimArguments:true,env:{...process.env,PIERZAINER_GROUND_CONTEXT:context},stdio:'ignore'});
|
return spawn(process.env.ComSpec??'cmd.exe',['/d','/s','/c',`""${bat}""`],{cwd,windowsHide:true,windowsVerbatimArguments:true,env:{...process.env,PIERZAINER_GROUND_CONTEXT:context},stdio:'ignore'});
|
||||||
@@ -42,10 +43,10 @@ export function groundLinkPlugin(root:string):Plugin{
|
|||||||
if(!existsSync(drawing)||!/\.(dwg|dxf)$/i.test(drawing)||drawing!==original&&!cached(drawing))throw new Error('종평면도를 선택하세요.');
|
if(!existsSync(drawing)||!/\.(dwg|dxf)$/i.test(drawing)||drawing!==original&&!cached(drawing))throw new Error('종평면도를 선택하세요.');
|
||||||
if(!existsSync(piers)||!/\.xlsx$/i.test(piers)||!cached(piers))throw new Error('교각 Excel을 선택하세요.');
|
if(!existsSync(piers)||!/\.xlsx$/i.test(piers)||!cached(piers))throw new Error('교각 Excel을 선택하세요.');
|
||||||
const id=randomUUID(),output=path.join(base,'data/output',`app_${id}`),job={output,logs:'',state:'running'};jobs.set(id,job);
|
const id=randomUUID(),output=path.join(base,'data/output',`app_${id}`),job={output,logs:'',state:'running'};jobs.set(id,job);
|
||||||
const python=existsSync(path.join(base,'.venv/Scripts/python.exe'))?path.join(base,'.venv/Scripts/python.exe'):'python';
|
const python=resolvePython(root);
|
||||||
const child=spawn(python,['-B','-u','-X','utf8','-m','pierzainer_ground.web_job',drawing,piers,output],{cwd:base,windowsHide:true,stdio:['ignore','pipe','pipe']});
|
const child=spawn(python,['-B','-u','-X','utf8','-m','pierzainer_ground.web_job',drawing,piers,output],{cwd:base,windowsHide:true,stdio:['ignore','pipe','pipe']});
|
||||||
const append=(data:Buffer)=>{job.logs=(job.logs+data.toString('utf8')).slice(-200000);};child.stdout.on('data',append);child.stderr.on('data',append);
|
const append=(data:Buffer)=>{job.logs=(job.logs+data.toString('utf8')).slice(-200000);};child.stdout.on('data',append);child.stderr.on('data',append);
|
||||||
child.once('error',error=>{job.state='error';job.logs+='\n'+error.message;});child.once('close',code=>{job.state=code===0?'done':'error';});reply(200,{id});return;
|
child.once('error',error=>{job.state='error';job.logs+='\n'+describeSpawnError(error,python);});child.once('close',code=>{job.state=code===0?'done':'error';});reply(200,{id});return;
|
||||||
}
|
}
|
||||||
if(action==='result'){
|
if(action==='result'){
|
||||||
const job=jobs.get(body.id);if(!job)throw new Error('지반 생성 작업을 찾을 수 없습니다.');
|
const job=jobs.get(body.id);if(!job)throw new Error('지반 생성 작업을 찾을 수 없습니다.');
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import './ground-popup.css';
|
import './ground-popup.css';
|
||||||
const API='/__pierzainer/ground-link';
|
const API='/__pierzainer/ground-link';
|
||||||
|
let importGround: ((text: string, name: string) => Promise<void>) | undefined;
|
||||||
|
export function registerGroundImporter(importer: (text: string, name: string) => Promise<void>) { importGround = importer; }
|
||||||
let pending:{file:File;folder?:string}|undefined,staged:Promise<string|undefined>=Promise.resolve(undefined),version=0;
|
let pending:{file:File;folder?:string}|undefined,staged:Promise<string|undefined>=Promise.resolve(undefined),version=0;
|
||||||
const request=async(action:string,body:object)=>{const r=await fetch(`${API}/${action}`,{method:'POST',headers:{'Content-Type':'application/json','X-Pierzainer-Action':'ground-link'},body:JSON.stringify(body)});const value=await r.json();if(!r.ok)throw new Error(value.error??'지반 프로그램 연동 실패');return value;};
|
const request=async(action:string,body:object)=>{const r=await fetch(`${API}/${action}`,{method:'POST',headers:{'Content-Type':'application/json','X-Pierzainer-Action':'ground-link'},body:JSON.stringify(body)});const value=await r.json();if(!r.ok)throw new Error(value.error??'지반 프로그램 연동 실패');return value;};
|
||||||
const base64=(file:File)=>new Promise<string>((resolve,reject)=>{const reader=new FileReader();reader.onerror=()=>reject(new Error('Excel 파일을 읽지 못했습니다.'));reader.onload=()=>resolve(String(reader.result).split(',')[1]);reader.readAsDataURL(file);});
|
const base64=(file:File)=>new Promise<string>((resolve,reject)=>{const reader=new FileReader();reader.onerror=()=>reject(new Error('Excel 파일을 읽지 못했습니다.'));reader.onload=()=>resolve(String(reader.result).split(',')[1]);reader.readAsDataURL(file);});
|
||||||
@@ -9,6 +11,10 @@ document.addEventListener('click',event=>{if(!(event.target as Element).closest(
|
|||||||
|
|
||||||
let opening=false,running=false,launchSerial=0;
|
let opening=false,running=false,launchSerial=0;
|
||||||
let popup:HTMLDialogElement;
|
let popup:HTMLDialogElement;
|
||||||
|
window.addEventListener('pierzainer:project-changed',()=>{
|
||||||
|
version++;launchSerial++;running=false;opening=false;
|
||||||
|
if(popup){popup.close();popup.querySelector<HTMLElement>('[data-status]')!.textContent='프로젝트가 변경되어 이전 작업의 결과를 연결하지 않습니다.';}
|
||||||
|
});
|
||||||
function getPopup(){
|
function getPopup(){
|
||||||
if(popup)return popup;
|
if(popup)return popup;
|
||||||
popup=document.createElement('dialog');popup.className='ground-popup';popup.setAttribute('aria-labelledby','ground-popup-title');
|
popup=document.createElement('dialog');popup.className='ground-popup';popup.setAttribute('aria-labelledby','ground-popup-title');
|
||||||
@@ -28,13 +34,13 @@ window.addEventListener('pierzainer:open-ground',async()=>{
|
|||||||
const drawingInput=dialog.querySelector<HTMLInputElement>('[data-drawing]')!,excelInput=dialog.querySelector<HTMLInputElement>('[data-excel]')!;
|
const drawingInput=dialog.querySelector<HTMLInputElement>('[data-drawing]')!,excelInput=dialog.querySelector<HTMLInputElement>('[data-excel]')!;
|
||||||
drawingInput.value='';excelInput.value='';drawingInput.disabled=excelInput.disabled=false;dialog.querySelector('[data-drawing-name]')!.textContent='파일 확인 중…';dialog.querySelector('[data-excel-name]')!.textContent='파일 확인 중…';run.disabled=true;status.textContent='입력 파일 확인 중…';logs.textContent='';dialog.showModal();
|
drawingInput.value='';excelInput.value='';drawingInput.disabled=excelInput.disabled=false;dialog.querySelector('[data-drawing-name]')!.textContent='파일 확인 중…';dialog.querySelector('[data-excel-name]')!.textContent='파일 확인 중…';run.disabled=true;status.textContent='입력 파일 확인 중…';logs.textContent='';dialog.showModal();
|
||||||
let stageId:string|undefined,drawing='';
|
let stageId:string|undefined,drawing='';
|
||||||
try{stageId=await staged;const prepared=await request('prepare',{stageId});drawing=prepared.drawing;
|
try{stageId=await staged;const prepared=await request('prepare',{stageId});if(snapshot!==version||ticket!==launchSerial)return;drawing=prepared.drawing;
|
||||||
const display=(kind:string,name:string,connected:boolean)=>{dialog.querySelector(`[data-${kind}-name]`)!.textContent=name;dialog.querySelector(`[data-pick="${kind}"]`)!.textContent=connected?'파일 변경':kind==='drawing'?'종평면도 선택':'Excel 선택';};
|
const display=(kind:string,name:string,connected:boolean)=>{dialog.querySelector(`[data-${kind}-name]`)!.textContent=name;dialog.querySelector(`[data-pick="${kind}"]`)!.textContent=connected?'파일 변경':kind==='drawing'?'종평면도 선택':'Excel 선택';};
|
||||||
const drawingName=drawing.split(/[\\/]/).pop()||'종평면도를 선택하세요.';const excelName=prepared.excelName||'교각 Excel을 선택하세요.';
|
const drawingName=drawing.split(/[\\/]/).pop()||'종평면도를 선택하세요.';const excelName=prepared.excelName||'교각 Excel을 선택하세요.';
|
||||||
display('drawing',drawingName,!!drawing);display('excel',excelName,prepared.excelSelected);
|
display('drawing',drawingName,!!drawing);display('excel',excelName,prepared.excelSelected);
|
||||||
drawingInput.onchange=()=>display('drawing',drawingInput.files?.[0]?.name||drawingName,!!drawingInput.files?.[0]||!!drawing);
|
drawingInput.onchange=()=>display('drawing',drawingInput.files?.[0]?.name||drawingName,!!drawingInput.files?.[0]||!!drawing);
|
||||||
excelInput.onchange=()=>display('excel',excelInput.files?.[0]?.name||excelName,!!excelInput.files?.[0]||prepared.excelSelected);status.textContent='입력을 확인하고 지반 생성을 누르세요.';run.disabled=false;
|
excelInput.onchange=()=>display('excel',excelInput.files?.[0]?.name||excelName,!!excelInput.files?.[0]||prepared.excelSelected);status.textContent='입력을 확인하고 지반 생성을 누르세요.';run.disabled=false;
|
||||||
}catch(error){status.textContent=(error as Error).message;opening=false;return;}
|
}catch(error){if(snapshot!==version||ticket!==launchSerial)return;status.textContent=(error as Error).message;opening=false;return;}
|
||||||
run.onclick=async()=>{
|
run.onclick=async()=>{
|
||||||
if(snapshot!==version){status.textContent='프로젝트가 변경되었습니다. 닫고 다시 열어 주세요.';return;}
|
if(snapshot!==version){status.textContent='프로젝트가 변경되었습니다. 닫고 다시 열어 주세요.';return;}
|
||||||
run.disabled=true;drawingInput.disabled=excelInput.disabled=true;
|
run.disabled=true;drawingInput.disabled=excelInput.disabled=true;
|
||||||
@@ -43,23 +49,26 @@ window.addEventListener('pierzainer:open-ground',async()=>{
|
|||||||
status.textContent='입력 파일 전달 중…';const chosen=drawingInput.files?.[0],excel=excelInput.files?.[0];
|
status.textContent='입력 파일 전달 중…';const chosen=drawingInput.files?.[0],excel=excelInput.files?.[0];
|
||||||
if(chosen)drawing=await upload(chosen);if(!drawing)throw new Error('종평면도를 선택하세요.');
|
if(chosen)drawing=await upload(chosen);if(!drawing)throw new Error('종평면도를 선택하세요.');
|
||||||
const excelPath=excel?await upload(excel):undefined;
|
const excelPath=excel?await upload(excel):undefined;
|
||||||
|
if(snapshot!==version||ticket!==launchSerial)return;
|
||||||
if(!excelPath&&!stageId)throw new Error('교각 Excel을 선택하세요.');
|
if(!excelPath&&!stageId)throw new Error('교각 Excel을 선택하세요.');
|
||||||
const job=await request('launch',{stageId,drawing,excel:excelPath});running=true;status.textContent='지반·지도 생성 중… 닫아도 생성은 계속됩니다.';
|
const job=await request('launch',{stageId,drawing,excel:excelPath});if(snapshot!==version||ticket!==launchSerial)return;running=true;status.textContent='지반·지도 생성 중… 닫아도 생성은 계속됩니다.';
|
||||||
const deadline=Date.now()+30*60*1000;
|
const deadline=Date.now()+30*60*1000;
|
||||||
const poll=async()=>{
|
const poll=async()=>{
|
||||||
if(snapshot!==version||ticket!==launchSerial){running=false;return;}
|
if(snapshot!==version||ticket!==launchSerial)return;
|
||||||
try{
|
try{
|
||||||
const result=await request('result',{id:job.id});logs.textContent=result.logs??'';logs.scrollTop=logs.scrollHeight;
|
const result=await request('result',{id:job.id});logs.textContent=result.logs??'';logs.scrollTop=logs.scrollHeight;
|
||||||
|
if(snapshot!==version||ticket!==launchSerial)return;
|
||||||
if(result.state==='error')throw new Error('지반 생성 실패. 진행 로그를 확인하세요.');
|
if(result.state==='error')throw new Error('지반 생성 실패. 진행 로그를 확인하세요.');
|
||||||
if(result.ready){
|
if(result.ready){
|
||||||
const input=document.querySelector<HTMLInputElement>('#ground-import');
|
if(!importGround)throw new Error('프로젝트 지반모델 불러오기를 사용할 수 없습니다.');
|
||||||
status.textContent=input?'지반 생성 완료. 프로젝트에 불러왔습니다.':'지반 생성 완료. 지반·지층 화면으로 돌아오면 불러옵니다.';
|
await importGround(result.text,result.filename);
|
||||||
if(input){const transfer=new DataTransfer();transfer.items.add(new File([result.text],result.filename,{type:'application/json'}));input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));running=false;run.disabled=false;drawingInput.disabled=excelInput.disabled=false;return;}
|
status.textContent='지반 생성 완료. 프로젝트 공통 모델로 불러왔습니다. 관리창에서 비교 후 적용하세요.';
|
||||||
|
running=false;run.disabled=false;drawingInput.disabled=excelInput.disabled=false;return;
|
||||||
}
|
}
|
||||||
if(Date.now()>deadline)throw new Error('응답 대기 시간이 지났습니다. 결과 폴더를 확인하세요.');setTimeout(poll,2000);
|
if(Date.now()>deadline)throw new Error('응답 대기 시간이 지났습니다. 결과 폴더를 확인하세요.');setTimeout(poll,2000);
|
||||||
}catch(error){status.textContent=(error as Error).message;running=false;run.disabled=false;drawingInput.disabled=excelInput.disabled=false;}
|
}catch(error){if(snapshot!==version||ticket!==launchSerial)return;status.textContent=(error as Error).message;running=false;run.disabled=false;drawingInput.disabled=excelInput.disabled=false;}
|
||||||
};setTimeout(poll,1000);
|
};setTimeout(poll,1000);
|
||||||
}catch(error){status.textContent=(error as Error).message;running=false;run.disabled=false;drawingInput.disabled=excelInput.disabled=false;}
|
}catch(error){if(snapshot!==version||ticket!==launchSerial)return;status.textContent=(error as Error).message;running=false;run.disabled=false;drawingInput.disabled=excelInput.disabled=false;}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
// Closing hides the panel; the current job and its polling continue.
|
// Closing hides the panel; the current job and its polling continue.
|
||||||
|
|||||||
@@ -29,6 +29,18 @@ class TerrainData:
|
|||||||
stats: dict = field(default_factory=dict)
|
stats: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
def _exe_path_arg(exe: str, file: Path) -> str:
|
||||||
|
"""Windows .exe를 WSL/Linux에서 부를 때 경로를 Windows 형식으로 바꾼다."""
|
||||||
|
import os
|
||||||
|
if os.name == "nt" or not exe.lower().endswith(".exe"):
|
||||||
|
return str(file)
|
||||||
|
wslpath = shutil.which("wslpath")
|
||||||
|
if not wslpath:
|
||||||
|
return str(file)
|
||||||
|
result = subprocess.run([wslpath, "-w", str(Path(file).resolve())], check=False, capture_output=True, text=True)
|
||||||
|
return result.stdout.strip() if result.returncode == 0 and result.stdout.strip() else str(file)
|
||||||
|
|
||||||
|
|
||||||
def convert_dwg_to_dxf(dwg: str | Path, out_dir: str | Path) -> Path:
|
def convert_dwg_to_dxf(dwg: str | Path, out_dir: str | Path) -> Path:
|
||||||
"""LibreDWG의 dwg2dxf 로 DWG를 DXF로 바꾼다."""
|
"""LibreDWG의 dwg2dxf 로 DWG를 DXF로 바꾼다."""
|
||||||
exe = shutil.which("dwg2dxf")
|
exe = shutil.which("dwg2dxf")
|
||||||
@@ -55,7 +67,7 @@ def convert_dwg_to_dxf(dwg: str | Path, out_dir: str | Path) -> Path:
|
|||||||
temporary=out.with_name(out.stem+'.converting.dxf')
|
temporary=out.with_name(out.stem+'.converting.dxf')
|
||||||
try:
|
try:
|
||||||
temporary.unlink(missing_ok=True)
|
temporary.unlink(missing_ok=True)
|
||||||
result=subprocess.run([exe, "-y", "-o", str(temporary), str(dwg)], check=False, capture_output=True, timeout=300)
|
result=subprocess.run([exe, "-y", "-o", _exe_path_arg(exe,temporary), _exe_path_arg(exe,dwg)], check=False, capture_output=True, timeout=300)
|
||||||
if result.returncode!=0 or not temporary.is_file() or temporary.stat().st_size==0:
|
if result.returncode!=0 or not temporary.is_file() or temporary.stat().st_size==0:
|
||||||
raise RuntimeError(f"DWG 변환에 실패했습니다: {dwg}")
|
raise RuntimeError(f"DWG 변환에 실패했습니다: {dwg}")
|
||||||
temporary.replace(out)
|
temporary.replace(out)
|
||||||
|
|||||||
@@ -12,11 +12,52 @@ import numpy as np
|
|||||||
from ezdxf import recover
|
from ezdxf import recover
|
||||||
from pyproj import Transformer
|
from pyproj import Transformer
|
||||||
from pierzainer_ground.dxf_terrain import _densify,read_source_document
|
from pierzainer_ground.dxf_terrain import _densify,read_source_document
|
||||||
|
from pierzainer_ground.config import GroundConfig
|
||||||
|
from pierzainer_ground.dxf_terrain import read_terrain_dxf
|
||||||
from pierzainer_ground.dtm import DTM
|
from pierzainer_ground.dtm import DTM
|
||||||
|
|
||||||
ROOT=Path(__file__).resolve().parent.parent
|
ROOT=Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def reject_height_outliers(entities, radius=15.0, max_dz=10.0, min_levels=3, same=1e-3):
|
||||||
|
"""Drop height entities that disagree with the surrounding surveyed surface.
|
||||||
|
|
||||||
|
Drawings often keep plan symbols, hatching or design outlines at a stray
|
||||||
|
constant elevation (e.g. EL 5.624 or -112.719 among 20-40 m contours).
|
||||||
|
Each entity's height is compared with the median of the *distinct* nearby
|
||||||
|
height levels (each level votes once, so a dense stray group of short
|
||||||
|
ticks cannot outvote real contours); the worst height level is removed
|
||||||
|
first and the test is repeated, so a large stray group cannot mask itself.
|
||||||
|
Sparse areas fall back to a 3x search radius.
|
||||||
|
Heights within `same` (1 mm) form one level, absorbing CAD float noise.
|
||||||
|
Returns (keep flags, [(index, z, deviation)]).
|
||||||
|
"""
|
||||||
|
from scipy.spatial import cKDTree
|
||||||
|
n=len(entities)
|
||||||
|
keep=np.ones(n,bool)
|
||||||
|
if n<3:return keep.tolist(),[]
|
||||||
|
xy=np.vstack([e[0] for e in entities]);owner=np.concatenate([np.full(len(e[0]),i) for i,e in enumerate(entities)])
|
||||||
|
zs=np.array([float(e[1]) for e in entities]);sample_z=zs[owner]
|
||||||
|
tree=cKDTree(xy);rejected=[]
|
||||||
|
near=lambda e,r:np.unique(np.concatenate(tree.query_ball_point(e[0],r)) if len(e[0]) else np.array([],int)).astype(int)
|
||||||
|
neighbours=[near(e,radius) for e in entities];wide=[near(e,3*radius) for e in entities]
|
||||||
|
for _ in range(64):
|
||||||
|
alive=keep[owner];dev=np.zeros(n)
|
||||||
|
for i in np.flatnonzero(keep):
|
||||||
|
nb=neighbours[i];nb=nb[alive[nb]&(np.abs(sample_z[nb]-zs[i])>same)]
|
||||||
|
if len(np.unique(np.round(sample_z[nb]/same)))<min_levels:nb=wide[i];nb=nb[alive[nb]&(np.abs(sample_z[nb]-zs[i])>same)]
|
||||||
|
levels=np.unique(np.round(sample_z[nb]/same))*same
|
||||||
|
# Peaks/pits legitimately exceed the median; require the height to
|
||||||
|
# lie clearly outside the whole local range as well.
|
||||||
|
outside=max(levels[0]-zs[i],zs[i]-levels[-1]) if len(levels) else 0.0
|
||||||
|
if len(levels)>=min_levels and outside>max_dz/2:dev[i]=zs[i]-float(np.median(levels))
|
||||||
|
worst=int(np.argmax(np.abs(dev)))
|
||||||
|
if abs(dev[worst])<=max_dz:break
|
||||||
|
group=np.flatnonzero(keep&(np.abs(zs-zs[worst])<=same)&(np.abs(dev)>max_dz))
|
||||||
|
keep[group]=False;rejected.extend((int(i),float(zs[i]),float(dev[i])) for i in group)
|
||||||
|
return keep.tolist(),rejected
|
||||||
|
|
||||||
|
|
||||||
def read_survey_block(source, project, doc=None):
|
def read_survey_block(source, project, doc=None):
|
||||||
"""Read actual height geometry in an explicitly selected survey block."""
|
"""Read actual height geometry in an explicitly selected survey block."""
|
||||||
if doc is None:
|
if doc is None:
|
||||||
@@ -24,6 +65,33 @@ def read_survey_block(source, project, doc=None):
|
|||||||
block=doc.blocks.get(project['terrain_block'])
|
block=doc.blocks.get(project['terrain_block'])
|
||||||
if block is None:
|
if block is None:
|
||||||
raise ValueError(f"측량 블록이 없습니다: {project['terrain_block']}")
|
raise ValueError(f"측량 블록이 없습니다: {project['terrain_block']}")
|
||||||
|
# A plan's modelspace mixes contours with borehole symbols and design
|
||||||
|
# outlines that also carry Z. Prefer the established semantic reader when
|
||||||
|
# this drawing declares known survey layers; retain the block reader for
|
||||||
|
# original survey XREFs with their own layer conventions.
|
||||||
|
cfg=GroundConfig(cad_axes=project.get('cad_axes','YX'),use_profile_points=False,
|
||||||
|
z_min=project.get('z_min',0),z_max=project.get('z_max',200),
|
||||||
|
contour_step=project['contour_step_m'],dedup_xy=project['dedup_m'])
|
||||||
|
allowed=cfg.layers.spot_block+cfg.layers.spot_text+cfg.layers.contour
|
||||||
|
if project.get('layers'):
|
||||||
|
allowed=tuple(layer for layer in allowed if layer in project['layers'])
|
||||||
|
if (block.name.lower()=='*model_space' and project.get('prefer_recognized_layers',True)
|
||||||
|
and any(e.dxf.layer in allowed for e in block)):
|
||||||
|
cfg.allowed_terrain_layers=allowed
|
||||||
|
terrain=read_terrain_dxf(source,cfg,document=doc)
|
||||||
|
a=terrain.points
|
||||||
|
if project.get('exclude_zero'):
|
||||||
|
a=a[np.abs(a[:,2])>1e-6]
|
||||||
|
if len(a)<3:
|
||||||
|
raise ValueError('인식된 측량 레이어에서 실제 표고점·등고선을 3점 이상 읽지 못했습니다.')
|
||||||
|
if project.get('terrain_crs') and project.get('crs'):
|
||||||
|
east,north=Transformer.from_crs(project['terrain_crs'],project['crs'],always_xy=True).transform(a[:,1],a[:,0])
|
||||||
|
a=np.column_stack([north,east,a[:,2]])
|
||||||
|
if not np.isfinite(a).all():
|
||||||
|
raise ValueError('측량 레이어 좌표 변환 결과가 유효하지 않습니다.')
|
||||||
|
sources=terrain.stats['by_source']
|
||||||
|
return a,{'semantic_layer_filter':1,'survey_points':sources['spot'],
|
||||||
|
'spot_text_points':sources['text'],'contour_points':sources['contour']},terrain.stats['contours']
|
||||||
to_model=(Transformer.from_crs(project['terrain_crs'],project['crs'],always_xy=True)
|
to_model=(Transformer.from_crs(project['terrain_crs'],project['crs'],always_xy=True)
|
||||||
if project.get('terrain_crs') and project.get('crs') else None)
|
if project.get('terrain_crs') and project.get('crs') else None)
|
||||||
points=[]; contours=[]; types=Counter()
|
points=[]; contours=[]; types=Counter()
|
||||||
@@ -54,9 +122,11 @@ def read_survey_block(source, project, doc=None):
|
|||||||
if e.is_closed: xy.append(xy[0])
|
if e.is_closed: xy.append(xy[0])
|
||||||
contours.append((xy,float(a[0,2]),e.dxf.handle)); types['constant_height_polylines']+=1
|
contours.append((xy,float(a[0,2]),e.dxf.handle)); types['constant_height_polylines']+=1
|
||||||
# POINT entities have priority over densified contour vertices.
|
# POINT entities have priority over densified contour vertices.
|
||||||
for xy,z,handle in contours:
|
sampled=[(np.asarray(_densify(xy,project['contour_step_m']),dtype=float).reshape(-1,2),z) for xy,z,handle in contours]
|
||||||
samples=_densify(xy,project['contour_step_m'])
|
sampled=[(np.array([[x,y]]),z) for x,y,z in points]+sampled
|
||||||
points.extend((x,y,z) for x,y in samples)
|
keep,rejected=reject_height_outliers(sampled,project.get('outlier_radius_m',15.0),project.get('outlier_dz_m',10.0)) if project.get('reject_outliers',True) else ([True]*len(sampled),[])
|
||||||
|
if rejected:types['rejected_height_outliers']=len(rejected)
|
||||||
|
points=[(x,y,z) for (xy,z),ok in zip(sampled,keep) if ok for x,y in xy]
|
||||||
a=np.array(points,dtype=float)
|
a=np.array(points,dtype=float)
|
||||||
if len(a)<3:
|
if len(a)<3:
|
||||||
raise ValueError('선택한 블록에서 실제 표고점·등고선을 3점 이상 읽지 못했습니다.')
|
raise ValueError('선택한 블록에서 실제 표고점·등고선을 3점 이상 읽지 못했습니다.')
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe,it,expect } from 'vitest';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { resolvePython,describeSpawnError } from './python-runtime';
|
||||||
|
|
||||||
|
const root='/repo',venv=path.join(root,'pierzainer_ground','.venv');
|
||||||
|
const has=(...files:string[])=>(file:string)=>files.includes(file);
|
||||||
|
|
||||||
|
describe('resolvePython',()=>{
|
||||||
|
it('uses the Windows venv on win32',()=>{
|
||||||
|
const exe=path.join(venv,'Scripts','python.exe');
|
||||||
|
expect(resolvePython(root,'win32',has(exe),{})).toBe(exe);
|
||||||
|
});
|
||||||
|
it('uses the POSIX venv on Linux/WSL',()=>{
|
||||||
|
const bin=path.join(venv,'bin','python');
|
||||||
|
expect(resolvePython(root,'linux',has(bin),{})).toBe(bin);
|
||||||
|
});
|
||||||
|
it('falls back to PIERZAINER_PYTHON, then python3 / python',()=>{
|
||||||
|
expect(resolvePython(root,'linux',has(),{PIERZAINER_PYTHON:'/opt/py'})).toBe('/opt/py');
|
||||||
|
expect(resolvePython(root,'linux',has(),{})).toBe('python3');
|
||||||
|
expect(resolvePython(root,'win32',has(),{})).toBe('python');
|
||||||
|
});
|
||||||
|
it('explains ENOENT instead of the raw spawn message',()=>{
|
||||||
|
const error=Object.assign(new Error('spawn python ENOENT'),{code:'ENOENT'});
|
||||||
|
expect(describeSpawnError(error,'python')).toContain('Python 실행 파일을 찾지 못했습니다');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Python used by the local dev-server tools (ground engine, quantity workbook, DWG export).
|
||||||
|
* Order: Windows venv → POSIX venv (WSL/Linux) → PIERZAINER_PYTHON → python3/python on PATH.
|
||||||
|
*/
|
||||||
|
export function resolvePython(root:string,platform:NodeJS.Platform=process.platform,exists:(file:string)=>boolean=existsSync,env:NodeJS.ProcessEnv=process.env):string{
|
||||||
|
const venv=path.join(root,'pierzainer_ground','.venv');
|
||||||
|
const candidates=platform==='win32'?[path.join(venv,'Scripts','python.exe')]:[path.join(venv,'bin','python'),path.join(venv,'Scripts','python.exe')];
|
||||||
|
const found=candidates.find(file=>exists(file));if(found)return found;
|
||||||
|
if(env.PIERZAINER_PYTHON)return env.PIERZAINER_PYTHON;
|
||||||
|
return platform==='win32'?'python':'python3';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable reason for a failed spawn (ENOENT otherwise shows only "spawn python ENOENT"). */
|
||||||
|
export function describeSpawnError(error:NodeJS.ErrnoException,python:string):string{
|
||||||
|
if(error.code==='ENOENT')return `Python 실행 파일을 찾지 못했습니다(${python}). pierzainer_ground/.venv를 만들고 requirements.txt를 설치하거나 PIERZAINER_PYTHON을 지정하세요.`;
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
@@ -25,4 +25,46 @@ class SurveyPolylineTests(unittest.TestCase):
|
|||||||
doc=ezdxf.new();doc.modelspace().add_polyline3d([(1,1,20),(2,2,30),(3,3,40)])
|
doc=ezdxf.new();doc.modelspace().add_polyline3d([(1,1,20),(2,2,30),(3,3,40)])
|
||||||
with self.assertRaises(ValueError):read_survey_block(Path('unused.dxf'),self.options(),doc=doc)
|
with self.assertRaises(ValueError):read_survey_block(Path('unused.dxf'),self.options(),doc=doc)
|
||||||
|
|
||||||
|
def contours(self,msp,levels):
|
||||||
|
for k,z in enumerate(levels):
|
||||||
|
msp.add_lwpolyline([(0,10*k),(200,10*k)],dxfattribs={'elevation':z})
|
||||||
|
|
||||||
|
def test_stray_constant_height_symbols_are_rejected(self):
|
||||||
|
# Ungok case: dense short ticks at EL 5.624 and a few at -112.719 among 20-40 m contours.
|
||||||
|
doc=ezdxf.new();msp=doc.modelspace();self.contours(msp,[28,29,30,31,32,33])
|
||||||
|
for i in range(60):msp.add_lwpolyline([(40+2*i,20),(40+2*i,22)],dxfattribs={'elevation':5.624})
|
||||||
|
for i in range(4):msp.add_lwpolyline([(100+i,25),(100+i,27)],dxfattribs={'elevation':-112.719})
|
||||||
|
points,counts,_=read_survey_block(Path('unused.dxf'),self.options(),doc=doc)
|
||||||
|
self.assertEqual(counts['rejected_height_outliers'],64)
|
||||||
|
self.assertGreaterEqual(points[:,2].min(),28)
|
||||||
|
|
||||||
|
def test_real_peak_above_contours_is_kept(self):
|
||||||
|
doc=ezdxf.new();msp=doc.modelspace();self.contours(msp,[70,72,74,76,78,80])
|
||||||
|
msp.add_lwpolyline([(100,25),(100,27)],dxfattribs={'elevation':84})
|
||||||
|
points,counts,_=read_survey_block(Path('unused.dxf'),self.options(),doc=doc)
|
||||||
|
self.assertNotIn('rejected_height_outliers',counts);self.assertEqual(points[:,2].max(),84)
|
||||||
|
|
||||||
|
def test_known_plan_layers_exclude_near_height_bore_and_base(self):
|
||||||
|
doc=ezdxf.new();msp=doc.modelspace()
|
||||||
|
for y,z in [(0,30),(100,32)]:
|
||||||
|
msp.add_lwpolyline([(0,y),(100,y)],dxfattribs={'layer':'CAA001','elevation':z})
|
||||||
|
doc.blocks.new('SPOT').add_circle((0,0),.1)
|
||||||
|
msp.add_blockref('SPOT',(20,50,31),dxfattribs={'layer':'CA002'})
|
||||||
|
msp.add_text('30.5',dxfattribs={'layer':'CA0021','insert':(80,50)})
|
||||||
|
for layer in ['CV-BORE-SYMB','CZ-BASE','CF-BRDG-NEWC']:
|
||||||
|
msp.add_lwpolyline([(45,45),(55,45),(55,55)],dxfattribs={'layer':layer,'elevation':27.5})
|
||||||
|
points,counts,_=read_survey_block(Path('unused.dxf'),self.options(),doc=doc)
|
||||||
|
self.assertGreaterEqual(points[:,2].min(),30)
|
||||||
|
self.assertEqual(counts['semantic_layer_filter'],1)
|
||||||
|
self.assertEqual(counts['survey_points'],1)
|
||||||
|
self.assertEqual(counts['spot_text_points'],1)
|
||||||
|
self.assertTrue(any(np.array_equal(p,[50,20,31]) for p in points))
|
||||||
|
self.assertTrue(any(np.array_equal(p,[50,80,30.5]) for p in points))
|
||||||
|
|
||||||
|
def test_known_layers_do_not_fall_back_to_symbols_when_empty(self):
|
||||||
|
doc=ezdxf.new();msp=doc.modelspace()
|
||||||
|
msp.add_lwpolyline([(0,0),(10,0)],dxfattribs={'layer':'CAA001','elevation':0})
|
||||||
|
msp.add_lwpolyline([(0,0),(10,0),(10,10)],dxfattribs={'layer':'CV-BORE-SYMB','elevation':28})
|
||||||
|
with self.assertRaises(ValueError):read_survey_block(Path('unused.dxf'),self.options(),doc=doc)
|
||||||
|
|
||||||
if __name__=='__main__':unittest.main()
|
if __name__=='__main__':unittest.main()
|
||||||
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
@@ -0,0 +1,64 @@
|
|||||||
|
// 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 preview=process.argv.includes('--horizontal-preview');
|
||||||
|
const lowerPreview=process.argv.includes('--lower-preview');
|
||||||
|
const out = path.resolve(__dirname, lowerPreview?'../.verify/landing-lower-20261109':process.argv.includes('--all')?'../.verify/landing-hero-20261109':'../.verify/landing-horizontal-20261109');
|
||||||
|
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'`);
|
||||||
|
const projectPath=process.env.OKSEONG_PROJECT||path.resolve(__dirname,'../.verify/ground-abutments-20261109/saved.pzr.json');
|
||||||
|
const projectText=fs.readFileSync(projectPath,'utf8');
|
||||||
|
const hash=require('node:crypto').createHash('sha256').update(projectText).digest('hex');
|
||||||
|
await ev(`window.__heroProject=JSON.parse(${JSON.stringify(projectText)})`);
|
||||||
|
const variants=lowerPreview ? [['lower-33',.65,0,30,true,true],['lower-27',.5,0,30,true,true],['lower-19',.35,0,30,true,true],['lower-27-cutaway',.5,0,30,true,true,8]] : preview ? [['horizontal',.85,0,30,true],['horizontal-smooth',.85,0,30,true,true]] : process.argv.includes('--all') ? [['low',.35,0],['balanced',.55,.12],['high',.8,-.12],['trim30',.55,.12,30],['trim20',.55,.12,20],['refined',.85,.12,30,true]] : [['horizontal-smooth',.85,0,30,true,true]];
|
||||||
|
for(const [name,elevation,along,margin,refined,smooth,cutaway] of variants){
|
||||||
|
const result=await ev(`(async()=>{const {renderLandingHero}=await import('/scripts/render-landing-hero.ts');return renderLandingHero(window.__heroProject,${elevation},${along},${margin},${refined},${smooth},${cutaway});})()`);
|
||||||
|
for(const type of ['png','webp'])fs.writeFileSync(path.join(out,name+'.'+type),Buffer.from(result[type].split(',')[1],'base64'));
|
||||||
|
fs.writeFileSync(path.join(out,name+'.json'),JSON.stringify({...result.metadata,sourceSha256:hash},null,2));
|
||||||
|
check(name+' includes six piers and two abutments',result.metadata.piers===6&&result.metadata.abutments===2&&result.metadata.points.length===8);
|
||||||
|
check(name+' all projected markers stay inside image',result.metadata.points.every(p=>p.x>0&&p.x<result.metadata.width&&p.y>0&&p.y<result.metadata.height));
|
||||||
|
check(name+' light samples actual terrain densely',result.metadata.terrainFlow.filter(Boolean).length>100&&result.metadata.terrainFlow.every(p=>!p||Math.abs(p.world[1]-p.el-.12)<.0001));
|
||||||
|
if(refined)check('both road extensions contain continuous terrain-supported sections',result.metadata.roads.length===2&&result.metadata.roads.every(r=>r.missingSections===0&&r.validSections===81&&r.triangles>0));
|
||||||
|
if(smooth){
|
||||||
|
check('outline trims real pixels without filling missing terrain',result.metadata.outline.addedOutsideSourcePixels===0&&result.metadata.outline.retainedTerrainRatio>.8&&result.metadata.outline.retainedTerrainRatio<.99);
|
||||||
|
check('smooth outline preserves all eight structure tops',await ev(`(()=>{const c=document.createElement('canvas');c.width=${result.metadata.width*2};c.height=${result.metadata.height*2};return new Promise(resolve=>{const i=new Image();i.onload=()=>{const ctx=c.getContext('2d');ctx.drawImage(i,0,0);resolve(${JSON.stringify(result.metadata.points)}.every(p=>{let n=0;for(let x=-8;x<=8;x++)for(let y=0;y<30;y++)n+=ctx.getImageData(Math.round(p.x*2+x),Math.round(p.y*2+y),1,1).data[3]>0;return n>15;}));};i.src=${JSON.stringify(result.png)};});})()`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Create a review sheet from native Three.js captures; validate local structure crops."""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from PIL import Image, ImageChops, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
out = root / '.verify/landing-horizontal-20261109'
|
||||||
|
metadata = json.loads((out / 'horizontal-smooth.json').read_text())
|
||||||
|
raw = Image.open(out / 'horizontal.png').convert('RGBA')
|
||||||
|
smooth = Image.open(out / 'horizontal-smooth.png').convert('RGBA')
|
||||||
|
checks = []
|
||||||
|
for point in metadata['points']:
|
||||||
|
x, y = point['x'] * 2, point['y'] * 2
|
||||||
|
box = (int(x - 48), int(y - 8), int(x + 48), int(y + 120))
|
||||||
|
assert ImageChops.difference(raw.crop(box), smooth.crop(box)).getbbox() is None, point['name']
|
||||||
|
checks.append(point['name'] + ' local structure pixels unchanged')
|
||||||
|
assert metadata['along'] == 0
|
||||||
|
assert metadata['outline']['addedOutsideSourcePixels'] == 0
|
||||||
|
checks.extend(['horizontal camera has zero along-axis component', 'no pixels added outside source'])
|
||||||
|
|
||||||
|
sheet = Image.new('RGB', (1600, 1050), '#f5f8fb')
|
||||||
|
draw = ImageDraw.Draw(sheet)
|
||||||
|
font = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 23)
|
||||||
|
small = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 17)
|
||||||
|
previous = out / 'before-horizontal/okseong-model-hero.png'
|
||||||
|
panels = [
|
||||||
|
('PREVIOUS / diagonal view, rough survey boundary', previous if previous.exists() else root / '랜딩페이지/assets/okseong-model-hero.png'),
|
||||||
|
('PROPOSAL / horizontal orthographic view, inward curved boundary', out / 'horizontal-smooth.png'),
|
||||||
|
]
|
||||||
|
for i, (label, path) in enumerate(panels):
|
||||||
|
y = 25 + i * 510
|
||||||
|
draw.rounded_rectangle((20, y, 1580, y + 480), radius=20, fill='white', outline='#dce4ed')
|
||||||
|
draw.text((45, y + 20), label, font=font, fill='#19334d')
|
||||||
|
image = Image.open(path).convert('RGBA')
|
||||||
|
image = image.crop(image.getbbox())
|
||||||
|
image.thumbnail((1500, 380), Image.Resampling.LANCZOS)
|
||||||
|
sheet.paste(image, ((1600 - image.width) // 2, y + 80 + (380 - image.height) // 2), image)
|
||||||
|
if i == 1:
|
||||||
|
draw.text((45, y + 445), '6 piers + 2 abutments preserved / original survey unchanged / preview only', font=small, fill='#52667a')
|
||||||
|
sheet.save(out / 'comparison.png')
|
||||||
|
(out / 'review-report.json').write_text(json.dumps({'result': 'PASS', 'checks': checks}, indent=2))
|
||||||
|
print(f'PASS {len(checks)} review checks; {out / "comparison.png"}')
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
/** Rounded capture footprint; the underlying survey grid remains untouched. */
|
||||||
|
export function roundedTerrainPlanes(axis: THREE.Vector3, start: number, end: number, halfWidth: number, radius: number) {
|
||||||
|
const lateral = new THREE.Vector3(-axis.z, 0, axis.x);
|
||||||
|
const corners = [[end-radius,halfWidth-radius,0],[start+radius,halfWidth-radius,90],[start+radius,-halfWidth+radius,180],[end-radius,-halfWidth+radius,270]];
|
||||||
|
const points: [number,number][]=[];
|
||||||
|
for(const [s,t,angle] of corners) for(let i=0;i<=8;i++) {
|
||||||
|
const a=(angle+i*90/8)*Math.PI/180;
|
||||||
|
points.push([s+radius*Math.cos(a),t+radius*Math.sin(a)]);
|
||||||
|
}
|
||||||
|
return points.map((p,i)=>{
|
||||||
|
const q=points[(i+1)%points.length],ds=q[0]-p[0],dt=q[1]-p[1];
|
||||||
|
const normal=axis.clone().multiplyScalar(-dt).addScaledVector(lateral,ds).normalize();
|
||||||
|
const origin=axis.clone().multiplyScalar(p[0]).addScaledVector(lateral,p[1]);
|
||||||
|
return new THREE.Plane(normal,-normal.dot(origin));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Landing-only road ribbons, joining the photographed road to ground at each abutment. */
|
||||||
|
export function buildRoadExtensions(terrain: THREE.Mesh, anchors: {name:string;kind:string;point:THREE.Vector3}[], axis: THREE.Vector3, top: number) {
|
||||||
|
const piers=anchors.filter(a=>a.kind==='pier').toSorted((a,b)=>a.point.dot(axis)-b.point.dot(axis));
|
||||||
|
const abuts=anchors.filter(a=>a.kind==='abutment').toSorted((a,b)=>a.point.dot(axis)-b.point.dot(axis));
|
||||||
|
if(piers.length<2||abuts.length!==2)throw new Error('Road extensions require two abutments and at least two piers');
|
||||||
|
const group=new THREE.Group();group.name='landing-road-extensions';
|
||||||
|
const ray=new THREE.Raycaster(),down=new THREE.Vector3(0,-1,0),widthM=8;
|
||||||
|
const sample=(x:number,z:number)=>{
|
||||||
|
ray.set(new THREE.Vector3(x,top+100,z),down);
|
||||||
|
return ray.intersectObject(terrain,false)[0]?.point;
|
||||||
|
};
|
||||||
|
const descriptions=[];
|
||||||
|
const ends=[{name:'A1–P1',points:[abuts[0].point,piers[0].point,piers[1].point],from:0,to:.58},
|
||||||
|
{name:'P6–A2',points:[piers.at(-2)!.point,piers.at(-1)!.point,abuts[1].point],from:.42,to:1}];
|
||||||
|
for(const end of ends){
|
||||||
|
const curve=new THREE.CatmullRomCurve3(end.points.map(p=>new THREE.Vector3(p.x,0,p.z)),false,'catmullrom',.2);
|
||||||
|
const steps=80,positions:number[]=[],colors:number[]=[],indices:number[]=[];
|
||||||
|
let previous=-1,validSections=0,missingSections=0;
|
||||||
|
for(let i=0;i<=steps;i++){
|
||||||
|
const t=i/steps,u=end.from+(end.to-end.from)*t,p=curve.getPoint(u),tangent=curve.getTangent(u).normalize();
|
||||||
|
const cross=new THREE.Vector3(-tangent.z,0,tangent.x);
|
||||||
|
const offsets=Array.from({length:17},(_,j)=>-widthM/2+widthM*j/16);
|
||||||
|
const hits=offsets.map(o=>sample(p.x+cross.x*o,p.z+cross.z*o));
|
||||||
|
if(hits.some(h=>!h)){previous=-1;missingSections++;continue;}
|
||||||
|
const first=positions.length/3;
|
||||||
|
const blend=end.from===0?1-THREE.MathUtils.smoothstep(t,.72,1):THREE.MathUtils.smoothstep(t,0,.28);
|
||||||
|
const color=new THREE.Color('#dcd8c7').multiplyScalar(1+Math.sin(i*2.33)*.018);
|
||||||
|
hits.forEach((hit,j)=>{positions.push(hit!.x,hit!.y+.15,hit!.z);colors.push(color.r,color.g,color.b,blend*(j===0||j===offsets.length-1?0:.96));});
|
||||||
|
if(previous>=0)for(let j=0;j<offsets.length-1;j++)indices.push(previous+j,first+j,previous+j+1,previous+j+1,first+j,first+j+1);
|
||||||
|
previous=first;validSections++;
|
||||||
|
}
|
||||||
|
const geometry=new THREE.BufferGeometry();geometry.setAttribute('position',new THREE.Float32BufferAttribute(positions,3));geometry.setAttribute('color',new THREE.Float32BufferAttribute(colors,4));geometry.setIndex(indices);geometry.computeVertexNormals();
|
||||||
|
const mesh=new THREE.Mesh(geometry,new THREE.MeshBasicMaterial({vertexColors:true,side:THREE.DoubleSide,transparent:true,depthWrite:false,polygonOffset:true,polygonOffsetFactor:-2,polygonOffsetUnits:-2}));
|
||||||
|
mesh.name=end.name;mesh.renderOrder=2;group.add(mesh);
|
||||||
|
descriptions.push({name:end.name,widthM,validSections,missingSections,triangles:indices.length/3,visualizationOnly:true});
|
||||||
|
}
|
||||||
|
return {group,descriptions};
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/** Capture-only silhouette trimming. Samples real alpha; never fills missing survey data. */
|
||||||
|
export function smoothLandingOutline(source: HTMLCanvasElement, terrain: HTMLCanvasElement, preserveExposedStructures = false) {
|
||||||
|
const width=source.width,height=source.height,scale=width/2172;
|
||||||
|
const alpha=terrain.getContext('2d')!.getImageData(0,0,width,height).data;
|
||||||
|
const tops:number[]=[],bottoms:number[]=[];
|
||||||
|
for(let x=0;x<width;x++) {
|
||||||
|
let top=height,bottom=-1;
|
||||||
|
for(let y=0;y<height;y++)if(alpha[(y*width+x)*4+3]>240){top=Math.min(top,y);bottom=y;}
|
||||||
|
tops.push(top);bottoms.push(bottom);
|
||||||
|
}
|
||||||
|
const support=tops.map((top,x)=>bottoms[x]-top>60*scale?x:-1).filter(x=>x>=0);
|
||||||
|
if(!support.length)throw new Error('No continuous terrain silhouette');
|
||||||
|
const left=support[0]+Math.ceil(12*scale),right=support.at(-1)!-Math.ceil(12*scale);
|
||||||
|
const inset=Math.ceil(4*scale),radius=Math.ceil(18*scale),average=Math.ceil(8*scale);
|
||||||
|
const eroded=(values:number[],upper:boolean)=>values.map((_,x)=>{
|
||||||
|
const local=values.slice(Math.max(0,x-radius-average),Math.min(width,x+radius+average+1));
|
||||||
|
return (upper?Math.max(...local):Math.min(...local))+(upper?inset:-inset);
|
||||||
|
});
|
||||||
|
const upper=eroded(tops,true),lower=eroded(bottoms,false);
|
||||||
|
const mean=(values:number[],x:number)=>{
|
||||||
|
const samples=values.slice(Math.max(0,x-average),Math.min(width,x+average+1));
|
||||||
|
return samples.reduce((a,b)=>a+b,0)/samples.length;
|
||||||
|
};
|
||||||
|
const points:[number,number][]=[];
|
||||||
|
const cap=Math.ceil(24*scale),step=Math.ceil(12*scale);
|
||||||
|
for(let x=left+cap;x<=right-cap;x+=step)points.push([x,mean(upper,x)]);
|
||||||
|
points.push([right-cap,mean(upper,right-cap)],[right,mean(upper,right-cap)+cap],[right,mean(lower,right-cap)-cap],[right-cap,mean(lower,right-cap)]);
|
||||||
|
for(let x=right-cap-step;x>=left+cap;x-=step)points.push([x,mean(lower,x)]);
|
||||||
|
points.push([left+cap,mean(lower,left+cap)],[left,mean(lower,left+cap)-cap],[left,mean(upper,left+cap)+cap],[left+cap,mean(upper,left+cap)]);
|
||||||
|
const mid=(a:[number,number],b:[number,number])=>[(a[0]+b[0])/2,(a[1]+b[1])/2];
|
||||||
|
const first=mid(points.at(-1)!,points[0]);
|
||||||
|
let path=`M${first[0]} ${first[1]}`;
|
||||||
|
points.forEach((p,i)=>{const next=mid(p,points[(i+1)%points.length]);path+=`Q${p[0]} ${p[1]} ${next[0]} ${next[1]}`;});path+='Z';
|
||||||
|
const canvas=document.createElement('canvas');canvas.width=width;canvas.height=height;
|
||||||
|
const ctx=canvas.getContext('2d')!;ctx.drawImage(source,0,0);
|
||||||
|
const original=ctx.getImageData(0,0,width,height).data;
|
||||||
|
ctx.globalCompositeOperation='destination-in';ctx.fill(new Path2D(path));
|
||||||
|
const outputImage=ctx.getImageData(0,0,width,height),output=outputImage.data;
|
||||||
|
// A cutaway may expose piles beyond the terrain silhouette. Preserve their rendered pixels.
|
||||||
|
if(preserveExposedStructures){
|
||||||
|
for(let i=0;i<alpha.length;i+=4)if(alpha[i+3]===0&&original[i+3]>0)output.set(original.subarray(i,i+4),i);
|
||||||
|
ctx.putImageData(outputImage,0,0);
|
||||||
|
}
|
||||||
|
// Compare to terrain alpha only: smoothing should remove its rough border, not invent pixels.
|
||||||
|
let before=0,after=0,addedPixels=0;
|
||||||
|
for(let i=3;i<alpha.length;i+=4){if(alpha[i]>240){before++;if(output[i]>240)after++;}if(original[i]===0&&output[i]>0)addedPixels++;}
|
||||||
|
return {canvas,metadata:{method:'inward-alpha-envelope-quadratic',path,retainedTerrainRatio:after/before,addedOutsideSourcePixels:addedPixels}};
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Promote one rendered camera variant; preserve the previous landing image."""
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
assets = root / '랜딩페이지/assets'
|
||||||
|
variant = sys.argv[1] if len(sys.argv) > 1 else 'horizontal-smooth'
|
||||||
|
if variant not in ('balanced', 'low', 'high', 'trim30', 'trim20', 'refined', 'horizontal-smooth'):
|
||||||
|
raise SystemExit('Unknown camera variant')
|
||||||
|
source = root / ('.verify/landing-horizontal-20261109' if variant == 'horizontal-smooth' else '.verify/landing-hero-20261109')
|
||||||
|
metadata = json.loads((source / f'{variant}.json').read_text())
|
||||||
|
if variant == 'horizontal-smooth':
|
||||||
|
assert metadata['along'] == 0 and metadata['outline']['addedOutsideSourcePixels'] == 0
|
||||||
|
assert metadata['piers'] == 6 and metadata['abutments'] == 2
|
||||||
|
# Full curve control points remain in the capture audit, outside the service bundle.
|
||||||
|
metadata['outline'].pop('path')
|
||||||
|
backup = source / ('before-horizontal' if variant == 'horizontal-smooth' else 'before-refined' if variant == 'refined' else 'before-trim30')
|
||||||
|
backup.mkdir(exist_ok=True)
|
||||||
|
for name in ('okseong-model-hero.png', 'okseong-model-hero.webp', 'okseong-hero-capture.ts'):
|
||||||
|
if (assets / name).exists() and not (backup / name).exists():
|
||||||
|
shutil.copyfile(assets / name, backup / name)
|
||||||
|
for kind in ('png', 'webp'):
|
||||||
|
shutil.copyfile(source / f'{variant}.{kind}', assets / f'okseong-model-hero.{kind}')
|
||||||
|
metadata['terrainFlow'] = [None if p is None else {'x': p['x'], 'y': p['y']} for p in metadata['terrainFlow']]
|
||||||
|
(assets / 'okseong-hero-capture.ts').write_text(
|
||||||
|
'// Generated by scripts/promote-landing-hero.py from actual model projection.\n'
|
||||||
|
'export const heroCapture = ' + json.dumps(metadata, ensure_ascii=False, indent=2) + ' as const;\n'
|
||||||
|
)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Regenerate the user's two inputs in a separate folder, reusing exact caches."""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
root=Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0,str(root/'pierzainer_ground'))
|
||||||
|
from pierzainer_ground.drawing_job import DrawingJob,build_drawing
|
||||||
|
|
||||||
|
old=root/'pierzainer_ground/data/output/app_b614afc0-6829-4aef-96e3-3ff1365ff5db'
|
||||||
|
target=root/'pierzainer_ground/data/output/ungok_corrected_20261110'
|
||||||
|
evidence=root/'.verify/ungok-terrain-fixed-20261110'
|
||||||
|
evidence.mkdir(parents=True,exist_ok=True)
|
||||||
|
settings=json.loads((old/'generation_settings.json').read_text())
|
||||||
|
old_file=old/'ground_model_3d_with_maps.json'
|
||||||
|
sources=[Path(settings['drawing']),Path(settings['piers']),old_file]
|
||||||
|
digest=lambda p:hashlib.sha256(p.read_bytes()).hexdigest()
|
||||||
|
before={str(p):digest(p) for p in sources}
|
||||||
|
if (target/'ground_model_3d_with_maps.json').exists():
|
||||||
|
raise SystemExit('Corrected result already exists; preserve it rather than overwrite.')
|
||||||
|
(target/'cache').mkdir(parents=True,exist_ok=True)
|
||||||
|
for path in (old/'cache').iterdir():
|
||||||
|
if path.name.endswith(('.dxf','.dxf.source.json')) or path.name=='map_layers.json':
|
||||||
|
shutil.copyfile(path,target/'cache'/path.name)
|
||||||
|
settings.update(output=str(target),allow_download=False)
|
||||||
|
log=[]
|
||||||
|
data=build_drawing(DrawingJob(**settings),lambda text:(log.append(text),print(text,flush=True)))
|
||||||
|
original=json.loads(old_file.read_text())
|
||||||
|
def metrics(d):
|
||||||
|
g=d['terrain_grid'];z=np.array([np.nan if v is None else v for v in g['z']]).reshape(g['ny'],g['nx'])
|
||||||
|
edges=np.concatenate([np.abs(np.diff(z,axis=0)).ravel(),np.abs(np.diff(z,axis=1)).ravel()]);edges=edges[np.isfinite(edges)]
|
||||||
|
return {'valid_nodes':int(np.isfinite(z).sum()),'min_el':float(np.nanmin(z)),'max_el':float(np.nanmax(z)),
|
||||||
|
'max_adjacent_dz':float(edges.max()),'edges_over_5m':int((edges>5).sum()),'edges_over_8m':int((edges>8).sum())}
|
||||||
|
checks={
|
||||||
|
'original_inputs_and_previous_result_unchanged':all(digest(p)==before[str(p)] for p in sources),
|
||||||
|
'all_16_centers_have_ground':len(data['piers'])==16 and all(p['ground']['center'] is not None for p in data['piers']),
|
||||||
|
'same_structure_ids_and_coordinates':[(p['id'],p['X'],p['Y']) for p in data['piers']]==[(p['id'],p['X'],p['Y']) for p in original['piers']],
|
||||||
|
'same_grid_layout':all(data['terrain_grid'][k]==original['terrain_grid'][k] for k in ('origin','step','nx','ny')),
|
||||||
|
'semantic_source_filter_used':data['terrain']['source_counts'].get('semantic_layer_filter')==1,
|
||||||
|
'no_adjacent_jumps_over_5m':metrics(data)['edges_over_5m']==0,
|
||||||
|
'both_embedded_maps_retained':data['map_status']['status']=='mapped' and {l['id'] for l in data['visualization']['layers']}=={'satellite','ground'},
|
||||||
|
'original_source_provenance_matches':data['surface_provenance']['sha256']==before[str(sources[0])] and data['surface_provenance']['pier_sha256']==before[str(sources[1])],
|
||||||
|
}
|
||||||
|
report={'result':'PASS' if all(checks.values()) else 'FAIL','checks':checks,'before':metrics(original),'after':metrics(data),'output':str(target/'ground_model_3d_with_maps.json'),'inputs_sha256':before,'terrain':data['terrain'],'log':log,
|
||||||
|
'centers':[{'id':new['id'],'before':prior['ground']['center'],'after':new['ground']['center']} for prior,new in zip(original['piers'],data['piers'])]}
|
||||||
|
(evidence/'report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2))
|
||||||
|
print(json.dumps(report,ensure_ascii=False))
|
||||||
|
if not all(checks.values()):raise SystemExit(1)
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { ProjectFile } from '../src/core/project';
|
||||||
|
import { buildPier } from '../src/core/geometry';
|
||||||
|
import { buildAllPiers } from '../src/view/all-piers';
|
||||||
|
import { buildTerrainMapGroup, disposeTerrainMapGroup } from '../src/view/terrain-map';
|
||||||
|
import { buildAbut } from '../src/core/abut/geometry';
|
||||||
|
import { toAbutInput } from '../src/core/abut/parse';
|
||||||
|
import { buildMembers } from '../src/core/abut/members';
|
||||||
|
import { buildWings } from '../src/core/abut/wings';
|
||||||
|
import { buildAbutGroup } from '../src/view/abut-scene';
|
||||||
|
import { abutSoles, resolveBearingRule } from '../src/core/abut/bearing-table';
|
||||||
|
import { bridgePairs } from '../src/core/bridge-pairs';
|
||||||
|
import { buildRoadExtensions, roundedTerrainPlanes } from './landing-ground-treatment';
|
||||||
|
import { smoothLandingOutline } from './landing-smooth-outline';
|
||||||
|
|
||||||
|
/** Actual structure/terrain capture with optional landing-only road extensions. */
|
||||||
|
export async function renderLandingHero(project: ProjectFile, elevation: number, along = 0, endMarginM?: number, refined = false, smoothOutline = false, cutawayFrontM?: number) {
|
||||||
|
const width = 2172, height = refined ? 870 : 724, aspect=width/height;
|
||||||
|
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true, preserveDrawingBuffer: true });
|
||||||
|
renderer.setPixelRatio(2); renderer.setSize(width, height); renderer.setClearColor(0xffffff, 0);
|
||||||
|
renderer.localClippingEnabled = true;
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.add(new THREE.HemisphereLight(0xffffff, 0x7c8a9a, 2.1));
|
||||||
|
const sun = new THREE.DirectionalLight(0xffffff, 2.4); sun.position.set(-100, 250, 150); scene.add(sun);
|
||||||
|
const pairs=bridgePairs(project.piers.map(p=>p.input),project.abutments??[]);
|
||||||
|
const entries = project.piers.map(p => ({ name: p.input.name, model: buildPier(p.input,{pairs}) }));
|
||||||
|
const pivot = entries[0].model.pivot, grid = project.groundModel?.terrainGrid;
|
||||||
|
const all = buildAllPiers(entries, grid); scene.add(all.piers); if (all.terrain) scene.add(all.terrain);
|
||||||
|
const anchors = entries.map(e => ({ name: e.name, kind: 'pier', point: new THREE.Vector3(e.model.pivot.e - pivot.e, e.model.copingRefTop + 1, -(e.model.pivot.n - pivot.n)) }));
|
||||||
|
for (const rec of project.abutments ?? []) {
|
||||||
|
const model = buildAbut(toAbutInput(rec));
|
||||||
|
const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape);
|
||||||
|
const soles = abutSoles(model, resolveBearingRule(rec.bearingRule), rec.bearingHeightMm, {
|
||||||
|
pairs,slopeI:rec.slopeI,
|
||||||
|
inputAzimuthDeg: Object.fromEntries(rec.bearings.filter(b => b.azimuthDeg !== undefined).map(b => [String(b.no), b.azimuthDeg!])),
|
||||||
|
});
|
||||||
|
scene.add(buildAbutGroup(model, members, buildWings(model, members, rec.wings, rec.wingDims), pivot, rec.groundEl, grid, rec.slab, soles, undefined, !!all.terrain));
|
||||||
|
anchors.push({ name: rec.name, kind: 'abutment', point: new THREE.Vector3(rec.ip2.e-pivot.e, Math.max(members.parapetTopStartEl, members.parapetTopEndEl)+1, -(rec.ip2.n-pivot.n)) });
|
||||||
|
}
|
||||||
|
const layers = project.groundModel?.mapLayers ?? [];
|
||||||
|
await Promise.all(layers.map(l => new Promise<void>((resolve, reject) => { const i = new Image(); i.onload = () => resolve(); i.onerror = reject; i.src = l.image; })));
|
||||||
|
let mapError = false;
|
||||||
|
const maps = buildTerrainMapGroup(layers, pivot, 0, 'satellite', () => { mapError = true; }, grid); scene.add(maps);
|
||||||
|
const deadline=performance.now()+15000;
|
||||||
|
while (!maps.children.every(m => {
|
||||||
|
const image=((m as THREE.Mesh).material as THREE.MeshBasicMaterial).map!.image as HTMLImageElement|null;
|
||||||
|
return image?.complete && image.naturalWidth>0;
|
||||||
|
})) {
|
||||||
|
if(mapError || performance.now()>deadline) throw new Error('Map texture loading failed or timed out');
|
||||||
|
await new Promise(resolve=>setTimeout(resolve,20));
|
||||||
|
}
|
||||||
|
if (mapError) throw new Error('Map texture loading failed');
|
||||||
|
scene.updateMatrixWorld(true);
|
||||||
|
const box = new THREE.Box3().setFromObject(scene), target = box.getCenter(new THREE.Vector3());
|
||||||
|
const axis = new THREE.Vector3(entries.at(-1)!.model.pivot.e-pivot.e, 0, -(entries.at(-1)!.model.pivot.n-pivot.n)).normalize();
|
||||||
|
if (endMarginM !== undefined) {
|
||||||
|
const ends=anchors.filter(a=>a.kind==='abutment').map(a=>a.point.dot(axis));
|
||||||
|
if(ends.length!==2)throw new Error('End margin requires both abutments');
|
||||||
|
const planes=refined ? roundedTerrainPlanes(axis,Math.min(...ends)-endMarginM,Math.max(...ends)+endMarginM,56,18) : [new THREE.Plane(axis.clone(),-Math.min(...ends)+endMarginM),new THREE.Plane(axis.clone().negate(),Math.max(...ends)+endMarginM)];
|
||||||
|
if(all.terrain)(all.terrain.material as THREE.Material).clippingPlanes=planes;
|
||||||
|
maps.children.forEach(m=>{((m as THREE.Mesh).material as THREE.Material).clippingPlanes=planes;});
|
||||||
|
}
|
||||||
|
const road=refined&&all.terrain ? buildRoadExtensions(all.terrain,anchors,axis,box.max.y) : null;
|
||||||
|
if(road)scene.add(road.group);
|
||||||
|
if(cutawayFrontM!==undefined){
|
||||||
|
const front=new THREE.Vector3(-axis.z,0,axis.x);
|
||||||
|
const plane=new THREE.Plane(front.negate(),cutawayFrontM);
|
||||||
|
for(const mesh of [all.terrain,...maps.children])if(mesh){
|
||||||
|
const material=(mesh as THREE.Mesh).material as THREE.Material;
|
||||||
|
material.clippingPlanes=[...(material.clippingPlanes??[]),plane];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scene.updateMatrixWorld(true);
|
||||||
|
const direction = new THREE.Vector3(-axis.z, elevation, axis.x).addScaledVector(axis, along).normalize();
|
||||||
|
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, .1, 10000);
|
||||||
|
camera.position.copy(target).addScaledVector(direction, 2000); camera.lookAt(target); camera.updateMatrixWorld(true);
|
||||||
|
const bounds = new THREE.Box3();
|
||||||
|
scene.traverse(obj => {
|
||||||
|
if (!(obj instanceof THREE.Mesh)) return;
|
||||||
|
for (let p: THREE.Object3D | null = obj; p; p=p.parent) if (!p.visible) return;
|
||||||
|
const pos = obj.geometry.getAttribute('position');
|
||||||
|
for (let i=0;i<pos.count;i++) {
|
||||||
|
const point=new THREE.Vector3().fromBufferAttribute(pos,i).applyMatrix4(obj.matrixWorld);
|
||||||
|
const material: THREE.Material=Array.isArray(obj.material)?obj.material[0]:obj.material;
|
||||||
|
if(material.clippingPlanes?.some(p=>p.distanceToPoint(point)<0))continue;
|
||||||
|
bounds.expandByPoint(point.applyMatrix4(camera.matrixWorldInverse));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const w = Math.max(bounds.max.x-bounds.min.x, (bounds.max.y-bounds.min.y)*aspect)*1.12, h=w/aspect;
|
||||||
|
const cx=(bounds.max.x+bounds.min.x)/2, cy=(bounds.max.y+bounds.min.y)/2;
|
||||||
|
camera.left=cx-w/2; camera.right=cx+w/2; camera.top=cy+h/2; camera.bottom=cy-h/2; camera.updateProjectionMatrix();
|
||||||
|
let terrainCanvas:HTMLCanvasElement|undefined;
|
||||||
|
if(smoothOutline){
|
||||||
|
const hidden=scene.children.filter(o=>o!==all.terrain&&o!==maps&&o!==road?.group&&!(o instanceof THREE.Light)&&o.visible);
|
||||||
|
hidden.forEach(o=>{o.visible=false;});renderer.render(scene,camera);
|
||||||
|
terrainCanvas=document.createElement('canvas');terrainCanvas.width=renderer.domElement.width;terrainCanvas.height=renderer.domElement.height;
|
||||||
|
terrainCanvas.getContext('2d')!.drawImage(renderer.domElement,0,0);
|
||||||
|
hidden.forEach(o=>{o.visible=true;});
|
||||||
|
}
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
const points=anchors.map(a => { const p=a.point.clone().project(camera); return { name:a.name,kind:a.kind,x:+((p.x+1)*width/2).toFixed(2),y:+((1-p.y)*height/2).toFixed(2) }; }).sort((a,b)=>a.x-b.x);
|
||||||
|
const ordered=anchors.toSorted((a,b)=>a.point.clone().project(camera).x-b.point.clone().project(camera).x);
|
||||||
|
const ray=new THREE.Raycaster(), down=new THREE.Vector3(0,-1,0), spacing=(grid?.step??5)/2;
|
||||||
|
const terrainFlow: ({x:number;y:number;el:number;world:[number,number,number]}|null)[]=[];
|
||||||
|
if(!all.terrain)throw new Error('Terrain mesh is required for surface-following light');
|
||||||
|
for(let i=0;i<ordered.length-1;i++){
|
||||||
|
const from=ordered[i].point,to=ordered[i+1].point;
|
||||||
|
const steps=Math.ceil(Math.hypot(to.x-from.x,to.z-from.z)/spacing);
|
||||||
|
for(let j=i===0?0:1;j<=steps;j++){
|
||||||
|
const p=from.clone().lerp(to,j/steps);p.y=box.max.y+100;
|
||||||
|
ray.set(p,down);const hit=ray.intersectObject(all.terrain,false)[0];
|
||||||
|
if(!hit){terrainFlow.push(null);continue;}
|
||||||
|
const surface=hit.point.clone();surface.y+=.12;const projected=surface.clone().project(camera);
|
||||||
|
terrainFlow.push({x:+((projected.x+1)*width/2).toFixed(3),y:+((1-projected.y)*height/2).toFixed(3),el:+hit.point.y.toFixed(5),world:surface.toArray()});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const smooth=terrainCanvas?smoothLandingOutline(renderer.domElement,terrainCanvas,cutawayFrontM!==undefined):undefined;
|
||||||
|
const output=smooth?.canvas??renderer.domElement;
|
||||||
|
const result={png:output.toDataURL('image/png'),webp:output.toDataURL('image/webp',.94),metadata:{width,height,elevation,along,...(cutawayFrontM!==undefined?{cutawayFrontM}:{}),...(smooth?{outline:smooth.metadata}:{}),...(endMarginM!==undefined?{endMarginM}:{}),...(refined?{cornerRadiusM:18,terrainHalfWidthM:56,roads:road?.descriptions}:{}),points,terrainFlow,camera:{position:camera.position.toArray(),target:target.toArray(),left:camera.left,right:camera.right,top:camera.top,bottom:camera.bottom},piers:entries.length,abutments:project.abutments?.length??0}};
|
||||||
|
disposeTerrainMapGroup(maps);
|
||||||
|
scene.traverse(o=>{if(o instanceof THREE.Mesh){o.geometry.dispose();for(const m of Array.isArray(o.material)?o.material:[o.material])m.dispose();}});
|
||||||
|
renderer.dispose();renderer.forceContextLoss();return result;
|
||||||
|
}
|
||||||
@@ -40,10 +40,10 @@ const delay=ms=>new Promise(r=>setTimeout(r,ms));let ws;
|
|||||||
await click('#review-tasks button');report.taskJump=await ev(`({focused:document.activeElement?.innerText,selected:document.querySelector('#rebar-table').innerText.includes('강조 중')})`);
|
await click('#review-tasks button');report.taskJump=await ev(`({focused:document.activeElement?.innerText,selected:document.querySelector('#rebar-table').innerText.includes('강조 중')})`);
|
||||||
await ev(`[...document.querySelectorAll('#rebar-tabs button')].find(b=>b.textContent.includes('확대기초')).click()`);
|
await ev(`[...document.querySelectorAll('#rebar-tabs button')].find(b=>b.textContent.includes('확대기초')).click()`);
|
||||||
await ev(`document.querySelector('#review-model-height').value='180';document.querySelector('#review-model-height').dispatchEvent(new Event('input',{bubbles:true}))`);
|
await ev(`document.querySelector('#review-model-height').value='180';document.querySelector('#review-model-height').dispatchEvent(new Event('input',{bubbles:true}))`);
|
||||||
await click('#review-inputs');report.inputJump=await ev(`({focused:document.activeElement?.id,scroll:document.querySelector('#review-workspace > .viewer').scrollTop})`);
|
await click('#review-inputs');report.inputJump=await ev(`({focused:document.activeElement?.id,scroll:document.querySelector('#review-workspace .workspace-pane > .viewer').scrollTop})`);
|
||||||
await click('#review-model-toggle');await click('#review-table');
|
await click('#review-model-toggle');await click('#review-table');
|
||||||
await ev(`document.querySelector('#view-rebar > .results').scrollTop=250`);await cap('05-collapsed-table-material-1280');
|
await ev(`document.querySelector('#view-rebar > .results').scrollTop=250`);await cap('05-collapsed-table-material-1280');
|
||||||
report.end=await ev(`({modelHidden:getComputedStyle(document.querySelector('#slot-rebar')).display,expanded:document.querySelector('#review-model-toggle').getAttribute('aria-expanded'),focused:document.activeElement.tagName,frames:[...document.querySelectorAll('#view-rebar > .panel,#review-workspace,#review-workspace > .viewer')].map(e=>({id:e.id,class:e.className,overflow:getComputedStyle(e).overflow,clientHeight:e.clientHeight,scrollHeight:e.scrollHeight,scrollTop:e.scrollTop}))})`);
|
report.end=await ev(`({modelHidden:getComputedStyle(document.querySelector('#slot-rebar')).display,expanded:document.querySelector('#review-model-toggle').getAttribute('aria-expanded'),focused:document.activeElement.tagName,frames:[...document.querySelectorAll('#view-rebar > .panel,#review-workspace,#review-workspace .workspace-pane > .viewer')].map(e=>({id:e.id,class:e.className,overflow:getComputedStyle(e).overflow,clientHeight:e.clientHeight,scrollHeight:e.scrollHeight,scrollTop:e.scrollTop}))})`);
|
||||||
report.finished=new Date().toISOString();fs.writeFileSync(path.join(out,'raw.json'),JSON.stringify(report,null,2));console.log(JSON.stringify(report));
|
report.finished=new Date().toISOString();fs.writeFileSync(path.join(out,'raw.json'),JSON.stringify(report,null,2));console.log(JSON.stringify(report));
|
||||||
await send('Page.close');ws.close();
|
await send('Page.close');ws.close();
|
||||||
})().catch(e=>{report.failure=String(e.stack||e);fs.writeFileSync(path.join(out,'raw.json'),JSON.stringify(report,null,2));console.error(e);if(ws)ws.close();process.exitCode=1});
|
})().catch(e=>{report.failure=String(e.stack||e);fs.writeFileSync(path.join(out,'raw.json'),JSON.stringify(report,null,2));console.error(e);if(ws)ws.close();process.exitCode=1});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""Read-only terrain diagnostics: grid slopes, isolated peaks, and source TIN."""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
import numpy as np
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
ROOT=Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0,str(ROOT/'pierzainer_ground'))
|
||||||
|
from pierzainer_ground.survey_terrain import read_survey_block
|
||||||
|
from pierzainer_ground.dxf_terrain import read_source_document, read_terrain_dxf
|
||||||
|
from pierzainer_ground.dtm import DTM
|
||||||
|
from pierzainer_ground.config import GroundConfig
|
||||||
|
|
||||||
|
out=ROOT/'.verify/ungok-terrain-review-20261110'
|
||||||
|
out.mkdir(parents=True,exist_ok=True)
|
||||||
|
folder=ROOT/'pierzainer_ground/data/output/app_b614afc0-6829-4aef-96e3-3ff1365ff5db'
|
||||||
|
data=json.loads((folder/'ground_model_3d_with_maps.json').read_text())
|
||||||
|
settings=json.loads((folder/'generation_settings.json').read_text())
|
||||||
|
g=data['terrain_grid']; z=np.array([np.nan if v is None else v for v in g['z']]).reshape(g['ny'],g['nx'])
|
||||||
|
edges=[]
|
||||||
|
for j in range(g['ny']):
|
||||||
|
for i in range(g['nx']):
|
||||||
|
for dj,di in [(0,1),(1,0)]:
|
||||||
|
if j+dj<g['ny'] and i+di<g['nx'] and np.isfinite(z[j,i]) and np.isfinite(z[j+dj,i+di]):
|
||||||
|
edges.append({'row':j,'col':i,'to_row':j+dj,'to_col':i+di,'z':float(z[j,i]),'to_z':float(z[j+dj,i+di]),'dz':float(abs(z[j+dj,i+di]-z[j,i]))})
|
||||||
|
edges.sort(key=lambda e:-e['dz'])
|
||||||
|
summary={'file':str(folder),'terrain':data['terrain'],'provenance':data.get('surface_provenance'),'grid':{k:v for k,v in g.items() if k!='z'},'valid_nodes':int(np.isfinite(z).sum()),'z_range':[float(np.nanmin(z)),float(np.nanmax(z))],'adjacent_edge_count':len(edges),'edges_over_5m':sum(e['dz']>5 for e in edges),'edges_over_8m':sum(e['dz']>8 for e in edges),'worst_edges':edges[:12]}
|
||||||
|
source=next((folder/'cache').glob('*.dxf'))
|
||||||
|
doc,_=read_source_document(source)
|
||||||
|
cfg=GroundConfig()
|
||||||
|
points,counts,_=read_survey_block(source,{'terrain_block':settings['terrain_block'],'terrain_crs':settings['terrain_crs'],'crs':settings['crs'],'cad_axes':settings['cad_axes'],'contour_step_m':cfg.contour_step,'dedup_m':cfg.dedup_xy,'z_min':settings['z_min'],'z_max':settings['z_max'],'exclude_zero':True,'prefer_recognized_layers':False},doc=doc)
|
||||||
|
lo=np.array(g['origin'])-settings['max_edge'];hi=np.array(g['origin'])+[(g['nx']-1)*g['step'],(g['ny']-1)*g['step']]+settings['max_edge']
|
||||||
|
points=points[np.all((points[:,:2]>=lo)&(points[:,:2]<=hi),axis=1)]
|
||||||
|
dtm=DTM(points,settings['max_edge'])
|
||||||
|
summary['source_counts']=counts
|
||||||
|
summary['raw_height_layers']=dict(Counter(e.dxf.layer for e in doc.blocks.get(settings['terrain_block']) if e.dxftype() in ('POLYLINE','LWPOLYLINE')))
|
||||||
|
# Counterfactual only: use the drawing's two contour layers, without replacing
|
||||||
|
# the generated user model or changing source geometry.
|
||||||
|
contours,contour_counts,_=read_survey_block(source,{'terrain_block':settings['terrain_block'],'terrain_crs':settings['terrain_crs'],'crs':settings['crs'],'cad_axes':settings['cad_axes'],'contour_step_m':cfg.contour_step,'dedup_m':cfg.dedup_xy,'z_min':settings['z_min'],'z_max':settings['z_max'],'exclude_zero':True,'layers':['CAA001','CAA002']},doc=doc)
|
||||||
|
contours=contours[np.all((contours[:,:2]>=lo)&(contours[:,:2]<=hi),axis=1)]
|
||||||
|
clean_dtm=DTM(contours,settings['max_edge'])
|
||||||
|
gx,gy=np.meshgrid(g['origin'][0]+np.arange(g['nx'])*g['step'],g['origin'][1]+np.arange(g['ny'])*g['step'])
|
||||||
|
clean=clean_dtm.z_many(np.column_stack([gx.ravel(),gy.ravel()])).reshape(z.shape)
|
||||||
|
clean_edges=np.concatenate([np.abs(np.diff(clean,axis=0)).ravel(),np.abs(np.diff(clean,axis=1)).ravel()])
|
||||||
|
clean_edges=clean_edges[np.isfinite(clean_edges)]
|
||||||
|
summary['contour_only_diagnostic']={'layers':['CAA001','CAA002'],'counts':contour_counts,'valid_nodes':int(np.isfinite(clean).sum()),'z_range':[float(np.nanmin(clean)),float(np.nanmax(clean))],'max_adjacent_dz':float(clean_edges.max()),'edges_over_5m':int((clean_edges>5).sum()),'edges_over_8m':int((clean_edges>8).sum()),'not_applied':True}
|
||||||
|
typed_cfg=GroundConfig(use_profile_points=False,z_min=settings['z_min'],z_max=settings['z_max'])
|
||||||
|
typed_cfg.allowed_terrain_layers=typed_cfg.layers.spot_block+typed_cfg.layers.spot_text+typed_cfg.layers.contour
|
||||||
|
typed=read_terrain_dxf(source,typed_cfg,document=doc)
|
||||||
|
typed_local=typed.points[np.all((typed.points[:,:2]>=lo)&(typed.points[:,:2]<=hi),axis=1)]
|
||||||
|
typed_dtm=DTM(typed_local,settings['max_edge'])
|
||||||
|
typed_grid=typed_dtm.z_many(np.column_stack([gx.ravel(),gy.ravel()])).reshape(z.shape)
|
||||||
|
typed_edges=np.concatenate([np.abs(np.diff(typed_grid,axis=0)).ravel(),np.abs(np.diff(typed_grid,axis=1)).ravel()])
|
||||||
|
typed_edges=typed_edges[np.isfinite(typed_edges)]
|
||||||
|
summary['typed_layer_diagnostic']={'layers':list(typed_cfg.allowed_terrain_layers),'counts':typed.stats['by_source'],'valid_nodes':int(np.isfinite(typed_grid).sum()),'z_range':[float(np.nanmin(typed_grid)),float(np.nanmax(typed_grid))],'max_adjacent_dz':float(typed_edges.max()),'edges_over_5m':int((typed_edges>5).sum()),'edges_over_8m':int((typed_edges>8).sum()),'covered_piers':sum(typed_dtm.z_at(p['X'],p['Y']) is not None for p in data['piers']),'not_applied':True}
|
||||||
|
summary['pier_diagnostic']=[]
|
||||||
|
for pier in data['piers']:
|
||||||
|
observed=clean_dtm.z_at(pier['X'],pier['Y'])
|
||||||
|
summary['pier_diagnostic'].append({'id':pier['id'],'generated':pier['ground']['center'],'contour_only':observed,'difference':None if observed is None else observed-pier['ground']['center']})
|
||||||
|
for e in summary['worst_edges']:
|
||||||
|
triangles=[]
|
||||||
|
for row,col in [(e['row'],e['col']),(e['to_row'],e['to_col'])]:
|
||||||
|
xy=np.array(g['origin'])+np.array([col,row])*g['step']; k=int(dtm.tri.find_simplex(xy-dtm.origin))
|
||||||
|
triangles.append({'xy':xy.tolist(),'vertices':points[dtm.tri.simplices[k]].tolist() if k>=0 else [],'predicted':dtm.z_at(*xy)})
|
||||||
|
e['source_triangles']=triangles
|
||||||
|
# Raw constant-height CAD entities retained in the selected TIN: look for
|
||||||
|
# closed design outlines vs survey contours near the worst interpolation.
|
||||||
|
raw=[]
|
||||||
|
for entity in doc.blocks.get(settings['terrain_block']):
|
||||||
|
if entity.dxftype() not in ('LWPOLYLINE','POLYLINE'):continue
|
||||||
|
pts=np.array([tuple(p) for p in (entity.vertices_in_wcs() if entity.dxftype()=='LWPOLYLINE' else entity.points_in_wcs())])
|
||||||
|
if len(pts)<2 or np.ptp(pts[:,2])>1e-6:continue
|
||||||
|
raw.append({'handle':entity.dxf.handle,'layer':entity.dxf.layer,'z':float(pts[0,2]),'vertices':len(pts),'closed':bool(entity.closed if entity.dxftype()=='LWPOLYLINE' else entity.is_closed),'xy_min':pts[:,:2].min(0).tolist(),'xy_max':pts[:,:2].max(0).tolist(),'xyz':pts.tolist()})
|
||||||
|
for e in summary['worst_edges'][:6]:
|
||||||
|
for tri in e['source_triangles']:
|
||||||
|
tri['entities']=[]
|
||||||
|
for north,east,height in tri['vertices']:
|
||||||
|
candidates=[]
|
||||||
|
for entity in raw:
|
||||||
|
if abs(entity['z']-height)>1e-3:continue
|
||||||
|
xy=np.array(entity['xyz'])[:,:2]; d=float(np.linalg.norm(xy-[east,north],axis=1).min())
|
||||||
|
candidates.append((d,entity))
|
||||||
|
candidates.sort(key=lambda r:r[0])
|
||||||
|
tri['entities'].append([{'distance_to_vertex':round(d,4),**{k:v for k,v in ent.items() if k!='xyz'}} for d,ent in candidates[:2]])
|
||||||
|
(out/'report.json').write_text(json.dumps(summary,ensure_ascii=False,indent=2))
|
||||||
|
print(json.dumps({k:v for k,v in summary.items() if k not in ('worst_edges','provenance')},ensure_ascii=False))
|
||||||
|
for e in summary['worst_edges'][:3]:print(json.dumps(e,ensure_ascii=False))
|
||||||
|
try:
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use('Agg')
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
fig,axes=plt.subplots(1,3,figsize=(16,5),constrained_layout=True)
|
||||||
|
extent=[g['origin'][0],g['origin'][0]+(g['nx']-1)*g['step'],g['origin'][1],g['origin'][1]+(g['ny']-1)*g['step']]
|
||||||
|
for ax,values,title in [(axes[0],z,'Generated: all height-bearing layers'),(axes[1],typed_grid,'Diagnostic: survey spots + contour layers')]:
|
||||||
|
im=ax.imshow(values,origin='lower',extent=extent,vmin=20,vmax=47,aspect='auto',cmap='terrain');ax.set_title(title);ax.set_xlabel('Northing (m)');ax.set_ylabel('Easting (m)')
|
||||||
|
fig.colorbar(im,ax=axes[:2],label='EL (m)',shrink=.7)
|
||||||
|
row=summary['worst_edges'][0]['to_row'];axis=g['origin'][0]+np.arange(g['nx'])*g['step']
|
||||||
|
axes[2].plot(axis,z[row],label='Generated');axes[2].plot(axis,typed_grid[row],label='Survey spots + contours');axes[2].set_title(f'Transect E={g["origin"][1]+row*g["step"]:.3f} m');axes[2].set_xlabel('Northing (m)');axes[2].set_ylabel('EL (m)');axes[2].legend();axes[2].grid(alpha=.25)
|
||||||
|
fig.savefig(out/'comparison.png',dpi=150);plt.close(fig)
|
||||||
|
except ImportError:
|
||||||
|
print('Plot dependency unavailable; numeric report preserved.')
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { it, expect } from 'vitest';
|
||||||
|
import { parseGroundModel } from '../src/core/ground-model';
|
||||||
|
import { parseWorkbook } from '../src/core/parseWorkbook';
|
||||||
|
import { buildPier } from '../src/core/geometry';
|
||||||
|
import { parseAbutments, toAbutInput } from '../src/core/abut/parse';
|
||||||
|
import { buildAbut } from '../src/core/abut/geometry';
|
||||||
|
import { buildMembers } from '../src/core/abut/members';
|
||||||
|
import { buildWings } from '../src/core/abut/wings';
|
||||||
|
import { buildAbutGroup } from '../src/view/abut-scene';
|
||||||
|
import { terrainGeometry } from '../src/view/terrain';
|
||||||
|
import { terrainMapGeometry } from '../src/view/terrain-map';
|
||||||
|
|
||||||
|
it('records terrain and map placement against the actual Okseong project', () => {
|
||||||
|
const folder = '/mnt/c/Users/User/Downloads/NAVER WORKS';
|
||||||
|
const ground = parseGroundModel(readFileSync(path.join(folder, '옥성교 지형.json'), 'utf8'));
|
||||||
|
const wb = XLSX.read(readFileSync(path.join(folder, '옥성교 데이터_261106.xlsx')), { type: 'buffer' });
|
||||||
|
const piers = parseWorkbook(wb).piers;
|
||||||
|
const abuts = parseAbutments(wb, piers.map(p => p.name)).abutments;
|
||||||
|
const pivot = buildPier(piers[0]).pivot, grid = ground.terrainGrid!;
|
||||||
|
const terrain = terrainGeometry(grid, pivot), pos = terrain.getAttribute('position');
|
||||||
|
const layers = ground.mapLayers!.map(layer => {
|
||||||
|
const g = terrainMapGeometry(layer, pivot, 0, grid);
|
||||||
|
return { id: layer.id, corners: layer.survey_corners, vertices: g.getAttribute('position').count, triangles: g.getIndex()!.count / 3 };
|
||||||
|
});
|
||||||
|
const duplicates = abuts.map(rec => {
|
||||||
|
const model = buildAbut(toAbutInput(rec));
|
||||||
|
const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape);
|
||||||
|
const root = buildAbutGroup(model, members, buildWings(model, members, rec.wings, rec.wingDims), pivot, rec.groundEl, grid, rec.slab);
|
||||||
|
const shared = buildAbutGroup(model, members, buildWings(model, members, rec.wings, rec.wingDims), pivot, rec.groundEl, grid, rec.slab, undefined, undefined, true);
|
||||||
|
expect(shared.getObjectByName('ground')).toBeUndefined();
|
||||||
|
root.updateMatrixWorld(true);
|
||||||
|
const meshes: object[] = [];
|
||||||
|
root.getObjectByName('ground')?.traverse(obj => {
|
||||||
|
if (!(obj instanceof THREE.Mesh)) return;
|
||||||
|
const vertices = obj.geometry.getAttribute('position');
|
||||||
|
let maxDisplacement = 0;
|
||||||
|
if (vertices.count === pos.count) for (let i = 0; i < vertices.count; i++) {
|
||||||
|
const actual = new THREE.Vector3().fromBufferAttribute(vertices, i).applyMatrix4(obj.matrixWorld);
|
||||||
|
const expected = new THREE.Vector3().fromBufferAttribute(pos, i);
|
||||||
|
maxDisplacement = Math.max(maxDisplacement, actual.distanceTo(expected));
|
||||||
|
}
|
||||||
|
meshes.push({ vertices: vertices.count, triangles: obj.geometry.getIndex()?.count! / 3, sameGrid: vertices.count === pos.count, maxDisplacementM: maxDisplacement });
|
||||||
|
});
|
||||||
|
const gx = (rec.ip2.n - grid.origin[0]) / grid.step, gy = (rec.ip2.e - grid.origin[1]) / grid.step;
|
||||||
|
const i = Math.floor(gx), j = Math.floor(gy);
|
||||||
|
const inside = i >= 0 && j >= 0 && i + 1 < grid.nx && j + 1 < grid.ny;
|
||||||
|
const heights = inside ? [grid.z[j * grid.nx + i], grid.z[j * grid.nx + i + 1], grid.z[(j + 1) * grid.nx + i], grid.z[(j + 1) * grid.nx + i + 1]] : [];
|
||||||
|
const valid = heights.length === 4 && heights.every(h => h !== null);
|
||||||
|
const u = gx - i, v = gy - j;
|
||||||
|
const sampledAtIp2 = valid ? heights[0]! * (1-u) * (1-v) + heights[1]! * u * (1-v) + heights[2]! * (1-u) * v + heights[3]! * u * v : null;
|
||||||
|
return { abutment: rec.name, excelGroundEl: rec.groundEl, ip2: rec.ip2, modelEntry: ground.piers.some(p => p.id === rec.name), ip2GridSample: sampledAtIp2, meshes };
|
||||||
|
});
|
||||||
|
const values = grid.z.filter((v): v is number => v !== null);
|
||||||
|
const report = { grid: { origin: grid.origin, step: grid.step, nx: grid.nx, ny: grid.ny, valid: values.length, total: grid.z.length, min: Math.min(...values), max: Math.max(...values) }, terrain: { vertices: pos.count, triangles: terrain.getIndex()!.count / 3 }, layers, duplicates };
|
||||||
|
const out = path.resolve('.verify/terrain-review-20261109'); mkdirSync(out, { recursive: true });
|
||||||
|
writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2));
|
||||||
|
console.log(JSON.stringify(report));
|
||||||
|
expect(layers.every(layer => layer.triangles === report.terrain.triangles)).toBe(true);
|
||||||
|
expect(duplicates.every(d => d.meshes.every(m => (m as { maxDisplacementM: number }).maxDisplacementM < 0.001))).toBe(true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Apply the project ground manager integration to the existing application."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
p = root / 'src/main.ts'
|
||||||
|
s = p.read_text()
|
||||||
|
s = "import { createProjectGroundManager } from './view/project-ground';\nimport { applyProjectGround, groundInputChanged } from './core/project-ground';\nimport { registerGroundImporter } from '../pierzainer_ground/ground-link';\n" + s
|
||||||
|
s = s.replace('let groundModel: GroundModelFile | null = null;', 'let groundModel: GroundModelFile | null = null;\nlet groundProjectVersion = 0;\nlet groundImportSerial = 0;')
|
||||||
|
marker = 'function el<K extends keyof HTMLElementTagNameMap>'
|
||||||
|
insert = '''async function importProjectGround(text: string, name: string): Promise<void> {
|
||||||
|
const version = groundProjectVersion, serial = ++groundImportSerial;
|
||||||
|
const imported = parseGroundModel(text);
|
||||||
|
await Promise.all((imported.mapLayers ?? []).map(decodeGroundMapImage));
|
||||||
|
if (version !== groundProjectVersion || serial !== groundImportSerial) throw new Error('프로젝트 또는 불러올 모델이 변경되어 이전 결과를 적용하지 않았습니다.');
|
||||||
|
groundModel = imported;
|
||||||
|
groundMapMode = imported.mapLayers?.length ? 'satellite' : 'none';
|
||||||
|
setGroundMapMode(groundMapMode);
|
||||||
|
state.visible.ground = true;
|
||||||
|
setupScene(); renderAll();
|
||||||
|
setStatus(`${name} 지반모델을 프로젝트 공통으로 불러왔습니다. 설계 입력은 비교 후 적용하세요.`);
|
||||||
|
}
|
||||||
|
registerGroundImporter(importProjectGround);
|
||||||
|
const projectGround = createProjectGroundManager({
|
||||||
|
snapshot: () => ({ model: groundModel, piers: state.piers }),
|
||||||
|
importModel: importProjectGround,
|
||||||
|
apply: (names, fields) => {
|
||||||
|
if (!groundModel) return;
|
||||||
|
for (const p of state.piers) if (names.has(p.input.name)) p.draft = applyProjectGround(p.draft, groundModel, fields);
|
||||||
|
renderAll();
|
||||||
|
},
|
||||||
|
regenerate: () => {
|
||||||
|
state.piers = state.piers.map(p => groundInputChanged(p.input, p.draft) ? regenerateEntry(p) : p);
|
||||||
|
setupScene(); renderAll();
|
||||||
|
setStatus('지반 입력이 변경된 교각을 다시 생성했습니다. 생성 상태와 알림을 확인하세요.');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
function resetProjectGround(): void {
|
||||||
|
groundProjectVersion++; groundImportSerial++;
|
||||||
|
projectGround.reset();
|
||||||
|
window.dispatchEvent(new Event('pierzainer:project-changed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
'''
|
||||||
|
assert marker in s
|
||||||
|
s = s.replace(marker, insert + marker, 1)
|
||||||
|
s = s.replace(' updatePartToggles();\n $<HTMLButtonElement>', ' updatePartToggles();\n projectGround.refresh();\n $<HTMLButtonElement>', 1)
|
||||||
|
start = s.index(' // ② 지반 모델 (승인 2026-10-23):')
|
||||||
|
end = s.index(' if (!groundModel) {', start)
|
||||||
|
s = s[:start] + " wrap.appendChild(el('p', '프로젝트 공통 지반모델은 ① 형상의 [프로젝트 지반모델]에서 생성·불러오기·선택 적용합니다.', 'muted small'));\n\n" + s[end:]
|
||||||
|
s = s.replace(' * The ground model import and the 지반고 apply stay at the bottom, as before.', ' * Project model generation/import is managed from ①; this card retains local inputs.')
|
||||||
|
s = s.replace(' state.project = { ...parsed.project };', ' resetProjectGround();\n state.project = { ...parsed.project };', 1)
|
||||||
|
marker = " state.project = { ...state.project, name, bridge: $<HTMLInputElement>('bridge-name').value.trim() };"
|
||||||
|
assert marker in s
|
||||||
|
s = s.replace(marker, " resetProjectGround();\n groundModel = null;\n groundMapMode = 'none';\n setGroundMapMode(groundMapMode);\n" + marker, 1)
|
||||||
|
p.write_text(s)
|
||||||
@@ -56,7 +56,7 @@ async function main() {
|
|||||||
await until(`!document.getElementById('dlg-apply').disabled && document.getElementById('import-abut-A1')?.checked === true`);
|
await until(`!document.getElementById('dlg-apply').disabled && document.getElementById('import-abut-A1')?.checked === true`);
|
||||||
};
|
};
|
||||||
const member = async (label) => { await click(`[...document.querySelectorAll('#rebar-tabs button')].find(b => b.textContent.startsWith(${JSON.stringify(label)}))`); await delay(100); };
|
const member = async (label) => { await click(`[...document.querySelectorAll('#rebar-tabs button')].find(b => b.textContent.startsWith(${JSON.stringify(label)}))`); await delay(100); };
|
||||||
const lower = () => ev(`document.querySelector('#review-workspace > section').getBoundingClientRect().height`);
|
const lower = () => ev(`document.querySelector('#review-workspace .workspace-pane > section').getBoundingClientRect().height`);
|
||||||
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
|
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('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
|
||||||
await until(`!!document.querySelector('[data-card="excel"] button') && typeof window.loadBytesToApp === 'function'`);
|
await until(`!!document.querySelector('[data-card="excel"] button') && typeof window.loadBytesToApp === 'function'`);
|
||||||
@@ -94,7 +94,7 @@ async function main() {
|
|||||||
report.layoutCollapsed = await lower();
|
report.layoutCollapsed = await lower();
|
||||||
check('collapse adds model area to evidence', report.layoutCollapsed > report.layoutResized + 150);
|
check('collapse adds model area to evidence', report.layoutCollapsed > report.layoutResized + 150);
|
||||||
await click(`document.getElementById('review-table')`);
|
await click(`document.getElementById('review-table')`);
|
||||||
check('table shortcut focuses and reveals table', await ev(`document.activeElement.matches('#rebar-table table.rebar-table') && document.activeElement.getBoundingClientRect().top >= document.querySelector('#review-workspace > section').getBoundingClientRect().top - 2`));
|
check('table shortcut focuses and reveals table', await ev(`document.activeElement.matches('#rebar-table table.rebar-table') && document.activeElement.getBoundingClientRect().top >= document.querySelector('#review-workspace .workspace-pane > section').getBoundingClientRect().top - 2`));
|
||||||
await shot('03-table-laptop');
|
await shot('03-table-laptop');
|
||||||
await click(`document.getElementById('review-inputs')`);
|
await click(`document.getElementById('review-inputs')`);
|
||||||
check('input shortcut preserves keyboard tab order', await ev(`document.activeElement.matches('#rebar-table input') && document.activeElement.tabIndex === 0`));
|
check('input shortcut preserves keyboard tab order', await ev(`document.activeElement.matches('#rebar-table input') && document.activeElement.tabIndex === 0`));
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// 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/ground-abutments-20261109');
|
||||||
|
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'`);
|
||||||
|
const selectFile=async(selector,files)=>{
|
||||||
|
const doc=await send('DOM.getDocument'),{nodeId}=await send('DOM.querySelector',{nodeId:doc.root.nodeId,selector});
|
||||||
|
await send('DOM.setFileInputFiles',{nodeId,files});
|
||||||
|
};
|
||||||
|
const folder='C:\\Users\\User\\Downloads\\NAVER WORKS';
|
||||||
|
await ev(`window.openSampleProject()`);
|
||||||
|
await selectFile('#file',[path.join(folder,'옥성교 데이터_261106.xlsx')]);
|
||||||
|
await until(`document.getElementById('file-name').textContent.includes('선택된 파일: 옥성교 데이터_261106.xlsx')`);
|
||||||
|
await ev(`(()=>{const e=document.getElementById('project-name');e.value='교대 지반 검증';e.dispatchEvent(new Event('input'));document.getElementById('import-dialog').showModal();})()`);
|
||||||
|
await click(`document.getElementById('dlg-apply')`);
|
||||||
|
await click(`document.getElementById('nav-shape')`);
|
||||||
|
await click(`document.getElementById('project-ground-open')`);
|
||||||
|
check('manager lists six piers and both abutments before model import',await ev(`document.querySelectorAll('[data-ground-pier]').length===8&&document.querySelectorAll('[data-ground-kind="교대"]').length===2`));
|
||||||
|
await selectFile('#ground-import',[path.join(folder,'옥성교 지형.json')]);
|
||||||
|
await until(`document.getElementById('project-ground-status').textContent.includes('8/8')`);
|
||||||
|
check('both abutments show IP2 grid source and finite comparison elevations',await ev(`[...document.querySelectorAll('[data-ground-kind="교대"]')].every(e=>e.closest('tr').textContent.includes('IP2 격자 보간')&&!e.disabled)`));
|
||||||
|
const values=()=>ev(`[...document.querySelectorAll('.project-ground-table tbody tr')].map(r=>({name:r.children[1].textContent,el:Number(r.children[2].textContent),model:Number(r.children[3].textContent)}))`);
|
||||||
|
const before=await values();
|
||||||
|
check('import alone preserves original abutment inputs',before.find(r=>r.name==='A1').el===11.24&&before.find(r=>r.name==='A2').el===14.43);
|
||||||
|
await click(`document.querySelector('[data-ground-pier="A1"]')`);
|
||||||
|
await click(`document.getElementById('project-ground-apply')`);
|
||||||
|
const one=await values();
|
||||||
|
check('only selected A1 changes; A2 and P1 remain unchanged',one.find(r=>r.name==='A1').el===11.789&&one.find(r=>r.name==='A2').el===14.43&&one.find(r=>r.name==='P1').el===before.find(r=>r.name==='P1').el);
|
||||||
|
await click(`document.getElementById('project-ground-select-all')`);
|
||||||
|
check('select all includes both types',await ev(`document.querySelectorAll('[data-ground-pier]:checked').length===8`));
|
||||||
|
await click(`document.getElementById('project-ground-apply')`);
|
||||||
|
const applied=await values();
|
||||||
|
check('A2 receives IP2 interpolation',applied.find(r=>r.name==='A2').el===14.61);
|
||||||
|
check('pier regeneration remains explicit',await ev(`!document.getElementById('project-ground-regenerate').disabled`));
|
||||||
|
await click(`document.getElementById('project-ground-regenerate')`);
|
||||||
|
check('pending pier changes regenerate',await ev(`document.getElementById('project-ground-regenerate').disabled`));
|
||||||
|
await click(`document.getElementById('project-ground-el')`);
|
||||||
|
await click(`document.getElementById('project-ground-layers')`);
|
||||||
|
check('missing abutment soil layers cannot be applied',await ev(`[...document.querySelectorAll('[data-ground-kind="교대"]')].every(e=>e.disabled)`));
|
||||||
|
await click(`document.getElementById('project-ground-el')`);
|
||||||
|
await click(`document.getElementById('project-ground-layers')`);
|
||||||
|
await shot('01-unified-ground');
|
||||||
|
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
|
||||||
|
await shot('02-shared-terrain');
|
||||||
|
await ev(`(()=>{const original=URL.createObjectURL;window.__saved=null;URL.createObjectURL=function(blob){blob.text().then(text=>{try{window.__saved=JSON.parse(text);}catch{}});return original.call(this,blob);};})()`);
|
||||||
|
await send('Browser.setDownloadBehavior',{behavior:'deny'});
|
||||||
|
await click(`document.getElementById('save-project')`);
|
||||||
|
await until(`document.getElementById('save-dialog').open`);
|
||||||
|
await click(`document.getElementById('sv-save')`);
|
||||||
|
await until(`!!window.__saved?.abutments`);
|
||||||
|
const saved=await ev(`window.__saved`);
|
||||||
|
check('saved project retains both abutment elevations and Excel source',Math.abs(saved.abutments.find(a=>a.name==='A1').groundEl-11.7886759049651)<1e-6&&Math.abs(saved.abutments.find(a=>a.name==='A2').groundEl-14.610314099767017)<1e-6&&saved.source.fileName==='옥성교 데이터_261106.xlsx');
|
||||||
|
const savedPath=path.join(out,'saved.pzr.json');fs.writeFileSync(savedPath,JSON.stringify(saved));
|
||||||
|
await selectFile('#project-file',[savedPath]);
|
||||||
|
await click(`document.getElementById('project-ground-open')`);
|
||||||
|
check('reopening project restores abutment inputs', (await values()).filter(r=>r.name==='A1'||r.name==='A2').every(r=>Math.abs(r.el-r.model)<0.001));
|
||||||
|
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();
|
||||||
|
});
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
// 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/ground-import-20261109');
|
||||||
|
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'`);
|
||||||
|
const fixture = await ev(`(async()=>{
|
||||||
|
const XLSX=await import('/node_modules/xlsx/xlsx.mjs'),{parseWorkbook}=await import('/src/core/parseWorkbook.ts');
|
||||||
|
const bytes=await(await fetch('/'+encodeURIComponent('도면추출')+'/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();
|
||||||
|
const base=parseWorkbook(XLSX.read(bytes)).piers.find(p=>p.name==='P1');
|
||||||
|
if(!base)throw new Error('Missing sample pier');
|
||||||
|
const {defaultLevelWidthMm}=await import('/src/core/geometry.ts');
|
||||||
|
const piers=['직접기초','직접기초','현장타설말뚝'].map((foundationType,i)=>{const input={...base,name:'P'+(i+1),foundationType,type:'T형',columnCount:1,groundEl:30,pileLengthMm:24000,castInPlaceShape:{clayBottomEl:16},columnDiameterMm:2000,footingLengthMm:10400,footingWidthMm:8150,footingHeightMm:2000,blindingMarginMm:100,blindingThicknessMm:100,pileKind:'강관',pileOuterMm:609.6,pileThicknessMm:12,copingH1Mm:1000,copingH2Mm:1500,levelWidthMm:defaultLevelWidthMm('T형',2000)};return{id:input.name,input,defaults:input,draft:input};});
|
||||||
|
return{schemaVersion:1,project:{name:'병합 검증',bridge:'검증',client:'',author:'',crs:'',memo:''},source:{fileName:'합성 표본',sheets:[]},piers,history:[]};
|
||||||
|
})()`);
|
||||||
|
const file = path.join(out, 'fixture.pzr.json'); fs.writeFileSync(file, JSON.stringify(fixture));
|
||||||
|
await ev('window.openSampleProject()');
|
||||||
|
await click(`document.querySelector('#file-menu summary')`); await click(`document.getElementById('open-project')`);
|
||||||
|
const doc = await send('DOM.getDocument');
|
||||||
|
const { nodeId } = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#project-file' });
|
||||||
|
await send('DOM.setFileInputFiles', { nodeId, files: [file] });
|
||||||
|
await until(`document.querySelectorAll('#rebar-pier-items button').length===3`);
|
||||||
|
const selectFile = async (selector, files) => {
|
||||||
|
const doc = await send('DOM.getDocument');
|
||||||
|
const {nodeId} = await send('DOM.querySelector', {nodeId:doc.root.nodeId, selector});
|
||||||
|
await send('DOM.setFileInputFiles', {nodeId, files});
|
||||||
|
};
|
||||||
|
await click(`document.getElementById('nav-shape')`);
|
||||||
|
check('shape has a visible project ground entry', await ev(`document.getElementById('project-ground-open').getBoundingClientRect().height>0`));
|
||||||
|
await click(`document.getElementById('project-ground-open')`);
|
||||||
|
check('manager opens without per-pier controls', await ev(`document.getElementById('project-ground-dialog').open&&document.querySelectorAll('#ground-import').length===1`));
|
||||||
|
const modelFile=path.join(out,'ground.json');
|
||||||
|
const raw={schema:2,generated:'2026-11-09',piers:[{id:'P1',ground:{center:29,excel:30},soil:{layers:[{name:'암반',bottom_el:10}]}},{id:'P2',ground:{center:null,excel:30}}]};
|
||||||
|
fs.writeFileSync(modelFile,JSON.stringify(raw));
|
||||||
|
await selectFile('#ground-import',[modelFile]);
|
||||||
|
await until(`document.getElementById('project-ground-info').textContent.includes('2026-11-09')`);
|
||||||
|
check('matching, out-of-range and missing piers are distinguished',await ev(`document.getElementById('project-ground-dialog').textContent.includes('지형 범위 밖')&&document.getElementById('project-ground-dialog').textContent.includes('모델에 없음')&&document.querySelector('[data-ground-pier="P2"]').disabled&&document.querySelector('[data-ground-pier="P3"]').disabled`));
|
||||||
|
check('import alone keeps current design input',await ev(`document.querySelector('.project-ground-table tbody tr').children[2].textContent==='30.000'`));
|
||||||
|
await click(`document.querySelector('[data-ground-pier="P1"]')`);
|
||||||
|
await click(`document.getElementById('project-ground-apply')`);
|
||||||
|
check('selected ground input applies and requires regeneration',await ev(`document.querySelector('.project-ground-table tbody tr').children[2].textContent==='29.000'&&!document.getElementById('project-ground-regenerate').disabled`));
|
||||||
|
await click(`document.getElementById('project-ground-regenerate')`);
|
||||||
|
check('regeneration consumes ground changes',await ev(`document.getElementById('project-ground-regenerate').disabled&&document.querySelector('.project-ground-table tbody tr').textContent.includes('지반고 일치')`));
|
||||||
|
await click(`document.getElementById('project-ground-el')`);
|
||||||
|
await click(`document.getElementById('project-ground-layers')`);
|
||||||
|
await click(`document.getElementById('project-ground-apply')`);
|
||||||
|
check('layers can be applied separately',await ev(`!document.getElementById('project-ground-regenerate').disabled&&document.querySelector('.project-ground-table tbody tr').children[2].textContent==='29.000'`));
|
||||||
|
await click(`document.getElementById('project-ground-regenerate')`);
|
||||||
|
await shot('01-project-ground');
|
||||||
|
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
|
||||||
|
await click(`document.getElementById('nav-plan')`);
|
||||||
|
await click(`[...document.querySelectorAll('#members button')].find(b=>b.textContent.includes('지반'))`);
|
||||||
|
check('per-pier ground card has no model generation or file picker',await ev(`!document.getElementById('view-plan').textContent.includes('지반 모델 파일 불러오기 · 지반 생성 프로그램')&&document.querySelectorAll('#view-plan input[type=file]').length===0`));
|
||||||
|
await ev(`(()=>{window.__savedGround=null;const original=URL.createObjectURL;URL.createObjectURL=function(blob){blob.text().then(text=>{try{window.__savedGround=JSON.parse(text);}catch{}});return original.call(this,blob);};})()`);
|
||||||
|
await click(`document.getElementById('save-project')`);
|
||||||
|
await until(`!!window.__savedGround?.groundModel`);
|
||||||
|
const saved=await ev(`window.__savedGround`);
|
||||||
|
check('project saves shared model once and applied inputs',saved.groundModel.piers.length===2&&saved.piers[0].input.groundEl===29&&saved.piers[0].input.strataRows[0].name==='암반'&&saved.piers[1].input.groundEl===30);
|
||||||
|
const savedFile=path.join(out,'saved.pzr.json');fs.writeFileSync(savedFile,JSON.stringify(saved));
|
||||||
|
await selectFile('#project-file',[savedFile]);
|
||||||
|
await until(`document.getElementById('project-ground-status').textContent.includes('불러옴')`);
|
||||||
|
check('project reopen restores ground model',await ev(`document.getElementById('project-ground-status').textContent.includes('2/3')`));
|
||||||
|
// Mock the external engine; deliver an in-flight result after switching projects.
|
||||||
|
await ev(`(()=>{const original=window.fetch;window.__releaseGround=null;window.__groundReady=false;window.fetch=async(url,init)=>{const path=String(url);if(path.includes('/ground-link/prepare'))return new Response(JSON.stringify({drawing:'test.dxf',excelName:'test.xlsx',excelSelected:true}));if(path.includes('/ground-link/upload'))return new Response(JSON.stringify({file:'test.xlsx'}));if(path.includes('/ground-link/launch'))return new Response(JSON.stringify({id:'mock'}));if(path.includes('/ground-link/result')){window.__groundReady=true;return new Promise(resolve=>window.__releaseGround=()=>resolve(new Response(JSON.stringify({ready:true,state:'done',filename:'old.json',text:${JSON.stringify(JSON.stringify({...raw,generated:'mock-generated'}))}}))));}return original(url,init);};})()`);
|
||||||
|
const excel=path.join(out,'source.xlsx');fs.writeFileSync(excel,'mock');
|
||||||
|
const startGeneration = async () => {
|
||||||
|
await ev(`window.__groundReady=false`);
|
||||||
|
await click(`document.getElementById('nav-shape')`);
|
||||||
|
await click(`document.getElementById('project-ground-open')`);
|
||||||
|
await click(`[...document.querySelectorAll('#project-ground-dialog button')].find(b=>b.textContent==='지반모델 생성')`);
|
||||||
|
await until(`document.querySelector('.ground-popup [data-run]')&&!document.querySelector('.ground-popup [data-run]').disabled`);
|
||||||
|
await selectFile('#ground-excel-file',[excel]);
|
||||||
|
await click(`document.querySelector('.ground-popup [data-run]')`);
|
||||||
|
await until(`window.__groundReady`);
|
||||||
|
};
|
||||||
|
await startGeneration();
|
||||||
|
await click(`document.querySelector('.ground-popup [data-close]')`);
|
||||||
|
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
|
||||||
|
await click(`document.getElementById('nav-rebar')`);
|
||||||
|
await ev(`window.__releaseGround()`);
|
||||||
|
await until(`document.querySelector('.ground-popup [data-status]').textContent.includes('생성 완료')`);
|
||||||
|
await click(`document.getElementById('nav-shape')`);
|
||||||
|
await click(`document.getElementById('project-ground-open')`);
|
||||||
|
check('generated model imports after leaving both manager and ground card',await ev(`document.getElementById('project-ground-info').textContent.includes('mock-generated')`));
|
||||||
|
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
|
||||||
|
await startGeneration();
|
||||||
|
await selectFile('#project-file',[file]);
|
||||||
|
await until(`document.getElementById('project-ground-status').textContent==='모델 없음'`);
|
||||||
|
await ev(`window.__releaseGround()`);
|
||||||
|
await delay(300);
|
||||||
|
check('late generated result does not contaminate another project',await ev(`document.getElementById('project-ground-status').textContent==='모델 없음'`));
|
||||||
|
check('project switch closes manager and generator',await ev(`!document.getElementById('project-ground-dialog').open&&!document.querySelector('.ground-popup').open`));
|
||||||
|
await selectFile('#project-file',[savedFile]);
|
||||||
|
await until(`document.getElementById('project-ground-status').textContent.includes('불러옴')`);
|
||||||
|
await ev(`(async()=>{const bytes=await(await fetch('/'+encodeURIComponent('도면추출')+'/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();window.loadBytesToApp(new Uint8Array(bytes),'새 프로젝트.xlsx');const name=document.getElementById('project-name');name.value='새 프로젝트';name.dispatchEvent(new Event('input'));document.getElementById('import-dialog').showModal();})()`);
|
||||||
|
await until(`document.getElementById('import-dialog').open&&!document.getElementById('dlg-apply').disabled`);
|
||||||
|
await click(`document.getElementById('dlg-apply')`);
|
||||||
|
await until(`document.getElementById('project-ground-status').textContent==='모델 없음'`);
|
||||||
|
check('new Excel import clears previous project ground model',await ev(`document.getElementById('project-ground-status').textContent==='모델 없음'`));
|
||||||
|
// Real generated JSON and the adjacent settings file reproduce the reported failure.
|
||||||
|
const actualFolder=path.resolve(__dirname,'../pierzainer_ground/data/output/drawing_01_옥성교_종평면도');
|
||||||
|
const actualModel=path.join(actualFolder,'ground_model.json');
|
||||||
|
const expected=JSON.parse(fs.readFileSync(actualModel,'utf8'));
|
||||||
|
const six={...fixture,piers:Array.from({length:6},(_,i)=>{
|
||||||
|
const input={...fixture.piers[0].input,name:'P'+(i+1)};
|
||||||
|
return{id:input.name,input,defaults:input,draft:input};
|
||||||
|
})};
|
||||||
|
const sixFile=path.join(out,'six-piers.pzr.json');fs.writeFileSync(sixFile,JSON.stringify(six));
|
||||||
|
await selectFile('#project-file',[sixFile]);
|
||||||
|
await until(`document.querySelectorAll('#pier-items button').length===6`);
|
||||||
|
await click(`document.getElementById('nav-shape')`);
|
||||||
|
await click(`document.getElementById('project-ground-open')`);
|
||||||
|
await selectFile('#ground-import',[path.join(actualFolder,'generation_settings.json')]);
|
||||||
|
await until(`document.getElementById('project-ground-dialog').textContent.includes('지반 생성 설정 파일')`);
|
||||||
|
check('settings file reports correct result filenames and blocks application',await ev(`document.getElementById('project-ground-apply').disabled&&document.getElementById('project-ground-status').textContent==='모델 없음'&&document.getElementById('project-ground-dialog').textContent.includes('ground_model_3d_with_maps.json')`));
|
||||||
|
await selectFile('#ground-import',[actualModel]);
|
||||||
|
await until(`document.getElementById('project-ground-status').textContent.includes('6/6')`);
|
||||||
|
check('real Okseong JSON imports all six piers',await ev(`document.querySelectorAll('[data-ground-pier]:not(:disabled)').length===6`));
|
||||||
|
await click(`[...document.querySelectorAll('#project-ground-dialog button')].find(b=>b.textContent==='적용 가능한 교각 선택')`);
|
||||||
|
check('select all enables application',await ev(`document.querySelectorAll('[data-ground-pier]:checked').length===6&&!document.getElementById('project-ground-apply').disabled`));
|
||||||
|
await click(`document.getElementById('project-ground-apply')`);
|
||||||
|
const applied=await ev(`[...document.querySelectorAll('.project-ground-table tbody tr')].map(row=>Number(row.children[2].textContent))`);
|
||||||
|
check('all six design elevations match real JSON',applied.every((el,i)=>el===expected.piers[i].ground.center));
|
||||||
|
await click(`document.getElementById('project-ground-regenerate')`);
|
||||||
|
check('all six applied elevations regenerate',await ev(`document.getElementById('project-ground-regenerate').disabled`));
|
||||||
|
await selectFile('#ground-import',[path.join(actualFolder,'generation_settings.json')]);
|
||||||
|
await until(`document.getElementById('project-ground-dialog').textContent.includes('지반 생성 설정 파일')`);
|
||||||
|
check('invalid replacement preserves the loaded project ground model',await ev(`document.getElementById('project-ground-status').textContent.includes('6/6')`));
|
||||||
|
await shot('02-real-okseong');
|
||||||
|
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();
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// Run with Windows Node and an isolated Chrome on CDP 9444; Vite must be on 5173.
|
||||||
|
// Verifies the landing intro-video trigger + modal (lazy load, play, Esc/backdrop close, focus, layouts).
|
||||||
|
const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict');
|
||||||
|
const out = path.resolve(__dirname, '../.verify/intro-video-20261109');
|
||||||
|
fs.mkdirSync(out, { recursive: true });
|
||||||
|
const report = { checks: [], errors: [], metrics: {} };
|
||||||
|
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, userGesture: true });
|
||||||
|
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
|
||||||
|
return r.result.value;
|
||||||
|
};
|
||||||
|
const until = async (expression, tries = 120) => {
|
||||||
|
for (let i = 0; i < tries; 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 shot = async name => {
|
||||||
|
const r = await send('Page.captureScreenshot', { format: 'png' });
|
||||||
|
fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64'));
|
||||||
|
};
|
||||||
|
const viewport = (width, height, mobile = false) => send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile });
|
||||||
|
const load = async () => {
|
||||||
|
await send('Page.navigate', { url: process.env.PIERZAINER_URL || 'http://127.0.0.1:5173/' });
|
||||||
|
await until(`!!document.querySelector('.landing-video-trigger')`);
|
||||||
|
await delay(1800); // entry animations
|
||||||
|
};
|
||||||
|
const mp4Requests = `performance.getEntriesByType('resource').filter(e=>e.name.includes('pierzainer-intro')).length`;
|
||||||
|
const pressEsc = async () => {
|
||||||
|
for (const type of ['keyDown', 'keyUp']) await send('Input.dispatchKeyEvent', { type, key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
|
||||||
|
};
|
||||||
|
|
||||||
|
await send('Page.enable'); await send('Runtime.enable');
|
||||||
|
await viewport(1600, 1000); await load();
|
||||||
|
await shot('01-landing-1600');
|
||||||
|
report.metrics.triggers = await ev(`[...document.querySelectorAll('.landing-guide-trigger,.landing-video-trigger')].map(e=>{const r=e.getBoundingClientRect();return[e.className,Math.round(r.left),Math.round(r.top),Math.round(r.right),Math.round(r.bottom)];})`);
|
||||||
|
check('trigger sits beside the guide button', await ev(`(()=>{const g=document.querySelector('.landing-guide-trigger').getBoundingClientRect(),v=document.querySelector('.landing-video-trigger').getBoundingClientRect();return v.width>0&&Math.abs(g.top-v.top)<1&&Math.abs(g.height-v.height)<1&&v.left>g.right;})()`));
|
||||||
|
check('video is not downloaded before opening', await ev(mp4Requests) === 0 && await ev(`!document.querySelector('#landing-video video source')`));
|
||||||
|
await shot('01-landing-1600');
|
||||||
|
|
||||||
|
await ev(`document.querySelector('.landing-video-trigger').click()`);
|
||||||
|
check('modal opens with focus on close button', await ev(`document.getElementById('landing-video').open&&document.activeElement.classList.contains('landing-video-close')`));
|
||||||
|
await until(`document.querySelector('#landing-video video').readyState>=3`, 200);
|
||||||
|
await delay(2200);
|
||||||
|
const media = await ev(`(()=>{const v=document.querySelector('#landing-video video');return{t:v.currentTime,d:v.duration,w:v.videoWidth,h:v.videoHeight,paused:v.paused,muted:v.muted,src:v.currentSrc.split('/').pop()};})()`);
|
||||||
|
report.metrics.media = media;
|
||||||
|
check('mp4 loads at 1920x1080 for about 56 s', media.w === 1920 && media.h === 1080 && Math.abs(media.d - 56) < 1);
|
||||||
|
check('video autoplays muted after opening', !media.paused && media.muted && media.t > 1);
|
||||||
|
check('modal fits the viewport', await ev(`(()=>{const r=document.getElementById('landing-video').getBoundingClientRect();return r.left>=0&&r.top>=0&&r.right<=innerWidth&&r.bottom<=innerHeight;})()`));
|
||||||
|
await shot('02-playing-1600');
|
||||||
|
|
||||||
|
await pressEsc(); await delay(200);
|
||||||
|
check('Esc closes, pauses, rewinds and restores focus', await ev(`(()=>{const v=document.querySelector('#landing-video video');return !document.getElementById('landing-video').open&&v.paused&&v.currentTime===0&&document.activeElement.classList.contains('landing-video-trigger');})()`));
|
||||||
|
|
||||||
|
await ev(`document.querySelector('.landing-video-trigger').click()`); await delay(300);
|
||||||
|
await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: 8, y: 8, button: 'left', clickCount: 1 });
|
||||||
|
await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: 8, y: 8, button: 'left', clickCount: 1 });
|
||||||
|
await delay(200);
|
||||||
|
check('backdrop click closes the modal', await ev(`!document.getElementById('landing-video').open`));
|
||||||
|
await ev(`document.querySelector('.landing-video-trigger').click()`); await delay(300);
|
||||||
|
await ev(`document.querySelector('.landing-video-close').click()`); await delay(200);
|
||||||
|
check('close button closes and pauses', await ev(`!document.getElementById('landing-video').open&&document.querySelector('#landing-video video').paused`));
|
||||||
|
|
||||||
|
for (const [w, h, mobile] of [[1280, 800, false], [1920, 1080, false], [390, 844, true]]) {
|
||||||
|
await viewport(w, h, mobile); await load();
|
||||||
|
check(`${w}px trigger visible without horizontal scroll`, await ev(`(()=>{const r=document.querySelector('.landing-video-trigger').getBoundingClientRect();return r.width>0&&r.right<=innerWidth&&document.documentElement.scrollWidth<=innerWidth;})()`));
|
||||||
|
await ev(`document.querySelector('.landing-video-trigger').click()`); await delay(500);
|
||||||
|
check(`${w}px modal fits viewport`, await ev(`(()=>{const r=document.getElementById('landing-video').getBoundingClientRect();return r.left>=0&&r.right<=innerWidth&&r.bottom<=innerHeight+1;})()`));
|
||||||
|
await shot(`03-modal-${w}`);
|
||||||
|
await ev(`document.querySelector('.landing-video-close').click()`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await viewport(1600, 1000);
|
||||||
|
await send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] });
|
||||||
|
await load();
|
||||||
|
await ev(`document.querySelector('.landing-video-trigger').click()`); await delay(1500);
|
||||||
|
check('reduced motion: opens without autoplay', await ev(`document.getElementById('landing-video').open&&document.querySelector('#landing-video video').paused`));
|
||||||
|
await send('Emulation.setEmulatedMedia', { features: [] });
|
||||||
|
|
||||||
|
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({ result: report.result, failure: report.failure, metrics: report.metrics, errors: report.errors })); ws?.close();
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
// 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/okseong-user-files-20261109');
|
||||||
|
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'`);
|
||||||
|
const fixture = await ev(`(async()=>{
|
||||||
|
const XLSX=await import('/node_modules/xlsx/xlsx.mjs'),{parseWorkbook}=await import('/src/core/parseWorkbook.ts');
|
||||||
|
const bytes=await(await fetch('/'+encodeURIComponent('도면추출')+'/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();
|
||||||
|
const base=parseWorkbook(XLSX.read(bytes)).piers.find(p=>p.name==='P1');
|
||||||
|
if(!base)throw new Error('Missing sample pier');
|
||||||
|
const {defaultLevelWidthMm}=await import('/src/core/geometry.ts');
|
||||||
|
const piers=['직접기초','직접기초','현장타설말뚝'].map((foundationType,i)=>{const input={...base,name:'P'+(i+1),foundationType,type:'T형',columnCount:1,groundEl:30,pileLengthMm:24000,castInPlaceShape:{clayBottomEl:16},columnDiameterMm:2000,footingLengthMm:10400,footingWidthMm:8150,footingHeightMm:2000,blindingMarginMm:100,blindingThicknessMm:100,pileKind:'강관',pileOuterMm:609.6,pileThicknessMm:12,copingH1Mm:1000,copingH2Mm:1500,levelWidthMm:defaultLevelWidthMm('T형',2000)};return{id:input.name,input,defaults:input,draft:input};});
|
||||||
|
return{schemaVersion:1,project:{name:'병합 검증',bridge:'검증',client:'',author:'',crs:'',memo:''},source:{fileName:'합성 표본',sheets:[]},piers,history:[]};
|
||||||
|
})()`);
|
||||||
|
const file = path.join(out, 'fixture.pzr.json'); fs.writeFileSync(file, JSON.stringify(fixture));
|
||||||
|
await ev('window.openSampleProject()');
|
||||||
|
await click(`document.querySelector('#file-menu summary')`); await click(`document.getElementById('open-project')`);
|
||||||
|
const doc = await send('DOM.getDocument');
|
||||||
|
const { nodeId } = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#project-file' });
|
||||||
|
await send('DOM.setFileInputFiles', { nodeId, files: [file] });
|
||||||
|
await until(`document.querySelectorAll('#rebar-pier-items button').length===3`);
|
||||||
|
const selectFile = async (selector, files) => {
|
||||||
|
const doc = await send('DOM.getDocument');
|
||||||
|
const {nodeId} = await send('DOM.querySelector', {nodeId:doc.root.nodeId, selector});
|
||||||
|
await send('DOM.setFileInputFiles', {nodeId, files});
|
||||||
|
};
|
||||||
|
await click(`document.getElementById('nav-shape')`);
|
||||||
|
check('shape has a visible project ground entry', await ev(`document.getElementById('project-ground-open').getBoundingClientRect().height>0`));
|
||||||
|
await click(`document.getElementById('project-ground-open')`);
|
||||||
|
check('manager opens without per-pier controls', await ev(`document.getElementById('project-ground-dialog').open&&document.querySelectorAll('#ground-import').length===1`));
|
||||||
|
const modelFile=path.join(out,'ground.json');
|
||||||
|
const raw={schema:2,generated:'2026-11-09',piers:[{id:'P1',ground:{center:29,excel:30},soil:{layers:[{name:'암반',bottom_el:10}]}},{id:'P2',ground:{center:null,excel:30}}]};
|
||||||
|
fs.writeFileSync(modelFile,JSON.stringify(raw));
|
||||||
|
await selectFile('#ground-import',[modelFile]);
|
||||||
|
await until(`document.getElementById('project-ground-info').textContent.includes('2026-11-09')`);
|
||||||
|
check('matching, out-of-range and missing piers are distinguished',await ev(`document.getElementById('project-ground-dialog').textContent.includes('지형 범위 밖')&&document.getElementById('project-ground-dialog').textContent.includes('모델에 없음')&&document.querySelector('[data-ground-pier="P2"]').disabled&&document.querySelector('[data-ground-pier="P3"]').disabled`));
|
||||||
|
check('import alone keeps current design input',await ev(`document.querySelector('.project-ground-table tbody tr').children[2].textContent==='30.000'`));
|
||||||
|
await click(`document.querySelector('[data-ground-pier="P1"]')`);
|
||||||
|
await click(`document.getElementById('project-ground-apply')`);
|
||||||
|
check('selected ground input applies and requires regeneration',await ev(`document.querySelector('.project-ground-table tbody tr').children[2].textContent==='29.000'&&!document.getElementById('project-ground-regenerate').disabled`));
|
||||||
|
await click(`document.getElementById('project-ground-regenerate')`);
|
||||||
|
check('regeneration consumes ground changes',await ev(`document.getElementById('project-ground-regenerate').disabled&&document.querySelector('.project-ground-table tbody tr').textContent.includes('지반고 일치')`));
|
||||||
|
await click(`document.getElementById('project-ground-el')`);
|
||||||
|
await click(`document.getElementById('project-ground-layers')`);
|
||||||
|
await click(`document.getElementById('project-ground-apply')`);
|
||||||
|
check('layers can be applied separately',await ev(`!document.getElementById('project-ground-regenerate').disabled&&document.querySelector('.project-ground-table tbody tr').children[2].textContent==='29.000'`));
|
||||||
|
await click(`document.getElementById('project-ground-regenerate')`);
|
||||||
|
await shot('01-project-ground');
|
||||||
|
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
|
||||||
|
await click(`document.getElementById('nav-plan')`);
|
||||||
|
await click(`[...document.querySelectorAll('#members button')].find(b=>b.textContent.includes('지반'))`);
|
||||||
|
check('per-pier ground card has no model generation or file picker',await ev(`!document.getElementById('view-plan').textContent.includes('지반 모델 파일 불러오기 · 지반 생성 프로그램')&&document.querySelectorAll('#view-plan input[type=file]').length===0`));
|
||||||
|
await ev(`(()=>{window.__savedGround=null;const original=URL.createObjectURL;URL.createObjectURL=function(blob){blob.text().then(text=>{try{window.__savedGround=JSON.parse(text);}catch{}});return original.call(this,blob);};})()`);
|
||||||
|
await click(`document.getElementById('save-project')`);
|
||||||
|
await until(`!!window.__savedGround?.groundModel`);
|
||||||
|
const saved=await ev(`window.__savedGround`);
|
||||||
|
check('project saves shared model once and applied inputs',saved.groundModel.piers.length===2&&saved.piers[0].input.groundEl===29&&saved.piers[0].input.strataRows[0].name==='암반'&&saved.piers[1].input.groundEl===30);
|
||||||
|
const savedFile=path.join(out,'saved.pzr.json');fs.writeFileSync(savedFile,JSON.stringify(saved));
|
||||||
|
await selectFile('#project-file',[savedFile]);
|
||||||
|
await until(`document.getElementById('project-ground-status').textContent.includes('불러옴')`);
|
||||||
|
check('project reopen restores ground model',await ev(`document.getElementById('project-ground-status').textContent.includes('2/3')`));
|
||||||
|
// Mock the external engine; deliver an in-flight result after switching projects.
|
||||||
|
await ev(`(()=>{const original=window.fetch;window.__releaseGround=null;window.__groundReady=false;window.fetch=async(url,init)=>{const path=String(url);if(path.includes('/ground-link/prepare'))return new Response(JSON.stringify({drawing:'test.dxf',excelName:'test.xlsx',excelSelected:true}));if(path.includes('/ground-link/upload'))return new Response(JSON.stringify({file:'test.xlsx'}));if(path.includes('/ground-link/launch'))return new Response(JSON.stringify({id:'mock'}));if(path.includes('/ground-link/result')){window.__groundReady=true;return new Promise(resolve=>window.__releaseGround=()=>resolve(new Response(JSON.stringify({ready:true,state:'done',filename:'old.json',text:${JSON.stringify(JSON.stringify({...raw,generated:'mock-generated'}))}}))));}return original(url,init);};})()`);
|
||||||
|
const excel=path.join(out,'source.xlsx');fs.writeFileSync(excel,'mock');
|
||||||
|
const startGeneration = async () => {
|
||||||
|
await ev(`window.__groundReady=false`);
|
||||||
|
await click(`document.getElementById('nav-shape')`);
|
||||||
|
await click(`document.getElementById('project-ground-open')`);
|
||||||
|
await click(`[...document.querySelectorAll('#project-ground-dialog button')].find(b=>b.textContent==='지반모델 생성')`);
|
||||||
|
await until(`document.querySelector('.ground-popup [data-run]')&&!document.querySelector('.ground-popup [data-run]').disabled`);
|
||||||
|
await selectFile('#ground-excel-file',[excel]);
|
||||||
|
await click(`document.querySelector('.ground-popup [data-run]')`);
|
||||||
|
await until(`window.__groundReady`);
|
||||||
|
};
|
||||||
|
await startGeneration();
|
||||||
|
await click(`document.querySelector('.ground-popup [data-close]')`);
|
||||||
|
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
|
||||||
|
await click(`document.getElementById('nav-rebar')`);
|
||||||
|
await ev(`window.__releaseGround()`);
|
||||||
|
await until(`document.querySelector('.ground-popup [data-status]').textContent.includes('생성 완료')`);
|
||||||
|
await click(`document.getElementById('nav-shape')`);
|
||||||
|
await click(`document.getElementById('project-ground-open')`);
|
||||||
|
check('generated model imports after leaving both manager and ground card',await ev(`document.getElementById('project-ground-info').textContent.includes('mock-generated')`));
|
||||||
|
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
|
||||||
|
await startGeneration();
|
||||||
|
await selectFile('#project-file',[file]);
|
||||||
|
await until(`document.getElementById('project-ground-status').textContent==='모델 없음'`);
|
||||||
|
await ev(`window.__releaseGround()`);
|
||||||
|
await delay(300);
|
||||||
|
check('late generated result does not contaminate another project',await ev(`document.getElementById('project-ground-status').textContent==='모델 없음'`));
|
||||||
|
check('project switch closes manager and generator',await ev(`!document.getElementById('project-ground-dialog').open&&!document.querySelector('.ground-popup').open`));
|
||||||
|
await selectFile('#project-file',[savedFile]);
|
||||||
|
await until(`document.getElementById('project-ground-status').textContent.includes('불러옴')`);
|
||||||
|
await ev(`(async()=>{const bytes=await(await fetch('/'+encodeURIComponent('도면추출')+'/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();window.loadBytesToApp(new Uint8Array(bytes),'새 프로젝트.xlsx');const name=document.getElementById('project-name');name.value='새 프로젝트';name.dispatchEvent(new Event('input'));document.getElementById('import-dialog').showModal();})()`);
|
||||||
|
await until(`document.getElementById('import-dialog').open&&!document.getElementById('dlg-apply').disabled`);
|
||||||
|
await click(`document.getElementById('dlg-apply')`);
|
||||||
|
await until(`document.getElementById('project-ground-status').textContent==='모델 없음'`);
|
||||||
|
check('new Excel import clears previous project ground model',await ev(`document.getElementById('project-ground-status').textContent==='모델 없음'`));
|
||||||
|
// Real generated JSON and the adjacent settings file reproduce the reported failure.
|
||||||
|
const actualFolder=path.resolve(__dirname,'../pierzainer_ground/data/output/drawing_01_옥성교_종평면도');
|
||||||
|
const actualModel=path.join(actualFolder,'ground_model.json');
|
||||||
|
const expected=JSON.parse(fs.readFileSync(actualModel,'utf8'));
|
||||||
|
const six={...fixture,piers:Array.from({length:6},(_,i)=>{
|
||||||
|
const input={...fixture.piers[0].input,name:'P'+(i+1)};
|
||||||
|
return{id:input.name,input,defaults:input,draft:input};
|
||||||
|
})};
|
||||||
|
const sixFile=path.join(out,'six-piers.pzr.json');fs.writeFileSync(sixFile,JSON.stringify(six));
|
||||||
|
await selectFile('#project-file',[sixFile]);
|
||||||
|
await until(`document.querySelectorAll('#pier-items button').length===6`);
|
||||||
|
await click(`document.getElementById('nav-shape')`);
|
||||||
|
await click(`document.getElementById('project-ground-open')`);
|
||||||
|
await selectFile('#ground-import',[path.join(actualFolder,'generation_settings.json')]);
|
||||||
|
await until(`document.getElementById('project-ground-dialog').textContent.includes('지반 생성 설정 파일')`);
|
||||||
|
check('settings file reports correct result filenames and blocks application',await ev(`document.getElementById('project-ground-apply').disabled&&document.getElementById('project-ground-status').textContent==='모델 없음'&&document.getElementById('project-ground-dialog').textContent.includes('ground_model_3d_with_maps.json')`));
|
||||||
|
await selectFile('#ground-import',[actualModel]);
|
||||||
|
await until(`document.getElementById('project-ground-status').textContent.includes('6/6')`);
|
||||||
|
check('real Okseong JSON imports all six piers',await ev(`document.querySelectorAll('[data-ground-pier]:not(:disabled)').length===6`));
|
||||||
|
await click(`[...document.querySelectorAll('#project-ground-dialog button')].find(b=>b.textContent==='적용 가능한 교각 선택')`);
|
||||||
|
check('select all enables application',await ev(`document.querySelectorAll('[data-ground-pier]:checked').length===6&&!document.getElementById('project-ground-apply').disabled`));
|
||||||
|
await click(`document.getElementById('project-ground-apply')`);
|
||||||
|
const applied=await ev(`[...document.querySelectorAll('.project-ground-table tbody tr')].map(row=>Number(row.children[2].textContent))`);
|
||||||
|
check('all six design elevations match real JSON',applied.every((el,i)=>el===expected.piers[i].ground.center));
|
||||||
|
await click(`document.getElementById('project-ground-regenerate')`);
|
||||||
|
check('all six applied elevations regenerate',await ev(`document.getElementById('project-ground-regenerate').disabled`));
|
||||||
|
await selectFile('#ground-import',[path.join(actualFolder,'generation_settings.json')]);
|
||||||
|
await until(`document.getElementById('project-ground-dialog').textContent.includes('지반 생성 설정 파일')`);
|
||||||
|
check('invalid replacement preserves the loaded project ground model',await ev(`document.getElementById('project-ground-status').textContent.includes('6/6')`));
|
||||||
|
await shot('02-real-okseong');
|
||||||
|
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
|
||||||
|
const userFolder=process.env.OKSEONG_FILES||'C:\\Users\\User\\Downloads\\NAVER WORKS';
|
||||||
|
const userExcel=path.join(userFolder,'옥성교 데이터_261106.xlsx'),userJson=path.join(userFolder,'옥성교 지형.json');
|
||||||
|
await selectFile('#file',[userExcel]);
|
||||||
|
await until(`document.getElementById('file-name').textContent.includes('선택된 파일: 옥성교 데이터_261106.xlsx')&&!document.getElementById('dlg-apply').disabled`);
|
||||||
|
await ev(`document.getElementById('import-dialog').showModal()`);
|
||||||
|
await click(`document.getElementById('dlg-apply')`);
|
||||||
|
await until(`document.getElementById('project-ground-status').textContent==='모델 없음'`);
|
||||||
|
const before=await ev(`[...document.querySelectorAll('#inputs-all tbody tr')].map(row=>row.textContent)`);
|
||||||
|
const projectBefore=await ev(`document.getElementById('project-line').textContent`);
|
||||||
|
await click(`document.getElementById('nav-shape')`);
|
||||||
|
await click(`document.getElementById('project-ground-open')`);
|
||||||
|
await selectFile('#ground-import',[userJson]);
|
||||||
|
await until(`document.getElementById('project-ground-status').textContent.includes('6/6')`);
|
||||||
|
check('user JSON imports six elevations plus terrain and two maps',await ev(`document.getElementById('project-ground-info').textContent.includes('지도 2개')&&document.getElementById('project-ground-info').textContent.includes('지형 있음')`));
|
||||||
|
check('importing embedded model preserves user Excel inputs and project',await ev(`JSON.stringify([...document.querySelectorAll('#inputs-all tbody tr')].map(row=>row.textContent))`)===JSON.stringify(before)&&await ev(`document.getElementById('project-line').textContent`)===projectBefore);
|
||||||
|
await click(`[...document.querySelectorAll('#project-ground-dialog button')].find(b=>b.textContent==='적용 가능한 교각 선택')`);
|
||||||
|
await click(`document.getElementById('project-ground-apply')`);
|
||||||
|
const userExpected=JSON.parse(fs.readFileSync(userJson,'utf8')).groundModel;
|
||||||
|
const userApplied=await ev(`[...document.querySelectorAll('.project-ground-table tbody tr')].map(row=>Number(row.children[2].textContent))`);
|
||||||
|
check('user Excel accepts all six elevations from embedded ground model',userApplied.length===6&&userApplied.every((el,i)=>el===userExpected.piers[i].centerEl));
|
||||||
|
await click(`document.getElementById('project-ground-regenerate')`);
|
||||||
|
check('user project regenerates after ground application',await ev(`document.getElementById('project-ground-regenerate').disabled`));
|
||||||
|
await shot('03-user-files-applied');
|
||||||
|
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
|
||||||
|
await send('Browser.setDownloadBehavior',{behavior:'deny'});
|
||||||
|
await ev(`window.__savedGround=null`);
|
||||||
|
await click(`document.getElementById('save-project')`);
|
||||||
|
await until(`document.getElementById('save-dialog').open`);
|
||||||
|
await click(`document.getElementById('sv-save')`);
|
||||||
|
await until(`window.__savedGround?.source?.fileName==='옥성교 데이터_261106.xlsx'`);
|
||||||
|
check('saved result retains user Excel source and applied embedded terrain',await ev(`window.__savedGround.piers.length===6&&window.__savedGround.piers[0].input.groundEl===18.011&&window.__savedGround.groundModel.mapLayers.length===2`));
|
||||||
|
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();
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
// 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/project-ground-20261109');
|
||||||
|
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'`);
|
||||||
|
const fixture = await ev(`(async()=>{
|
||||||
|
const XLSX=await import('/node_modules/xlsx/xlsx.mjs'),{parseWorkbook}=await import('/src/core/parseWorkbook.ts');
|
||||||
|
const bytes=await(await fetch('/'+encodeURIComponent('도면추출')+'/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();
|
||||||
|
const base=parseWorkbook(XLSX.read(bytes)).piers.find(p=>p.name==='P1');
|
||||||
|
if(!base)throw new Error('Missing sample pier');
|
||||||
|
const {defaultLevelWidthMm}=await import('/src/core/geometry.ts');
|
||||||
|
const piers=['직접기초','직접기초','현장타설말뚝'].map((foundationType,i)=>{const input={...base,name:'P'+(i+1),foundationType,type:'T형',columnCount:1,groundEl:30,pileLengthMm:24000,castInPlaceShape:{clayBottomEl:16},columnDiameterMm:2000,footingLengthMm:10400,footingWidthMm:8150,footingHeightMm:2000,blindingMarginMm:100,blindingThicknessMm:100,pileKind:'강관',pileOuterMm:609.6,pileThicknessMm:12,copingH1Mm:1000,copingH2Mm:1500,levelWidthMm:defaultLevelWidthMm('T형',2000)};return{id:input.name,input,defaults:input,draft:input};});
|
||||||
|
return{schemaVersion:1,project:{name:'병합 검증',bridge:'검증',client:'',author:'',crs:'',memo:''},source:{fileName:'합성 표본',sheets:[]},piers,history:[]};
|
||||||
|
})()`);
|
||||||
|
const file = path.join(out, 'fixture.pzr.json'); fs.writeFileSync(file, JSON.stringify(fixture));
|
||||||
|
await ev('window.openSampleProject()');
|
||||||
|
await click(`document.querySelector('#file-menu summary')`); await click(`document.getElementById('open-project')`);
|
||||||
|
const doc = await send('DOM.getDocument');
|
||||||
|
const { nodeId } = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#project-file' });
|
||||||
|
await send('DOM.setFileInputFiles', { nodeId, files: [file] });
|
||||||
|
await until(`document.querySelectorAll('#rebar-pier-items button').length===3`);
|
||||||
|
const selectFile = async (selector, files) => {
|
||||||
|
const doc = await send('DOM.getDocument');
|
||||||
|
const {nodeId} = await send('DOM.querySelector', {nodeId:doc.root.nodeId, selector});
|
||||||
|
await send('DOM.setFileInputFiles', {nodeId, files});
|
||||||
|
};
|
||||||
|
await click(`document.getElementById('nav-shape')`);
|
||||||
|
check('shape has a visible project ground entry', await ev(`document.getElementById('project-ground-open').getBoundingClientRect().height>0`));
|
||||||
|
await click(`document.getElementById('project-ground-open')`);
|
||||||
|
check('manager opens without per-pier controls', await ev(`document.getElementById('project-ground-dialog').open&&document.querySelectorAll('#ground-import').length===1`));
|
||||||
|
const modelFile=path.join(out,'ground.json');
|
||||||
|
const raw={schema:2,generated:'2026-11-09',piers:[{id:'P1',ground:{center:29,excel:30},soil:{layers:[{name:'암반',bottom_el:10}]}},{id:'P2',ground:{center:null,excel:30}}]};
|
||||||
|
fs.writeFileSync(modelFile,JSON.stringify(raw));
|
||||||
|
await selectFile('#ground-import',[modelFile]);
|
||||||
|
await until(`document.getElementById('project-ground-info').textContent.includes('2026-11-09')`);
|
||||||
|
check('matching, out-of-range and missing piers are distinguished',await ev(`document.getElementById('project-ground-dialog').textContent.includes('지형 범위 밖')&&document.getElementById('project-ground-dialog').textContent.includes('모델에 없음')&&document.querySelector('[data-ground-pier="P2"]').disabled&&document.querySelector('[data-ground-pier="P3"]').disabled`));
|
||||||
|
check('import alone keeps current design input',await ev(`document.querySelector('.project-ground-table tbody tr').children[2].textContent==='30.000'`));
|
||||||
|
await click(`document.querySelector('[data-ground-pier="P1"]')`);
|
||||||
|
await click(`document.getElementById('project-ground-apply')`);
|
||||||
|
check('selected ground input applies and requires regeneration',await ev(`document.querySelector('.project-ground-table tbody tr').children[2].textContent==='29.000'&&!document.getElementById('project-ground-regenerate').disabled`));
|
||||||
|
await click(`document.getElementById('project-ground-regenerate')`);
|
||||||
|
check('regeneration consumes ground changes',await ev(`document.getElementById('project-ground-regenerate').disabled&&document.querySelector('.project-ground-table tbody tr').textContent.includes('지반고 일치')`));
|
||||||
|
await click(`document.getElementById('project-ground-el')`);
|
||||||
|
await click(`document.getElementById('project-ground-layers')`);
|
||||||
|
await click(`document.getElementById('project-ground-apply')`);
|
||||||
|
check('layers can be applied separately',await ev(`!document.getElementById('project-ground-regenerate').disabled&&document.querySelector('.project-ground-table tbody tr').children[2].textContent==='29.000'`));
|
||||||
|
await click(`document.getElementById('project-ground-regenerate')`);
|
||||||
|
await shot('01-project-ground');
|
||||||
|
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
|
||||||
|
await click(`document.getElementById('nav-plan')`);
|
||||||
|
await click(`[...document.querySelectorAll('#members button')].find(b=>b.textContent.includes('지반'))`);
|
||||||
|
check('per-pier ground card has no model generation or file picker',await ev(`!document.getElementById('view-plan').textContent.includes('지반 모델 파일 불러오기 · 지반 생성 프로그램')&&document.querySelectorAll('#view-plan input[type=file]').length===0`));
|
||||||
|
await ev(`(()=>{window.__savedGround=null;const original=URL.createObjectURL;URL.createObjectURL=function(blob){blob.text().then(text=>{try{window.__savedGround=JSON.parse(text);}catch{}});return original.call(this,blob);};})()`);
|
||||||
|
await click(`document.getElementById('save-project')`);
|
||||||
|
await until(`!!window.__savedGround?.groundModel`);
|
||||||
|
const saved=await ev(`window.__savedGround`);
|
||||||
|
check('project saves shared model once and applied inputs',saved.groundModel.piers.length===2&&saved.piers[0].input.groundEl===29&&saved.piers[0].input.strataRows[0].name==='암반'&&saved.piers[1].input.groundEl===30);
|
||||||
|
const savedFile=path.join(out,'saved.pzr.json');fs.writeFileSync(savedFile,JSON.stringify(saved));
|
||||||
|
await selectFile('#project-file',[savedFile]);
|
||||||
|
await until(`document.getElementById('project-ground-status').textContent.includes('불러옴')`);
|
||||||
|
check('project reopen restores ground model',await ev(`document.getElementById('project-ground-status').textContent.includes('2/3')`));
|
||||||
|
// Mock the external engine; deliver an in-flight result after switching projects.
|
||||||
|
await ev(`(()=>{const original=window.fetch;window.__releaseGround=null;window.__groundReady=false;window.fetch=async(url,init)=>{const path=String(url);if(path.includes('/ground-link/prepare'))return new Response(JSON.stringify({drawing:'test.dxf',excelName:'test.xlsx',excelSelected:true}));if(path.includes('/ground-link/upload'))return new Response(JSON.stringify({file:'test.xlsx'}));if(path.includes('/ground-link/launch'))return new Response(JSON.stringify({id:'mock'}));if(path.includes('/ground-link/result')){window.__groundReady=true;return new Promise(resolve=>window.__releaseGround=()=>resolve(new Response(JSON.stringify({ready:true,state:'done',filename:'old.json',text:${JSON.stringify(JSON.stringify({...raw,generated:'mock-generated'}))}}))));}return original(url,init);};})()`);
|
||||||
|
const excel=path.join(out,'source.xlsx');fs.writeFileSync(excel,'mock');
|
||||||
|
const startGeneration = async () => {
|
||||||
|
await ev(`window.__groundReady=false`);
|
||||||
|
await click(`document.getElementById('nav-shape')`);
|
||||||
|
await click(`document.getElementById('project-ground-open')`);
|
||||||
|
await click(`[...document.querySelectorAll('#project-ground-dialog button')].find(b=>b.textContent==='지반모델 생성')`);
|
||||||
|
await until(`document.querySelector('.ground-popup [data-run]')&&!document.querySelector('.ground-popup [data-run]').disabled`);
|
||||||
|
await selectFile('#ground-excel-file',[excel]);
|
||||||
|
await click(`document.querySelector('.ground-popup [data-run]')`);
|
||||||
|
await until(`window.__groundReady`);
|
||||||
|
};
|
||||||
|
await startGeneration();
|
||||||
|
await click(`document.querySelector('.ground-popup [data-close]')`);
|
||||||
|
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
|
||||||
|
await click(`document.getElementById('nav-rebar')`);
|
||||||
|
await ev(`window.__releaseGround()`);
|
||||||
|
await until(`document.querySelector('.ground-popup [data-status]').textContent.includes('생성 완료')`);
|
||||||
|
await click(`document.getElementById('nav-shape')`);
|
||||||
|
await click(`document.getElementById('project-ground-open')`);
|
||||||
|
check('generated model imports after leaving both manager and ground card',await ev(`document.getElementById('project-ground-info').textContent.includes('mock-generated')`));
|
||||||
|
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
|
||||||
|
await startGeneration();
|
||||||
|
await selectFile('#project-file',[file]);
|
||||||
|
await until(`document.getElementById('project-ground-status').textContent==='모델 없음'`);
|
||||||
|
await ev(`window.__releaseGround()`);
|
||||||
|
await delay(300);
|
||||||
|
check('late generated result does not contaminate another project',await ev(`document.getElementById('project-ground-status').textContent==='모델 없음'`));
|
||||||
|
check('project switch closes manager and generator',await ev(`!document.getElementById('project-ground-dialog').open&&!document.querySelector('.ground-popup').open`));
|
||||||
|
await selectFile('#project-file',[savedFile]);
|
||||||
|
await until(`document.getElementById('project-ground-status').textContent.includes('불러옴')`);
|
||||||
|
await ev(`(async()=>{const bytes=await(await fetch('/'+encodeURIComponent('도면추출')+'/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();window.loadBytesToApp(new Uint8Array(bytes),'새 프로젝트.xlsx');const name=document.getElementById('project-name');name.value='새 프로젝트';name.dispatchEvent(new Event('input'));document.getElementById('import-dialog').showModal();})()`);
|
||||||
|
await until(`document.getElementById('import-dialog').open&&!document.getElementById('dlg-apply').disabled`);
|
||||||
|
await click(`document.getElementById('dlg-apply')`);
|
||||||
|
await until(`document.getElementById('project-ground-status').textContent==='모델 없음'`);
|
||||||
|
check('new Excel import clears previous project ground model',await ev(`document.getElementById('project-ground-status').textContent==='모델 없음'`));
|
||||||
|
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();
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// 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/quantity-generation-20261106');
|
||||||
|
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: 'http://127.0.0.1:5173/' });
|
||||||
|
await until(`typeof window.openSampleProject==='function'`);
|
||||||
|
const fixture = await ev(`(async()=>{
|
||||||
|
const XLSX=await import('/node_modules/xlsx/xlsx.mjs'),{parseWorkbook}=await import('/src/core/parseWorkbook.ts');
|
||||||
|
const bytes=await(await fetch('/'+encodeURIComponent('도면추출')+'/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();
|
||||||
|
const base=parseWorkbook(XLSX.read(bytes)).piers.find(p=>p.name==='P1');
|
||||||
|
if(!base)throw new Error('Missing sample pier');
|
||||||
|
const {defaultLevelWidthMm}=await import('/src/core/geometry.ts');
|
||||||
|
const piers=['직접기초','직접기초','현장타설말뚝'].map((foundationType,i)=>{const input={...base,name:'P'+(i+1),foundationType,type:'T형',columnCount:1,groundEl:30,pileLengthMm:24000,castInPlaceShape:{clayBottomEl:16},columnDiameterMm:2000,footingLengthMm:10400,footingWidthMm:8150,footingHeightMm:2000,blindingMarginMm:100,blindingThicknessMm:100,pileKind:'강관',pileOuterMm:609.6,pileThicknessMm:12,copingH1Mm:1000,copingH2Mm:1500,levelWidthMm:defaultLevelWidthMm('T형',2000)};return{id:input.name,input,defaults:input,draft:input};});
|
||||||
|
return{schemaVersion:1,project:{name:'병합 검증',bridge:'검증',client:'',author:'',crs:'',memo:''},source:{fileName:'합성 표본',sheets:[]},piers,history:[]};
|
||||||
|
})()`);
|
||||||
|
const file = path.join(out, 'fixture.pzr.json'); fs.writeFileSync(file, JSON.stringify(fixture));
|
||||||
|
await ev('window.openSampleProject()');
|
||||||
|
await click(`document.querySelector('#file-menu summary')`); await click(`document.getElementById('open-project')`);
|
||||||
|
const doc = await send('DOM.getDocument');
|
||||||
|
const { nodeId } = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#project-file' });
|
||||||
|
await send('DOM.setFileInputFiles', { nodeId, files: [file] });
|
||||||
|
await until(`document.querySelectorAll('#rebar-pier-items button').length===3`);
|
||||||
|
await click(`document.getElementById('nav-rebar')`);
|
||||||
|
check('local rebar screen renders', await ev(`!document.getElementById('view-rebar').hidden&&!!document.querySelector('#rebar-table table')`));
|
||||||
|
await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b=>b.textContent.startsWith('P3 ·'))`);
|
||||||
|
await click(`[...document.querySelectorAll('#rebar-tabs button')].find(b=>b.textContent.startsWith('기둥'))`);
|
||||||
|
check('unified cast input and physical rebar preserved', await ev(`!!document.getElementById('cip-apply')&&document.getElementById('rebar-table').textContent.includes('말뚝-P1')`));
|
||||||
|
await shot('01-cast');
|
||||||
|
await click(`[...document.querySelectorAll('button')].find(b=>b.textContent==='철근재료표 보기')`);
|
||||||
|
check('materials is the default result tab', await ev(`!document.getElementById('view-quantity').hidden&&document.querySelector('[data-takeoff-mode="materials"]').getAttribute('aria-pressed')==='true'`));
|
||||||
|
check('local material table preserved', await ev(`document.getElementById('quantity-body').textContent.includes('말뚝-P1')`));
|
||||||
|
await shot('02-materials');
|
||||||
|
check('partial results explicitly export as review only', await ev(`!document.getElementById('quantity-export').disabled&&document.getElementById('quantity-export').textContent.includes('검토용')`));
|
||||||
|
check('piece details expose count and individual lengths', await ev(`!!document.querySelector('#quantity-body details table')&&document.getElementById('quantity-body').textContent.includes('가공 본수')`));
|
||||||
|
await click(`[...document.querySelectorAll('#quantity-pier-items button')].find(b=>b.textContent.startsWith('P1 ·'))`);
|
||||||
|
check('current regular pier permits export', await ev(`!document.getElementById('quantity-export').disabled`));
|
||||||
|
await click(`document.querySelector('[data-takeoff-mode="takeoff"]')`);
|
||||||
|
check('selected pier receives automatic rebar weight', await ev(`!document.querySelector('input[data-variable="rebar"]')&&document.querySelector('.takeoff-workspace').textContent.includes('기존 철근재료표')`));
|
||||||
|
await ev(`(()=>{const e=document.querySelector('select[aria-label="수량산출 대상 교각"]');e.value='P2';e.dispatchEvent(new Event('change'));})()`);
|
||||||
|
check('another pier retains automatic weight despite material filter', await ev(`document.querySelector('select[aria-label="수량산출 대상 교각"]').value==='P2'&&!document.querySelector('input[data-variable="rebar"]')&&document.querySelector('.takeoff-workspace').textContent.includes('기존 철근재료표')`));
|
||||||
|
await click(`document.getElementById('nav-drawing')`);
|
||||||
|
await until(`!!document.querySelector('#view-drawing svg')`);
|
||||||
|
check('origin drawing screen renders finite geometry', await ev(`!document.getElementById('view-drawing').hidden&&!/NaN|Infinity/.test(document.querySelector('#view-drawing svg').outerHTML)`));
|
||||||
|
await shot('03-drawing');
|
||||||
|
await click(`document.getElementById('ga-materials')`);
|
||||||
|
check('drawing shortcut opens materials rather than takeoff', await ev(`!document.getElementById('view-quantity').hidden&&document.querySelector('[data-takeoff-mode="materials"]').getAttribute('aria-pressed')==='true'`));
|
||||||
|
await click(`document.getElementById('nav-plan')`);
|
||||||
|
check('plan remains reachable after drawing', await ev(`!document.getElementById('view-plan').hidden`));
|
||||||
|
await ev(`(()=>{const e=document.getElementById('edit-copingLengthMm');e.value=Number(e.value)+100;e.dispatchEvent(new Event('change'));})()`);
|
||||||
|
await click(`document.getElementById('nav-rebar')`);
|
||||||
|
await click(`document.getElementById('rebar-materials')`);
|
||||||
|
check('dirty geometry blocks Excel and print with reason', await ev(`document.getElementById('quantity-export').disabled&&document.getElementById('quantity-print').disabled&&document.getElementById('quantity-body').textContent.includes('재생성 필요')`));
|
||||||
|
for (const width of [1280, 1600, 1920]) {
|
||||||
|
await send('Emulation.setDeviceMetricsOverride', { width, height: 1000, deviceScaleFactor: 1, mobile: false });
|
||||||
|
await delay(150);
|
||||||
|
check(`one-row header preserved at ${width}`, await ev(`document.querySelector('.top').getBoundingClientRect().height<=65&&document.documentElement.scrollWidth<=innerWidth`));
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
});
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// 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/quantity-navigation-20261109');
|
||||||
|
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'`);
|
||||||
|
const fixture = await ev(`(async()=>{
|
||||||
|
const XLSX=await import('/node_modules/xlsx/xlsx.mjs'),{parseWorkbook}=await import('/src/core/parseWorkbook.ts');
|
||||||
|
const bytes=await(await fetch('/'+encodeURIComponent('도면추출')+'/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();
|
||||||
|
const base=parseWorkbook(XLSX.read(bytes)).piers.find(p=>p.name==='P1');
|
||||||
|
if(!base)throw new Error('Missing sample pier');
|
||||||
|
const {defaultLevelWidthMm}=await import('/src/core/geometry.ts');
|
||||||
|
const piers=['직접기초','직접기초','현장타설말뚝'].map((foundationType,i)=>{const input={...base,name:'P'+(i+1),foundationType,type:'T형',columnCount:1,groundEl:30,pileLengthMm:24000,castInPlaceShape:{clayBottomEl:16},columnDiameterMm:2000,footingLengthMm:10400,footingWidthMm:8150,footingHeightMm:2000,blindingMarginMm:100,blindingThicknessMm:100,pileKind:'강관',pileOuterMm:609.6,pileThicknessMm:12,copingH1Mm:1000,copingH2Mm:1500,levelWidthMm:defaultLevelWidthMm('T형',2000)};return{id:input.name,input,defaults:input,draft:input};});
|
||||||
|
return{schemaVersion:1,project:{name:'병합 검증',bridge:'검증',client:'',author:'',crs:'',memo:''},source:{fileName:'합성 표본',sheets:[]},piers,history:[]};
|
||||||
|
})()`);
|
||||||
|
const file = path.join(out, 'fixture.pzr.json'); fs.writeFileSync(file, JSON.stringify(fixture));
|
||||||
|
await ev('window.openSampleProject()');
|
||||||
|
await click(`document.querySelector('#file-menu summary')`); await click(`document.getElementById('open-project')`);
|
||||||
|
const doc = await send('DOM.getDocument');
|
||||||
|
const { nodeId } = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#project-file' });
|
||||||
|
await send('DOM.setFileInputFiles', { nodeId, files: [file] });
|
||||||
|
await until(`document.querySelectorAll('#rebar-pier-items button').length===3`);
|
||||||
|
await click(`document.getElementById('nav-rebar')`);
|
||||||
|
check('local rebar screen renders', await ev(`!document.getElementById('view-rebar').hidden&&!!document.querySelector('#rebar-table table')`));
|
||||||
|
await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b=>b.textContent.startsWith('P3 ·'))`);
|
||||||
|
await click(`[...document.querySelectorAll('#rebar-tabs button')].find(b=>b.textContent.startsWith('기둥'))`);
|
||||||
|
check('unified cast input and physical rebar preserved', await ev(`!!document.getElementById('cip-apply')&&document.getElementById('rebar-table').textContent.includes('말뚝-P1')`));
|
||||||
|
await shot('01-cast');
|
||||||
|
await click(`[...document.querySelectorAll('button')].find(b=>b.textContent==='철근재료표 보기')`);
|
||||||
|
check('materials is the default result tab', await ev(`!document.getElementById('view-quantity').hidden&&document.querySelector('[data-takeoff-mode="materials"]').getAttribute('aria-pressed')==='true'`));
|
||||||
|
check('local material table preserved', await ev(`document.getElementById('quantity-body').textContent.includes('말뚝-P1')`));
|
||||||
|
await shot('02-materials');
|
||||||
|
check('partial results explicitly export as review only', await ev(`!document.getElementById('quantity-export').disabled&&document.getElementById('quantity-export').textContent.includes('검토용')`));
|
||||||
|
check('piece details expose count and individual lengths', await ev(`!!document.querySelector('#quantity-body details table')&&document.getElementById('quantity-body').textContent.includes('가공 본수')`));
|
||||||
|
await click(`[...document.querySelectorAll('#quantity-pier-items button')].find(b=>b.textContent.startsWith('P1 ·'))`);
|
||||||
|
check('current regular pier permits export', await ev(`!document.getElementById('quantity-export').disabled`));
|
||||||
|
await click(`document.querySelector('[data-takeoff-mode="takeoff"]')`);
|
||||||
|
check('selected pier receives automatic rebar weight', await ev(`!document.querySelector('input[data-variable="rebar"]')&&document.querySelector('.takeoff-workspace').textContent.includes('기존 철근재료표')`));
|
||||||
|
await ev(`(()=>{const e=document.querySelector('select[aria-label="수량산출 대상 교각"]');e.value='P2';e.dispatchEvent(new Event('change'));})()`);
|
||||||
|
check('another pier retains automatic weight despite material filter', await ev(`document.querySelector('select[aria-label="수량산출 대상 교각"]').value==='P2'&&!document.querySelector('input[data-variable="rebar"]')&&document.querySelector('.takeoff-workspace').textContent.includes('기존 철근재료표')`));
|
||||||
|
check('takeoff displays input, results and preview panels', await ev(`document.querySelectorAll('.tq-dashboard-grid > section, .tq-dashboard-grid > aside').length===3&&!!document.querySelector('.takeoff-excel-preview')`));
|
||||||
|
await shot('04-takeoff');
|
||||||
|
await click(`document.getElementById('nav-drawing')`);
|
||||||
|
await until(`!!document.querySelector('#view-drawing svg')`);
|
||||||
|
check('origin drawing screen renders finite geometry', await ev(`!document.getElementById('view-drawing').hidden&&!/NaN|Infinity/.test(document.querySelector('#view-drawing svg').outerHTML)`));
|
||||||
|
await shot('03-drawing');
|
||||||
|
await click(`document.getElementById('ga-materials')`);
|
||||||
|
check('drawing shortcut preserves takeoff tab and selected pier', await ev(`!document.getElementById('view-quantity').hidden&&document.querySelector('[data-takeoff-mode="takeoff"]').getAttribute('aria-pressed')==='true'&&document.querySelector('select[aria-label="수량산출 대상 교각"]').value==='P2'`));
|
||||||
|
check('quantity header alone is highlighted', await ev(`document.getElementById('nav-quantity').getAttribute('aria-current')==='true'&&document.getElementById('nav-rebar').getAttribute('aria-current')==='false'`));
|
||||||
|
await click(`document.getElementById('nav-rebar')`);
|
||||||
|
await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b=>b.textContent.startsWith('P1 ·'))`);
|
||||||
|
await click(`document.getElementById('nav-quantity')`);
|
||||||
|
check('header entry restores previous result pier after another screen changes selection', await ev(`document.querySelector('select[aria-label="수량산출 대상 교각"]').value==='P2'`));
|
||||||
|
await click(`document.querySelector('[data-takeoff-mode="materials"]')`);
|
||||||
|
await click(`document.getElementById('nav-rebar')`);
|
||||||
|
await click(`document.getElementById('rebar-materials')`);
|
||||||
|
check('rebar shortcut preserves materials tab', await ev(`document.querySelector('[data-takeoff-mode="materials"]').getAttribute('aria-pressed')==='true'`));
|
||||||
|
await click(`[...document.querySelectorAll('#quantity-pier-items button')].find(b=>b.textContent.startsWith('P1 ·'))`);
|
||||||
|
await click(`document.getElementById('nav-plan')`);
|
||||||
|
check('plan remains reachable after drawing', await ev(`!document.getElementById('view-plan').hidden`));
|
||||||
|
await ev(`(()=>{const e=document.getElementById('edit-copingLengthMm');e.value=Number(e.value)+100;e.dispatchEvent(new Event('change'));})()`);
|
||||||
|
await click(`document.getElementById('nav-rebar')`);
|
||||||
|
await click(`document.getElementById('rebar-materials')`);
|
||||||
|
check('dirty geometry blocks Excel and print with reason', await ev(`document.getElementById('quantity-export').disabled&&document.getElementById('quantity-print').disabled&&document.getElementById('quantity-body').textContent.includes('재생성 필요')`));
|
||||||
|
for (const width of [1280, 1600, 1920]) {
|
||||||
|
await send('Emulation.setDeviceMetricsOverride', { width, height: 1000, deviceScaleFactor: 1, mobile: false });
|
||||||
|
await delay(150);
|
||||||
|
check(`one-row header preserved at ${width}`, await ev(`document.querySelector('.top').getBoundingClientRect().height<=65&&document.documentElement.scrollWidth<=innerWidth`));
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
});
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// 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/takeoff-alignment-20261106/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('Browser.setDownloadBehavior', {behavior:'deny'}); // Request transport is mocked below; native Excel is verified separately.
|
||||||
|
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
|
||||||
|
await send('Page.navigate', { url: 'http://127.0.0.1:5173/' });
|
||||||
|
await until(`typeof window.openSampleProject==='function'`);
|
||||||
|
const fixture = await ev(`(async()=>{
|
||||||
|
const XLSX=await import('/node_modules/xlsx/xlsx.mjs'),{parseWorkbook}=await import('/src/core/parseWorkbook.ts');
|
||||||
|
const bytes=await(await fetch('/'+encodeURIComponent('도면추출')+'/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();
|
||||||
|
const base=parseWorkbook(XLSX.read(bytes)).piers.find(p=>p.name==='P1');
|
||||||
|
if(!base)throw new Error('Missing sample pier');
|
||||||
|
const {defaultLevelWidthMm}=await import('/src/core/geometry.ts');
|
||||||
|
const piers=['직접기초','직접기초','현장타설말뚝'].map((foundationType,i)=>{const input={...base,name:'P'+(i+1),foundationType,type:'T형',columnCount:1,groundEl:30,pileLengthMm:24000,castInPlaceShape:{clayBottomEl:16},columnDiameterMm:2000,footingLengthMm:10400,footingWidthMm:8150,footingHeightMm:2000,blindingMarginMm:100,blindingThicknessMm:100,pileKind:'강관',pileOuterMm:609.6,pileThicknessMm:12,copingH1Mm:1000,copingH2Mm:1500,levelWidthMm:defaultLevelWidthMm('T형',2000)};return{id:input.name,input,defaults:input,draft:input};});
|
||||||
|
return{schemaVersion:1,project:{name:'병합 검증',bridge:'검증',client:'',author:'',crs:'',memo:''},source:{fileName:'합성 표본',sheets:[]},piers,history:[]};
|
||||||
|
})()`);
|
||||||
|
for(const pier of fixture.piers){
|
||||||
|
const settings={values:{r20:0,r21:2.8,r22:0,r23:0},reviewed:true};
|
||||||
|
for(const key of ['input','defaults','draft'])pier[key]={...pier[key],quantityTakeoff:settings};
|
||||||
|
}
|
||||||
|
const file = path.join(out, 'fixture.pzr.json'); fs.writeFileSync(file, JSON.stringify(fixture));
|
||||||
|
await ev('window.openSampleProject()');
|
||||||
|
await click(`document.querySelector('#file-menu summary')`); await click(`document.getElementById('open-project')`);
|
||||||
|
const doc = await send('DOM.getDocument');
|
||||||
|
const { nodeId } = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#project-file' });
|
||||||
|
await send('DOM.setFileInputFiles', { nodeId, files: [file] });
|
||||||
|
await until(`document.querySelectorAll('#rebar-pier-items button').length===3`);
|
||||||
|
await click(`document.getElementById('nav-rebar')`);
|
||||||
|
check('local rebar screen renders', await ev(`!document.getElementById('view-rebar').hidden&&!!document.querySelector('#rebar-table table')`));
|
||||||
|
await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b=>b.textContent.startsWith('P3 ·'))`);
|
||||||
|
await click(`[...document.querySelectorAll('#rebar-tabs button')].find(b=>b.textContent.startsWith('기둥'))`);
|
||||||
|
check('unified cast input and physical rebar preserved', await ev(`!!document.getElementById('cip-apply')&&document.getElementById('rebar-table').textContent.includes('말뚝-P1')`));
|
||||||
|
await shot('01-cast');
|
||||||
|
await click(`[...document.querySelectorAll('button')].find(b=>b.textContent==='철근재료표 보기')`);
|
||||||
|
check('materials is the default result tab', await ev(`!document.getElementById('view-quantity').hidden&&document.querySelector('[data-takeoff-mode="materials"]').getAttribute('aria-pressed')==='true'`));
|
||||||
|
check('local material table preserved', await ev(`document.getElementById('quantity-body').textContent.includes('말뚝-P1')`));
|
||||||
|
await shot('02-materials');
|
||||||
|
check('partial results explicitly export as review only', await ev(`!document.getElementById('quantity-export').disabled&&document.getElementById('quantity-export').textContent.includes('검토용')`));
|
||||||
|
check('piece details expose count and individual lengths', await ev(`!!document.querySelector('#quantity-body details table')&&document.getElementById('quantity-body').textContent.includes('가공 본수')`));
|
||||||
|
await click(`[...document.querySelectorAll('#quantity-pier-items button')].find(b=>b.textContent.startsWith('P1 ·'))`);
|
||||||
|
check('current regular pier permits export', await ev(`!document.getElementById('quantity-export').disabled`));
|
||||||
|
await click(`document.querySelector('[data-takeoff-mode="takeoff"]')`);
|
||||||
|
check('selected pier receives automatic rebar weight', await ev(`!document.querySelector('input[data-variable="rebar"]')&&document.querySelector('.takeoff-workspace').textContent.includes('기존 철근재료표')`));
|
||||||
|
check('selected and all Excel exports are separate', await ev(`document.querySelector('[data-takeoff-export="selected"]').textContent==='선택 교각 Excel'&&document.querySelector('[data-takeoff-export="all"]').textContent==='전체 교각 Excel'`));
|
||||||
|
await click(`document.querySelector('button[aria-label="수량산출 재계산"]')`);
|
||||||
|
await ev(`(()=>{
|
||||||
|
const original=window.fetch;window.__takeoffRequests=[];
|
||||||
|
window.fetch=(url,options)=>String(url).includes('/__pierzainer/quantity/export')?new Promise(resolve=>{window.__takeoffRequests.push(JSON.parse(options.body));window.__finishTakeoff=()=>resolve(new Response(new Uint8Array([80,75]),{status:200}));}):original(url,options);
|
||||||
|
})()`);
|
||||||
|
await click(`document.querySelector('[data-takeoff-export="selected"]')`);
|
||||||
|
await until(`window.__takeoffRequests.length===1`);
|
||||||
|
check('selected request carries one pier and model solplate mode', await ev(`window.__takeoffRequests[0].piers.length===1&&window.__takeoffRequests[0].piers[0].pier==='P1'&&window.__takeoffRequests[0].piers[0].solplateMode==='model'`));
|
||||||
|
await ev(`(()=>{const e=document.querySelector('input[data-variable="r21"]');e.value='4';e.dispatchEvent(new Event('change'));})()`);
|
||||||
|
check('rerender during export keeps both export buttons locked', await ev(`[...document.querySelectorAll('[data-takeoff-export]')].every(b=>b.disabled)`));
|
||||||
|
await ev(`window.__finishTakeoff()`);
|
||||||
|
await until(`!document.querySelector('[data-takeoff-export="selected"]').disabled`);
|
||||||
|
check('async export preserves edited input and displays snapshot warning', await ev(`document.querySelector('input[data-variable="r21"]').value==='4'&&document.querySelector('.takeoff-workspace [role="status"]').textContent.includes('입력은 보존')`));
|
||||||
|
const requests=await ev(`window.__takeoffRequests.length`);
|
||||||
|
await click(`document.querySelector('[data-takeoff-export="all"]')`);
|
||||||
|
await delay(200);
|
||||||
|
check('all export validates other incomplete piers before requesting server', await ev(`window.__takeoffRequests.length===${requests}&&document.querySelector('.takeoff-workspace [role="status"]').textContent.includes('P3')`));
|
||||||
|
await ev(`(()=>{const e=document.querySelector('select[aria-label="수량산출 대상 교각"]');e.value='P2';e.dispatchEvent(new Event('change'));})()`);
|
||||||
|
check('another pier retains automatic weight despite material filter', await ev(`document.querySelector('select[aria-label="수량산출 대상 교각"]').value==='P2'&&!document.querySelector('input[data-variable="rebar"]')&&document.querySelector('.takeoff-workspace').textContent.includes('기존 철근재료표')`));
|
||||||
|
await click(`document.getElementById('nav-drawing')`);
|
||||||
|
await until(`!!document.querySelector('#view-drawing svg')`);
|
||||||
|
check('origin drawing screen renders finite geometry', await ev(`!document.getElementById('view-drawing').hidden&&!/NaN|Infinity/.test(document.querySelector('#view-drawing svg').outerHTML)`));
|
||||||
|
await shot('03-drawing');
|
||||||
|
await click(`document.getElementById('ga-materials')`);
|
||||||
|
check('drawing shortcut opens materials rather than takeoff', await ev(`!document.getElementById('view-quantity').hidden&&document.querySelector('[data-takeoff-mode="materials"]').getAttribute('aria-pressed')==='true'`));
|
||||||
|
await click(`document.getElementById('nav-plan')`);
|
||||||
|
check('plan remains reachable after drawing', await ev(`!document.getElementById('view-plan').hidden`));
|
||||||
|
await ev(`(()=>{const e=document.getElementById('edit-copingLengthMm');e.value=Number(e.value)+100;e.dispatchEvent(new Event('change'));})()`);
|
||||||
|
await click(`document.getElementById('nav-rebar')`);
|
||||||
|
await click(`document.getElementById('rebar-materials')`);
|
||||||
|
check('dirty geometry blocks Excel and print with reason', await ev(`document.getElementById('quantity-export').disabled&&document.getElementById('quantity-print').disabled&&document.getElementById('quantity-body').textContent.includes('재생성 필요')`));
|
||||||
|
for (const width of [1280, 1600, 1920]) {
|
||||||
|
await send('Emulation.setDeviceMetricsOverride', { width, height: 1000, deviceScaleFactor: 1, mobile: false });
|
||||||
|
await delay(150);
|
||||||
|
check(`one-row header preserved at ${width}`, await ev(`document.querySelector('.top').getBoundingClientRect().height<=65&&document.documentElement.scrollWidth<=innerWidth`));
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Run with Windows Node against isolated Chrome CDP 9444 and Vite 5173.
|
||||||
|
const fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict');
|
||||||
|
const root=path.resolve(__dirname,'..'),out=path.join(root,'.verify/ungok-terrain-fixed-20261110/browser');
|
||||||
|
fs.mkdirSync(out,{recursive:true});
|
||||||
|
const report={checks:[],errors:[]};let ws;
|
||||||
|
const delay=ms=>new Promise(r=>setTimeout(r,ms));
|
||||||
|
(async()=>{
|
||||||
|
const tab=await(await fetch('http://127.0.0.1:9444/json/new?about:blank',{method:'PUT'})).json();
|
||||||
|
ws=new WebSocket(tab.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(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);};
|
||||||
|
const send=(method,params={})=>new Promise((resolve,reject)=>{const id=++seq,timer=setTimeout(()=>reject(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 Error(r.exceptionDetails.exception?.description||r.exceptionDetails.text);return r.result.value;};
|
||||||
|
const until=async expression=>{for(let i=0;i<150;i++){if(await ev(expression))return;await delay(200);}throw 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 Error('Missing target '+${JSON.stringify(expression)});e.click();})()`);
|
||||||
|
const file=async(selector,name)=>{const d=await send('DOM.getDocument'),{nodeId}=await send('DOM.querySelector',{nodeId:d.root.nodeId,selector});await send('DOM.setFileInputFiles',{nodeId,files:[name]});};
|
||||||
|
const shot=async name=>{const s=await send('Page.captureScreenshot',{format:'png'});fs.writeFileSync(path.join(out,name+'.png'),Buffer.from(s.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:'http://127.0.0.1:5173/'});await until(`typeof window.openSampleProject==='function'`);
|
||||||
|
const settings=JSON.parse(fs.readFileSync(path.join(root,'pierzainer_ground/data/output/ungok_corrected_20261110/generation_settings.json')));
|
||||||
|
const workbook='\\\\wsl.localhost\\Ubuntu'+settings.piers.replaceAll('/','\\');
|
||||||
|
await ev('window.openSampleProject()');await file('#file',workbook);
|
||||||
|
await until(`document.getElementById('file-name').textContent.includes('piers.xlsx')`);
|
||||||
|
await ev(`(()=>{const e=document.getElementById('project-name');e.value='운곡교 지반 수정 검증';e.dispatchEvent(new Event('input'));document.getElementById('import-dialog').showModal();})()`);
|
||||||
|
await until(`!document.getElementById('dlg-apply').disabled`);
|
||||||
|
await click(`document.getElementById('dlg-apply')`);await click(`document.getElementById('nav-shape')`);await click(`document.getElementById('project-ground-open')`);
|
||||||
|
check('actual workbook lists all 16 structures',await ev(`document.querySelectorAll('[data-ground-pier]').length===16`));
|
||||||
|
await file('#ground-import',path.join(root,'pierzainer_ground/data/output/ungok_corrected_20261110/ground_model_3d_with_maps.json'));
|
||||||
|
await until(`document.getElementById('project-ground-status').textContent.includes('16/16')`);
|
||||||
|
check('all structures have applicable corrected ground',await ev(`[...document.querySelectorAll('[data-ground-pier]')].every(e=>!e.disabled)`));
|
||||||
|
await click(`document.getElementById('project-ground-select-all')`);
|
||||||
|
check('all 16 structures are selected for application',await ev(`document.querySelectorAll('[data-ground-pier]:checked').length===16`));
|
||||||
|
await click(`document.getElementById('project-ground-apply')`);
|
||||||
|
await click(`document.getElementById('project-ground-regenerate')`);
|
||||||
|
await until(`document.getElementById('project-ground-regenerate').disabled`);
|
||||||
|
check('all changes regenerate successfully',await ev(`document.getElementById('project-ground-regenerate').disabled`));
|
||||||
|
await shot('01-corrected-manager');await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
|
||||||
|
await delay(1000);await shot('02-corrected-terrain');
|
||||||
|
await send('Browser.setDownloadBehavior',{behavior:'deny'});
|
||||||
|
await ev(`(()=>{window.__ungokSaved=null;const original=URL.createObjectURL;URL.createObjectURL=function(blob){blob.text().then(t=>{try{window.__ungokSaved=JSON.parse(t);}catch{}});return original.call(this,blob);};})()`);
|
||||||
|
await click(`document.getElementById('save-project')`);await until(`document.getElementById('save-dialog').open`);await click(`document.getElementById('sv-save')`);await until(`!!window.__ungokSaved?.groundModel`);
|
||||||
|
const saved=await ev(`window.__ungokSaved`);fs.writeFileSync(path.join(out,'verified.pzr.json'),JSON.stringify(saved));
|
||||||
|
check('saved project retains 16 corrected ground records and two maps',saved.groundModel.piers.length===16&&saved.groundModel.mapLayers.length===2);
|
||||||
|
const expected=JSON.parse(fs.readFileSync(path.join(root,'pierzainer_ground/data/output/ungok_corrected_20261110/ground_model_3d_with_maps.json')));
|
||||||
|
check('saved terrain elevations equal generated JSON',JSON.stringify(saved.groundModel.terrainGrid.z)===JSON.stringify(expected.terrain_grid.z));
|
||||||
|
check('browser runtime has no 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();});
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// Run with Windows Node and an isolated Chrome on CDP 9444; Vite must be on 5173.
|
||||||
|
// ②·③ center workspace: stacked ↔ side-by-side, splitter drag/keyboard, persistence, width fallback.
|
||||||
|
const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict');
|
||||||
|
const root = path.resolve(__dirname, '..');
|
||||||
|
const out = path.resolve(root, '.verify/workspace-layout-20261109');
|
||||||
|
fs.mkdirSync(out, { recursive: true });
|
||||||
|
const report = { checks: [], errors: [], metrics: {} };
|
||||||
|
let ws, send;
|
||||||
|
const delay = ms => new Promise(r => setTimeout(r, ms));
|
||||||
|
let target;
|
||||||
|
(async () => {
|
||||||
|
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 === 'Page.javascriptDialogOpening') ws.send(JSON.stringify({ id: 900000 + (++seq), method: 'Page.handleJavaScriptDialog', params: { accept: true } }));
|
||||||
|
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)), 8000);
|
||||||
|
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, tries = 120) => {
|
||||||
|
for (let i = 0; i < tries; i++) { try { if (await ev(expression)) return; } catch { /* navigating */ } 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 => { await delay(250); const r = await send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64')); };
|
||||||
|
const viewport = (width, height) => send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false });
|
||||||
|
const rects = ws_ => ev(`(()=>{const w=document.getElementById('${ws_}'),r=e=>{const b=e.getBoundingClientRect();return{l:Math.round(b.left),t:Math.round(b.top),r:Math.round(b.right),b:Math.round(b.bottom),w:Math.round(b.width),h:Math.round(b.height)};};const c=document.querySelector('#viewer-block canvas');return{ws:r(w),slot:r(w.querySelector('.viewer-slot')),split:r(w.querySelector('.workspace-splitter')),pane:r(w.querySelector('.workspace-pane')),canvas:c?r(c):null,side:w.classList.contains('layout-side'),note:w.querySelector('.layout-note').textContent,label:w.querySelector('[data-size-label]').textContent,value:w.querySelector('output').textContent};})()`);
|
||||||
|
const drag = async (sel, dx, dy) => {
|
||||||
|
const b = await ev(`(()=>{const r=document.querySelector('${sel}').getBoundingClientRect();return{x:r.left+r.width/2,y:r.top+r.height/2};})()`);
|
||||||
|
await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: b.x, y: b.y, button: 'left', buttons: 1, clickCount: 1 });
|
||||||
|
for (let i = 1; i <= 5; i++) await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: b.x + dx * i / 5, y: b.y + dy * i / 5, button: 'left', buttons: 1 });
|
||||||
|
await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: b.x + dx, y: b.y + dy, button: 'left', buttons: 0, clickCount: 1 });
|
||||||
|
await delay(250);
|
||||||
|
};
|
||||||
|
const key = async (k, code, vk) => { for (const type of ['keyDown', 'keyUp']) await send('Input.dispatchKeyEvent', { type, key: k, code, windowsVirtualKeyCode: vk }); await delay(120); };
|
||||||
|
|
||||||
|
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
|
||||||
|
await viewport(1600, 1000);
|
||||||
|
await send('Page.navigate', { url: process.env.PIERZAINER_URL || 'http://127.0.0.1:5173/' });
|
||||||
|
await until(`!!document.querySelector('[data-card="excel"] button') && typeof window.loadBytesToApp === 'function'`);
|
||||||
|
await ev(`Object.keys(localStorage).filter(k=>k.startsWith('pierzainer.workspace-layout')||k==='pierzainer.review-layout').forEach(k=>localStorage.removeItem(k))`);
|
||||||
|
await send('Page.reload'); await delay(2500);
|
||||||
|
await until(`!!document.querySelector('[data-card="excel"] button') && typeof window.loadBytesToApp === 'function'`);
|
||||||
|
await click(`document.querySelector('[data-card="excel"] button')`);
|
||||||
|
await ev(`document.getElementById('project-name').value='배치 검증';document.getElementById('project-name').dispatchEvent(new Event('input'));`);
|
||||||
|
const doc = await send('DOM.getDocument');
|
||||||
|
const node = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#file' });
|
||||||
|
await send('DOM.setFileInputFiles', { nodeId: node.nodeId, files: [path.resolve(root, '../../RAG/운곡교P1입력.xlsx')] });
|
||||||
|
await until(`!document.getElementById('dlg-apply').disabled`);
|
||||||
|
await click(`document.getElementById('dlg-apply')`);
|
||||||
|
await delay(800);
|
||||||
|
|
||||||
|
// ② 계획
|
||||||
|
await click(`document.getElementById('nav-plan')`); await delay(600);
|
||||||
|
let r = await rects('plan-workspace');
|
||||||
|
check('② default is stacked: model above the input pane', !r.side && r.slot.b <= r.pane.t && r.slot.w === r.pane.w);
|
||||||
|
check('② layout switch shows 상하 pressed', await ev(`document.querySelector('#plan-workspace [data-layout="stack"]').getAttribute('aria-pressed')==='true'`));
|
||||||
|
await shot('01-plan-stack');
|
||||||
|
await click(`document.querySelector('#plan-workspace [data-layout="side"]')`); await delay(500);
|
||||||
|
r = await rects('plan-workspace'); report.metrics.planSide = r;
|
||||||
|
check('② 좌우: model left, pane right, same top', r.side && r.slot.r <= r.pane.l && Math.abs(r.slot.t - r.pane.t) <= 1);
|
||||||
|
check('② 좌우 starts near 50 % and shows width label', Math.abs(r.slot.w / r.ws.w - 0.5) < 0.03 && r.label === '모델 폭' && r.value === '50%');
|
||||||
|
check('② 3D canvas follows the narrower slot', r.canvas && r.slot.w - r.canvas.w >= 0 && r.slot.w - r.canvas.w <= 40 && r.canvas.h > 300);
|
||||||
|
check('② pane scrolls independently', await ev(`(()=>{const p=document.querySelector('#plan-workspace .workspace-pane');return getComputedStyle(p).overflowY==='auto';})()`));
|
||||||
|
await shot('02-plan-side');
|
||||||
|
await drag('#plan-workspace .workspace-splitter', -Math.round(r.ws.w * 0.1), 0);
|
||||||
|
r = await rects('plan-workspace');
|
||||||
|
check('② splitter drag narrows model to about 40 %', r.value === '40%' && Math.abs(r.slot.w / r.ws.w - 0.4) < 0.03);
|
||||||
|
await ev(`document.querySelector('#plan-workspace .workspace-splitter').focus()`);
|
||||||
|
await key('ArrowRight', 'ArrowRight', 39);
|
||||||
|
check('② splitter keyboard step (+2 %)', (await rects('plan-workspace')).value === '42%' && await ev(`document.querySelector('#plan-workspace .workspace-splitter').getAttribute('aria-valuenow')==='42'`));
|
||||||
|
await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: r.split.l + 6, y: r.split.t + 100, button: 'left', clickCount: 2 });
|
||||||
|
await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: r.split.l + 6, y: r.split.t + 100, button: 'left', clickCount: 2 });
|
||||||
|
await ev(`document.querySelector('#plan-workspace .workspace-splitter').dispatchEvent(new MouseEvent('dblclick',{bubbles:true}))`); await delay(200);
|
||||||
|
check('② double-click resets to 50 %', (await rects('plan-workspace')).value === '50%');
|
||||||
|
await click(`document.getElementById('plan-model-toggle')`); await delay(300);
|
||||||
|
r = await rects('plan-workspace');
|
||||||
|
check('② collapsed model: pane takes the full width', r.slot.w === 0 && Math.abs(r.pane.w - r.ws.w) <= 2);
|
||||||
|
await click(`document.getElementById('plan-model-toggle')`); await delay(300);
|
||||||
|
|
||||||
|
// ③ 배근 stays independent
|
||||||
|
await click(`document.getElementById('nav-rebar')`); await delay(700);
|
||||||
|
r = await rects('review-workspace');
|
||||||
|
check('③ preference is independent of ② (still stacked, 240 px)', !r.side && r.value === '240 px' && r.slot.b <= r.pane.t);
|
||||||
|
await drag('#review-workspace .workspace-splitter', 0, 100);
|
||||||
|
r = await rects('review-workspace');
|
||||||
|
check('③ stacked splitter drag grows model height', r.value === '340 px' && Math.abs(r.slot.h - 340) <= 2);
|
||||||
|
await click(`document.querySelector('#review-workspace [data-layout="side"]')`); await delay(500);
|
||||||
|
r = await rects('review-workspace'); report.metrics.rebarSide = r;
|
||||||
|
check('③ 좌우: model left, rebar table right', r.side && r.slot.r <= r.pane.l && r.canvas && r.slot.w - r.canvas.w >= 0 && r.slot.w - r.canvas.w <= 40);
|
||||||
|
await click(`document.getElementById('review-table')`); await delay(300);
|
||||||
|
check('③ 철근표 shortcut still reveals the table in 좌우', await ev(`(()=>{const t=document.activeElement,p=document.querySelector('#review-workspace .workspace-pane > section').getBoundingClientRect();return t.matches('#rebar-table table.rebar-table')&&t.getBoundingClientRect().top>=p.top-2;})()`));
|
||||||
|
await shot('03-rebar-side');
|
||||||
|
|
||||||
|
// persistence
|
||||||
|
await send('Page.reload'); await delay(2500); await until(`typeof window.loadBytesToApp==='function'`); await delay(800);
|
||||||
|
check('preferences persist per stage after reload', await ev(`(()=>{const p=JSON.parse(localStorage.getItem('pierzainer.workspace-layout.plan')),q=JSON.parse(localStorage.getItem('pierzainer.workspace-layout.rebar'));return p.mode==='side'&&p.width===50&&q.mode==='side'&&q.height===340;})()`));
|
||||||
|
await until(`typeof window.openSampleProject==='function'`); await ev('window.openSampleProject()'); await delay(1500);
|
||||||
|
await click(`document.getElementById('nav-plan')`); await delay(600);
|
||||||
|
check('② restored 좌우 when shown after reload', await ev(`document.getElementById('plan-workspace').classList.contains('layout-side')`));
|
||||||
|
await click(`document.getElementById('nav-rebar')`); await delay(600);
|
||||||
|
check('③ restored 좌우 and 340 px height after reload', await ev(`document.getElementById('review-workspace').classList.contains('layout-side')&&getComputedStyle(document.getElementById('review-workspace')).getPropertyValue('--review-model-height').trim()==='340px'`));
|
||||||
|
await click(`document.getElementById('nav-plan')`); await delay(400);
|
||||||
|
|
||||||
|
// width fallback
|
||||||
|
await viewport(1280, 800); await delay(500);
|
||||||
|
check('1280 px: side request falls back to stacked with a note', await ev(`(()=>{const w=document.getElementById('plan-workspace');return !w.classList.contains('layout-side')&&w.querySelector('.layout-note').textContent.includes('좁아')&&w.querySelector('[data-layout="side"]').getAttribute('aria-pressed')==='true';})()`));
|
||||||
|
await viewport(1920, 1080); await delay(500);
|
||||||
|
check('1920 px: side-by-side restored automatically', await ev(`document.getElementById('plan-workspace').classList.contains('layout-side')`));
|
||||||
|
await viewport(1000, 900); await delay(500);
|
||||||
|
check('1000 px (mobile frame): stacked, splitter hidden', await ev(`(()=>{const w=document.getElementById('plan-workspace');return !w.classList.contains('layout-side')&&getComputedStyle(w.querySelector('.workspace-splitter')).display==='none';})()`));
|
||||||
|
for (const width of [1280, 1600, 1920]) {
|
||||||
|
await viewport(width, 1000); await delay(300);
|
||||||
|
check(`${width} px: no horizontal page scroll`, await ev(`document.documentElement.scrollWidth<=innerWidth`));
|
||||||
|
}
|
||||||
|
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({ result: report.result, failure: report.failure, errors: report.errors }));
|
||||||
|
if (target?.id) fetch('http://127.0.0.1:9444/json/close/' + target.id).catch(() => {});
|
||||||
|
ws?.close();
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
* - 솔플레이트 (결정 2026-11-06, spec_abut §5·QA-15, src/core/sole-plate.ts): S = 같은 거더의 짝 받침(A1 ↔ P1 전열,
|
* - 솔플레이트 (결정 2026-11-06, spec_abut §5·QA-15, src/core/sole-plate.ts): S = 같은 거더의 짝 받침(A1 ↔ P1 전열,
|
||||||
* A2 ↔ Pn)으로 계산, 짝이 없으면 교대 종단경사 i(엑셀 Abut). L = A + 100, W = B + 100 (A × B 기본 700 × 640, 교각 P1
|
* A2 ↔ Pn)으로 계산, 짝이 없으면 교대 종단경사 i(엑셀 Abut). L = A + 100, W = B + 100 (A × B 기본 700 × 640, 교각 P1
|
||||||
* 전열 값 가정). 받침이 1열이라 T는 교대 전체에 하나: max(22, ⌈22 + L·|S|/2⌉). 거더 하면 고정 → 블럭 상면 = z − (T − T_ref).
|
* 전열 값 가정). 받침이 1열이라 T는 교대 전체에 하나: max(22, ⌈22 + L·|S|/2⌉). 거더 하면 고정 → 블럭 상면 = z − (T − T_ref).
|
||||||
* - h3 기본값 = 받침 시트의 받침높이 − T_ref (A1 275 − 30 = 245): 엑셀 받침높이가 정한 거더 하면을 그대로 둔다
|
* - h3 기본값 = 받침 시트의 받침높이 − T_ref (A1 275 − 30 = 245), 받침마다 그 받침의 값 (2026-11-10): 엑셀 받침높이가 정한 거더 하면을 그대로 둔다
|
||||||
* (시안은 교각 P1의 h3 181을 가정했으나 교대 시트에 받침높이가 있어 그 값을 지킨다).
|
* (시안은 교각 P1의 h3 181을 가정했으나 교대 시트에 받침높이가 있어 그 값을 지킨다).
|
||||||
* - 높이: E = h1 (블럭 콘크리트) + h2 (몰탈패드 50) + h3 + h4 (= T). z = 패드 상면 = EL.X.
|
* - 높이: E = h1 (블럭 콘크리트) + h2 (몰탈패드 50) + h3 + h4 (= T). z = 패드 상면 = EL.X.
|
||||||
*/
|
*/
|
||||||
@@ -62,7 +62,9 @@ export function abutSoles(model: AbutModel, rule: AbutBearingRule, heightMm: num
|
|||||||
model.name,
|
model.name,
|
||||||
model.bearings.map((b) => ({ no: b.no, row: '1열', azimuthDeg: b.azimuthDeg ?? 0, inputAzimuthDeg: ctx.inputAzimuthDeg?.[String(b.no)] })),
|
model.bearings.map((b) => ({ no: b.no, row: '1열', azimuthDeg: b.azimuthDeg ?? 0, inputAzimuthDeg: ctx.inputAzimuthDeg?.[String(b.no)] })),
|
||||||
{
|
{
|
||||||
h3Mm: rule.h3Mm ?? heightMm - rule.tRefMm, tRefMm: rule.tRefMm, tMinMm: rule.tMinMm, marginMm: rule.marginMm, plates,
|
// A typed h3 is for every bearing; otherwise h3 = 그 받침의 받침높이 (없으면 교대 값) − T_ref.
|
||||||
|
h3Mm: rule.h3Mm, tRefMm: rule.tRefMm,
|
||||||
|
h3ByNo: Object.fromEntries(model.bearings.map((b) => [String(b.no), (b.heightMm ?? heightMm) - rule.tRefMm])), tMinMm: rule.tMinMm, marginMm: rule.marginMm, plates,
|
||||||
tByRow: rule.tMm === undefined ? undefined : { '1열': rule.tMm },
|
tByRow: rule.tMm === undefined ? undefined : { '1열': rule.tMm },
|
||||||
slopePct: rule.slopePct === undefined ? undefined : Object.fromEntries(model.bearings.map((b) => [String(b.no), rule.slopePct!])),
|
slopePct: rule.slopePct === undefined ? undefined : Object.fromEntries(model.bearings.map((b) => [String(b.no), rule.slopePct!])),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export interface AbutBearing {
|
|||||||
seatEl?: number;
|
seatEl?: number;
|
||||||
/** 받침 방위각 (deg, model-xy: 북에서 시계방향, 교각과 같은 변환 swapXYAzimuth). 없으면 블럭은 교대축에 맞춘다. */
|
/** 받침 방위각 (deg, model-xy: 북에서 시계방향, 교각과 같은 변환 swapXYAzimuth). 없으면 블럭은 교대축에 맞춘다. */
|
||||||
azimuthDeg?: number;
|
azimuthDeg?: number;
|
||||||
|
/** 이 받침의 받침높이 H (mm, 2026-11-10). 없으면 교대 받침높이를 쓴다. */
|
||||||
|
heightMm?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 날개벽 형식, 측별(spec_abut §4.7): 일반형 / 단축형 / 없음. */
|
/** 날개벽 형식, 측별(spec_abut §4.7): 일반형 / 단축형 / 없음. */
|
||||||
@@ -87,6 +89,8 @@ export interface AbutBearingModel {
|
|||||||
blockHeightMm: number;
|
blockHeightMm: number;
|
||||||
/** 받침 방위각 (deg, model-xy). 입력이 없으면 undefined. */
|
/** 받침 방위각 (deg, model-xy). 입력이 없으면 undefined. */
|
||||||
azimuthDeg?: number;
|
azimuthDeg?: number;
|
||||||
|
/** 이 받침의 받침높이 H (mm). 입력이 없으면 undefined. */
|
||||||
|
heightMm?: number;
|
||||||
/** 블럭 평면 회전 (deg, −90 … 90): 받침 방위각과 교대축 직각(전면) 방향 사이의 각. 장면에서는 rotateY(이 값). 입력이 없으면 0. */
|
/** 블럭 평면 회전 (deg, −90 … 90): 받침 방위각과 교대축 직각(전면) 방향 사이의 각. 장면에서는 rotateY(이 값). 입력이 없으면 0. */
|
||||||
rotDeg: number;
|
rotDeg: number;
|
||||||
}
|
}
|
||||||
@@ -164,7 +168,7 @@ export function buildAbut(input: AbutInput): AbutModel {
|
|||||||
const s = dot(sub(b.pos, input.ip1), u);
|
const s = dot(sub(b.pos, input.ip1), u);
|
||||||
const seatEl = b.seatEl ?? input.seatEl + (b.z - minZ);
|
const seatEl = b.seatEl ?? input.seatEl + (b.z - minZ);
|
||||||
const blockHeightMm = Math.round((b.z - seatEl) * 1000);
|
const blockHeightMm = Math.round((b.z - seatEl) * 1000);
|
||||||
return { no: b.no, s, lateral: lateral[i], z: b.z, seatEl, blockHeightMm, azimuthDeg: b.azimuthDeg, rotDeg: bearingRotDeg(b.azimuthDeg, u, frontVec) };
|
return { no: b.no, s, lateral: lateral[i], z: b.z, seatEl, blockHeightMm, azimuthDeg: b.azimuthDeg, heightMm: b.heightMm, rotDeg: bearingRotDeg(b.azimuthDeg, u, frontVec) };
|
||||||
});
|
});
|
||||||
|
|
||||||
const sorted = [...bearings].sort((a, b) => a.s - b.s);
|
const sorted = [...bearings].sort((a, b) => a.s - b.s);
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export interface AbutRecord {
|
|||||||
ip2: AbutPoint;
|
ip2: AbutPoint;
|
||||||
ip3: AbutPoint;
|
ip3: AbutPoint;
|
||||||
bearings: AbutRecordBearing[];
|
bearings: AbutRecordBearing[];
|
||||||
/** 받침 행에서 읽은 받침높이 (mm). 모든 받침이 같아야 하며, 다르면 AP07 경고. */
|
/** 첫 받침의 받침높이 (mm): 받침별 값이 없을 때의 기본값. 받침마다 다르면 받침별 값을 쓴다 (2026-11-10, AP07 폐지). */
|
||||||
bearingHeightMm: number;
|
bearingHeightMm: number;
|
||||||
/** 측별 날개벽 형식 (편집 카드에서 정함). Absent = 양측 일반형. */
|
/** 측별 날개벽 형식 (편집 카드에서 정함). Absent = 양측 일반형. */
|
||||||
wings?: { left: WingForm; right: WingForm };
|
wings?: { left: WingForm; right: WingForm };
|
||||||
@@ -220,9 +220,8 @@ export function parseAbutments(wb: XLSX.WorkBook, reservedNames: readonly string
|
|||||||
const missing = [ab.ip1, ab.ip2, ab.ip3].some((p) => !Number.isFinite(p.e) || !Number.isFinite(p.n));
|
const missing = [ab.ip1, ab.ip2, ab.ip3].some((p) => !Number.isFinite(p.e) || !Number.isFinite(p.n));
|
||||||
if (missing) issues.push({ code: 'AP05', level: 'E', pier: ab.name, sheet: ab.sheet, message: `교대 ${ab.name}: IP1·IP2·IP3 중 빠진 점이 있습니다.` });
|
if (missing) issues.push({ code: 'AP05', level: 'E', pier: ab.name, sheet: ab.sheet, message: `교대 ${ab.name}: IP1·IP2·IP3 중 빠진 점이 있습니다.` });
|
||||||
if (ab.bearings.length < 2) issues.push({ code: 'AP06', level: 'E', pier: ab.name, sheet: ab.sheet, message: `교대 ${ab.name}: 받침이 2개 이상 필요합니다.` });
|
if (ab.bearings.length < 2) issues.push({ code: 'AP06', level: 'E', pier: ab.name, sheet: ab.sheet, message: `교대 ${ab.name}: 받침이 2개 이상 필요합니다.` });
|
||||||
const heights = [...new Set(ab.bearings.map((b) => b.bearingHeightMm))];
|
// 받침높이가 받침마다 달라도 받침별 값을 쓴다 (2026-11-10). 교대 값은 첫 받침의 값.
|
||||||
if (heights.length > 1) issues.push({ code: 'AP07', level: 'W', pier: ab.name, sheet: ab.sheet, message: `교대 ${ab.name}: 받침높이가 받침마다 다릅니다 (${heights.join(', ')} mm). 첫 값을 씁니다.` });
|
ab.bearingHeightMm = ab.bearings[0]?.bearingHeightMm ?? NaN;
|
||||||
ab.bearingHeightMm = heights[0] ?? NaN;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { abutments: abutments.filter((a) => Number.isFinite(a.ip1.e) && a.bearings.length >= 2), issues };
|
return { abutments: abutments.filter((a) => Number.isFinite(a.ip1.e) && a.bearings.length >= 2), issues };
|
||||||
@@ -238,7 +237,7 @@ export function toAbutInput(rec: AbutRecord): AbutInput {
|
|||||||
ip2: rec.ip2,
|
ip2: rec.ip2,
|
||||||
ip3: rec.ip3,
|
ip3: rec.ip3,
|
||||||
// 계산 방위각을 적용한 받침 (2026-11-06) use that value; the record keeps the workbook value.
|
// 계산 방위각을 적용한 받침 (2026-11-06) use that value; the record keeps the workbook value.
|
||||||
bearings: rec.bearings.map((b) => ({ no: b.no, pos: b.pos, z: b.z, seatEl: b.seatEl, azimuthDeg: rec.azimuthApplied?.[String(b.no)] ?? b.azimuthDeg })),
|
bearings: rec.bearings.map((b) => ({ no: b.no, pos: b.pos, z: b.z, seatEl: b.seatEl, azimuthDeg: rec.azimuthApplied?.[String(b.no)] ?? b.azimuthDeg, heightMm: Number.isFinite(b.bearingHeightMm) ? b.bearingHeightMm : undefined })),
|
||||||
skewDeg: rec.skewDeg,
|
skewDeg: rec.skewDeg,
|
||||||
slopeI: rec.slopeI,
|
slopeI: rec.slopeI,
|
||||||
wings: rec.wings,
|
wings: rec.wings,
|
||||||
|
|||||||
+83
-21
@@ -7,7 +7,7 @@ import { type FootingRebarSettings, DEFAULT_FOOTING_REBAR } from './rebar/footin
|
|||||||
import { layoutText, resolveRowCounts, rowLayout, type PileGeometry } from './piles';
|
import { layoutText, resolveRowCounts, rowLayout, type PileGeometry } from './piles';
|
||||||
import type { PierInput, Row, SolplatePlan } from './types';
|
import type { PierInput, Row, SolplatePlan } from './types';
|
||||||
import type { BearingPair } from './bearing-pair';
|
import type { BearingPair } from './bearing-pair';
|
||||||
import { type BearingSole, structureSoles } from './sole-plate';
|
import { type BearingSole, SOLE_DEFAULTS, structureSoles } from './sole-plate';
|
||||||
import { type CopingGeom, P1_COPING_GEOM } from './coping-geom';
|
import { type CopingGeom, P1_COPING_GEOM } from './coping-geom';
|
||||||
import { castShape } from './cast-in-place-shape';
|
import { castShape } from './cast-in-place-shape';
|
||||||
|
|
||||||
@@ -33,6 +33,8 @@ export const DIM = {
|
|||||||
padThickness: 0.05,
|
padThickness: 0.05,
|
||||||
padSize: 1.0,
|
padSize: 1.0,
|
||||||
blockSize: 1.2,
|
blockSize: 1.2,
|
||||||
|
/** W11 (제안 2026-11-10): 받침블럭 콘크리트(패드 제외) 최소 높이 = 피복 50 + D13 = 63 mm (교대 I01과 같은 기준). */
|
||||||
|
blockMinHeight: 0.063,
|
||||||
footingL: 10.4,
|
footingL: 10.4,
|
||||||
footingW: 8.15,
|
footingW: 8.15,
|
||||||
footingH: 2.0,
|
footingH: 2.0,
|
||||||
@@ -126,6 +128,11 @@ export interface FootingModel {
|
|||||||
bottom: number;
|
bottom: number;
|
||||||
length: number;
|
length: number;
|
||||||
width: number;
|
width: number;
|
||||||
|
/**
|
||||||
|
* One footing across the kink (2026-11-10): this record is one stretch's part, reaching past the mitre (the mitre plane
|
||||||
|
* trims it), and gFrom … gTo is the whole footing in unfolded g. Rebar and quantities take the pair as one footing.
|
||||||
|
*/
|
||||||
|
bent?: { gFrom: number; gTo: number; part: 0 | 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BlindingModel {
|
export interface BlindingModel {
|
||||||
@@ -329,21 +336,37 @@ export function buildPier(input: PierInput, ctx: PierContext = {}): PierModel {
|
|||||||
};
|
};
|
||||||
const frontPlaced = front.map((b) => ({ no: b.no, row: '전열' as Row, z: b.z, ...place(b) }));
|
const frontPlaced = front.map((b) => ({ no: b.no, row: '전열' as Row, z: b.z, ...place(b) }));
|
||||||
const backPlaced = back.map((b) => ({ no: b.no, row: '후열' as Row, z: b.z, ...place(b) }));
|
const backPlaced = back.map((b) => ({ no: b.no, row: '후열' as Row, z: b.z, ...place(b) }));
|
||||||
if (unequalRows) { frontPlaced.sort((a,b)=>a.g-b.g); backPlaced.sort((a,b)=>a.g-b.g); }
|
// Rows run along the coping (2026-11-10): bearing numbers may run against IP1 → IP3 (산외교 P9 ① at g 12.1, ③ at 1.3),
|
||||||
|
// so the steps are always built in g order, not number order.
|
||||||
|
frontPlaced.sort((a,b)=>a.g-b.g); backPlaced.sort((a,b)=>a.g-b.g);
|
||||||
|
|
||||||
// Step boundaries sit midway between consecutive front bearings in coping coordinates (spec §4.3).
|
// Step boundaries sit midway between consecutive front bearings in coping coordinates (spec §4.3).
|
||||||
|
// A step boundary less than half a block from the kink moves onto it (2026-11-10), so the step face is the mitre
|
||||||
|
// plane and the two stretches meet without a wedge; only when both bearings stay half a block away from the kink.
|
||||||
|
const halfBlock = DIM.blockSize / 2;
|
||||||
|
const toKink = (bound: number, gLeft: number, gRight: number) =>
|
||||||
|
kinkG !== null && Math.abs(bound - kinkG) < halfBlock && kinkG - gLeft >= halfBlock && gRight - kinkG >= halfBlock ? kinkG : bound;
|
||||||
const bounds = [0];
|
const bounds = [0];
|
||||||
for (let k = 0; k < frontPlaced.length - 1; k++) {
|
for (let k = 0; k < frontPlaced.length - 1; k++) {
|
||||||
bounds.push((frontPlaced[k].g + frontPlaced[k + 1].g) / 2);
|
bounds.push(toKink((frontPlaced[k].g + frontPlaced[k + 1].g) / 2, frontPlaced[k].g, frontPlaced[k + 1].g));
|
||||||
}
|
}
|
||||||
bounds.push(L);
|
bounds.push(L);
|
||||||
const segments: Segment[] = frontPlaced.map((b, k) => ({ s0: bounds[k], s1: bounds[k + 1], top: b.z - h1 }));
|
// 교좌면 EL per bearing (2026-11-10): when given, it is the coping top of that bearing's step; otherwise z − h1 (z − h2 behind).
|
||||||
|
const seatOf = new Map(input.bearings.map((r) => [r.no, r.seatEl]));
|
||||||
|
const segments: Segment[] = frontPlaced.map((b, k) => ({ s0: bounds[k], s1: bounds[k + 1], top: seatOf.get(b.no) ?? b.z - h1 }));
|
||||||
|
|
||||||
// Lateral step (spec §4.3, Q1·Q2 추천안): front and back tops differ by 5 mm or more in some segment.
|
// Lateral step (spec §4.3, Q1·Q2 추천안): front and back tops differ by 5 mm or more in some segment.
|
||||||
|
// The back steps break midway between consecutive back bearings (2026-11-10), as the front ones do: back bearings need
|
||||||
|
// not sit opposite the front ones (산외교 P11 ⑥ ↔ ⑦). With equal counts, a back bound less than half a block from the
|
||||||
|
// front bound snaps to it, so slightly staggered rows (P1, ≈ 0.09 m) keep one boundary and no sliver step.
|
||||||
|
const backBound = (k: number) => {
|
||||||
|
const own = (backPlaced[k-1].g + backPlaced[k].g) / 2;
|
||||||
|
return !unequalRows && Math.abs(own - bounds[k]) < halfBlock ? bounds[k] : toKink(own, backPlaced[k-1].g, backPlaced[k].g);
|
||||||
|
};
|
||||||
const backSegments: Segment[] = backPlaced.map((b,k) => ({
|
const backSegments: Segment[] = backPlaced.map((b,k) => ({
|
||||||
s0: unequalRows ? (k === 0 ? 0 : (backPlaced[k-1].g+b.g)/2) : segments[k].s0,
|
s0: k === 0 ? 0 : backBound(k),
|
||||||
s1: unequalRows ? (k === backPlaced.length-1 ? L : (b.g+backPlaced[k+1].g)/2) : segments[k].s1,
|
s1: k === backPlaced.length-1 ? L : backBound(k + 1),
|
||||||
top: b.z-h2,
|
top: seatOf.get(b.no) ?? b.z-h2,
|
||||||
}));
|
}));
|
||||||
const stepBounds = [...new Set([0,L,...segments.slice(1).map(s=>s.s0),...backSegments.slice(1).map(s=>s.s0)])].sort((a,b)=>a-b);
|
const stepBounds = [...new Set([0,L,...segments.slice(1).map(s=>s.s0),...backSegments.slice(1).map(s=>s.s0)])].sort((a,b)=>a-b);
|
||||||
const stepSamples = stepBounds.slice(1).map((end,k)=>(stepBounds[k]+end)/2);
|
const stepSamples = stepBounds.slice(1).map((end,k)=>(stepBounds[k]+end)/2);
|
||||||
@@ -389,34 +412,66 @@ export function buildPier(input: PierInput, ctx: PierContext = {}): PierModel {
|
|||||||
};
|
};
|
||||||
// Each stretch uses the same outline shifted to its own base; the mitre plane trims the overlap at the kink.
|
// Each stretch uses the same outline shifted to its own base; the mitre plane trims the overlap at the kink.
|
||||||
const shifted = (pts: [number, number][], b: number): [number, number][] => pts.map(([s, z]) => [s - b, z]);
|
const shifted = (pts: [number, number][], b: number): [number, number][] => pts.map(([s, z]) => [s - b, z]);
|
||||||
|
// A step at the kink (2026-11-10): each stretch's step face is square to its own axis, the mitre is not, so the face
|
||||||
|
// moves past the mitre spread (B/2)·tan(θ/2) into the trimmed side: stretch 0 keeps its left top up to the mitre and
|
||||||
|
// stretch 1 its right top back to it, and the visible step is the mitre plane itself (no sliver on either half).
|
||||||
|
const mitreSpread = (B / 2) * Math.tan((Math.abs(ip4.angleDeg) * Math.PI) / 360) + 0.01;
|
||||||
|
const atKink = (pts: [number, number][], k: number): [number, number][] =>
|
||||||
|
kinkG === null || mitreSpread >= halfBlock ? pts
|
||||||
|
: pts.map(([s, z]) => [Math.abs(s - kinkG) < 1e-9 ? kinkG + (k === 0 ? mitreSpread : -mitreSpread) : s, z]);
|
||||||
|
|
||||||
const meanN = (rows: { n: number }[]) => rows.reduce((sum, r) => sum + r.n, 0) / rows.length;
|
const meanN = (rows: { n: number }[]) => rows.reduce((sum, r) => sum + r.n, 0) / rows.length;
|
||||||
const copingParts: CopingPart[] = [];
|
const copingParts: CopingPart[] = [];
|
||||||
const stretchCount = kinkG === null ? 1 : 2;
|
const stretchCount = kinkG === null ? 1 : 2;
|
||||||
|
// One front/back line for the whole pier (2026-11-10), so the parts of the two stretches meet at the kink without a
|
||||||
|
// sideways jog; per stretch only when some bearing would be closer than half a block to that line.
|
||||||
|
const nAll = (meanN(frontPlaced) + meanN(backPlaced)) / 2;
|
||||||
|
const oneLine = [...frontPlaced, ...backPlaced].every((b) => Math.abs(b.n - nAll) >= halfBlock);
|
||||||
for (let k = 0; k < stretchCount; k++) {
|
for (let k = 0; k < stretchCount; k++) {
|
||||||
const fRows = frontPlaced.filter((b) => b.k === k);
|
const fRows = oneLine ? frontPlaced : frontPlaced.filter((b) => b.k === k);
|
||||||
const bRows = backPlaced.filter((b) => b.k === k);
|
const bRows = oneLine ? backPlaced : backPlaced.filter((b) => b.k === k);
|
||||||
const nStep = fRows.length && bRows.length
|
const nStep = fRows.length && bRows.length
|
||||||
? (meanN(fRows) + meanN(bRows)) / 2
|
? (meanN(fRows) + meanN(bRows)) / 2
|
||||||
: (meanN(frontPlaced) + meanN(backPlaced)) / 2;
|
: nAll;
|
||||||
if (lateralStep) {
|
if (lateralStep) {
|
||||||
|
// The front row may lie on either side of the axis (it depends on the IP1 → IP3 direction), so each part takes the
|
||||||
|
// side of its own row; the order stays [front, back].
|
||||||
|
const frontLow = (fRows.length && bRows.length ? meanN(fRows) - meanN(bRows) : meanN(frontPlaced) - meanN(backPlaced)) <= 0;
|
||||||
copingParts.push(
|
copingParts.push(
|
||||||
{ k, nFrom: -planHalfB, nTo: nStep, profile: shifted(outline(segments), base[k]) },
|
{ k, nFrom: frontLow ? -planHalfB : nStep, nTo: frontLow ? nStep : planHalfB, profile: shifted(atKink(outline(segments), k), base[k]) },
|
||||||
{ k, nFrom: nStep, nTo: planHalfB, profile: shifted(outline(backSegments), base[k]) },
|
{ k, nFrom: frontLow ? nStep : -planHalfB, nTo: frontLow ? planHalfB : nStep, profile: shifted(atKink(outline(backSegments), k), base[k]) },
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
copingParts.push({ k, nFrom: -planHalfB, nTo: planHalfB, profile: shifted(outline(segments), base[k]) });
|
copingParts.push({ k, nFrom: -planHalfB, nTo: planHalfB, profile: shifted(atKink(outline(segments), k), base[k]) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fd = footingDims(input);
|
const fd = footingDims(input);
|
||||||
// Footings are grouped per stretch so that a 일체기초 never crosses the kink; each group is aligned with its stretch.
|
// Footings are grouped per stretch so that a 일체기초 never crosses the kink; each group is aligned with its stretch.
|
||||||
const footingSets: { s: number; k: number; length: number }[] = [];
|
const footingSets: { s: number; k: number; length: number; bent?: FootingModel['bent'] }[] = [];
|
||||||
for (let k = 0; k < stretchCount; k++) {
|
for (let k = 0; k < stretchCount; k++) {
|
||||||
for (const g of footingGroups(colG.filter((_, i) => colK[i] === k), fd.L)) {
|
for (const g of footingGroups(colG.filter((_, i) => colK[i] === k), fd.L)) {
|
||||||
footingSets.push({ s: g.s - base[k], k, length: g.length });
|
footingSets.push({ s: g.s - base[k], k, length: g.length });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Footings that meet or overlap across the kink become one bent footing (2026-11-10, 산외교 P11: 2.75 m overlap).
|
||||||
|
// Each part reaches past the mitre spread (W/2)·tan(θ/2) so the trimmed concrete joins without a wedge gap.
|
||||||
|
if (kinkG !== null) {
|
||||||
|
const last0 = footingSets.filter((f) => f.k === 0).sort((a, b) => b.s - a.s)[0];
|
||||||
|
const first1 = footingSets.filter((f) => f.k === 1).sort((a, b) => a.s - b.s)[0];
|
||||||
|
if (last0 && first1) {
|
||||||
|
const spreadF = (fd.W / 2) * Math.tan((Math.abs(ip4.angleDeg) * Math.PI) / 360);
|
||||||
|
const g0s = last0.s - last0.length / 2, g0e = last0.s + last0.length / 2;
|
||||||
|
const g1s = kinkG + first1.s - first1.length / 2, g1e = kinkG + first1.s + first1.length / 2;
|
||||||
|
if (g0e >= g1s - 2 * spreadF) {
|
||||||
|
const ext = spreadF + 0.01;
|
||||||
|
const p0e = Math.max(g0e, kinkG + ext), p1s = Math.min(g1s, kinkG - ext) - kinkG;
|
||||||
|
const bent = { gFrom: g0s, gTo: g1e };
|
||||||
|
Object.assign(last0, { s: (g0s + p0e) / 2, length: p0e - g0s, bent: { ...bent, part: 0 as const } });
|
||||||
|
Object.assign(first1, { s: (p1s + g1e - kinkG) / 2, length: g1e - kinkG - p1s, bent: { ...bent, part: 1 as const } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const cast = castShape(input);
|
const cast = castShape(input);
|
||||||
if (input.foundationType === '현장타설말뚝' && (!Number.isFinite(cast.pileTopEl) || cast.pileTopEl >= levelBottom || !Number.isFinite(input.pileLengthMm) || input.pileLengthMm <= 0)) throw new Error('현장타설말뚝은 지반고가 코핑 Level 하면보다 낮고 말뚝 길이가 양수여야 합니다.');
|
if (input.foundationType === '현장타설말뚝' && (!Number.isFinite(cast.pileTopEl) || cast.pileTopEl >= levelBottom || !Number.isFinite(input.pileLengthMm) || input.pileLengthMm <= 0)) throw new Error('현장타설말뚝은 지반고가 코핑 Level 하면보다 낮고 말뚝 길이가 양수여야 합니다.');
|
||||||
@@ -441,7 +496,7 @@ export function buildPier(input: PierInput, ctx: PierContext = {}): PierModel {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
for (const f of footingSets) {
|
for (const f of footingSets) {
|
||||||
footings.push({ s: f.s, k: f.k, top: footingTop, bottom: input.footingBottomEl, length: f.length, width: fd.W });
|
footings.push({ s: f.s, k: f.k, top: footingTop, bottom: input.footingBottomEl, length: f.length, width: fd.W, ...(f.bent ? { bent: f.bent } : {}) });
|
||||||
blindings.push({
|
blindings.push({
|
||||||
s: f.s,
|
s: f.s,
|
||||||
k: f.k,
|
k: f.k,
|
||||||
@@ -450,10 +505,14 @@ export function buildPier(input: PierInput, ctx: PierContext = {}): PierModel {
|
|||||||
width: fd.W + fd.blM,
|
width: fd.W + fd.blM,
|
||||||
thickness: fd.blT,
|
thickness: fd.blT,
|
||||||
});
|
});
|
||||||
if (input.foundationType === '말뚝기초') {
|
if (input.foundationType === '말뚝기초' && f.bent?.part !== 1) {
|
||||||
const g = pileGeometry(f.length, fd.W);
|
// A bent footing lays its piles once over the unfolded length; each pile goes to the stretch of its side of the kink.
|
||||||
|
const len = f.bent ? f.bent.gTo - f.bent.gFrom : f.length;
|
||||||
|
const g = pileGeometry(len, fd.W);
|
||||||
const counts = resolveRowCounts(input.pileRows, g);
|
const counts = resolveRowCounts(input.pileRows, g);
|
||||||
piles.push(...pilesFor(f.s, input.footingBottomEl, pileLength, g, counts, fd.pileD).map((p) => ({ ...p, k: f.k })));
|
const centre = f.bent ? (f.bent.gFrom + f.bent.gTo) / 2 : f.s;
|
||||||
|
piles.push(...pilesFor(centre, input.footingBottomEl, pileLength, g, counts, fd.pileD).map((p) =>
|
||||||
|
f.bent && kinkG !== null && p.s > kinkG ? { ...p, s: p.s - kinkG, k: 1 } : { ...p, k: f.k }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -465,8 +524,11 @@ export function buildPier(input: PierInput, ctx: PierContext = {}): PierModel {
|
|||||||
const skewOf = (no: number, k: number) => rowSkewDeg(bearingAz.get(no) ?? NaN, axis.segments[k].u);
|
const skewOf = (no: number, k: number) => rowSkewDeg(bearingAz.get(no) ?? NaN, axis.segments[k].u);
|
||||||
// Solplate of every bearing (결정 2026-11-06): S from the girder pair (ctx.pairs over the whole bridge), else the
|
// Solplate of every bearing (결정 2026-11-06): S from the girder pair (ctx.pairs over the whole bridge), else the
|
||||||
// 종단경사 input, else the other row; T per row; the girder underside stays, so the block top drops by T − T_ref.
|
// 종단경사 input, else the other row; T per row; the girder underside stays, so the block top drops by T − T_ref.
|
||||||
|
// 받침높이 per bearing (2026-11-10): h3 = H − T_ref keeps the girder underside at z + H; a typed h3 still wins.
|
||||||
|
const tRef = input.sole?.tRefMm ?? SOLE_DEFAULTS.tRefMm;
|
||||||
|
const h3ByNo = Object.fromEntries(input.bearings.filter((b) => b.heightMm !== undefined).map((b) => [String(b.no), b.heightMm! - tRef]));
|
||||||
const soles = new Map(structureSoles(input.name, input.bearings.map((b) => ({ no: b.no, row: b.row, azimuthDeg: azimuthOf(b), inputAzimuthDeg: b.azimuthDeg })),
|
const soles = new Map(structureSoles(input.name, input.bearings.map((b) => ({ no: b.no, row: b.row, azimuthDeg: azimuthOf(b), inputAzimuthDeg: b.azimuthDeg })),
|
||||||
input.sole, ctx.pairs ?? [], input.longitudinalSlope === undefined ? undefined : input.longitudinalSlope * 100).map((s) => [s.no, s]));
|
{ ...input.sole, h3ByNo }, ctx.pairs ?? [], input.longitudinalSlope === undefined ? undefined : input.longitudinalSlope * 100).map((s) => [s.no, s]));
|
||||||
const planOf = (no: number, k: number, skewDeg: number): SolplatePlan => {
|
const planOf = (no: number, k: number, skewDeg: number): SolplatePlan => {
|
||||||
const s = soles.get(no)!;
|
const s = soles.get(no)!;
|
||||||
// The block's c axis in (E, N): (−sin d)·u + (cos d)·nrm; T1 goes to the end of c that points uphill.
|
// The block's c axis in (E, N): (−sin d)·u + (cos d)·nrm; T1 goes to the end of c that points uphill.
|
||||||
@@ -478,9 +540,9 @@ export function buildPier(input: PierInput, ctx: PierContext = {}): PierModel {
|
|||||||
};
|
};
|
||||||
const dropOf = (no: number) => soles.get(no)!.dropMm / 1000;
|
const dropOf = (no: number) => soles.get(no)!.dropMm / 1000;
|
||||||
// 교좌면 EL (2026-11-03): when the input gives it, the block bottom is that EL; otherwise the 두부보 상면 slope.
|
// 교좌면 EL (2026-11-03): when the input gives it, the block bottom is that EL; otherwise the 두부보 상면 slope.
|
||||||
const seatOf = new Map(input.bearings.map((r) => [r.no, r.seatEl]));
|
// seatCalc stays the slope value (z − h1, z − h2) as the W10 reference.
|
||||||
frontPlaced.forEach((b) => {
|
frontPlaced.forEach((b) => {
|
||||||
const seatCalc = segmentTop(segments, b.g);
|
const seatCalc = b.z - h1;
|
||||||
const bottom = seatOf.get(b.no) ?? seatCalc;
|
const bottom = seatOf.get(b.no) ?? seatCalc;
|
||||||
const skew = skewOf(b.no, b.k), drop = dropOf(b.no);
|
const skew = skewOf(b.no, b.k), drop = dropOf(b.no);
|
||||||
blocks.push({ no: b.no, row: '전열', k: b.k, s: b.s, g: b.g, n: b.n, bottom, top: b.z - DIM.padThickness - drop, padTop: b.z - drop, rowSkewDeg: skew, plan: planOf(b.no, b.k, skew), seatCalc });
|
blocks.push({ no: b.no, row: '전열', k: b.k, s: b.s, g: b.g, n: b.n, bottom, top: b.z - DIM.padThickness - drop, padTop: b.z - drop, rowSkewDeg: skew, plan: planOf(b.no, b.k, skew), seatCalc });
|
||||||
|
|||||||
@@ -72,8 +72,21 @@ function numOrNull(v: unknown): number | null {
|
|||||||
export function checkGroundModel(raw: unknown): GroundModelFile {
|
export function checkGroundModel(raw: unknown): GroundModelFile {
|
||||||
const obj = raw as Partial<GroundModelFile> | null;
|
const obj = raw as Partial<GroundModelFile> | null;
|
||||||
if (!obj || typeof obj !== 'object') throw new Error('지반 모델 파일 형식이 아닙니다.');
|
if (!obj || typeof obj !== 'object') throw new Error('지반 모델 파일 형식이 아닙니다.');
|
||||||
|
if (!('schema' in obj) && 'schemaVersion' in obj) {
|
||||||
|
const project = obj as { schemaVersion?: unknown; groundModel?: unknown };
|
||||||
|
if (project.schemaVersion !== 1) throw new Error(`프로젝트 형식 번호 ${String(project.schemaVersion)}에서 지반모델을 가져올 수 없습니다.`);
|
||||||
|
if (project.groundModel == null) throw new Error('이 프로젝트 파일에는 지반모델이 없습니다. 프로젝트 전체는 [불러오기 → 프로젝트 열기]에서 열 수 있습니다. 지반 결과는 ground_model.json을 선택하세요.');
|
||||||
|
return checkStoredGroundModel(project.groundModel);
|
||||||
|
}
|
||||||
const knownSchema = schemaOrLegacy(obj);
|
const knownSchema = schemaOrLegacy(obj);
|
||||||
if (knownSchema === undefined) {
|
if (knownSchema === undefined) {
|
||||||
|
const data = obj as Record<string, unknown>;
|
||||||
|
if ('drawing' in data && 'output' in data && typeof data.piers === 'string') {
|
||||||
|
throw new Error('지반 생성 설정 파일입니다. 계산 결과인 ground_model.json 또는 ground_model_3d_with_maps.json을 선택하세요.');
|
||||||
|
}
|
||||||
|
if (!('schema' in data)) {
|
||||||
|
throw new Error('계산된 지반모델을 찾을 수 없습니다. ground_model.json 또는 ground_model_3d_with_maps.json을 선택하세요. 설정·진단·결과 요약 JSON은 적용할 수 없습니다.');
|
||||||
|
}
|
||||||
throw new Error(`지반 모델 형식 번호 ${String((obj as { schema?: unknown }).schema)}을(를) 읽을 수 없습니다 (지원: ${READABLE_SCHEMAS.join(', ')}).`);
|
throw new Error(`지반 모델 형식 번호 ${String((obj as { schema?: unknown }).schema)}을(를) 읽을 수 없습니다 (지원: ${READABLE_SCHEMAS.join(', ')}).`);
|
||||||
}
|
}
|
||||||
if (!READABLE_SCHEMAS.includes(knownSchema as number)) {
|
if (!READABLE_SCHEMAS.includes(knownSchema as number)) {
|
||||||
|
|||||||
@@ -188,5 +188,7 @@ function toBearing(c: Cell[]): Bearing {
|
|||||||
row: String(c[6]) as Row,
|
row: String(c[6]) as Row,
|
||||||
// 교좌면_EL (m), optional: blank = not given, so the model keeps the 두부보 상면 slope.
|
// 교좌면_EL (m), optional: blank = not given, so the model keeps the 두부보 상면 slope.
|
||||||
seatEl: c[7] === undefined || c[7] === null || c[7] === '' ? undefined : Number(c[7]),
|
seatEl: c[7] === undefined || c[7] === null || c[7] === '' ? undefined : Number(c[7]),
|
||||||
|
// 받침높이_mm, optional (2026-11-10): blank = the pier h3 setting.
|
||||||
|
heightMm: c[8] === undefined || c[8] === null || c[8] === '' ? undefined : Number(c[8]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { GroundModelFile, TerrainGrid } from './ground-model';
|
||||||
|
import type { AbutRecord } from './abut/parse';
|
||||||
|
import type { PierInput } from './types';
|
||||||
|
|
||||||
|
export function applyProjectGround(draft: PierInput, model: GroundModelFile, options: { ground: boolean; layers: boolean }): PierInput {
|
||||||
|
const entry = model.piers.find(p => p.id === draft.name);
|
||||||
|
if (!entry) return draft;
|
||||||
|
return { ...draft,
|
||||||
|
...(options.ground && entry.centerEl !== null && Number.isFinite(entry.centerEl) ? { groundEl: entry.centerEl } : {}),
|
||||||
|
...(options.layers && entry.layers?.length ? { strataRows: entry.layers.map(row => ({ ...row })) } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groundInputChanged(input: PierInput, draft: PierInput): boolean {
|
||||||
|
return input.groundEl !== draft.groundEl || JSON.stringify(input.strataRows ?? []) !== JSON.stringify(draft.strataRows ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No extrapolation or filling null nodes; coordinates are survey north/east. */
|
||||||
|
export function sampleGroundGrid(grid: TerrainGrid, point: { e: number; n: number }): number | null {
|
||||||
|
const x = (point.n - grid.origin[0]) / grid.step, y = (point.e - grid.origin[1]) / grid.step;
|
||||||
|
if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || y < 0 || x > grid.nx - 1 || y > grid.ny - 1) return null;
|
||||||
|
const i = Math.min(Math.floor(x), grid.nx - 2), j = Math.min(Math.floor(y), grid.ny - 2);
|
||||||
|
const h = [grid.z[j * grid.nx + i], grid.z[j * grid.nx + i + 1], grid.z[(j + 1) * grid.nx + i], grid.z[(j + 1) * grid.nx + i + 1]];
|
||||||
|
if (h.some(v => v == null || !Number.isFinite(v))) return null;
|
||||||
|
const u = x - i, v = y - j;
|
||||||
|
return h[0]! * (1-u) * (1-v) + h[1]! * u * (1-v) + h[2]! * (1-u) * v + h[3]! * u * v;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function abutGroundFromModel(model: GroundModelFile | null, abut: Pick<AbutRecord, 'name' | 'ip2'>) {
|
||||||
|
const explicit = model?.piers.find(p => p.id === abut.name);
|
||||||
|
if (explicit) return { ...explicit, source: 'JSON 개별값' };
|
||||||
|
const centerEl = model?.terrainGrid ? sampleGroundGrid(model.terrainGrid, abut.ip2) : null;
|
||||||
|
return { id: abut.name, centerEl, layers: undefined, source: centerEl === null ? 'IP2 격자 자료 없음' : 'IP2 격자 보간' };
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import { quantityRows, type Quantity } from './rebar/quantity';
|
||||||
|
|
||||||
|
export interface QuantityRecord {
|
||||||
|
name: string;
|
||||||
|
quantity: Quantity | null;
|
||||||
|
dirty: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Export checks use the same records as the visible result, including ungenerated piers. */
|
||||||
|
export function quantityExportIssues(records: QuantityRecord[]): string[] {
|
||||||
|
return records.flatMap(r => r.dirty ? [`${r.name}: 형상 재생성 필요`] : !r.quantity ? [`${r.name}: 형상 없음`] : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function quantityPieceRows(records: QuantityRecord[]): (string | number)[][] {
|
||||||
|
const rows: (string | number)[][] = [['교각', '부재', '강종', '번호', '규격', '조각', '길이 (mm)', '가공 본수', '총길이 (m)', '총중량 (kg)']];
|
||||||
|
for (const r of records) for (const g of r.quantity?.groups ?? []) for (const d of g.diameters) for (const line of d.lines) {
|
||||||
|
line.pieces.forEach((p, i) => {
|
||||||
|
const totalM = p.length * p.count / 1000;
|
||||||
|
rows.push([r.name, line.member, `SD${g.grade}`, line.name, line.size, i + 1, p.length, p.count, totalM, totalM * line.kgPerM]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generated values remain numbers; only the display format rounds them. */
|
||||||
|
export function createQuantityWorkbook(records: QuantityRecord[], meta: { project: string; bridge: string; generatedAt: string; abutmentCount: number }): XLSX.WorkBook {
|
||||||
|
const issues = quantityExportIssues(records);
|
||||||
|
if (!records.length || issues.length) throw new Error(issues.join('\n') || '출력할 교각이 없습니다.');
|
||||||
|
const wb = XLSX.utils.book_new(), used = new Set(['합계', '가공 상세', '커플러', '검토 항목', '생성 정보']);
|
||||||
|
const append = (name: string, rows: (string | number)[][]) => XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(rows), name);
|
||||||
|
const sheetName = (name: string) => {
|
||||||
|
const base = name.replace(/[[\]:*?/\\\x00-\x1f]/g, '_').replace(/^'+|'+$/g, '') || '교각';
|
||||||
|
let candidate = base.slice(0, 31), n = 1;
|
||||||
|
while (used.has(candidate.toLowerCase())) { const suffix = `_${n++}`; candidate = base.slice(0, 31 - suffix.length) + suffix; }
|
||||||
|
used.add(candidate.toLowerCase()); return candidate;
|
||||||
|
};
|
||||||
|
const summary: (string | number)[][] = [['교각', '합계 (kg)', '할증 포함 (kg)', 'ton', '계산 상태']];
|
||||||
|
const couplers: (string | number)[][] = [['교각', '번호', '개수']];
|
||||||
|
const review: (string | number)[][] = [['교각', '미계산·검토 항목']];
|
||||||
|
let kg = 0, kgWithWaste = 0;
|
||||||
|
for (const r of records) {
|
||||||
|
const q = r.quantity!;
|
||||||
|
const status = q.missing.length ? '일부 계산 · 검토용' : '계산 완료';
|
||||||
|
append(sheetName(r.name), [['교각', r.name], ['계산 상태', status], ['할증 (%)', q.wastePercent], [], ...quantityRows(q)]);
|
||||||
|
summary.push([r.name, q.kg, q.kgWithWaste, q.ton, status]);
|
||||||
|
kg += q.kg; kgWithWaste += q.kgWithWaste;
|
||||||
|
q.couplers.forEach(c => couplers.push([r.name, c.name, c.count]));
|
||||||
|
q.missing.forEach(m => review.push([r.name, m]));
|
||||||
|
}
|
||||||
|
const partial = records.some(r => r.quantity!.missing.length);
|
||||||
|
summary.push([partial ? '계산된 부재 소계 · 전체 수량 미완료' : '합계', kg, kgWithWaste, kgWithWaste / 1000, partial ? '검토용' : '계산 완료']);
|
||||||
|
append('합계', summary); append('가공 상세', quantityPieceRows(records)); append('커플러', couplers); append('검토 항목', review);
|
||||||
|
append('생성 정보', [['프로젝트', meta.project], ['교량', meta.bridge], ['생성 시각', meta.generatedAt], ['대상', records.map(r => r.name).join(', ')], ['출력 상태', partial ? '검토용 · 전체 수량 미완료' : '계산 완료'], ['범위', `교각 ${records.length}기 · 교대 ${meta.abutmentCount}기 제외`], ['수량 기준', '할증 전 실제 길이 · 가공 상세 길이×본수로 검산'], ['원표 수량', '철근표 행 수량이며 가공 조각 본수와 다를 수 있음']]);
|
||||||
|
return wb;
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* 꺾인 코핑의 철근 접기 (결정 2026-11-10). Pure.
|
||||||
|
*
|
||||||
|
* 코핑 철근은 코핑을 편 좌표 (g = 코핑 시작에서 축을 따라 잰 거리, EL, n)로 계산한다. 꺾인 코핑에서는:
|
||||||
|
* - 교축 방향 철근은 꺾임점에서 꺾은 한 개: 그 철근의 n에서 미터면과 만나는 점에서 꺾는다. 길이는 실제 경로로 다시 잰다
|
||||||
|
* (편 길이 + a0(n) − a1(n), 바깥쪽이 길고 안쪽이 짧다).
|
||||||
|
* - 교축 직각 철근 (전단 · 크로스타이)은 각 구간 축에 직각이다. 미터 쐐기에 걸리는 것은 빼고, 꺾임점에 전단철근
|
||||||
|
* 1조를 미터 방향으로 둔다 (가장 가까운 전단 위치의 모양 그대로).
|
||||||
|
* 조각 좌표는 각 구간의 국부 좌표 (s, EL, n)이다: 구간 0은 s = g, 구간 1은 s = g − 꺾임점 g.
|
||||||
|
*/
|
||||||
|
import type { PierModel } from '../geometry';
|
||||||
|
|
||||||
|
export type FoldPoint = [number, number, number];
|
||||||
|
export interface FoldPiece { k: 0 | 1; pts: FoldPoint[] }
|
||||||
|
|
||||||
|
export interface CopingFold {
|
||||||
|
/** g of the kink (m). */
|
||||||
|
kinkG: number;
|
||||||
|
/** Local s of the mitre at offset n, in stretch 0 (from the kink, + ahead) and stretch 1 (from its base). */
|
||||||
|
a0: (n: number) => number;
|
||||||
|
a1: (n: number) => number;
|
||||||
|
/** Largest |a| over the coping width (m): half the wedge the mitre sweeps. */
|
||||||
|
wedge: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function copingFold(model: PierModel): CopingFold | null {
|
||||||
|
if (!model.kink || !model.mitre || model.stretches.length < 2) return null;
|
||||||
|
const m = model.mitre;
|
||||||
|
const dot = (a: { e: number; n: number }, b: { e: number; n: number }) => a.e * b.e + a.n * b.n;
|
||||||
|
const [s0, s1] = model.stretches;
|
||||||
|
// A point (s, n) of stretch k is on the mitre when (s − s_kink)·(u·m) + n·(nrm·m) = 0.
|
||||||
|
const r0 = dot(s0.nrm, m) / dot(s0.u, m);
|
||||||
|
const r1 = dot(s1.nrm, m) / dot(s1.u, m);
|
||||||
|
return { kinkG: s1.base, a0: (n) => -n * r0, a1: (n) => -n * r1, wedge: (model.copingB / 2) * Math.max(Math.abs(r0), Math.abs(r1)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathLength = (pts: FoldPoint[]) => pts.slice(1).reduce((sum, p, i) => sum + Math.hypot(p[0] - pts[i][0], p[1] - pts[i][1], p[2] - pts[i][2]), 0);
|
||||||
|
|
||||||
|
/** One bar from the unfolded g into stretch pieces; a run across the kink turns at its own mitre point. Length in mm. */
|
||||||
|
export function foldBar(f: CopingFold, pts: FoldPoint[]): { pieces: FoldPiece[]; length: number } {
|
||||||
|
const side = (x: number): 0 | 1 => (x <= f.kinkG + 1e-12 ? 0 : 1);
|
||||||
|
const local = (p: FoldPoint, k: 0 | 1): FoldPoint => [k === 0 ? p[0] : p[0] - f.kinkG, p[1], p[2]];
|
||||||
|
const pieces: FoldPiece[] = [{ k: side(pts[0][0]), pts: [local(pts[0], side(pts[0][0]))] }];
|
||||||
|
for (let i = 1; i < pts.length; i++) {
|
||||||
|
const a = pts[i - 1], b = pts[i];
|
||||||
|
const ka = side(a[0]), kb = side(b[0]);
|
||||||
|
if (ka !== kb) {
|
||||||
|
const t = (f.kinkG - a[0]) / (b[0] - a[0]);
|
||||||
|
const z = a[1] + t * (b[1] - a[1]), n = a[2] + t * (b[2] - a[2]);
|
||||||
|
const m0: FoldPoint = [f.kinkG + f.a0(n), z, n], m1: FoldPoint = [f.a1(n), z, n];
|
||||||
|
// Both are the same world point on the mitre, seen from each stretch.
|
||||||
|
if (ka === 0) { pieces[pieces.length - 1].pts.push(m0); pieces.push({ k: 1, pts: [m1] }); }
|
||||||
|
else { pieces[pieces.length - 1].pts.push(m1); pieces.push({ k: 0, pts: [m0] }); }
|
||||||
|
}
|
||||||
|
pieces[pieces.length - 1].pts.push(local(b, kb));
|
||||||
|
}
|
||||||
|
return { pieces, length: pieces.reduce((sum, p) => sum + pathLength(p.pts), 0) * 1000 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True for a bar across the width: every point at one g. */
|
||||||
|
export const isTransverse = (pts: FoldPoint[]) => pts.every((p) => Math.abs(p[0] - pts[0][0]) < 1e-9) && pts.some((p) => Math.abs(p[2] - pts[0][2]) > 1e-9);
|
||||||
|
|
||||||
|
/** True when a transverse bar at g would cut the mitre plane somewhere over its width. */
|
||||||
|
export function inWedge(f: CopingFold, pts: FoldPoint[], margin = 0): boolean {
|
||||||
|
const reach = Math.max(...pts.map((p) => Math.max(Math.abs(f.a0(p[2])), Math.abs(f.a1(p[2])))));
|
||||||
|
return Math.abs(pts[0][0] - f.kinkG) <= reach + margin + 1e-9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A transverse bar laid on the mitre (stretch 0 coordinates): each point moves to the mitre at its n, plus `dx` along the axis. */
|
||||||
|
export function onMitre(f: CopingFold, pts: FoldPoint[], dx = 0): { pieces: FoldPiece[]; length: number } {
|
||||||
|
const moved = pts.map((p): FoldPoint => [f.kinkG + f.a0(p[2]) + dx, p[1], p[2]]);
|
||||||
|
return { pieces: [{ k: 0, pts: moved }], length: pathLength(moved) * 1000 };
|
||||||
|
}
|
||||||
+10
-47
@@ -5,6 +5,9 @@
|
|||||||
import { DEFAULT_MATERIAL, developmentLength, fyOf, hookLength, Material, NOMINAL_DIAMETER } from './common';
|
import { DEFAULT_MATERIAL, developmentLength, fyOf, hookLength, Material, NOMINAL_DIAMETER } from './common';
|
||||||
import { type CopingGeom, P1_COPING_GEOM } from '../coping-geom';
|
import { type CopingGeom, P1_COPING_GEOM } from '../coping-geom';
|
||||||
import { DEFAULT_COPING_REBAR } from './coping-settings';
|
import { DEFAULT_COPING_REBAR } from './coping-settings';
|
||||||
|
import { widthLayout } from './coping-main';
|
||||||
|
import { type StepBar, stepBars, STEP_MIN_MM } from './step-bars';
|
||||||
|
import type { PierModel } from '../geometry';
|
||||||
export const COPING_REBAR = {
|
export const COPING_REBAR = {
|
||||||
/** 피복 (최외측 철근 중심까지). */
|
/** 피복 (최외측 철근 중심까지). */
|
||||||
cover: 100,
|
cover: 100,
|
||||||
@@ -138,17 +141,6 @@ export function copingSideBars(L: number, zBottom: number, zBreak: number, g: Co
|
|||||||
/** C15 (거꾸로 된 U, 3852) and C16 (L형, 4070 / 4069) lengths, mm (코핑철근.md §7). */
|
/** C15 (거꾸로 된 U, 3852) and C16 (L형, 4070 / 4069) lengths, mm (코핑철근.md §7). */
|
||||||
export const STEP_LENGTHS = { C15: 3852, C16: [4070, 4069] as const, C14: 3447.5 } as const;
|
export const STEP_LENGTHS = { C15: 3852, C16: [4070, 4069] as const, C14: 3447.5 } as const;
|
||||||
|
|
||||||
/**
|
|
||||||
* Stair zone of the P1 coping (코핑철근.md §7.1): each segment from x to x (mm from the coping start), and its top rise
|
|
||||||
* above the lowest top (mm). The stair positions are the document's; the model has no stair geometry yet.
|
|
||||||
*/
|
|
||||||
export const STAIR_SEGMENTS = [
|
|
||||||
{ from: 2196, to: 4701, rise: 72 }, // 2구간
|
|
||||||
{ from: 4701, to: 7207, rise: 144 }, // 3구간
|
|
||||||
{ from: 7207, to: 9712, rise: 215 }, // 4구간
|
|
||||||
{ from: 9712, to: 12000, rise: 287 }, // 5구간 (to = L in the document)
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
/** C14 end gap e_14 (코핑철근.md §8.1) and the inset of its ends from the side faces: c + (d_shear + d_14)/2 = 122.25. */
|
/** C14 end gap e_14 (코핑철근.md §8.1) and the inset of its ends from the side faces: c + (d_shear + d_14)/2 = 122.25. */
|
||||||
const C14_END_GAP = 500;
|
const C14_END_GAP = 500;
|
||||||
export const C14_INSET = COPING_REBAR.cover + (NOMINAL_DIAMETER.H25 + NOMINAL_DIAMETER.H19) / 2;
|
export const C14_INSET = COPING_REBAR.cover + (NOMINAL_DIAMETER.H25 + NOMINAL_DIAMETER.H19) / 2;
|
||||||
@@ -212,44 +204,15 @@ export function c14Length(B: number): number {
|
|||||||
return B - 2 * C14_INSET + 2 * Math.round(hookLength('main90', NOMINAL_DIAMETER.H19)); // legs rounded to mm (296)
|
return B - 2 * C14_INSET + 2 * Math.round(hookLength('main90', NOMINAL_DIAMETER.H19)); // legs rounded to mm (296)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StepBar {
|
|
||||||
/** Position across the width, mm from the centre line. */
|
|
||||||
n: number;
|
|
||||||
/** Start and end along the coping (mm). */
|
|
||||||
x0: number;
|
|
||||||
x1: number;
|
|
||||||
/** EL of the horizontal run (m). */
|
|
||||||
z: number;
|
|
||||||
/** Legs: C15 hangs at both ends, C16 only at its start. */
|
|
||||||
legs: 'both' | 'start';
|
|
||||||
/** Leg length l_d (mm). */
|
|
||||||
leg: number;
|
|
||||||
/** Developed length (mm): the horizontal run plus the leg(s) at its ends. */
|
|
||||||
length: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* C15 (거꾸로 된 U, on the highest segment) and C16 (L형, on segments 3 and 4), 11 bars each across the width (코핑철근.md §7).
|
* C15 · C16 of a T형 coping from the model's own steps (결정 2026-11-10, src/core/rebar/step-bars.ts).
|
||||||
* Across: 137 from the cover line, then @pitch (피복선 기준). pitch = 2 × 주철근 기본간격 (승인 2026-10-26; 250 at P1).
|
* Across: every second interior main-bar grid line from the first (P1: 137.5 from the cover line, 10@250, 11 bars).
|
||||||
* Along: from each segment start + c to the next end − c.
|
|
||||||
*/
|
*/
|
||||||
export function stairBars(L: number, B: number, topEl: number, m: Material = DEFAULT_MATERIAL, pitch = 2 * DEFAULT_COPING_REBAR.mainSpacing): { c15: StepBar[]; c16: StepBar[] } {
|
export function tStepBars(model: PierModel, m: Material = DEFAULT_MATERIAL): { bars: StepBar[]; warnings: string[] } {
|
||||||
const ld = stepDevLength(m);
|
const B = model.copingB * 1000;
|
||||||
const c = COPING_REBAR.cover;
|
const w = widthLayout(B, model.copingRebar);
|
||||||
const ns = Array.from({ length: 11 }, (_, k) => -B / 2 + c + 137 + pitch * k);
|
const across = Array.from({ length: Math.floor(w.n / 2) + 1 }, (_, k) => (w.yE + w.g + 2 * k * w.spacing - B / 2) / 1000);
|
||||||
const seg = STAIR_SEGMENTS;
|
return stepBars(model, { cover: COPING_REBAR.cover, ld: stepDevLength(m), d: NOMINAL_DIAMETER.H19, stepMin: STEP_MIN_MM, across });
|
||||||
const top = (i: number) => topEl + seg[i].rise / 1000;
|
|
||||||
const c15Z = top(3) - c / 1000;
|
|
||||||
const c16Z = (i: number) => top(i) - c / 1000 - NOMINAL_DIAMETER.H19 / 1000; // (d_C15 + d_C16)/2 = 19.1
|
|
||||||
const x0c15 = seg[3].from + c;
|
|
||||||
const x1c15 = L - c;
|
|
||||||
const c15: StepBar[] = ns.map((n) => ({ n, x0: x0c15, x1: x1c15, z: c15Z, legs: 'both', leg: ld, length: x1c15 - x0c15 + 2 * ld }));
|
|
||||||
const c16: StepBar[] = [1, 2].flatMap((i) => {
|
|
||||||
const x0 = seg[i].from + c;
|
|
||||||
const x1 = seg[i].to - c + ld; // the horizontal run runs on past the segment end by l_d
|
|
||||||
return ns.map((n): StepBar => ({ n, x0, x1, z: c16Z(i), legs: 'start', leg: ld, length: x1 - x0 + ld }));
|
|
||||||
});
|
|
||||||
return { c15, c16 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* 확대기초 철근의 계산 단위 (2026-11-10). Pure.
|
||||||
|
*
|
||||||
|
* model.footings는 구간마다 한 기록이다. 꺾임점을 지나는 꺾인 기초(bent)는 두 기록(구간 0 · 1 조각)이지만 철근과 수량은
|
||||||
|
* 하나의 기초로 계산한다: 편 좌표(g)의 길이 gTo − gFrom, 가운데 g, 두 구간의 기둥 모두. 철근표와 3D가 같은 단위를 쓴다.
|
||||||
|
* 3D는 편 좌표 철근을 coping-fold.ts로 꺾임점에서 접는다 (교축 철근은 미터점에서 꺾음).
|
||||||
|
*/
|
||||||
|
import type { PierModel } from '../geometry';
|
||||||
|
|
||||||
|
export interface FootingUnit {
|
||||||
|
/** Centre along the axis (m): the stretch's local s, or the unfolded g of a bent footing. */
|
||||||
|
s: number;
|
||||||
|
k: number;
|
||||||
|
length: number;
|
||||||
|
width: number;
|
||||||
|
top: number;
|
||||||
|
bottom: number;
|
||||||
|
/** Columns on this footing, as x mm from its centre (for the B9 column-zone rule). */
|
||||||
|
columnX: number[];
|
||||||
|
/** True for one footing across the kink, in unfolded g (stretch 0 coordinates before folding). */
|
||||||
|
bent: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function footingUnits(model: PierModel): FootingUnit[] {
|
||||||
|
const base1 = model.stretches[1]?.base ?? 0;
|
||||||
|
const gOf = (c: { s: number; k: number }) => (c.k === 0 ? c.s : c.s + base1);
|
||||||
|
const out: FootingUnit[] = [];
|
||||||
|
for (const f of model.footings) {
|
||||||
|
if (f.bent) {
|
||||||
|
if (f.bent.part === 1) continue;
|
||||||
|
const s = (f.bent.gFrom + f.bent.gTo) / 2, length = f.bent.gTo - f.bent.gFrom;
|
||||||
|
const columnX = model.columns.filter((c) => Math.abs(gOf(c) - s) <= length / 2).map((c) => (gOf(c) - s) * 1000);
|
||||||
|
out.push({ s, k: 0, length, width: f.width, top: f.top, bottom: f.bottom, columnX, bent: true });
|
||||||
|
} else {
|
||||||
|
const columnX = model.columns.filter((c) => c.k === f.k && Math.abs(c.s - f.s) <= f.length / 2).map((c) => (c.s - f.s) * 1000);
|
||||||
|
out.push({ s: f.s, k: f.k, length: f.length, width: f.width, top: f.top, bottom: f.bottom, columnX, bent: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -8,9 +8,12 @@
|
|||||||
*/
|
*/
|
||||||
import type { PierModel } from '../geometry';
|
import type { PierModel } from '../geometry';
|
||||||
import { DEFAULT_MATERIAL, developmentLength, fyOf, hookLength, type Material, NOMINAL_DIAMETER, spliceLength } from './common';
|
import { DEFAULT_MATERIAL, developmentLength, fyOf, hookLength, type Material, NOMINAL_DIAMETER, spliceLength } from './common';
|
||||||
|
import { stepBars, stepBlockBars } from './step-bars';
|
||||||
|
import { copingFold, foldBar, type FoldPiece, inWedge, isTransverse, onMitre } from './coping-fold';
|
||||||
|
|
||||||
export type CopingPoint = [number, number, number];
|
export type CopingPoint = [number, number, number];
|
||||||
export interface CopingBar { name: string; size: string; diameterMm: number; pts: CopingPoint[]; length: number }
|
/** pieces: on a bent coping, the bar in stretch-local coordinates (coping-fold.ts); pts stay unfolded. */
|
||||||
|
export interface CopingBar { name: string; size: string; diameterMm: number; pts: CopingPoint[]; length: number; pieces?: FoldPiece[] }
|
||||||
|
|
||||||
/** Actual concrete lower envelope, including every haunch vertex. Coordinates are m, EL m. */
|
/** Actual concrete lower envelope, including every haunch vertex. Coordinates are m, EL m. */
|
||||||
export function copingEnvelope(model: PierModel) {
|
export function copingEnvelope(model: PierModel) {
|
||||||
@@ -48,7 +51,7 @@ export const MULTI_REBAR = {
|
|||||||
standardMm: 8000,
|
standardMm: 8000,
|
||||||
/** C14: vertical pitch, pitch along the axis, stagger between rows. */
|
/** C14: vertical pitch, pitch along the axis, stagger between rows. */
|
||||||
c14Vertical: 0.6, c14Along: 1.2, c14Stagger: 0.6,
|
c14Vertical: 0.6, c14Along: 1.2, c14Stagger: 0.6,
|
||||||
/** h_16: a step at least this much above the end top gets C15 (m). */
|
/** h_16: a step at least this much above the row's lowest top gets C15 / C16 (m, step-bars.ts). */
|
||||||
stepMin: 0.1,
|
stepMin: 0.1,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -132,6 +135,7 @@ export function multicolCopingInfo(model: PierModel, material: Material = DEFAUL
|
|||||||
}
|
}
|
||||||
const c = settings.cover / 1000, s = settings.spacingS / 1000, sd = MULTI_REBAR.denseSpacingMm / 1000;
|
const c = settings.cover / 1000, s = settings.spacingS / 1000, sd = MULTI_REBAR.denseSpacingMm / 1000;
|
||||||
const envelope = copingEnvelope(model);
|
const envelope = copingEnvelope(model);
|
||||||
|
const fold = copingFold(model);
|
||||||
const add = (name: string, size: keyof typeof NOMINAL_DIAMETER, pts: CopingPoint[]) => {
|
const add = (name: string, size: keyof typeof NOMINAL_DIAMETER, pts: CopingPoint[]) => {
|
||||||
if (pts.length < 2) return;
|
if (pts.length < 2) return;
|
||||||
bars.push({ name, size, diameterMm: NOMINAL_DIAMETER[size], pts, length: copingPathLength(pts) });
|
bars.push({ name, size, diameterMm: NOMINAL_DIAMETER[size], pts, length: copingPathLength(pts) });
|
||||||
@@ -180,7 +184,10 @@ export function multicolCopingInfo(model: PierModel, material: Material = DEFAUL
|
|||||||
// Smallest n with every piece ≤ 8 m (hooks included), joints at the n-section points ∓ l_s (C1 −, C4 +).
|
// Smallest n with every piece ≤ 8 m (hooks included), joints at the n-section points ∓ l_s (C1 −, C4 +).
|
||||||
const pieces = (shift: number, hooks: boolean) => {
|
const pieces = (shift: number, hooks: boolean) => {
|
||||||
for (let n = 1; n < 100; n++) {
|
for (let n = 1; n < 100; n++) {
|
||||||
const cuts = Array.from({ length: n - 1 }, (_, j) => a + (b - a) * (j + 1) / n + shift);
|
// On a bent coping a lap keeps clear of the mitre (2026-11-10): a cut within l_s/2 + the mitre wedge moves out.
|
||||||
|
const clear = fold ? ls25 / 2 + fold.wedge : 0;
|
||||||
|
const cuts = Array.from({ length: n - 1 }, (_, j) => a + (b - a) * (j + 1) / n + shift)
|
||||||
|
.map((x) => (fold && Math.abs(x - fold.kinkG) < clear ? fold.kinkG + Math.sign(x - fold.kinkG || 1) * clear : x));
|
||||||
const ends = [a, ...cuts, b];
|
const ends = [a, ...cuts, b];
|
||||||
const spans = ends.slice(1).map((e, i) => [i === 0 ? ends[0] : ends[i] - ls25 / 2, i === n - 1 ? e : e + ls25 / 2] as [number, number]);
|
const spans = ends.slice(1).map((e, i) => [i === 0 ? ends[0] : ends[i] - ls25 / 2, i === n - 1 ? e : e + ls25 / 2] as [number, number]);
|
||||||
const lengths = spans.map(([p, q], i) => (q - p) + (hooks && i === 0 ? h25 : 0) + (hooks && i === n - 1 ? h25 : 0));
|
const lengths = spans.map(([p, q], i) => (q - p) + (hooks && i === 0 ? h25 : 0) + (hooks && i === n - 1 ? h25 : 0));
|
||||||
@@ -283,17 +290,37 @@ export function multicolCopingInfo(model: PierModel, material: Material = DEFAUL
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- C15 계단부 (§8): 끝단보다 h_16 이상 높은 계단 전체에 ⊓ 하나 ------------------------------------------
|
// --- C15 · C16 계단부: T형과 같은 규칙 (결정 2026-11-10, step-bars.ts) ---------------------------------------
|
||||||
const front = model.segments;
|
// 가장 높은 계단마다 ⊓ (C15), 그 밖의 계단(최저 상면 + h_16 이상)마다 L형 (C16). 폭 방향은 상면 주철근 하나 건너.
|
||||||
const endTop = Math.min(front[0].top, front.at(-1)!.top);
|
const stairs = stepBars(model, { cover: c * 1000, ld: Math.round(developmentLength(d('H19'), fy('H19'), material.fck)), d: d('H19'),
|
||||||
const high = front.filter(g => g.top - endTop >= MULTI_REBAR.stepMin - 1e-9);
|
stepMin: MULTI_REBAR.stepMin * 1000, across: top.filter((_, i) => i % 2 === 0) });
|
||||||
if (high.length) {
|
for (const b of stairs.bars) add(b.name, 'H19', b.pts.map(([x, z]): CopingPoint => [x / 1000, z, b.n]));
|
||||||
const from = high[0].s0 + c, to = high.at(-1)!.s1 - c;
|
for (const message of stairs.warnings) warnings.push({ code: 'RB-S01', message });
|
||||||
const z15 = Math.min(...high.map(g => g.top)) - c;
|
// 전·후열 단차 300 이상: 높은 쪽 블록에 C17 ㄷ형 띠 (전단간격) · C18 피부철근 (측면간격), H16 (결정 2026-11-10).
|
||||||
const ld = developmentLength(d('H19'), fy('H19'), material.fck) / 1000;
|
for (const b of stepBlockBars(model, { cover: c * 1000, d: d('H16'), ld: Math.round(developmentLength(d('H16'), fy('H16'), material.fck)),
|
||||||
top.filter((_, i) => i % 2 === 0).forEach(y => add('C15', 'H19', [[from, z15 - ld, y], [from, z15, y], [to, z15, y], [to, z15 - ld, y]]));
|
tieSpacing: settings.spacingS, skinSpacing: settings.sideSpacing })) add(b.name, 'H16', b.pts);
|
||||||
const above = Math.max(...front.map(g => g.top)) - (z15 + c);
|
|
||||||
if (above > MULTI_REBAR.stepMin + 1e-9) warnings.push({ code: 'RB-M04', message: `가장 높은 계단 상면이 C15보다 ${Math.round(above * 1000)} mm 높습니다 (h_16 ${MULTI_REBAR.stepMin * 1000} 초과). C16 병행은 미정 (Q-M7).` });
|
// Bent coping (결정 2026-11-10, coping-fold.ts): runs along the axis turn at their mitre point; bars across the width that
|
||||||
|
// would cut the mitre are left out, and one shear set (the position nearest the kink) is laid on the mitre.
|
||||||
|
if (fold) {
|
||||||
|
const shear = ['C9', 'C10', 'C11', 'C9·C11 사이'];
|
||||||
|
const tShear = bars.filter((b) => shear.includes(b.name) && isTransverse(b.pts));
|
||||||
|
const xNear = tShear.length ? tShear.reduce((best, b) => (Math.abs(b.pts[0][0] - fold.kinkG) < Math.abs(best - fold.kinkG) ? b.pts[0][0] : best), tShear[0].pts[0][0]) : null;
|
||||||
|
const group = xNear === null ? [] : tShear.filter((b) => Math.abs(b.pts[0][0] - xNear) < 0.1);
|
||||||
|
const kept = bars.filter((b) => !(isTransverse(b.pts) && inWedge(fold, b.pts, shear.includes(b.name) ? 0.1 : 0)));
|
||||||
|
const folded: CopingBar[] = kept.map((b) => {
|
||||||
|
if (isTransverse(b.pts)) {
|
||||||
|
const k = b.pts[0][0] <= fold.kinkG ? 0 : 1;
|
||||||
|
return { ...b, pieces: [{ k, pts: b.pts.map((p): CopingPoint => [k === 0 ? p[0] : p[0] - fold.kinkG, p[1], p[2]]) }] };
|
||||||
|
}
|
||||||
|
const f = foldBar(fold, b.pts);
|
||||||
|
return { ...b, pieces: f.pieces, length: f.length };
|
||||||
|
});
|
||||||
|
for (const b of group) {
|
||||||
|
const f = onMitre(fold, b.pts, b.pts[0][0] - xNear!);
|
||||||
|
folded.push({ ...b, pieces: f.pieces, length: f.length });
|
||||||
|
}
|
||||||
|
bars.splice(0, bars.length, ...folded);
|
||||||
}
|
}
|
||||||
|
|
||||||
// RB-M03: 상면 1단 외 철근이 12 m를 넘으면 이음 미정 (Q-M12).
|
// RB-M03: 상면 1단 외 철근이 12 m를 넘으면 이음 미정 (Q-M12).
|
||||||
|
|||||||
@@ -110,11 +110,11 @@ export function buildQuantity(rows: RebarRow[], material: Material, incomplete:
|
|||||||
|
|
||||||
/** The 철근재료표 as rows (for the Excel export): group and diameter headers, lines, subtotals, totals. */
|
/** The 철근재료표 as rows (for the Excel export): group and diameter headers, lines, subtotals, totals. */
|
||||||
export function quantityRows(q: Quantity): (string | number)[][] {
|
export function quantityRows(q: Quantity): (string | number)[][] {
|
||||||
const out: (string | number)[][] = [['강종', '번호', '형상', '규격', '개수', '길이 (mm)', '총길이 (m)', '단위중량 (kg/m)', '총중량 (kg)']];
|
const out: (string | number)[][] = [['강종', '번호', '형상', '규격', '원표 수량', '길이 (mm)', '총길이 (m)', '단위중량 (kg/m)', '총중량 (kg)', '가공 본수', '부재']];
|
||||||
for (const g of q.groups) {
|
for (const g of q.groups) {
|
||||||
for (const d of g.diameters) {
|
for (const d of g.diameters) {
|
||||||
for (const l of d.lines) {
|
for (const l of d.lines) {
|
||||||
out.push([`SD${g.grade}`, l.name, l.shape, l.size, l.count, l.length === null ? '여러 길이' : round(l.length), round(l.totalM), round(l.kgPerM, 3), round(l.kg)]);
|
out.push([`SD${g.grade}`, l.name, l.shape, l.size, l.count, l.length === null ? '여러 길이' : round(l.length), round(l.totalM), round(l.kgPerM, 3), round(l.kg), l.pieceCount, l.member]);
|
||||||
}
|
}
|
||||||
out.push(['', `${d.size} 소계`, '', d.size, '', '', round(d.totalM), '', round(d.kg)]);
|
out.push(['', `${d.size} 소계`, '', d.size, '', '', round(d.totalM), '', round(d.kg)]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
/**
|
||||||
|
* C15 · C16 계단부 철근, T형·다주형 공통 규칙 (결정 2026-11-10, 코핑철근.md §7을 모델 계단으로 일반화). Pure.
|
||||||
|
*
|
||||||
|
* - 열마다 (전·후열 단차가 있으면 전열·후열 코핑 쪽 각각, 없으면 전체 폭) 그 열의 계단(model.segments / backSegments)을 본다.
|
||||||
|
* 상면이 같은 이웃 구간은 하나로 합친다. 경계는 mm로 반올림한다 (도면 치수, 문서 값 재현).
|
||||||
|
* - 계단 구간 = 그 열의 최저 상면보다 h_16 이상 높은 구간. 없으면 철근 없음.
|
||||||
|
* - C15 (⊓): 이웃보다 모두 높은 구간마다. z = 상면 − c, 수평 = 구간 시작 + c ~ 구간 끝 − c, 다리 양끝 l_d.
|
||||||
|
* - C16 (L): 나머지 계단 구간. 연직 다리는 낮은 쪽 끝 (낮은 이웃 쪽, 코핑 끝에 붙어 안쪽이 높으면 코핑 끝 쪽) + c,
|
||||||
|
* 수평은 높은 쪽으로 구간 끝 − c 에서 l_d 더 (코핑 끝 − c 를 넘지 않음). z = 상면 − c − d_H19.
|
||||||
|
* - 양쪽 이웃이 모두 높은 계단 구간은 규칙 밖이라 철근을 두지 않고 알린다 (RB-S01).
|
||||||
|
* - 꺾인 코핑: 좌표가 코핑을 편 g이므로 꺾임점을 지나는 철근은 꺾은 한 개다 (결정 2026-11-10).
|
||||||
|
* - 다리와 상면 주철근 간섭은 미정으로 둔다 (Q-K10).
|
||||||
|
*/
|
||||||
|
import type { PierModel } from '../geometry';
|
||||||
|
|
||||||
|
/** h_16 (mm): the least rise above the row's lowest top that gets a step bar (코핑철근.md, Q-K10 유지 2026-11-10). */
|
||||||
|
export const STEP_MIN_MM = 100;
|
||||||
|
|
||||||
|
export type StepRow = '전체' | '전열' | '후열';
|
||||||
|
|
||||||
|
export interface StepBar {
|
||||||
|
name: 'C15' | 'C16';
|
||||||
|
row: StepRow;
|
||||||
|
/** Across the width (m, model n). */
|
||||||
|
n: number;
|
||||||
|
/** Horizontal run, x0 < x1 (mm from the coping start), and its EL (m). */
|
||||||
|
x0: number;
|
||||||
|
x1: number;
|
||||||
|
z: number;
|
||||||
|
/** Where the leg(s) hang: both ends (C15), or the start / end of the run (C16). */
|
||||||
|
legs: 'both' | 'start' | 'end';
|
||||||
|
/** Leg length l_d (mm). */
|
||||||
|
leg: number;
|
||||||
|
/** Developed length (mm): run plus leg(s). */
|
||||||
|
length: number;
|
||||||
|
/** Centre line (x mm, EL m) from the first leg end to the last. */
|
||||||
|
pts: [number, number][];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StepOptions {
|
||||||
|
/** Cover c (mm). */
|
||||||
|
cover: number;
|
||||||
|
/** Development length l_d (mm). */
|
||||||
|
ld: number;
|
||||||
|
/** H19 diameter (mm): C16 sits this much below its top so it passes under the higher bar. */
|
||||||
|
d: number;
|
||||||
|
/** h_16 (mm). */
|
||||||
|
stepMin: number;
|
||||||
|
/** Candidate positions across the width (m, model n), e.g. every second main-bar grid line. */
|
||||||
|
across: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Step { s0: number; s1: number; top: number }
|
||||||
|
|
||||||
|
/** Merge neighbours with the same top (within 1 mm) and round the bounds to mm. */
|
||||||
|
function merged(segments: { s0: number; s1: number; top: number }[]): Step[] {
|
||||||
|
const out: Step[] = [];
|
||||||
|
for (const g of segments) {
|
||||||
|
const s = { s0: Math.round(g.s0 * 1000), s1: Math.round(g.s1 * 1000), top: g.top };
|
||||||
|
const last = out[out.length - 1];
|
||||||
|
if (last && Math.abs(last.top - s.top) < 0.001) last.s1 = s.s1;
|
||||||
|
else out.push(s);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stepBars(model: PierModel, opt: StepOptions): { bars: StepBar[]; warnings: string[] } {
|
||||||
|
const L = Math.round(model.copingL * 1000);
|
||||||
|
const c = opt.cover;
|
||||||
|
const half = model.copingB / 2;
|
||||||
|
const parts = model.copingParts.filter((p) => p.k === 0);
|
||||||
|
// Rows: with a lateral step the front and back parts (order [front, back]), each to its own width; else the whole width.
|
||||||
|
const rows: { row: StepRow; segments: Step[]; lo: number; hi: number }[] = parts.length === 2 && model.backSegments
|
||||||
|
? ([['전열', model.segments, parts[0]], ['후열', model.backSegments, parts[1]]] as const).map(([row, segs, p]) => ({
|
||||||
|
row,
|
||||||
|
segments: merged(segs),
|
||||||
|
// The split line is a step face inside the coping: keep the cover from it as from the side faces.
|
||||||
|
lo: p.nFrom + (p.nFrom > -half + 1e-9 ? c / 1000 : 0),
|
||||||
|
hi: p.nTo - (p.nTo < half - 1e-9 ? c / 1000 : 0),
|
||||||
|
}))
|
||||||
|
: [{ row: '전체', segments: merged(model.segments), lo: -half, hi: half }];
|
||||||
|
|
||||||
|
const bars: StepBar[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
for (const r of rows) {
|
||||||
|
const ns = opt.across.filter((n) => n >= r.lo - 1e-9 && n <= r.hi + 1e-9);
|
||||||
|
const zMin = Math.min(...r.segments.map((g) => g.top));
|
||||||
|
r.segments.forEach((g, i) => {
|
||||||
|
if ((g.top - zMin) * 1000 < opt.stepMin - 1e-6) return;
|
||||||
|
const left = r.segments[i - 1], right = r.segments[i + 1];
|
||||||
|
const leftHigher = !!left && left.top > g.top + 1e-6;
|
||||||
|
const rightHigher = !!right && right.top > g.top + 1e-6;
|
||||||
|
const ld = opt.ld;
|
||||||
|
if (!leftHigher && !rightHigher) {
|
||||||
|
const x0 = g.s0 + c, x1 = g.s1 - c, z = g.top - c / 1000;
|
||||||
|
for (const n of ns) bars.push({ name: 'C15', row: r.row, n, x0, x1, z, legs: 'both', leg: ld, length: x1 - x0 + 2 * ld,
|
||||||
|
pts: [[x0, z - ld / 1000], [x0, z], [x1, z], [x1, z - ld / 1000]] });
|
||||||
|
} else if (rightHigher && !leftHigher) {
|
||||||
|
const x0 = g.s0 + c, x1 = Math.min(g.s1 - c + ld, L - c), z = g.top - (c + opt.d) / 1000;
|
||||||
|
for (const n of ns) bars.push({ name: 'C16', row: r.row, n, x0, x1, z, legs: 'start', leg: ld, length: x1 - x0 + ld,
|
||||||
|
pts: [[x0, z - ld / 1000], [x0, z], [x1, z]] });
|
||||||
|
} else if (leftHigher && !rightHigher) {
|
||||||
|
const x1 = g.s1 - c, x0 = Math.max(g.s0 + c - ld, c), z = g.top - (c + opt.d) / 1000;
|
||||||
|
for (const n of ns) bars.push({ name: 'C16', row: r.row, n, x0, x1, z, legs: 'end', leg: ld, length: x1 - x0 + ld,
|
||||||
|
pts: [[x1, z - ld / 1000], [x1, z], [x0, z]] });
|
||||||
|
} else {
|
||||||
|
warnings.push(`${r.row === '전체' ? '' : `${r.row} `}계단 ${(g.s0 / 1000).toFixed(3)}~${(g.s1 / 1000).toFixed(3)} m는 양쪽이 더 높아 C15·C16 규칙 밖입니다. 철근을 두지 않았습니다.`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { bars, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 전·후열 단차 블록 보강의 최소 단차 (mm, 결정 2026-11-10). */
|
||||||
|
export const STEP_BLOCK_MIN_MM = 300;
|
||||||
|
|
||||||
|
export interface StepBlockBar {
|
||||||
|
name: 'C17' | 'C18';
|
||||||
|
/** Unfolded centre line (g m, EL m, n m). */
|
||||||
|
pts: [number, number, number][];
|
||||||
|
/** Developed length (mm). */
|
||||||
|
length: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StepBlockOptions {
|
||||||
|
/** Cover c (mm). */
|
||||||
|
cover: number;
|
||||||
|
/** H16 diameter and development length l_d (mm). */
|
||||||
|
d: number;
|
||||||
|
ld: number;
|
||||||
|
/** C17 spacing along the axis = 전단간격, C18 vertical spacing = 측면간격 (mm). */
|
||||||
|
tieSpacing: number;
|
||||||
|
skinSpacing: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전·후열 단차 블록 보강 (결정 2026-11-10): 전열·후열 상면 차가 300 mm 이상인 구간에서, 높은 쪽 블록에
|
||||||
|
* - C17 ㄷ형 띠철근 (H16): 블록 폭 방향 (코핑 안 단차면 + c ~ 측면 − c), 위 = 높은 상면 − c, 다리 양쪽을 낮은 상면 아래 l_d까지.
|
||||||
|
* 교축 방향으로 전단간격, 구간 시작 + c ~ 끝 − c에 고르게.
|
||||||
|
* - C18 수평 피부철근 (H16): 단차면 안쪽 (C17 다리 안), 낮은 상면 + 측면간격마다 높은 상면 − c 아래까지, 구간 시작 + c ~ 끝 − c.
|
||||||
|
* 구간 = 전열·후열 계단 경계를 합친 사이. 구간마다 따로 두고 이웃 구간과 잇지 않는다 (가정).
|
||||||
|
*/
|
||||||
|
export function stepBlockBars(model: PierModel, opt: StepBlockOptions): StepBlockBar[] {
|
||||||
|
const parts = model.copingParts.filter((p) => p.k === 0);
|
||||||
|
if (parts.length !== 2 || !model.backSegments) return [];
|
||||||
|
const half = model.copingB / 2;
|
||||||
|
const c = opt.cover / 1000, ld = opt.ld / 1000, d = opt.d / 1000;
|
||||||
|
const [front] = parts;
|
||||||
|
const split = front.nFrom > -half + 1e-9 ? front.nFrom : front.nTo;
|
||||||
|
const frontDir = Math.sign((front.nFrom + front.nTo) / 2 - split);
|
||||||
|
const topAt = (segs: { s0: number; s1: number; top: number }[], x: number) =>
|
||||||
|
(segs.find((s) => x >= s.s0 && x < s.s1) ?? segs[segs.length - 1]).top;
|
||||||
|
const bounds = [...new Set([0, model.copingL, ...model.segments.map((s) => s.s0), ...model.backSegments.map((s) => s.s0)].map((v) => Math.round(v * 1000) / 1000))].sort((a, b) => a - b);
|
||||||
|
const len = (pts: [number, number, number][]) => pts.slice(1).reduce((s, p, i) => s + Math.hypot(p[0] - pts[i][0], p[1] - pts[i][1], p[2] - pts[i][2]), 0) * 1000;
|
||||||
|
const out: StepBlockBar[] = [];
|
||||||
|
for (let i = 1; i < bounds.length; i++) {
|
||||||
|
const a = bounds[i - 1], b = bounds[i], mid = (a + b) / 2;
|
||||||
|
const fTop = topAt(model.segments, mid), bTop = topAt(model.backSegments, mid);
|
||||||
|
if (Math.abs(fTop - bTop) * 1000 < STEP_BLOCK_MIN_MM - 1e-6) continue;
|
||||||
|
const backHigh = bTop > fTop;
|
||||||
|
const dir = backHigh ? -frontDir : frontDir;
|
||||||
|
const hi = Math.max(fTop, bTop), lo = Math.min(fTop, bTop);
|
||||||
|
const nA = split + dir * c, nB = dir * (half - c);
|
||||||
|
const zt = hi - c, legEnd = lo - ld;
|
||||||
|
const span = b - a - 2 * c;
|
||||||
|
if (span <= 0) continue;
|
||||||
|
const s = opt.tieSpacing / 1000;
|
||||||
|
const count = Math.floor(span / s + 1e-9) + 1;
|
||||||
|
const start = a + c + (span - (count - 1) * s) / 2;
|
||||||
|
for (let k = 0; k < count; k++) {
|
||||||
|
const x = start + k * s;
|
||||||
|
const pts: [number, number, number][] = [[x, legEnd, nA], [x, zt, nA], [x, zt, nB], [x, legEnd, nB]];
|
||||||
|
out.push({ name: 'C17', pts, length: len(pts) });
|
||||||
|
}
|
||||||
|
const nSkin = nA + dir * d;
|
||||||
|
for (let z = lo + opt.skinSpacing / 1000; z < zt - 1e-9; z += opt.skinSpacing / 1000) {
|
||||||
|
const pts: [number, number, number][] = [[a + c, z, nSkin], [b - c, z, nSkin]];
|
||||||
|
out.push({ name: 'C18', pts, length: len(pts) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
+63
-30
@@ -4,14 +4,16 @@ import { copingDefaults } from './coping-settings';
|
|||||||
import { DEFAULT_COLUMN_REBAR } from './column-settings';
|
import { DEFAULT_COLUMN_REBAR } from './column-settings';
|
||||||
import { DEFAULT_FOOTING_REBAR, type FootingRebarSettings } from './footing-settings';
|
import { DEFAULT_FOOTING_REBAR, type FootingRebarSettings } from './footing-settings';
|
||||||
import { bottomPiece, topBarLength, widthLayout, topSplicePieces } from './coping-main';
|
import { bottomPiece, topBarLength, widthLayout, topSplicePieces } from './coping-main';
|
||||||
import { shearBars, shearBarsMulti, copingSideBars, SIDE_HOOK_LEG, c14Bars, c14Length, stairBars, stepDevLength, undersideAt, shearULength, capLength, PIECES_PER_POSITION } from './coping';
|
import { shearBars, shearBarsMulti, copingSideBars, SIDE_HOOK_LEG, c14Bars, c14Length, COPING_REBAR, tStepBars, stepDevLength, undersideAt, shearULength, capLength, PIECES_PER_POSITION } from './coping';
|
||||||
import { sideRowLevels } from '../coping-geom';
|
import { sideRowLevels } from '../coping-geom';
|
||||||
import { CAST_PENDING } from './cast-in-place';
|
import { CAST_PENDING } from './cast-in-place';
|
||||||
import { castRebarLayout } from './cast-layout';
|
import { castRebarLayout } from './cast-layout';
|
||||||
import { multicolCopingInfo } from './multicol-coping';
|
import { multicolCopingInfo } from './multicol-coping';
|
||||||
|
import { stepBlockBars } from './step-bars';
|
||||||
|
import { footingUnits } from './footing-units';
|
||||||
import { columnRebar, columnInputFor } from './column';
|
import { columnRebar, columnInputFor } from './column';
|
||||||
import { bottomBars, crossBars, sideBars, assemblyBars, shearB9 } from './footing';
|
import { bottomBars, crossBars, sideBars, assemblyBars, shearB9 } from './footing';
|
||||||
import { DEFAULT_MATERIAL, FckByMember, fyOf, isAssumedMaterial, Material, materialFor, splitPieces, STANDARD_BAR } from './common';
|
import { DEFAULT_MATERIAL, developmentLength, FckByMember, fyOf, isAssumedMaterial, Material, materialFor, NOMINAL_DIAMETER, splitPieces, STANDARD_BAR } from './common';
|
||||||
|
|
||||||
/** 근거 badge (spec §6.1): where the value comes from. */
|
/** 근거 badge (spec §6.1): where the value comes from. */
|
||||||
export type Basis = 'input' | 'default' | 'calc' | 'assumed';
|
export type Basis = 'input' | 'default' | 'calc' | 'assumed';
|
||||||
@@ -56,7 +58,8 @@ const MULTI_NOTE: Record<string, string> = {
|
|||||||
C7: '하면 전 길이, Level 꺾임점 겹침, 양끝 위로 갈고리', C8: '경간마다 오목 하면, 기둥 중심 ± 2230 안쪽',
|
C7: '하면 전 길이, Level 꺾임점 겹침, 양끝 위로 갈고리', C8: '경간마다 오목 하면, 기둥 중심 ± 2230 안쪽',
|
||||||
C9: '전단 · 테이퍼·헌치', C10: '전단 · 기둥 Level', C11: '전단 · 기둥 사이 오목',
|
C9: '전단 · 테이퍼·헌치', C10: '전단 · 기둥 Level', C11: '전단 · 기둥 사이 오목',
|
||||||
C12: '측면 상단 (전 길이), 다리 위치마다', C13: '측면 하단 변화구간 (하면에서 끊김), 다리 위치마다',
|
C12: '측면 상단 (전 길이), 다리 위치마다', C13: '측면 하단 변화구간 (하면에서 끊김), 다리 위치마다',
|
||||||
C14: '크로스타이 연직 600 · 교각축 1200 엇갈림', C15: '계단부 ⊓ (끝단보다 100 이상 높은 계단 전체)',
|
C14: '크로스타이 연직 600 · 교각축 1200 엇갈림', C15: '계단부 ⊓ (가장 높은 계단)', C16: '계단부 L형 (그 밖의 계단, 낮은 쪽 다리)',
|
||||||
|
C17: '전·후열 단차 300 이상 블록 ㄷ형 띠 (전단간격)', C18: '전·후열 단차 블록 수평 피부철근 (측면간격)',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Table metadata per bar (코핑철근.md, 확대기초철근 (1).md, 기둥철근 (1).md, 받침블럭철근.md). */
|
/** Table metadata per bar (코핑철근.md, 확대기초철근 (1).md, 기둥철근 (1).md, 받침블럭철근.md). */
|
||||||
@@ -77,8 +80,10 @@ const META: Record<string, Pick<RebarRow, 'spacing' | 'actual' | 'position' | 'd
|
|||||||
C12: { spacing: '250', actual: '250', position: '측면 상단 (끝면까지)', direction: '교각축', shape: '일자 + 좌우 ㄷ형', basis: 'calc' },
|
C12: { spacing: '250', actual: '250', position: '측면 상단 (끝면까지)', direction: '교각축', shape: '일자 + 좌우 ㄷ형', basis: 'calc' },
|
||||||
C13: { spacing: '250', actual: '250', position: '측면 하단 변화구간 (경사 하면에서 끊김)', direction: '교각축', shape: '일자 + 좌우 ㄷ형', basis: 'calc' },
|
C13: { spacing: '250', actual: '250', position: '측면 하단 변화구간 (경사 하면에서 끊김)', direction: '교각축', shape: '일자 + 좌우 ㄷ형', basis: 'calc' },
|
||||||
C14: { spacing: '1000 / 2000', actual: '1000', position: '직각 수평 (위 2줄 · 아래 엇갈림)', direction: '교각축 직각', shape: '⊓ (폭 방향)', basis: 'calc' },
|
C14: { spacing: '1000 / 2000', actual: '1000', position: '직각 수평 (위 2줄 · 아래 엇갈림)', direction: '교각축 직각', shape: '⊓ (폭 방향)', basis: 'calc' },
|
||||||
C15: { spacing: '250', actual: '250', position: '계단부 가장 높은 5구간', direction: '교각축', shape: '거꾸로 된 U', basis: 'calc' },
|
C15: { spacing: '250', actual: '250', position: '계단부 가장 높은 구간', direction: '교각축', shape: '거꾸로 된 U', basis: 'calc' },
|
||||||
C16: { spacing: '250', actual: '250', position: '계단부 3·4구간', direction: '교각축', shape: 'L형', basis: 'calc' },
|
C16: { spacing: '250', actual: '250', position: '계단부 그 밖의 구간 (최저 상면 + 100 이상)', direction: '교각축', shape: 'L형', basis: 'calc' },
|
||||||
|
C17: { spacing: '250', actual: '250', position: '전·후열 단차 300 이상 블록', direction: '교각축 직각', shape: 'ㄷ형', basis: 'calc' },
|
||||||
|
C18: { spacing: '250', actual: '250', position: '전·후열 단차 블록 단차면', direction: '교각축', shape: '직선', basis: 'calc' },
|
||||||
P1: { spacing: '원주 46등분', actual: '—', position: '외측 r 875', direction: '연직', shape: '아래 끝 90° 갈고리, 커플러 이음', basis: 'calc' },
|
P1: { spacing: '원주 46등분', actual: '—', position: '외측 r 875', direction: '연직', shape: '아래 끝 90° 갈고리, 커플러 이음', basis: 'calc' },
|
||||||
P2: { spacing: '원주 46등분', actual: '—', position: '내측 r 843.2', direction: '연직', shape: '직선, 커플러 이음', basis: 'calc' },
|
P2: { spacing: '원주 46등분', actual: '—', position: '내측 r 843.2', direction: '연직', shape: '직선, 커플러 이음', basis: 'calc' },
|
||||||
'주철근 커플러': { spacing: '@3000 (P2 +150)', actual: '—', position: 'P1·P2 각 줄', direction: '원통', shape: 'L 150 · Ø60', basis: 'default' },
|
'주철근 커플러': { spacing: '@3000 (P2 +150)', actual: '—', position: 'P1·P2 각 줄', direction: '원통', shape: 'L 150 · Ø60', basis: 'default' },
|
||||||
@@ -237,7 +242,8 @@ export function rebarSummary(
|
|||||||
// Coping (코핑철근.md §5–§8)
|
// Coping (코핑철근.md §5–§8)
|
||||||
const colS = model.columns.map((c) => c.s * 1000);
|
const colS = model.columns.map((c) => c.s * 1000);
|
||||||
const nCol = Math.max(1, colS.length);
|
const nCol = Math.max(1, colS.length);
|
||||||
if (nCol <= 1 || model.kink !== null) {
|
// A bent multi-column coping keeps the multi-column rules (2026-11-10); only one column uses the T형 rows.
|
||||||
|
if (nCol <= 1) {
|
||||||
const width = widthLayout(B, model.copingRebar);
|
const width = widthLayout(B, model.copingRebar);
|
||||||
const perRow = width.count / 2; // cycle split, 12 per row
|
const perRow = width.count / 2; // cycle split, 12 per row
|
||||||
// CP04 (승인 2026-10-26): the cycle split needs an even count (n + 3), so n must be odd. Warning only; the input is kept.
|
// CP04 (승인 2026-10-26): the cycle split needs an even count (n + 3), so n must be odd. Warning only; the input is kept.
|
||||||
@@ -298,9 +304,30 @@ export function rebarSummary(
|
|||||||
const lineCount = (line: number) => c14.filter((b) => b.line === line).length;
|
const lineCount = (line: number) => c14.filter((b) => b.line === line).length;
|
||||||
const omitted = c14.filter((b) => !b.legs).length;
|
const omitted = c14.filter((b) => !b.legs).length;
|
||||||
rows.push({ member: 'coping', name: 'C14', size: 'H19', count: c14.length, length: c14Length(model.copingB * 1000), note: `위 2 줄 ${lineCount(1)} · ${lineCount(2)}본씩, 아래 ${[3, 4, 5, 6].map(lineCount).join(' · ')} (합 ${c14.length}) · 연직 발 생략 ${omitted}개 (하면 밖)` });
|
rows.push({ member: 'coping', name: 'C14', size: 'H19', count: c14.length, length: c14Length(model.copingB * 1000), note: `위 2 줄 ${lineCount(1)} · ${lineCount(2)}본씩, 아래 ${[3, 4, 5, 6].map(lineCount).join(' · ')} (합 ${c14.length}) · 연직 발 생략 ${omitted}개 (하면 밖)` });
|
||||||
const stairs = stairBars(model.copingL * 1000, model.copingB * 1000, model.copingRefTop, mCoping, 2 * model.copingRebar.mainSpacing);
|
// C15 · C16 from the model's own steps (결정 2026-11-10): none when no step is h_16 above the lowest top.
|
||||||
rows.push({ member: 'coping', name: 'C15', size: 'H19', count: stairs.c15.length, length: stairs.c15[0].length, note: '계단부, 가장 높은 구간 (거꾸로 U)' });
|
const steps = tStepBars(model, mCoping);
|
||||||
rows.push({ member: 'coping', name: 'C16', size: 'H19', count: stairs.c16.length, length: stairs.c16[0].length, note: `계단부, 3·4구간 (${Math.round(stairs.c16[0].length)} / ${Math.round(stairs.c16[stairs.c16.length - 1].length)})` });
|
for (const name of ['C15', 'C16'] as const) {
|
||||||
|
const bars = steps.bars.filter((b) => b.name === name);
|
||||||
|
if (!bars.length) continue;
|
||||||
|
const byLength = new Map<number, number>();
|
||||||
|
for (const b of bars) byLength.set(Math.round(b.length), (byLength.get(Math.round(b.length)) ?? 0) + 1);
|
||||||
|
const pieces = [...byLength].map(([length, count]) => ({ length, count }));
|
||||||
|
rows.push({ member: 'coping', name, size: 'H19', count: bars.length, length: pieces.length === 1 ? pieces[0].length : null, ...(pieces.length > 1 ? { pieces } : {}),
|
||||||
|
note: `${name === 'C15' ? '계단부, 가장 높은 구간 (거꾸로 U)' : '계단부, 그 밖의 계단 구간 (L형, 낮은 쪽 다리)'} · ${pieces.map((q) => `${q.length} × ${q.count}`).join(' / ')}` });
|
||||||
|
}
|
||||||
|
for (const message of steps.warnings) warnings.push({ code: 'RB-S01', target: 'coping', message });
|
||||||
|
// 전·후열 단차 블록 보강 C17 · C18 (결정 2026-11-10): 단차 300 mm 이상인 구간만.
|
||||||
|
const block = stepBlockBars(model, { cover: COPING_REBAR.cover, d: NOMINAL_DIAMETER.H16, ld: Math.round(developmentLength(NOMINAL_DIAMETER.H16, fyOf(mCoping, 'H16'), mCoping.fck)),
|
||||||
|
tieSpacing: model.copingRebar.spacingS, skinSpacing: model.copingRebar.sideSpacing });
|
||||||
|
for (const name of ['C17', 'C18'] as const) {
|
||||||
|
const bars = block.filter((b) => b.name === name);
|
||||||
|
if (!bars.length) continue;
|
||||||
|
const byLength = new Map<number, number>();
|
||||||
|
for (const b of bars) byLength.set(Math.round(b.length), (byLength.get(Math.round(b.length)) ?? 0) + 1);
|
||||||
|
const pieces = [...byLength].map(([length, count]) => ({ length, count }));
|
||||||
|
rows.push({ member: 'coping', name, size: 'H16', count: bars.length, length: pieces.length === 1 ? pieces[0].length : null, ...(pieces.length > 1 ? { pieces } : {}),
|
||||||
|
note: name === 'C17' ? '전·후열 단차 300 이상 블록, ㄷ형 띠 (다리 낮은 상면 아래 l_d)' : '전·후열 단차 블록, 단차면 수평 피부철근' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Column (기둥철근.md · plan C4), P1 defaults
|
// Column (기둥철근.md · plan C4), P1 defaults
|
||||||
@@ -365,33 +392,39 @@ export function rebarSummary(
|
|||||||
if (model.footings.length > 0) {
|
if (model.footings.length > 0) {
|
||||||
// Each footing (분리 or 일체) gives the same bar set. Several footings add their counts per bar name;
|
// Each footing (분리 or 일체) gives the same bar set. Several footings add their counts per bar name;
|
||||||
// the length is the longest of them (the largest footing governs the bar).
|
// the length is the longest of them (the largest footing governs the bar).
|
||||||
const agg = new Map<string, FootingBar>();
|
// Per footing (2026-11-10): the columns of its own stretch only, and every bar length kept (a bent pier has footings
|
||||||
for (const f of model.footings) {
|
// of different lengths; the longest no longer stands for all).
|
||||||
// Columns on this footing, as x mm from the footing centre (for the B9 column-zone rule).
|
const byName = new Map<string, { first: FootingBar; lengths: Map<number, number> }>();
|
||||||
const columnX = model.columns.filter((c) => Math.abs(c.s - f.s) <= f.length / 2).map((c) => (c.s - f.s) * 1000);
|
// One unit per real footing: a bent footing across the kink is one footing in unfolded g (footing-units.ts).
|
||||||
for (const b of footingBarList(f.length * 1000, f.width * 1000, (f.top - f.bottom) * 1000, columnX, model.footingRebar)) {
|
for (const f of footingUnits(model)) {
|
||||||
const cur = agg.get(b.name);
|
for (const b of footingBarList(f.length * 1000, f.width * 1000, (f.top - f.bottom) * 1000, f.columnX, model.footingRebar)) {
|
||||||
if (cur) {
|
const cur = byName.get(b.name) ?? { first: b, lengths: new Map<number, number>() };
|
||||||
cur.count += b.count;
|
cur.lengths.set(b.length, (cur.lengths.get(b.length) ?? 0) + b.count);
|
||||||
cur.length = Math.max(cur.length, b.length);
|
byName.set(b.name, cur);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const { first: b, lengths } of byName.values()) {
|
||||||
|
// Over 정척: two pieces lapped (추천안, Q-B6); both pieces count (the row used to weigh only one per bar).
|
||||||
|
const pieces: { length: number; count: number }[] = [];
|
||||||
|
let lap: number | null = null;
|
||||||
|
for (const [length, count] of lengths) {
|
||||||
|
const split = b.name === 'B9' ? null : splitPieces(length, 25.4, fyOf(mFooting, b.size), mFooting.fck, STANDARD_BAR);
|
||||||
|
if (split) { pieces.push({ length: split.pieceLength, count: 2 * count }); lap = split.lap; } else pieces.push({ length, count });
|
||||||
|
}
|
||||||
|
const count = [...lengths.values()].reduce((sum, n) => sum + n, 0);
|
||||||
|
const longest = Math.max(...lengths.keys());
|
||||||
|
if (lap !== null) {
|
||||||
|
rows.push({ member: 'footing', name: b.name, size: b.size, count, length: pieces.length === 1 ? pieces[0].length : null, pieces,
|
||||||
|
note: `${b.note}, 2조각 이음 (추천안, 겹침 ${Math.round(lap)})` });
|
||||||
|
warnings.push({ code: 'RB-F08', message: `${b.name} 길이 ${Math.round(longest)} mm > 정척 ${STANDARD_BAR} mm: 2조각 이음을 추천안으로 적용 (Q-B6).`, target: 'footing', bar: b.name });
|
||||||
} else {
|
} else {
|
||||||
agg.set(b.name, { ...b });
|
rows.push({ member: 'footing', name: b.name, size: b.size, count, length: pieces.length === 1 ? pieces[0].length : null, ...(pieces.length > 1 ? { pieces } : {}), note: b.note });
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const b of agg.values()) {
|
|
||||||
const split = splitPieces(b.length, 25.4, fyOf(mFooting, b.size), mFooting.fck, STANDARD_BAR);
|
|
||||||
if (split && b.length > STANDARD_BAR && b.name !== 'B9') {
|
|
||||||
rows.push({ member: 'footing', name: b.name, size: b.size, count: b.count, length: split.pieceLength, note: `${b.note}, 2조각 이음 (추천안, 겹침 ${Math.round(split.lap)})` });
|
|
||||||
warnings.push({ code: 'RB-F08', message: `${b.name} 길이 ${Math.round(b.length)} mm > 정척 ${STANDARD_BAR} mm: 2조각 이음을 추천안으로 적용 (Q-B6).`, target: 'footing', bar: b.name });
|
|
||||||
} else {
|
|
||||||
rows.push({ member: 'footing', name: b.name, size: b.size, count: b.count, length: b.length, note: b.note });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
warnings.push({ code: 'RB-F00', message: '현장타설말뚝은 확대기초가 없어 확대기초 철근을 만들지 않습니다.', target: 'footing' });
|
warnings.push({ code: 'RB-F00', message: '현장타설말뚝은 확대기초가 없어 확대기초 철근을 만들지 않습니다.', target: 'footing' });
|
||||||
}
|
}
|
||||||
if (nCol > 1 && model.kink === null) {
|
if (nCol > 1) {
|
||||||
const info = multicolCopingInfo(model, mCoping);
|
const info = multicolCopingInfo(model, mCoping);
|
||||||
const grouped = new Map<string, typeof info.bars>();
|
const grouped = new Map<string, typeof info.bars>();
|
||||||
for (const b of info.bars) {
|
for (const b of info.bars) {
|
||||||
|
|||||||
@@ -12,8 +12,10 @@
|
|||||||
import { type BearingPair, pairOf } from './bearing-pair';
|
import { type BearingPair, pairOf } from './bearing-pair';
|
||||||
|
|
||||||
export interface SoleSettings {
|
export interface SoleSettings {
|
||||||
/** 받침 본체 높이 h3 (mm). */
|
/** 받침 본체 높이 h3 (mm), typed for the whole structure. It wins over h3ByNo. */
|
||||||
h3Mm?: number;
|
h3Mm?: number;
|
||||||
|
/** h3 per bearing number (mm), from that bearing's 받침높이 − T_ref (2026-11-10). Absent = h3Mm or the default. */
|
||||||
|
h3ByNo?: Record<string, number>;
|
||||||
/** T_ref: the solplate thickness the workbook z assumed (mm). */
|
/** T_ref: the solplate thickness the workbook z assumed (mm). */
|
||||||
tRefMm?: number;
|
tRefMm?: number;
|
||||||
/** T_min: the thin-end minimum (mm). */
|
/** T_min: the thin-end minimum (mm). */
|
||||||
@@ -130,7 +132,7 @@ export function structureSoles(structure: string, bearings: SoleBearing[], setti
|
|||||||
azimuthInputDeg: b.inputAzimuthDeg ?? b.azimuthDeg,
|
azimuthInputDeg: b.inputAzimuthDeg ?? b.azimuthDeg,
|
||||||
azimuthCalcDeg: pair ? (pair.from.structure === structure && pair.from.no === b.no ? pair.fromCalcDeg : pair.toCalcDeg) : undefined,
|
azimuthCalcDeg: pair ? (pair.from.structure === structure && pair.from.no === b.no ? pair.fromCalcDeg : pair.toCalcDeg) : undefined,
|
||||||
azimuthCheckSec: pair ? (pair.from.structure === structure && pair.from.no === b.no ? pair.fromCheckSec : pair.toCheckSec) : undefined,
|
azimuthCheckSec: pair ? (pair.from.structure === structure && pair.from.no === b.no ? pair.fromCheckSec : pair.toCheckSec) : undefined,
|
||||||
slopePct, slopeSource: source, aMm, bMm, L, W, T, tTyped: typed, T1, T2, tReq: req[i], h3: s.h3Mm,
|
slopePct, slopeSource: source, aMm, bMm, L, W, T, tTyped: typed, T1, T2, tReq: req[i], h3: settings.h3Mm ?? s.h3ByNo?.[String(b.no)] ?? s.h3Mm,
|
||||||
dropMm: T - s.tRefMm, uphill: sign ? { e: sign * dir.e, n: sign * dir.n } : null, warnings,
|
dropMm: T - s.tRefMm, uphill: sign ? { e: sign * dir.e, n: sign * dir.n } : null, warnings,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ export function buildTemplateWorkbook(): XLSX.WorkBook {
|
|||||||
const bearing = [
|
const bearing = [
|
||||||
note('받침 1행당 받침 1개. 전열·후열 수는 같아야 합니다. 위치는 전열 또는 후열.'),
|
note('받침 1행당 받침 1개. 전열·후열 수는 같아야 합니다. 위치는 전열 또는 후열.'),
|
||||||
note('z_m은 받침 EL(m), 방위각은 거더 방향입니다.'),
|
note('z_m은 받침 EL(m), 방위각은 거더 방향입니다.'),
|
||||||
['교각명', '받침번호', 'x_m', 'y_m', 'z_m', '방위각', '위치', '교좌면_EL'],
|
note('받침높이_mm는 패드 상면(z)에서 솔플레이트 상면까지입니다. 비우면 교각의 받침 높이 h3 설정을 씁니다.'),
|
||||||
|
['교각명', '받침번호', 'x_m', 'y_m', 'z_m', '방위각', '위치', '교좌면_EL', '받침높이_mm'],
|
||||||
];
|
];
|
||||||
|
|
||||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(pier), '교각');
|
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(pier), '교각');
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export interface Bearing {
|
|||||||
row: Row;
|
row: Row;
|
||||||
/** 교좌면 EL (m): the block bottom under this bearing. Absent = the 두부보 상면 slope (the coping top). */
|
/** 교좌면 EL (m): the block bottom under this bearing. Absent = the 두부보 상면 slope (the coping top). */
|
||||||
seatEl?: number;
|
seatEl?: number;
|
||||||
|
/** 받침높이 H (mm) of this bearing: pad top (z) to solplate top, so h3 = H − T_ref (2026-11-10). Absent = the pier h3. */
|
||||||
|
heightMm?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PierInput {
|
export interface PierInput {
|
||||||
|
|||||||
+20
-2
@@ -5,6 +5,7 @@ import { integratedCheck } from './bearing-layout';
|
|||||||
import { checkBlockRebar } from './rebar/block-check';
|
import { checkBlockRebar } from './rebar/block-check';
|
||||||
import { castSettingsIssues } from './rebar/cast-in-place';
|
import { castSettingsIssues } from './rebar/cast-in-place';
|
||||||
import { castShape, castShapeIssues } from './cast-in-place-shape';
|
import { castShape, castShapeIssues } from './cast-in-place-shape';
|
||||||
|
import { SOLE_DEFAULTS } from './sole-plate';
|
||||||
|
|
||||||
const EXPECTED_COLUMNS: Record<PierType, number> = { 'T형': 1, '2주형': 2, '3주형': 3 };
|
const EXPECTED_COLUMNS: Record<PierType, number> = { 'T형': 1, '2주형': 2, '3주형': 3 };
|
||||||
|
|
||||||
@@ -34,6 +35,15 @@ export function validateInput(input: PierInput, issues: Issue[]): boolean {
|
|||||||
});
|
});
|
||||||
ok = false;
|
ok = false;
|
||||||
}
|
}
|
||||||
|
// 받침높이 per bearing (2026-11-10): h3 = H − T_ref must be positive unless the pier h3 is typed.
|
||||||
|
if (input.sole?.h3Mm === undefined) {
|
||||||
|
const tRef = input.sole?.tRefMm ?? SOLE_DEFAULTS.tRefMm;
|
||||||
|
for (const b of input.bearings) {
|
||||||
|
if (b.heightMm === undefined || (Number.isFinite(b.heightMm) && b.heightMm - tRef > 0)) continue;
|
||||||
|
issues.push({ code: 'S01', level: 'E', pier: input.name, message: `받침 ${b.no}번 받침높이 ${b.heightMm} mm가 T_ref ${tRef} mm보다 커야 받침 본체 h3를 만들 수 있습니다.` });
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
const front = input.bearings.filter((b) => b.row === '전열').length;
|
const front = input.bearings.filter((b) => b.row === '전열').length;
|
||||||
const back = input.bearings.filter((b) => b.row === '후열').length;
|
const back = input.bearings.filter((b) => b.row === '후열').length;
|
||||||
if (front === 0 || back === 0) {
|
if (front === 0 || back === 0) {
|
||||||
@@ -48,6 +58,7 @@ export function validateInput(input: PierInput, issues: Issue[]): boolean {
|
|||||||
/** Geometry-level checks (spec §10 W01–W05, W07, I01) on a built model. */
|
/** Geometry-level checks (spec §10 W01–W05, W07, I01) on a built model. */
|
||||||
export function validatePier(input: PierInput, model: PierModel, issues: Issue[]): void {
|
export function validatePier(input: PierInput, model: PierModel, issues: Issue[]): void {
|
||||||
// W10 (2026-11-03): the input 교좌면 EL should match the 두부보 상면 slope within 5 mm; the input wins.
|
// W10 (2026-11-03): the input 교좌면 EL should match the 두부보 상면 slope within 5 mm; the input wins.
|
||||||
|
// Since 2026-11-10 the input also sets the coping top of that step, so this is information (I), not a warning.
|
||||||
for (const b of model.blocks) {
|
for (const b of model.blocks) {
|
||||||
const seat = input.bearings.find((r) => r.no === b.no)?.seatEl;
|
const seat = input.bearings.find((r) => r.no === b.no)?.seatEl;
|
||||||
if (seat === undefined) continue;
|
if (seat === undefined) continue;
|
||||||
@@ -55,9 +66,9 @@ export function validatePier(input: PierInput, model: PierModel, issues: Issue[]
|
|||||||
if (Math.abs(diffMm) > 5) {
|
if (Math.abs(diffMm) > 5) {
|
||||||
issues.push({
|
issues.push({
|
||||||
code: 'W10',
|
code: 'W10',
|
||||||
level: 'W',
|
level: 'I',
|
||||||
pier: input.name,
|
pier: input.name,
|
||||||
message: `${b.row} 받침 ${b.no}번 교좌면 EL ${seat.toFixed(3)}은(는) 두부보 상면 계산값 ${b.seatCalc.toFixed(3)}과 ${diffMm.toFixed(1)} mm 차이가 납니다 (허용 5 mm). 입력값을 씁니다.`,
|
message: `${b.row} 받침 ${b.no}번 교좌면 EL ${seat.toFixed(3)}은(는) 두부보 상면 계산값 ${b.seatCalc.toFixed(3)}과 ${diffMm.toFixed(1)} mm 차이가 납니다 (허용 5 mm). 입력값으로 받침블럭 바닥과 코핑 상면을 만듭니다.`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,6 +85,13 @@ export function validatePier(input: PierInput, model: PierModel, issues: Issue[]
|
|||||||
if (minBlock <= 0) {
|
if (minBlock <= 0) {
|
||||||
issues.push({ code: 'W02', level: 'W', pier: input.name, message: '받침블럭 콘크리트 높이가 0 이하입니다. 블럭을 생략합니다.' });
|
issues.push({ code: 'W02', level: 'W', pier: input.name, message: '받침블럭 콘크리트 높이가 0 이하입니다. 블럭을 생략합니다.' });
|
||||||
}
|
}
|
||||||
|
// W11 (2026-11-10): every block between 0 and the minimum height, from its own 교좌면 EL and z.
|
||||||
|
for (const b of model.blocks) {
|
||||||
|
const h = b.top - b.bottom;
|
||||||
|
if (h > 0 && h < DIM.blockMinHeight - 1e-9) {
|
||||||
|
issues.push({ code: 'W11', level: 'W', pier: input.name, message: `${b.row} 받침 ${b.no}번 받침블럭 콘크리트 ${Math.round(h * 1000)} mm가 최소 ${DIM.blockMinHeight * 1000} mm보다 낮습니다. 교좌면 EL과 받침 z를 확인하세요.` });
|
||||||
|
}
|
||||||
|
}
|
||||||
// W03: bearing centre closer than half a block width to a step boundary (interior boundaries only).
|
// W03: bearing centre closer than half a block width to a step boundary (interior boundaries only).
|
||||||
const halfBlock = DIM.blockSize / 2;
|
const halfBlock = DIM.blockSize / 2;
|
||||||
for (const b of model.bearings) {
|
for (const b of model.bearings) {
|
||||||
|
|||||||
+138
-103
@@ -1,3 +1,7 @@
|
|||||||
|
import { createProjectGroundManager } from './view/project-ground';
|
||||||
|
import { hasTerrainSurface } from './view/terrain';
|
||||||
|
import { abutGroundFromModel, applyProjectGround, groundInputChanged } from './core/project-ground';
|
||||||
|
import { registerGroundImporter } from '../pierzainer_ground/ground-link';
|
||||||
import './style.css';
|
import './style.css';
|
||||||
import { createDrawingWorkspace } from '../도면작성/workspace';
|
import { createDrawingWorkspace } from '../도면작성/workspace';
|
||||||
import { createTakeoffWorkspace } from '../수량산출/workspace';
|
import { createTakeoffWorkspace } from '../수량산출/workspace';
|
||||||
@@ -86,13 +90,14 @@ import { applyCopy, COPY_ITEMS, planCopy, type CopyItem } from './core/copy';
|
|||||||
import { copyCandidates, copyItemsFor, copyItemsWithOverrides, hasCopyItems, selectedTargets, type CopyCandidate } from './core/copy-targets';
|
import { copyCandidates, copyItemsFor, copyItemsWithOverrides, hasCopyItems, selectedTargets, type CopyCandidate } from './core/copy-targets';
|
||||||
import { quantityTargetNames } from './core/quantity-scope';
|
import { quantityTargetNames } from './core/quantity-scope';
|
||||||
import { aggregateQuantities, type QuantityTotal } from './core/quantity-total';
|
import { aggregateQuantities, type QuantityTotal } from './core/quantity-total';
|
||||||
|
import { createQuantityWorkbook, quantityExportIssues, quantityPieceRows, type QuantityRecord } from './core/quantity-workbook';
|
||||||
import { defaultLevelWidthMm, pileGeometry } from './core/geometry';
|
import { defaultLevelWidthMm, pileGeometry } from './core/geometry';
|
||||||
import { rebarSummary, type RebarRow } from './core/rebar/summary';
|
import { rebarSummary, type RebarRow } from './core/rebar/summary';
|
||||||
import { shearSegments } from './core/rebar/coping';
|
import { shearSegments } from './core/rebar/coping';
|
||||||
import { buildRebar3D, type RebarColorMode, type RebarScene } from './view/rebar3d';
|
import { buildRebar3D, type RebarColorMode, type RebarScene } from './view/rebar3d';
|
||||||
import { buildAbutRebar3D } from './view/abut-rebar3d';
|
import { buildAbutRebar3D } from './view/abut-rebar3d';
|
||||||
import { DEFAULT_MATERIAL, FCK_MEMBERS, fckChanged, fyOf, GRADE_CHOICES, IMPORTED_FCK, isAssumedMaterial, materialFor, materialFrom, type BendFactors, type FckByMember, type FckMember, type GradeKey, type Material } from './core/rebar/common';
|
import { DEFAULT_MATERIAL, FCK_MEMBERS, fckChanged, fyOf, GRADE_CHOICES, IMPORTED_FCK, isAssumedMaterial, materialFor, materialFrom, type BendFactors, type FckByMember, type FckMember, type GradeKey, type Material } from './core/rebar/common';
|
||||||
import { buildQuantity, quantityRows, type Quantity, type QuantityLine } from './core/rebar/quantity';
|
import { buildQuantity, type Quantity, type QuantityLine } from './core/rebar/quantity';
|
||||||
import { groundElFor, parseGroundModel, strataRowsFor, type GroundMapLayer, type GroundModelFile } from './core/ground-model';
|
import { groundElFor, parseGroundModel, strataRowsFor, type GroundMapLayer, type GroundModelFile } from './core/ground-model';
|
||||||
import { CORROSION_ALLOWANCE_MM, GROUND_POSITIONS, parseNValue, SEA_DATUM, STRATA_NAMES } from './core/ground';
|
import { CORROSION_ALLOWANCE_MM, GROUND_POSITIONS, parseNValue, SEA_DATUM, STRATA_NAMES } from './core/ground';
|
||||||
import { bearingEdgeDistances, integratedCheck, PAIR_ECCENTRICITY_LIMIT_MM, pairEccentricities } from './core/bearing-layout';
|
import { bearingEdgeDistances, integratedCheck, PAIR_ECCENTRICITY_LIMIT_MM, pairEccentricities } from './core/bearing-layout';
|
||||||
@@ -130,6 +135,8 @@ import { type BarSummary, elevationRows, solplateRows } from './core/bearing-tab
|
|||||||
|
|
||||||
/** 지반 모델 (ground_model.json) for the project. Its values reach a pier's 지반고 only when the user applies them. */
|
/** 지반 모델 (ground_model.json) for the project. Its values reach a pier's 지반고 only when the user applies them. */
|
||||||
let groundModel: GroundModelFile | null = null;
|
let groundModel: GroundModelFile | null = null;
|
||||||
|
let groundProjectVersion = 0;
|
||||||
|
let groundImportSerial = 0;
|
||||||
import type { TypeFilter } from './core/selection';
|
import type { TypeFilter } from './core/selection';
|
||||||
import { choose, defaultSummary, isSelectable, SHAPE_GROUPS, UNDEFINED_REASON, type MemberId } from './core/shape-options';
|
import { choose, defaultSummary, isSelectable, SHAPE_GROUPS, UNDEFINED_REASON, type MemberId } from './core/shape-options';
|
||||||
import { parseProject, SCHEMA_VERSION, serializeProject, type ProjectFile, type ProjectInfo } from './core/project';
|
import { parseProject, SCHEMA_VERSION, serializeProject, type ProjectFile, type ProjectInfo } from './core/project';
|
||||||
@@ -138,6 +145,7 @@ import type { Issue, PierInput, PierType, StrataRow } from './core/types';
|
|||||||
import { buildPierScene, setRebarView, setRebarViewOn, viewPosition, type PartName, type PierScene, type ViewKind } from './view/scene';
|
import { buildPierScene, setRebarView, setRebarViewOn, viewPosition, type PartName, type PierScene, type ViewKind } from './view/scene';
|
||||||
import { buildAllPiers, type AllPiers } from './view/all-piers';
|
import { buildAllPiers, type AllPiers } from './view/all-piers';
|
||||||
import { buildTerrainMapGroup, disposeTerrainMapGroup, setTerrainMapMode } from './view/terrain-map';
|
import { buildTerrainMapGroup, disposeTerrainMapGroup, setTerrainMapMode } from './view/terrain-map';
|
||||||
|
import { initWorkspaceLayout } from './view/workspace-layout';
|
||||||
|
|
||||||
/** One pier as shown in the list. `checked` is the multi-select state (시안 ①). */
|
/** One pier as shown in the list. `checked` is the multi-select state (시안 ①). */
|
||||||
interface Pier extends PierValues {
|
interface Pier extends PierValues {
|
||||||
@@ -147,8 +155,9 @@ interface Pier extends PierValues {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Part toggles in the order of 시안 ① "부재 표시". Each toggle controls one or more scene parts. */
|
/** Part toggles in the order of 시안 ① "부재 표시". Each toggle controls one or more scene parts. */
|
||||||
/** ① 형상 생성, ② 형상계획, ③ 철근배근, ④ 도면작성 (origin). 'quantity' is the 재료표 · 수량산출 screen opened from ③ (시안: no header step, 2026-11-06). */
|
/** ① 형상 생성, ② 형상계획, ③ 철근배근, ④ 도면작성, ⑤ 재료표·수량산출. */
|
||||||
type Stage = 'shape' | 'plan' | 'rebar' | 'drawing' | 'quantity';
|
type Stage = 'shape' | 'plan' | 'rebar' | 'drawing' | 'quantity';
|
||||||
|
let quantitySelected: string | null = null;
|
||||||
|
|
||||||
/** 부재 표시 toggles (2026-11-03). `pier` parts follow the pier scenes; `abut` names follow the abutment groups (abut-scene.ts). */
|
/** 부재 표시 toggles (2026-11-03). `pier` parts follow the pier scenes; `abut` names follow the abutment groups (abut-scene.ts). */
|
||||||
const PART_GROUPS: { label: string; pier: PartName[]; abut: string[] }[] = [
|
const PART_GROUPS: { label: string; pier: PartName[]; abut: string[] }[] = [
|
||||||
@@ -284,18 +293,67 @@ const $ = <T extends HTMLElement>(id: string): T => document.getElementById(id)
|
|||||||
const drawingWorkspace = createDrawingWorkspace($('view-drawing'), () => ({
|
const drawingWorkspace = createDrawingWorkspace($('view-drawing'), () => ({
|
||||||
project: state.project, selected: state.active,
|
project: state.project, selected: state.active,
|
||||||
piers: state.piers.map(p => ({ input: p.input, model: p.model, dirty: pendingKeys(p).length > 0 })),
|
piers: state.piers.map(p => ({ input: p.input, model: p.model, dirty: pendingKeys(p).length > 0 })),
|
||||||
}), selectPier);
|
}), selectPier, () => openQuantity());
|
||||||
|
|
||||||
const quantityRebarTon = new Map<string,number|null>();
|
const quantityResults = new Map<string, Quantity>();
|
||||||
const quantityWorkspace = createTakeoffWorkspace($('view-quantity'), () => ({
|
const quantityWorkspace = createTakeoffWorkspace($('view-quantity'), () => ({
|
||||||
project: state.project, selected: state.active,
|
project: state.project, selected: state.active,
|
||||||
piers: state.piers.map(p => ({input:p.input,model:p.model,dirty:pendingKeys(p).length>0,rebarTon:quantityRebarTon.get(p.input.name)})),
|
piers: state.piers.map(p => {
|
||||||
|
const q = quantityResults.get(p.input.name);
|
||||||
|
return {input:p.input,model:p.model,dirty:pendingKeys(p).length>0,rebarTon:q&&!q.missing.length?q.kg/1000:null,rebarIssues:q?.missing};
|
||||||
|
}),
|
||||||
}), selectPier, (name,data) => {
|
}), selectPier, (name,data) => {
|
||||||
const pier=state.piers.find(p=>p.input.name===name);if(!pier)return;
|
const pier=state.piers.find(p=>p.input.name===name);if(!pier)return;
|
||||||
pier.input={...pier.input,quantityTakeoff:data};pier.draft={...pier.draft,quantityTakeoff:data};
|
pier.input={...pier.input,quantityTakeoff:data};pier.draft={...pier.draft,quantityTakeoff:data};
|
||||||
renderSaveStatus();scheduleBackup();
|
renderSaveStatus();scheduleBackup();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function importProjectGround(text: string, name: string): Promise<void> {
|
||||||
|
const version = groundProjectVersion, serial = ++groundImportSerial;
|
||||||
|
const imported = parseGroundModel(text);
|
||||||
|
await Promise.all((imported.mapLayers ?? []).map(decodeGroundMapImage));
|
||||||
|
if (version !== groundProjectVersion || serial !== groundImportSerial) throw new Error('프로젝트 또는 불러올 모델이 변경되어 이전 결과를 적용하지 않았습니다.');
|
||||||
|
groundModel = imported;
|
||||||
|
groundMapMode = imported.mapLayers?.length ? 'satellite' : 'none';
|
||||||
|
setGroundMapMode(groundMapMode);
|
||||||
|
state.visible.ground = true;
|
||||||
|
setupScene(); renderAll();
|
||||||
|
setStatus(`${name} 지반모델을 프로젝트 공통으로 불러왔습니다. 설계 입력은 비교 후 적용하세요.`);
|
||||||
|
}
|
||||||
|
registerGroundImporter(importProjectGround);
|
||||||
|
const projectGround = createProjectGroundManager({
|
||||||
|
snapshot: () => ({ model: groundModel, piers: state.piers, abutments: state.abutments }),
|
||||||
|
importModel: async file => {
|
||||||
|
const version = groundProjectVersion, serial = ++groundImportSerial;
|
||||||
|
const text = await file.text();
|
||||||
|
if (version !== groundProjectVersion || serial !== groundImportSerial) throw new Error('프로젝트 또는 불러올 모델이 변경되어 이전 파일을 불러오지 않았습니다.');
|
||||||
|
await importProjectGround(text, file.name);
|
||||||
|
},
|
||||||
|
apply: (names, fields) => {
|
||||||
|
if (!groundModel) return;
|
||||||
|
for (const p of state.piers) if (names.has(`pier:${p.input.name}`)) p.draft = applyProjectGround(p.draft, groundModel, fields);
|
||||||
|
state.abutments = state.abutments.map(a => {
|
||||||
|
if (!names.has(`abut:${a.name}`)) return a;
|
||||||
|
const entry = abutGroundFromModel(groundModel, a);
|
||||||
|
let next = fields.ground && entry.centerEl !== null ? applyAbutEdit(a, 'groundEl', entry.centerEl) : a;
|
||||||
|
if (fields.layers && entry.layers?.length) next = setAbutGround(next, { strataRows: entry.layers.map(row => ({ ...row })) });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setupScene();
|
||||||
|
renderAll();
|
||||||
|
},
|
||||||
|
regenerate: () => {
|
||||||
|
state.piers = state.piers.map(p => groundInputChanged(p.input, p.draft) ? regenerateEntry(p) : p);
|
||||||
|
setupScene(); renderAll();
|
||||||
|
setStatus('지반 입력이 변경된 교각을 다시 생성했습니다. 생성 상태와 알림을 확인하세요.');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
function resetProjectGround(): void {
|
||||||
|
groundProjectVersion++; groundImportSerial++;
|
||||||
|
projectGround.reset();
|
||||||
|
window.dispatchEvent(new Event('pierzainer:project-changed'));
|
||||||
|
}
|
||||||
|
|
||||||
function el<K extends keyof HTMLElementTagNameMap>(tag: K, text?: string, cls?: string): HTMLElementTagNameMap[K] {
|
function el<K extends keyof HTMLElementTagNameMap>(tag: K, text?: string, cls?: string): HTMLElementTagNameMap[K] {
|
||||||
const node = document.createElement(tag);
|
const node = document.createElement(tag);
|
||||||
if (text !== undefined) node.textContent = text;
|
if (text !== undefined) node.textContent = text;
|
||||||
@@ -687,7 +745,8 @@ function setupScene(): void {
|
|||||||
inputAzimuthDeg: Object.fromEntries(rec.bearings.filter((x) => x.azimuthDeg !== undefined).map((x) => [String(x.no), x.azimuthDeg!])),
|
inputAzimuthDeg: Object.fromEntries(rec.bearings.filter((x) => x.azimuthDeg !== undefined).map((x) => [String(x.no), x.azimuthDeg!])),
|
||||||
});
|
});
|
||||||
const group = buildAbutGroup(model, members, buildWings(model, members, rec.wings, rec.wingDims), pivot, rec.groundEl, groundModel?.terrainGrid, rec.slab, soles,
|
const group = buildAbutGroup(model, members, buildWings(model, members, rec.wings, rec.wingDims), pivot, rec.groundEl, groundModel?.terrainGrid, rec.slab, soles,
|
||||||
jointPositions(model.length, model.bearings.map((b) => b.s), rec.shape?.shrinkJointCount));
|
jointPositions(model.length, model.bearings.map((b) => b.s), rec.shape?.shrinkJointCount),
|
||||||
|
!!(groundModel?.terrainGrid && hasTerrainSurface(groundModel.terrainGrid) && shownPiers().some(p => p.model) && piersShown(state.stage, state.abutActive)));
|
||||||
group.visible = abutShown(state.stage, state.abutActive, rec.name);
|
group.visible = abutShown(state.stage, state.abutActive, rec.name);
|
||||||
// The 부재 표시 toggles hold for a new abutment group too.
|
// The 부재 표시 toggles hold for a new abutment group too.
|
||||||
for (const [name, on] of Object.entries(state.abutVisible)) {
|
for (const [name, on] of Object.entries(state.abutVisible)) {
|
||||||
@@ -2214,6 +2273,9 @@ let lastScrolledPier: string | null = null;
|
|||||||
|
|
||||||
function renderAll(): void {
|
function renderAll(): void {
|
||||||
updatePartToggles();
|
updatePartToggles();
|
||||||
|
projectGround.refresh();
|
||||||
|
$<HTMLButtonElement>('rebar-materials').disabled = !state.piers.length;
|
||||||
|
$('rebar-materials').title = '교각 재료표·수량산출을 엽니다. 교대는 집계 대상에서 제외됩니다.';
|
||||||
const allToggle = $<HTMLButtonElement>('all-toggle');
|
const allToggle = $<HTMLButtonElement>('all-toggle');
|
||||||
allToggle.setAttribute('aria-pressed', String(state.allView));
|
allToggle.setAttribute('aria-pressed', String(state.allView));
|
||||||
allToggle.title = `${shownPiers().filter((p) => p.model).length}기를 한 화면에 (① 형상 생성)`;
|
allToggle.title = `${shownPiers().filter((p) => p.model).length}기를 한 화면에 (① 형상 생성)`;
|
||||||
@@ -2701,6 +2763,11 @@ function setStage(stage: Stage): void {
|
|||||||
// ① opens on 전체 보기 (default). The button is gone, so 전체 보기 returns each time ① is entered.
|
// ① opens on 전체 보기 (default). The button is gone, so 전체 보기 returns each time ① is entered.
|
||||||
if (stage === 'shape' && state.stage !== 'shape') state.allView = true;
|
if (stage === 'shape' && state.stage !== 'shape') state.allView = true;
|
||||||
const previous = state.stage;
|
const previous = state.stage;
|
||||||
|
if (previous === 'quantity') quantitySelected = state.active;
|
||||||
|
if (stage === 'quantity') {
|
||||||
|
if (quantitySelected && state.piers.some(p => p.input.name === quantitySelected)) state.active = quantitySelected;
|
||||||
|
state.abutActive = null;
|
||||||
|
}
|
||||||
state.stage = stage;
|
state.stage = stage;
|
||||||
// Switching 전체 보기 on or off in ① needs the scene rebuilt for the new layout.
|
// Switching 전체 보기 on or off in ① needs the scene rebuilt for the new layout.
|
||||||
// Entering or leaving ② or ③ changes which objects are drawn (visibility.ts), so the scene is rebuilt then.
|
// Entering or leaving ② or ③ changes which objects are drawn (visibility.ts), so the scene is rebuilt then.
|
||||||
@@ -2719,8 +2786,7 @@ function setStage(stage: Stage): void {
|
|||||||
placeViewer(stage);
|
placeViewer(stage);
|
||||||
if (rebar3d) rebar3d.group.visible = stage === 'rebar';
|
if (rebar3d) rebar3d.group.visible = stage === 'rebar';
|
||||||
// The highlight is the 'current' class (style.css); aria-current is set alongside it for assistive tech.
|
// The highlight is the 'current' class (style.css); aria-current is set alongside it for assistive tech.
|
||||||
// The 재료표 screen belongs to ③, so ③ stays highlighted while it is open.
|
const nav: [string, Stage[]][] = [['nav-shape', ['shape']], ['nav-plan', ['plan']], ['nav-rebar', ['rebar']], ['nav-drawing', ['drawing']], ['nav-quantity', ['quantity']]];
|
||||||
const nav: [string, Stage[]][] = [['nav-shape', ['shape']], ['nav-plan', ['plan']], ['nav-rebar', ['rebar', 'quantity']], ['nav-drawing', ['drawing']]];
|
|
||||||
for (const [id, keys] of nav) {
|
for (const [id, keys] of nav) {
|
||||||
const btn = $<HTMLButtonElement>(id);
|
const btn = $<HTMLButtonElement>(id);
|
||||||
btn.classList.toggle('current', keys.includes(stage));
|
btn.classList.toggle('current', keys.includes(stage));
|
||||||
@@ -4568,15 +4634,19 @@ function renderQuantityLink(box: HTMLElement, rows: RebarRow[], incomplete: read
|
|||||||
const line = el('p', `철근재료표 · ${q.missing.length ? '계산된 부재 소계 · 전체 수량 미완료' : '합계'} ${q.kg.toFixed(1)} kg · 할증 포함 ${q.kgWithWaste.toFixed(1)} kg`, 'pnote');
|
const line = el('p', `철근재료표 · ${q.missing.length ? '계산된 부재 소계 · 전체 수량 미완료' : '합계'} ${q.kg.toFixed(1)} kg · 할증 포함 ${q.kgWithWaste.toFixed(1)} kg`, 'pnote');
|
||||||
const link = el('button', '철근재료표 보기', 'btn small');
|
const link = el('button', '철근재료표 보기', 'btn small');
|
||||||
link.type = 'button';
|
link.type = 'button';
|
||||||
link.addEventListener('click', () => setStage('quantity'));
|
link.addEventListener('click', () => openQuantity());
|
||||||
box.append(line, link);
|
box.append(line, link);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openQuantity(): void {
|
||||||
|
setStage('quantity');
|
||||||
|
}
|
||||||
|
|
||||||
/** ④ 재료표: the 철근재료표 of each target pier, bars by 강종 → 직경 → 번호, with subtotals and the totals. */
|
/** ④ 재료표: the 철근재료표 of each target pier, bars by 강종 → 직경 → 번호, with subtotals and the totals. */
|
||||||
/** The piers the 재료표 shows: 전체, or the pier chosen in the list. */
|
/** The piers the 재료표 shows: 전체, or the pier chosen in the list. */
|
||||||
function quantityTargets(): Pier[] {
|
function quantityTargets(): Pier[] {
|
||||||
const names = new Set(quantityTargetNames(state.piers.map((p) => ({ name: p.input.name, hasModel: p.model !== null })), state.quantityAll, state.active));
|
const names = new Set(quantityTargetNames(state.piers.map((p) => ({ name: p.input.name, hasModel: p.model !== null })), state.quantityAll, state.active));
|
||||||
return state.piers.filter((p) => names.has(p.input.name));
|
return state.piers.filter((p) => state.quantityAll || names.has(p.input.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Length cell of a 재료표 line: the length, or 여러 길이 with the pieces on hover. */
|
/** Length cell of a 재료표 line: the length, or 여러 길이 with the pieces on hover. */
|
||||||
@@ -4591,7 +4661,7 @@ function quantityTable(q: Quantity): HTMLTableElement {
|
|||||||
const table = el('table', undefined, 'grid-table rebar-table');
|
const table = el('table', undefined, 'grid-table rebar-table');
|
||||||
const head = el('thead');
|
const head = el('thead');
|
||||||
const hr = el('tr');
|
const hr = el('tr');
|
||||||
for (const h of ['강종', '번호', '형상', '규격', '개수', '길이 (mm)', '총길이 (m)', '단위중량 (kg/m)', '총중량 (kg)']) hr.appendChild(el('th', h));
|
for (const h of ['강종', '번호', '형상', '규격', '원표 수량', '길이 (mm)', '총길이 (m)', '단위중량 (kg/m)', '총중량 (kg)', '가공 본수']) hr.appendChild(el('th', h));
|
||||||
head.appendChild(hr);
|
head.appendChild(hr);
|
||||||
table.appendChild(head);
|
table.appendChild(head);
|
||||||
const body = el('tbody');
|
const body = el('tbody');
|
||||||
@@ -4600,12 +4670,12 @@ function quantityTable(q: Quantity): HTMLTableElement {
|
|||||||
for (const l of d.lines) {
|
for (const l of d.lines) {
|
||||||
body.appendChild(el('tr')).append(
|
body.appendChild(el('tr')).append(
|
||||||
el('td', `SD${g.grade}`), el('td', l.name, 'bar-no'), el('td', l.shape), el('td', l.size), el('td', String(l.count)),
|
el('td', `SD${g.grade}`), el('td', l.name, 'bar-no'), el('td', l.shape), el('td', l.size), el('td', String(l.count)),
|
||||||
lengthCell(l), el('td', l.totalM.toFixed(1)), el('td', l.kgPerM.toFixed(3)), el('td', l.kg.toFixed(1)),
|
lengthCell(l), el('td', l.totalM.toFixed(1)), el('td', l.kgPerM.toFixed(3)), el('td', l.kg.toFixed(1)), el('td', String(l.pieceCount)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
body.appendChild(el('tr', undefined, 'subtotal')).append(el('td', ''), el('td', `${d.size} 소계`), el('td', ''), el('td', d.size), el('td', ''), el('td', ''), el('td', d.totalM.toFixed(1)), el('td', ''), el('td', d.kg.toFixed(1)));
|
body.appendChild(el('tr', undefined, 'subtotal')).append(el('td', ''), el('td', `${d.size} 소계`), el('td', ''), el('td', d.size), el('td', ''), el('td', ''), el('td', d.totalM.toFixed(1)), el('td', ''), el('td', d.kg.toFixed(1)), el('td', String(d.lines.reduce((n,l)=>n+l.pieceCount,0))));
|
||||||
}
|
}
|
||||||
body.appendChild(el('tr', undefined, 'subtotal')).append(el('td', `SD${g.grade} 소계`), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', g.kg.toFixed(1)));
|
body.appendChild(el('tr', undefined, 'subtotal')).append(el('td', `SD${g.grade} 소계`), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', g.kg.toFixed(1)), el('td', ''));
|
||||||
}
|
}
|
||||||
table.appendChild(body);
|
table.appendChild(body);
|
||||||
return table;
|
return table;
|
||||||
@@ -4617,17 +4687,27 @@ function renderQuantityScreen(): void {
|
|||||||
renderPierRows($<HTMLUListElement>('quantity-pier-items'), 'quantity');
|
renderPierRows($<HTMLUListElement>('quantity-pier-items'), 'quantity');
|
||||||
const allButton = $<HTMLButtonElement>('quantity-all');
|
const allButton = $<HTMLButtonElement>('quantity-all');
|
||||||
const generated = state.piers.filter((p) => p.model).length;
|
const generated = state.piers.filter((p) => p.model).length;
|
||||||
allButton.textContent = `전체 합계 (${generated}기)`;
|
allButton.textContent = `교각 전체 (${state.piers.length}기 · 생성 ${generated}기)`;
|
||||||
allButton.setAttribute('aria-pressed', String(state.quantityAll));
|
allButton.setAttribute('aria-pressed', String(state.quantityAll));
|
||||||
const targets = quantityTargets();
|
const targets = quantityTargets();
|
||||||
$('quantity-note').textContent = targets.length
|
$('quantity-note').textContent = targets.length
|
||||||
? `${state.quantityAll ? `전체 ${targets.length}기` : targets[0].input.name} · 할증 ${state.material.waste}% (기본 0%, 가정). 삿갓 길이는 h − 100으로 가정(Q-K5). 교각 목록에서 고른 교각만 보며, 전체를 누르면 모든 교각을 보여 줍니다.`
|
? `${state.quantityAll ? `전체 ${targets.length}기` : targets[0].input.name} · 할증 ${state.material.waste}% (기본 0%, 가정). 삿갓 길이는 h − 100으로 가정(Q-K5). 교각 목록에서 고른 교각만 보며, 전체를 누르면 모든 교각을 보여 줍니다.`
|
||||||
: '계산된 교각이 없습니다. 교각을 생성하세요.';
|
: '계산된 교각이 없습니다. 교각을 생성하세요.';
|
||||||
const entries = targets.flatMap((pier) =>
|
quantityResults.clear();
|
||||||
pier.model ? [{ pier, q: pierQuantity(pier) }] : [],
|
for (const pier of state.piers) if (pier.model) quantityResults.set(pier.input.name, pierQuantity(pier));
|
||||||
);
|
const records = quantityRecords(targets), issues = quantityExportIssues(records);
|
||||||
quantityRebarTon.clear();
|
const entries = targets.flatMap(pier => {
|
||||||
for(const {pier,q} of entries)quantityRebarTon.set(pier.input.name,q.missing.length?null:q.kg/1000);
|
const q = quantityResults.get(pier.input.name);
|
||||||
|
return q ? [{pier,q}] : [];
|
||||||
|
});
|
||||||
|
const partial = entries.some(e=>e.q.missing.length);
|
||||||
|
const output = $<HTMLButtonElement>('quantity-export');
|
||||||
|
output.disabled = !targets.length || issues.length > 0;
|
||||||
|
output.textContent = partial ? '검토용 Excel' : 'Excel 출력';
|
||||||
|
output.title = issues.join(' · ') || (partial ? '미계산 항목을 제외한 검토용 소계입니다.' : '현재 계산 결과를 출력합니다.');
|
||||||
|
$<HTMLButtonElement>('quantity-print').disabled = output.disabled;
|
||||||
|
if (issues.length) body.appendChild(el('p', `출력 불가 · ${issues.join(' · ')}`, 'pnote'));
|
||||||
|
body.appendChild(el('p', `대상: 교각 ${targets.length}기 · 교대 ${state.abutments.length}기 제외. 원표 수량은 철근표 행 수량이며 가공 본수와 다를 수 있습니다.`, 'pnote'));
|
||||||
quantityWorkspace.refresh();
|
quantityWorkspace.refresh();
|
||||||
// 전체 with more than one pier: the bridge total first (교량 전체 집계); the per-pier tables follow.
|
// 전체 with more than one pier: the bridge total first (교량 전체 집계); the per-pier tables follow.
|
||||||
if (state.quantityAll && entries.length > 1) body.appendChild(bridgeTotalBlock(aggregateQuantities(entries.map((e) => e.q)), entries.length));
|
if (state.quantityAll && entries.length > 1) body.appendChild(bridgeTotalBlock(aggregateQuantities(entries.map((e) => e.q)), entries.length));
|
||||||
@@ -4635,6 +4715,14 @@ function renderQuantityScreen(): void {
|
|||||||
body.appendChild(el('h3', `${pier.input.name} · ${pier.input.type}`));
|
body.appendChild(el('h3', `${pier.input.name} · ${pier.input.type}`));
|
||||||
if (q.missing.length) body.appendChild(el('p', `전체 수량 미완료 · 미계산 항목 ${q.missing.length}개 제외: ${q.missing.join(', ')}`, 'pnote'));
|
if (q.missing.length) body.appendChild(el('p', `전체 수량 미완료 · 미계산 항목 ${q.missing.length}개 제외: ${q.missing.join(', ')}`, 'pnote'));
|
||||||
body.appendChild(quantityTable(q));
|
body.appendChild(quantityTable(q));
|
||||||
|
const details = el('details'); details.appendChild(el('summary', '길이별 가공 상세'));
|
||||||
|
const table = el('table', undefined, 'grid-table rebar-table');
|
||||||
|
const rows = quantityPieceRows([{name:pier.input.name,quantity:q,dirty:false}]);
|
||||||
|
for (const [i,row] of rows.entries()) {
|
||||||
|
const tr = el('tr'); for (const value of row) tr.appendChild(el(i===0?'th':'td', typeof value==='number'?String(Math.round(value*1000)/1000):value));
|
||||||
|
table.appendChild(tr);
|
||||||
|
}
|
||||||
|
details.appendChild(table); body.appendChild(details);
|
||||||
body.appendChild(el('p', `${q.missing.length ? '계산된 부재 소계 (전체 수량 미완료)' : '합계'} ${q.kg.toFixed(1)} kg · 할증 포함 ${q.kgWithWaste.toFixed(1)} kg (${q.ton.toFixed(3)} ton) · 커플러 ${q.couplers.map((c) => `${c.name} ${c.count}개`).join(', ') || '없음'}`, 'pnote'));
|
body.appendChild(el('p', `${q.missing.length ? '계산된 부재 소계 (전체 수량 미완료)' : '합계'} ${q.kg.toFixed(1)} kg · 할증 포함 ${q.kgWithWaste.toFixed(1)} kg (${q.ton.toFixed(3)} ton) · 커플러 ${q.couplers.map((c) => `${c.name} ${c.count}개`).join(', ') || '없음'}`, 'pnote'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4671,20 +4759,18 @@ function pierQuantity(pier: Pier): Quantity {
|
|||||||
return buildQuantity(result.rows, state.material, result.incomplete);
|
return buildQuantity(result.rows, state.material, result.incomplete);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function quantityRecords(targets = quantityTargets()): QuantityRecord[] {
|
||||||
|
return targets.map(pier => ({name:pier.input.name,quantity:quantityResults.get(pier.input.name)??null,dirty:pendingKeys(pier).length>0}));
|
||||||
|
}
|
||||||
|
|
||||||
function exportQuantityWorkbook(): void {
|
function exportQuantityWorkbook(): void {
|
||||||
const targets = quantityTargets();
|
renderQuantityScreen();
|
||||||
if (!targets.length) return;
|
try {
|
||||||
const wb = XLSX.utils.book_new();
|
const records = quantityRecords();
|
||||||
const summary: (string | number)[][] = [['교각', '합계 (kg)', '할증 포함 (kg)', 'ton', '계산 상태']];
|
const wb = createQuantityWorkbook(records, {project:state.project.name,bridge:state.project.bridge,generatedAt:new Date().toISOString(),abutmentCount:state.abutments.length});
|
||||||
for (const pier of targets) {
|
const prefix = records.some(r=>r.quantity?.missing.length) ? '검토용_' : '';
|
||||||
if (!pier.model) continue;
|
XLSX.writeFile(wb, `${prefix}${state.project.bridge || '교량'}_철근재료표.xlsx`);
|
||||||
const q = pierQuantity(pier);
|
} catch (error) { setError((error as Error).message); }
|
||||||
const sheet = pier.input.name.replace(/[[\]:*?/\\]/g, '_').slice(0, 31);
|
|
||||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(quantityRows(q)), sheet);
|
|
||||||
summary.push([pier.input.name, Math.round(q.kg * 10) / 10, Math.round(q.kgWithWaste * 10) / 10, Math.round(q.ton * 1000) / 1000, q.missing.length ? `전체 수량 미완료: ${q.missing.join(' · ')}` : '계산 완료']);
|
|
||||||
}
|
|
||||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(summary), '합계');
|
|
||||||
XLSX.writeFile(wb, `${state.project.bridge || '교량'}_철근재료표.xlsx`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$<HTMLButtonElement>('quantity-all').addEventListener('click', () => {
|
$<HTMLButtonElement>('quantity-all').addEventListener('click', () => {
|
||||||
@@ -4692,7 +4778,11 @@ $<HTMLButtonElement>('quantity-all').addEventListener('click', () => {
|
|||||||
renderAll();
|
renderAll();
|
||||||
});
|
});
|
||||||
$<HTMLButtonElement>('quantity-export').addEventListener('click', exportQuantityWorkbook);
|
$<HTMLButtonElement>('quantity-export').addEventListener('click', exportQuantityWorkbook);
|
||||||
$<HTMLButtonElement>('quantity-print').addEventListener('click', () => window.print());
|
$<HTMLButtonElement>('quantity-print').addEventListener('click', () => {
|
||||||
|
const issues = quantityExportIssues(quantityRecords());
|
||||||
|
if (issues.length) { setError(issues.join(' · ')); return; }
|
||||||
|
window.print();
|
||||||
|
});
|
||||||
|
|
||||||
/** Above the 철근표: selection count, select-all / clear for the shown member, and 선택만 보기 (승인 2026-10-23). */
|
/** Above the 철근표: selection count, select-all / clear for the shown member, and 선택만 보기 (승인 2026-10-23). */
|
||||||
function rebarSelectBar(names: string[]): HTMLDivElement {
|
function rebarSelectBar(names: string[]): HTMLDivElement {
|
||||||
@@ -5006,7 +5096,7 @@ function strataRowsEditor(rows: StrataRow[], groundEl: number, set: (rows: Strat
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* ② 지반·지층 card (시안 2026-10-26; 승인 2026-10-26: 지반고는 계산에 쓰고, 지층은 행으로 입력하며, 지하수위는 직접 입력).
|
* ② 지반·지층 card (시안 2026-10-26; 승인 2026-10-26: 지반고는 계산에 쓰고, 지층은 행으로 입력하며, 지하수위는 직접 입력).
|
||||||
* The ground model import and the 지반고 apply stay at the bottom, as before.
|
* Project model generation/import is managed from ①; this card retains local inputs.
|
||||||
*/
|
*/
|
||||||
function renderGroundModel(box: HTMLDivElement, pier: Pier): void {
|
function renderGroundModel(box: HTMLDivElement, pier: Pier): void {
|
||||||
const name = pier.input.name;
|
const name = pier.input.name;
|
||||||
@@ -5102,41 +5192,7 @@ function renderGroundModel(box: HTMLDivElement, pier: Pier): void {
|
|||||||
wrap.appendChild(fig);
|
wrap.appendChild(fig);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ② 지반 모델 (승인 2026-10-23): the pier-centre ground EL from ground_model.json. Applied only when the user presses the button.
|
wrap.appendChild(el('p', '프로젝트 공통 지반모델은 ① 형상의 [프로젝트 지반모델]에서 생성·불러오기·선택 적용합니다.', 'muted small'));
|
||||||
|
|
||||||
wrap.appendChild(el('p', '지반 모델 (지반 모델 결과 · ground_model.json)', 'lbl'));
|
|
||||||
const importInput = el('input');
|
|
||||||
importInput.type = 'file';
|
|
||||||
importInput.accept = '.json';
|
|
||||||
importInput.id = 'ground-import';
|
|
||||||
importInput.addEventListener('change', async () => {
|
|
||||||
const file = importInput.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
try {
|
|
||||||
const imported = parseGroundModel(await file.text());
|
|
||||||
await Promise.all((imported.mapLayers ?? []).map(decodeGroundMapImage));
|
|
||||||
groundModel = imported;
|
|
||||||
groundMapMode = groundModel.mapLayers?.length ? 'satellite' : 'none';
|
|
||||||
setGroundMapMode(groundMapMode);
|
|
||||||
state.visible.ground = true;
|
|
||||||
const mapStatus = groundModel.mapLayers?.length ? '2D 지도·위성사진을 지반면 위에 굴곡대로 표시합니다.' : '포함된 지도 이미지가 없습니다.';
|
|
||||||
setStatus(`${file.name}에서 교각 ${groundModel.piers.length}개의 지반 모델 값을 불러왔습니다. 3D 지반 표시를 켰습니다. ${mapStatus}`);
|
|
||||||
setupScene();
|
|
||||||
renderAll();
|
|
||||||
} catch (err) {
|
|
||||||
setError(`지반 모델을 읽지 못했습니다: ${(err as Error).message}`);
|
|
||||||
} finally {
|
|
||||||
importInput.value = '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const groundLaunch = el('button', '지반 모델 파일 불러오기 · 지반 생성 프로그램');
|
|
||||||
groundLaunch.type = 'button';groundLaunch.className = 'btn';
|
|
||||||
groundLaunch.addEventListener('click', () => window.dispatchEvent(new Event('pierzainer:open-ground')));
|
|
||||||
wrap.appendChild(groundLaunch);
|
|
||||||
const linkStatus = el('p', '현재 Excel을 사용하여 종평면도에서 지반을 생성합니다.', 'muted small');linkStatus.id='ground-link-status';wrap.appendChild(linkStatus);
|
|
||||||
const importLabel = el('label', '기존 지반 JSON 직접 불러오기');
|
|
||||||
importLabel.appendChild(importInput);
|
|
||||||
wrap.appendChild(importLabel);
|
|
||||||
|
|
||||||
if (!groundModel) {
|
if (!groundModel) {
|
||||||
wrap.appendChild(el('p', '지반 모델을 불러오면 이 교각의 지반고를 엑셀 값과 비교하고 적용할 수 있습니다.', 'muted small'));
|
wrap.appendChild(el('p', '지반 모델을 불러오면 이 교각의 지반고를 엑셀 값과 비교하고 적용할 수 있습니다.', 'muted small'));
|
||||||
@@ -5717,8 +5773,10 @@ function writeProjectFile(): void {
|
|||||||
/** Opens a project from its text. A backup stays unsaved until the user saves it. */
|
/** Opens a project from its text. A backup stays unsaved until the user saves it. */
|
||||||
function openProjectText(text: string, saved: boolean): void {
|
function openProjectText(text: string, saved: boolean): void {
|
||||||
const parsed = parseProject(text);
|
const parsed = parseProject(text);
|
||||||
|
resetProjectGround();
|
||||||
state.project = { ...parsed.project };
|
state.project = { ...parsed.project };
|
||||||
state.source = parsed.source;
|
state.source = parsed.source;
|
||||||
|
quantitySelected = null;
|
||||||
state.history = parsed.history;
|
state.history = parsed.history;
|
||||||
state.material = materialFrom(parsed.common);
|
state.material = materialFrom(parsed.common);
|
||||||
state.fckByMember = { ...parsed.fckByMember };
|
state.fckByMember = { ...parsed.fckByMember };
|
||||||
@@ -6015,7 +6073,12 @@ function applyImport(): void {
|
|||||||
const anyPier = !!p && p.entries.some((e) => e.checked && selectable(p, e));
|
const anyPier = !!p && p.entries.some((e) => e.checked && selectable(p, e));
|
||||||
const anyAbut = !!p && p.abutments.some((rec) => p.selectedAbutments.has(rec.name) && selectableAbut(p, rec));
|
const anyAbut = !!p && p.abutments.some((rec) => p.selectedAbutments.has(rec.name) && selectableAbut(p, rec));
|
||||||
if (!p || !name || (!anyPier && !anyAbut)) return;
|
if (!p || !name || (!anyPier && !anyAbut)) return;
|
||||||
|
resetProjectGround();
|
||||||
|
groundModel = null;
|
||||||
|
groundMapMode = 'none';
|
||||||
|
setGroundMapMode(groundMapMode);
|
||||||
state.project = { ...state.project, name, bridge: $<HTMLInputElement>('bridge-name').value.trim() };
|
state.project = { ...state.project, name, bridge: $<HTMLInputElement>('bridge-name').value.trim() };
|
||||||
|
quantitySelected = null;
|
||||||
state.piers = p.entries.filter((e) => e.checked && selectable(p, e));
|
state.piers = p.entries.filter((e) => e.checked && selectable(p, e));
|
||||||
state.abutments = p.abutments.filter((rec) => p.selectedAbutments.has(rec.name) && selectableAbut(p, rec));
|
state.abutments = p.abutments.filter((rec) => p.selectedAbutments.has(rec.name) && selectableAbut(p, rec));
|
||||||
state.abutDefaults = JSON.parse(JSON.stringify(state.abutments)) as AbutRecord[];
|
state.abutDefaults = JSON.parse(JSON.stringify(state.abutments)) as AbutRecord[];
|
||||||
@@ -6111,7 +6174,9 @@ $<HTMLButtonElement>('nav-shape').addEventListener('click', () => setStage('shap
|
|||||||
$<HTMLButtonElement>('nav-plan').addEventListener('click', () => setStage('plan'));
|
$<HTMLButtonElement>('nav-plan').addEventListener('click', () => setStage('plan'));
|
||||||
$<HTMLButtonElement>('nav-rebar').addEventListener('click', () => setStage('rebar'));
|
$<HTMLButtonElement>('nav-rebar').addEventListener('click', () => setStage('rebar'));
|
||||||
$<HTMLButtonElement>('quantity-back').addEventListener('click', () => setStage('rebar'));
|
$<HTMLButtonElement>('quantity-back').addEventListener('click', () => setStage('rebar'));
|
||||||
|
$<HTMLButtonElement>('rebar-materials').addEventListener('click', openQuantity);
|
||||||
$<HTMLButtonElement>('nav-drawing').addEventListener('click', () => setStage('drawing'));
|
$<HTMLButtonElement>('nav-drawing').addEventListener('click', () => setStage('drawing'));
|
||||||
|
$<HTMLButtonElement>('nav-quantity').addEventListener('click', openQuantity);
|
||||||
$<HTMLInputElement>('mat-fck').addEventListener('change', (ev) => setMaterial('fck', (ev.target as HTMLInputElement).value));
|
$<HTMLInputElement>('mat-fck').addEventListener('change', (ev) => setMaterial('fck', (ev.target as HTMLInputElement).value));
|
||||||
$<HTMLInputElement>('mat-waste').addEventListener('change', (ev) => setWaste((ev.target as HTMLInputElement).value));
|
$<HTMLInputElement>('mat-waste').addEventListener('change', (ev) => setWaste((ev.target as HTMLInputElement).value));
|
||||||
for (const k of ['small16', 'main25', 'main35', 'main51'] as const) {
|
for (const k of ['small16', 'main25', 'main35', 'main51'] as const) {
|
||||||
@@ -6319,28 +6384,10 @@ function restorePanel(id: string): void {
|
|||||||
restorePanel('parts-info');
|
restorePanel('parts-info');
|
||||||
restorePanel('color-info');
|
restorePanel('color-info');
|
||||||
|
|
||||||
/** Viewer sizing is a local display preference, separate from project/design data. */
|
/** Viewer sizing/arrangement is a local display preference, separate from project/design data. */
|
||||||
function initReviewWorkspace(): void {
|
function initReviewWorkspace(): void {
|
||||||
const workspace = $('review-workspace');
|
initWorkspaceLayout($('plan-workspace'));
|
||||||
const slider = $<HTMLInputElement>('review-model-height');
|
initWorkspaceLayout($('review-workspace'));
|
||||||
const toggle = $<HTMLButtonElement>('review-model-toggle');
|
|
||||||
let collapsed = false;
|
|
||||||
try {
|
|
||||||
const saved = JSON.parse(localStorage.getItem('pierzainer.review-layout') ?? 'null');
|
|
||||||
if (saved && Number.isFinite(saved.height) && saved.height >= 180 && saved.height <= 560) slider.value = String(saved.height);
|
|
||||||
collapsed = saved?.collapsed === true;
|
|
||||||
} catch { /* A blocked store must not prevent resizing. */ }
|
|
||||||
const apply = () => {
|
|
||||||
workspace.style.setProperty('--review-model-height', `${slider.value}px`);
|
|
||||||
workspace.classList.toggle('model-collapsed', collapsed);
|
|
||||||
$('review-model-height-value').textContent = `${slider.value} px`;
|
|
||||||
toggle.textContent = collapsed ? '모델 펼치기' : '모델 접기';
|
|
||||||
toggle.setAttribute('aria-expanded', String(!collapsed));
|
|
||||||
slider.disabled = collapsed;
|
|
||||||
try { localStorage.setItem('pierzainer.review-layout', JSON.stringify({ height: Number(slider.value), collapsed })); } catch { /* Display preference only. */ }
|
|
||||||
};
|
|
||||||
slider.addEventListener('input', apply);
|
|
||||||
toggle.addEventListener('click', () => { collapsed = !collapsed; apply(); });
|
|
||||||
const jump = (selector: string) => {
|
const jump = (selector: string) => {
|
||||||
const node = document.querySelector<HTMLElement>(selector);
|
const node = document.querySelector<HTMLElement>(selector);
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
@@ -6350,18 +6397,6 @@ function initReviewWorkspace(): void {
|
|||||||
};
|
};
|
||||||
$('review-inputs').addEventListener('click', () => jump('#rebar-table .abut-rebar-inputs input, #rebar-table input[type="number"], #rebar-table select'));
|
$('review-inputs').addEventListener('click', () => jump('#rebar-table .abut-rebar-inputs input, #rebar-table input[type="number"], #rebar-table select'));
|
||||||
$('review-table').addEventListener('click', () => jump('#rebar-table table.rebar-table'));
|
$('review-table').addEventListener('click', () => jump('#rebar-table table.rebar-table'));
|
||||||
apply();
|
|
||||||
new ResizeObserver(() => {
|
|
||||||
if (workspace.clientHeight === 0 || !window.matchMedia('(min-width: 1101px)').matches) return;
|
|
||||||
const available = workspace.clientHeight - workspace.querySelector<HTMLElement>('.workspace-tools')!.offsetHeight - 264;
|
|
||||||
const max = Math.max(180, Math.min(560, Math.floor(available / 20) * 20));
|
|
||||||
const next = Math.min(Number(slider.value), max);
|
|
||||||
if (slider.max !== String(max) || slider.value !== String(next)) {
|
|
||||||
slider.max = String(max);
|
|
||||||
slider.value = String(next);
|
|
||||||
apply();
|
|
||||||
}
|
|
||||||
}).observe(workspace);
|
|
||||||
}
|
}
|
||||||
initReviewWorkspace();
|
initReviewWorkspace();
|
||||||
|
|
||||||
|
|||||||
+43
-4
@@ -13,6 +13,15 @@
|
|||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#project-ground-dialog { width: min(1050px, 94vw); max-height: 88vh; overflow: auto; padding: 20px; border: 1px solid var(--line); border-radius: 8px; color: var(--text); background: var(--panel); }
|
||||||
|
#project-ground-dialog::backdrop { background: #0006; }
|
||||||
|
.project-ground-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
|
||||||
|
#project-ground-dialog fieldset { display: flex; flex-wrap: wrap; gap: 16px; }
|
||||||
|
#project-ground-dialog label { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.project-ground-table { overflow: auto; max-height: 42vh; }
|
||||||
|
.project-ground-table table { min-width: 720px; }
|
||||||
|
#project-ground-dialog > p { font-size: 12px; }
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
@@ -767,8 +776,21 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
|
|||||||
.grid > #viewer-block .canvas { flex: 1 1 auto; height: auto; min-height: 240px; }
|
.grid > #viewer-block .canvas { flex: 1 1 auto; height: auto; min-height: 240px; }
|
||||||
/* ②–④: the model keeps a fixed band at the top; the table scrolls below it. */
|
/* ②–④: the model keeps a fixed band at the top; the table scrolls below it. */
|
||||||
.center-col { display: grid; grid-template-rows: clamp(280px, 48vh, 560px) minmax(0, 1fr); gap: 12px; }
|
.center-col { display: grid; grid-template-rows: clamp(280px, 48vh, 560px) minmax(0, 1fr); gap: 12px; }
|
||||||
.review-workspace { grid-template-rows: auto minmax(180px, var(--review-model-height, 240px)) minmax(0, 1fr); }
|
/* ②·③ workspace: tools / model / splitter / pane (상하) or model | splitter | pane (좌우). */
|
||||||
.review-workspace.model-collapsed { grid-template-rows: auto minmax(0, 1fr); }
|
.workspace { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(180px, var(--review-model-height, clamp(280px, 48vh, 560px))) 12px minmax(0, 1fr); gap: 0; }
|
||||||
|
.workspace > .workspace-tools { margin-bottom: 8px; }
|
||||||
|
.workspace.model-collapsed { grid-template-rows: auto minmax(0, 1fr); }
|
||||||
|
.workspace.layout-side { grid-template-columns: minmax(0, var(--model-width, 50%)) 12px minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); }
|
||||||
|
.workspace.layout-side > .workspace-tools { grid-column: 1 / -1; }
|
||||||
|
.workspace.layout-side > .viewer-slot { grid-column: 1; grid-row: 2; }
|
||||||
|
.workspace.layout-side > .workspace-splitter { grid-column: 2; grid-row: 2; }
|
||||||
|
.workspace.layout-side > .workspace-pane { grid-column: 3; grid-row: 2; }
|
||||||
|
.workspace.layout-side.model-collapsed { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
.workspace.layout-side.model-collapsed > .workspace-pane { grid-column: 1 / -1; }
|
||||||
|
.workspace-pane { min-height: 0; overflow: auto; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.workspace-pane > .viewer { flex: none; }
|
||||||
|
.review-workspace > .workspace-pane { overflow: hidden; }
|
||||||
|
.review-workspace > .workspace-pane > .viewer { flex: 1 1 auto; min-height: 0; overflow: auto; }
|
||||||
.viewer-slot { min-height: 0; height: 100%; display: flex; flex-direction: column; }
|
.viewer-slot { min-height: 0; height: 100%; display: flex; flex-direction: column; }
|
||||||
.viewer-slot > .viewer { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
|
.viewer-slot > .viewer { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
|
||||||
.viewer-slot .canvas { flex: 1 1 auto; height: auto; min-height: 180px; }
|
.viewer-slot .canvas { flex: 1 1 auto; height: auto; min-height: 180px; }
|
||||||
@@ -784,11 +806,28 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
|
|||||||
.viewer-slot .canvas { height: 60vh; min-height: 420px; flex: none; }
|
.viewer-slot .canvas { height: 60vh; min-height: 420px; flex: none; }
|
||||||
.grid > .review-workspace { max-height: none; }
|
.grid > .review-workspace { max-height: none; }
|
||||||
.review-workspace .viewer-slot .canvas { height: var(--review-model-height, 240px); min-height: 180px; }
|
.review-workspace .viewer-slot .canvas { height: var(--review-model-height, 240px); min-height: 180px; }
|
||||||
.review-workspace > .viewer { max-height: 75vh; overflow: auto; }
|
.review-workspace .workspace-pane > .viewer { max-height: 75vh; overflow: auto; }
|
||||||
|
.workspace-pane { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.workspace-splitter { display: none; }
|
||||||
.rebar-table thead th { position: sticky; top: 0; background: #ffffff; z-index: 1; }
|
.rebar-table thead th { position: sticky; top: 0; background: #ffffff; z-index: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.review-workspace.model-collapsed #slot-rebar { display: none; }
|
.workspace.model-collapsed > .viewer-slot, .workspace.model-collapsed > .workspace-splitter { display: none; }
|
||||||
|
|
||||||
|
/* ②·③ 배치 전환 · 분할선 (2026-11-09) */
|
||||||
|
.layout-switch { display: inline-flex; border: 1px solid var(--line, #d5dde6); border-radius: 8px; overflow: hidden; }
|
||||||
|
.layout-switch .btn { border: 0; border-radius: 0; min-width: 52px; }
|
||||||
|
.layout-switch .btn + .btn { border-left: 1px solid var(--line, #d5dde6); }
|
||||||
|
.layout-switch .btn[aria-pressed="true"] { background: #e8f1fb; color: #0b4f8a; font-weight: 700; }
|
||||||
|
.layout-note:empty { display: none; }
|
||||||
|
.workspace-splitter { position: relative; cursor: row-resize; touch-action: none; border-radius: 6px; outline: none; }
|
||||||
|
.workspace-splitter::after { content: ""; position: absolute; left: 50%; top: 50%; width: 48px; height: 4px; transform: translate(-50%, -50%); border-radius: 2px; background: #c3cfdb; transition: background .15s; }
|
||||||
|
.workspace.layout-side > .workspace-splitter { cursor: col-resize; }
|
||||||
|
.workspace.layout-side > .workspace-splitter::after { width: 4px; height: 48px; }
|
||||||
|
.workspace-splitter:hover::after, .workspace.is-resizing > .workspace-splitter::after { background: #2f7bc0; }
|
||||||
|
.workspace-splitter:focus-visible { box-shadow: inset 0 0 0 2px #2f7bc0; }
|
||||||
|
.workspace.is-resizing { user-select: none; cursor: row-resize; }
|
||||||
|
.workspace.layout-side.is-resizing { cursor: col-resize; }
|
||||||
|
|
||||||
/* 3D 영역 (승인 2026-10-23): 제목은 교각 목록으로. 좌측 스택(부재 표시 · 시점), 우측 스택(색 기준 · 원점)을 캔버스 위에 둠. */
|
/* 3D 영역 (승인 2026-10-23): 제목은 교각 목록으로. 좌측 스택(부재 표시 · 시점), 우측 스택(색 기준 · 원점)을 캔버스 위에 둠. */
|
||||||
#viewer-block { position: relative; }
|
#viewer-block { position: relative; }
|
||||||
|
|||||||
@@ -248,6 +248,8 @@ export function buildAbutGroup(
|
|||||||
soles?: BearingSole[],
|
soles?: BearingSole[],
|
||||||
/** 수축줄눈 위치 s (m, core/abut/joints.ts). Absent = the default count between the bearings. */
|
/** 수축줄눈 위치 s (m, core/abut/joints.ts). Absent = the default count between the bearings. */
|
||||||
jointS?: number[],
|
jointS?: number[],
|
||||||
|
/** Another object already draws the shared terrain in this scene. */
|
||||||
|
sharedTerrain = false,
|
||||||
): THREE.Group {
|
): THREE.Group {
|
||||||
const root = new THREE.Group();
|
const root = new THREE.Group();
|
||||||
root.name = model.name;
|
root.name = model.name;
|
||||||
@@ -486,13 +488,16 @@ export function buildAbutGroup(
|
|||||||
// piers); otherwise a flat pad at 지반고 covers the footing and the wings.
|
// piers); otherwise a flat pad at 지반고 covers the footing and the wings.
|
||||||
const footprint = [model.ip1, input3(model), ...wings.flatMap((w) => w.corners)];
|
const footprint = [model.ip1, input3(model), ...wings.flatMap((w) => w.corners)];
|
||||||
const grid = terrain && pivot && covers(terrain, footprint, ABUT_GROUND_MARGIN_M) ? terrain : undefined;
|
const grid = terrain && pivot && covers(terrain, footprint, ABUT_GROUND_MARGIN_M) ? terrain : undefined;
|
||||||
if (grid && pivot) {
|
if (grid && pivot && !sharedTerrain) {
|
||||||
const mesh = new THREE.Mesh(terrainGeometry(grid, pivot), new THREE.MeshStandardMaterial({ color: 0x6b8f4e, roughness: 0.9, transparent: true, opacity: 0.35, depthWrite: false, side: THREE.DoubleSide }));
|
const geometry = terrainGeometry(grid, pivot);
|
||||||
|
// Terrain is already in the common scene frame; cancel the root placement once.
|
||||||
|
geometry.applyMatrix4(abutPlacement(model, pivot).invert());
|
||||||
|
const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial({ color: 0x6b8f4e, roughness: 0.9, transparent: true, opacity: 0.35, depthWrite: false, side: THREE.DoubleSide }));
|
||||||
const group = new THREE.Group();
|
const group = new THREE.Group();
|
||||||
group.name = 'ground';
|
group.name = 'ground';
|
||||||
group.add(mesh);
|
group.add(mesh);
|
||||||
root.add(group);
|
root.add(group);
|
||||||
} else if (groundEl !== undefined) {
|
} else if (!sharedTerrain && groundEl !== undefined) {
|
||||||
const ts = [members.shape.footingBackM, members.shape.footingFrontM, ...wings.flatMap((w) => w.corners.map((c) => toAxis(model, c).t))];
|
const ts = [members.shape.footingBackM, members.shape.footingFrontM, ...wings.flatMap((w) => w.corners.map((c) => toAxis(model, c).t))];
|
||||||
const tMin = Math.min(...ts) - ABUT_GROUND_MARGIN_M;
|
const tMin = Math.min(...ts) - ABUT_GROUND_MARGIN_M;
|
||||||
const tMax = Math.max(...ts) + ABUT_GROUND_MARGIN_M;
|
const tMax = Math.max(...ts) + ABUT_GROUND_MARGIN_M;
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import type { GroundModelFile } from '../core/ground-model';
|
||||||
|
import type { PierInput } from '../core/types';
|
||||||
|
import { abutGroundFromModel, groundInputChanged } from '../core/project-ground';
|
||||||
|
import type { AbutRecord } from '../core/abut/parse';
|
||||||
|
|
||||||
|
type Snapshot = { model: GroundModelFile | null; piers: { input: PierInput; draft: PierInput }[]; abutments: AbutRecord[] };
|
||||||
|
const node = <K extends keyof HTMLElementTagNameMap>(tag: K, text?: string) => {
|
||||||
|
const e = document.createElement(tag); if (text !== undefined) e.textContent = text; return e;
|
||||||
|
};
|
||||||
|
export function createProjectGroundManager(options: {
|
||||||
|
snapshot: () => Snapshot;
|
||||||
|
importModel: (file: File) => Promise<void>;
|
||||||
|
apply: (names: Set<string>, fields: { ground: boolean; layers: boolean }) => void;
|
||||||
|
regenerate: () => void;
|
||||||
|
}) {
|
||||||
|
const dialog = node('dialog'); dialog.id = 'project-ground-dialog';
|
||||||
|
dialog.setAttribute('aria-labelledby', 'project-ground-title');
|
||||||
|
const header = node('div'); header.className = 'viewer-head';
|
||||||
|
const title = node('h2', '프로젝트 지반모델'); title.id = 'project-ground-title';
|
||||||
|
const close = node('button', '닫기'); close.type = 'button'; close.className = 'btn-o'; close.onclick = () => dialog.close();
|
||||||
|
header.append(title, close);
|
||||||
|
const controls = node('div'); controls.className = 'project-ground-actions';
|
||||||
|
const generate = node('button', '지반모델 생성'); generate.type = 'button'; generate.className = 'btn-o';
|
||||||
|
generate.onclick = () => window.dispatchEvent(new Event('pierzainer:open-ground'));
|
||||||
|
const picker = node('input'); picker.type = 'file'; picker.accept = '.json'; picker.id = 'ground-import'; picker.hidden = true;
|
||||||
|
const load = node('button', '기존 JSON 불러오기'); load.type = 'button'; load.className = 'btn-o'; load.onclick = () => picker.click();
|
||||||
|
const message = node('p'); message.setAttribute('role', 'status'); message.className = 'pnote';
|
||||||
|
picker.onchange = async () => {
|
||||||
|
const file = picker.files?.[0]; if (!file) return;
|
||||||
|
try { await options.importModel(file); message.textContent = `${file.name}에서 지반모델을 불러왔습니다. 기존 입력은 유지됩니다. 적용할 구조물을 선택하세요.`; }
|
||||||
|
catch (error) { message.textContent = `${file.name}: ${(error as Error).message}`; }
|
||||||
|
finally { picker.value = ''; }
|
||||||
|
};
|
||||||
|
controls.append(generate, load, picker);
|
||||||
|
const info = node('p'); info.id = 'project-ground-info'; info.className = 'pnote';
|
||||||
|
const fields = node('fieldset'); const legend = node('legend', '선택 구조물에 적용할 값'); fields.append(legend);
|
||||||
|
const ground = node('input'); ground.type = 'checkbox'; ground.checked = true; ground.id = 'project-ground-el';
|
||||||
|
const layers = node('input'); layers.type = 'checkbox'; layers.id = 'project-ground-layers';
|
||||||
|
const gl = node('label'); gl.append(ground, ' 지반고'); const ll = node('label'); ll.append(layers, ' 지층 (기존 지층·N값 대체)'); fields.append(gl, ll);
|
||||||
|
const scroll = node('div'); scroll.className = 'project-ground-table';
|
||||||
|
const table = node('table'); table.className = 'grid-table'; scroll.append(table);
|
||||||
|
const actions = node('div'); actions.className = 'project-ground-actions';
|
||||||
|
const all = node('button', '적용 가능한 구조물 선택'); all.type = 'button'; all.className = 'btn-o'; all.id = 'project-ground-select-all';
|
||||||
|
const apply = node('button', '선택 구조물에 적용'); apply.type = 'button'; apply.id = 'project-ground-apply'; apply.className = 'btn';
|
||||||
|
const regenerate = node('button'); regenerate.type = 'button'; regenerate.id = 'project-ground-regenerate'; regenerate.className = 'btn-o'; regenerate.onclick = options.regenerate;
|
||||||
|
const selected = new Set<string>();
|
||||||
|
let lastModel: GroundModelFile | null = null;
|
||||||
|
const rows = () => {
|
||||||
|
const snap = options.snapshot();
|
||||||
|
return [
|
||||||
|
...snap.abutments.map(a => ({ key: `abut:${a.name}`, name: a.name, kind: '교대', current: a.groundEl, layers: a.ground?.strataRows, pending: false, entry: snap.model ? abutGroundFromModel(snap.model, a) : undefined })),
|
||||||
|
...snap.piers.map(p => ({ key: `pier:${p.input.name}`, name: p.input.name, kind: '교각', current: p.draft.groundEl, layers: p.draft.strataRows, pending: groundInputChanged(p.input, p.draft), entry: snap.model?.piers.find(e => e.id === p.input.name) ? { ...snap.model.piers.find(e => e.id === p.input.name)!, source: 'JSON 개별값' } : undefined })),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
const eligible = (key: string) => {
|
||||||
|
const entry = rows().find(p => p.key === key)?.entry;
|
||||||
|
return !!entry && ((ground.checked && entry.centerEl !== null) || (layers.checked && !!entry.layers?.length));
|
||||||
|
};
|
||||||
|
const update = () => { apply.disabled = ![...selected].some(eligible); };
|
||||||
|
all.onclick = () => { for (const p of rows()) if (eligible(p.key)) selected.add(p.key); refresh(); };
|
||||||
|
// Keep the message outside refresh so applying a model retains the result notice.
|
||||||
|
apply.onclick = () => { options.apply(new Set([...selected].filter(eligible)), { ground: ground.checked, layers: layers.checked }); message.textContent = '선택한 값을 적용했습니다. 교대는 즉시 갱신되며, 변경 교각은 다시 생성하세요.'; };
|
||||||
|
ground.onchange = layers.onchange = () => refresh();
|
||||||
|
function refresh() {
|
||||||
|
const { model, piers } = options.snapshot();
|
||||||
|
if (model !== lastModel) { selected.clear(); lastModel = model; }
|
||||||
|
const items = rows();
|
||||||
|
document.getElementById('project-ground-status')!.textContent = model ? `불러옴 · 구조물 ${items.filter(p => p.entry?.centerEl != null).length}/${items.length}기 연결` : '모델 없음';
|
||||||
|
if (!dialog.open) return;
|
||||||
|
info.textContent = model ? `생성일 ${model.generated || '정보 없음'} · 모델 ${model.piers.length}기 · 지형 ${model.terrainGrid ? '있음' : '없음'} · 지도 ${model.mapLayers?.length ?? 0}개. 프로젝트에 함께 저장됩니다.` : '지반모델 JSON 또는 지반모델이 포함된 프로젝트 JSON을 불러온 뒤 적용할 교각을 선택하세요. 프로젝트 JSON에서는 지반모델만 가져옵니다. 생성 설정 JSON은 사용할 수 없습니다.';
|
||||||
|
table.replaceChildren();
|
||||||
|
const head = node('thead'), hr = node('tr'); for (const label of ['선택', '구조물', '현재 입력 EL (m)', '모델 EL (m)', '차이 (m)', '모델 지층', '상태', '종류', '값의 출처']) hr.append(node('th', label)); head.append(hr); table.append(head);
|
||||||
|
const body = node('tbody');
|
||||||
|
for (const p of items) {
|
||||||
|
const entry = p.entry, value = entry?.centerEl;
|
||||||
|
const row = node('tr'), cell = node('td'), check = node('input'); check.type = 'checkbox'; check.dataset.groundPier = p.name; check.dataset.groundKind = p.kind; check.setAttribute('aria-label', `${p.name} ${p.kind} 적용 선택`); check.disabled = !eligible(p.key); check.checked = selected.has(p.key) && !check.disabled;
|
||||||
|
check.onchange = () => { if (check.checked) selected.add(p.key); else selected.delete(p.key); update(); }; cell.append(check); row.append(cell);
|
||||||
|
let status = !model ? '모델 없음' : !entry ? '모델에 없음' : value === null ? '지형 범위 밖/자료 없음' : p.pending ? '형상 재생성 필요' : value === p.current ? '지반고 일치' : '지반고 차이';
|
||||||
|
if (entry?.layers?.length && JSON.stringify(entry.layers) !== JSON.stringify(p.layers ?? [])) status += ' · 지층 차이';
|
||||||
|
for (const text of [p.name, p.current.toFixed(3), value == null ? '—' : value.toFixed(3), value == null ? '—' : (value - p.current).toFixed(3), `${entry?.layers?.length ?? 0}개`, status, p.kind, entry?.source ?? '—']) row.append(node('td', text)); body.append(row);
|
||||||
|
}
|
||||||
|
table.append(body);
|
||||||
|
const count = piers.filter(p => groundInputChanged(p.input, p.draft)).length;
|
||||||
|
regenerate.textContent = `변경 교각 다시 생성 (${count}기)`; regenerate.disabled = count === 0;
|
||||||
|
all.disabled = !items.some(p => eligible(p.key)); update();
|
||||||
|
}
|
||||||
|
actions.append(all, apply, regenerate);
|
||||||
|
dialog.append(header, controls, info, fields, scroll, actions, message, node('p', '교대는 JSON 개별값이 없으면 IP2 위치의 유효 격자 4점으로 지반고를 보간합니다. 범위 밖·자료 없음은 적용하지 않으며 지층은 개별 자료만 사용합니다. 교대는 적용 즉시 갱신됩니다. 교각은 다시 생성할 때 다른 수정 입력도 함께 반영됩니다.'));
|
||||||
|
document.body.append(dialog);
|
||||||
|
document.getElementById('project-ground-open')!.onclick = () => { dialog.showModal(); refresh(); };
|
||||||
|
return { refresh, reset: () => { selected.clear(); ground.checked = true; layers.checked = false; message.textContent = ''; dialog.close(); } };
|
||||||
|
}
|
||||||
+68
-50
@@ -2,15 +2,18 @@ import * as THREE from 'three';
|
|||||||
import type { PierModel } from '../core/geometry';
|
import type { PierModel } from '../core/geometry';
|
||||||
import { assemblyEndOffset, assemblyGridIndices, assemblyJs, b10Levels, b9Bars, gridPositions, grid, layers, legLength, legOffsets, sideDepth } from '../core/rebar/footing';
|
import { assemblyEndOffset, assemblyGridIndices, assemblyJs, b10Levels, b9Bars, gridPositions, grid, layers, legLength, legOffsets, sideDepth } from '../core/rebar/footing';
|
||||||
import { bottomPiece, MAIN, topSplicePieces, widthLayout } from '../core/rebar/coping-main';
|
import { bottomPiece, MAIN, topSplicePieces, widthLayout } from '../core/rebar/coping-main';
|
||||||
import { C14_INSET, c14Bars, CAP, COPING_REBAR, SHEAR_LOOP_STEP, shearBars, shearBarsMulti, shearUHeight, copingSideBars, SIDE_HOOK_LEG, stairBars, undersideAt } from '../core/rebar/coping';
|
import { C14_INSET, c14Bars, CAP, COPING_REBAR, SHEAR_LOOP_STEP, shearBars, shearBarsMulti, shearUHeight, copingSideBars, SIDE_HOOK_LEG, tStepBars, undersideAt } from '../core/rebar/coping';
|
||||||
import { sideRowLevels } from '../core/coping-geom';
|
import { sideRowLevels } from '../core/coping-geom';
|
||||||
import { columnRebar, columnInputFor } from '../core/rebar/column';
|
import { columnRebar, columnInputFor } from '../core/rebar/column';
|
||||||
import { castRebarLayout } from '../core/rebar/cast-layout';
|
import { castRebarLayout } from '../core/rebar/cast-layout';
|
||||||
import { multicolCopingBars } from '../core/rebar/multicol-coping';
|
import { multicolCopingBars } from '../core/rebar/multicol-coping';
|
||||||
|
import { copingFold, foldBar } from '../core/rebar/coping-fold';
|
||||||
|
import { stepBlockBars } from '../core/rebar/step-bars';
|
||||||
|
import { footingUnits } from '../core/rebar/footing-units';
|
||||||
import { barDepths, barsAcross } from '../core/rebar/bearing-block';
|
import { barDepths, barsAcross } from '../core/rebar/bearing-block';
|
||||||
import { blockFramePoint } from '../core/geometry';
|
import { blockFramePoint } from '../core/geometry';
|
||||||
import { INTEGRATED_BLOCK } from '../core/integrated-block';
|
import { INTEGRATED_BLOCK } from '../core/integrated-block';
|
||||||
import { bendRadius, DEFAULT_MATERIAL, FckByMember, hookLength, Material, materialFor, NOMINAL_DIAMETER } from '../core/rebar/common';
|
import { bendRadius, DEFAULT_MATERIAL, developmentLength, FckByMember, fyOf, hookLength, Material, materialFor, NOMINAL_DIAMETER } from '../core/rebar/common';
|
||||||
import { diameterGroupIndex, DIAMETER_GROUPS, memberOfBar, type RebarMemberKey } from '../core/rebar/color';
|
import { diameterGroupIndex, DIAMETER_GROUPS, memberOfBar, type RebarMemberKey } from '../core/rebar/color';
|
||||||
import { stretchMatrix } from './scene';
|
import { stretchMatrix } from './scene';
|
||||||
|
|
||||||
@@ -98,7 +101,6 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
|
|||||||
const Ms = m.stretches.map((_, k) => stretchMatrix(m, k));
|
const Ms = m.stretches.map((_, k) => stretchMatrix(m, k));
|
||||||
const L = m.copingL;
|
const L = m.copingL;
|
||||||
const B = m.copingB;
|
const B = m.copingB;
|
||||||
const straight = m.kink === null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A bar through several points as one tube, each bend rounded to its centre-line radius (수정 2026-10-23).
|
* A bar through several points as one tube, each bend rounded to its centre-line radius (수정 2026-10-23).
|
||||||
@@ -163,14 +165,27 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
|
|||||||
byName.set(name, list);
|
byName.set(name, list);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Coping (straight coping only; a bent coping is not drawn in 3D yet).
|
// Coping. A bent coping draws each bar's pieces in their own stretch (coping-fold.ts, 2026-11-10).
|
||||||
if (straight && m.columns.length > 1) {
|
if (m.columns.length > 1) {
|
||||||
for (const b of multicolCopingBars(m, mCoping)) {
|
for (const b of multicolCopingBars(m, mCoping)) {
|
||||||
if (/^C[1-8](-\d+)?$/.test(b.name)) addPath(b.name, 0, b.pts, b.diameterMm, COLOR.coping);
|
for (const p of b.pieces ?? [{ k: 0 as const, pts: b.pts }]) {
|
||||||
else for (let i = 1; i < b.pts.length; i++) addBar(b.name, 0, b.pts[i - 1], b.pts[i], b.diameterMm, COLOR.shear);
|
if (/^C[1-8](-\d+)?$/.test(b.name)) addPath(b.name, p.k, p.pts, b.diameterMm, COLOR.coping);
|
||||||
|
else for (let i = 1; i < p.pts.length; i++) addBar(b.name, p.k, p.pts[i - 1], p.pts[i], b.diameterMm, COLOR.shear);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (straight && m.columns.length <= 1) {
|
}
|
||||||
|
if (m.columns.length <= 1) {
|
||||||
|
// T형 on a bent coping: the unfolded bars turn at their mitre point (the mitre shear set and the wedge are multi-column only).
|
||||||
|
const fold = copingFold(m);
|
||||||
|
const tBar = (name: string, k: number, a: [number, number, number], b: [number, number, number], dMm: number, color: number) => {
|
||||||
|
if (!fold) return addBar(name, k, a, b, dMm, color);
|
||||||
|
for (const p of foldBar(fold, [a, b]).pieces) for (let i = 1; i < p.pts.length; i++) addBar(name, p.k, p.pts[i - 1], p.pts[i], dMm, color);
|
||||||
|
};
|
||||||
|
const tPath = (name: string, k: number, pts: [number, number, number][], dMm: number, color: number) => {
|
||||||
|
if (!fold) return addPath(name, k, pts, dMm, color);
|
||||||
|
for (const p of foldBar(fold, pts).pieces) addPath(name, p.k, p.pts, dMm, color);
|
||||||
|
};
|
||||||
|
|
||||||
const w = widthLayout(B * 1000, m.copingRebar);
|
const w = widthLayout(B * 1000, m.copingRebar);
|
||||||
const xs = [w.yE, ...Array.from({ length: w.n + 1 }, (_, i) => w.yE + w.g + w.spacing * i), B * 1000 - w.yE].map((v) => mm(v) - B / 2);
|
const xs = [w.yE, ...Array.from({ length: w.n + 1 }, (_, i) => w.yE + w.g + w.spacing * i), B * 1000 - w.yE].map((v) => mm(v) - B / 2);
|
||||||
const cover = 0.1;
|
const cover = 0.1;
|
||||||
@@ -189,8 +204,8 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
|
|||||||
xs.forEach((n, j) => {
|
xs.forEach((n, j) => {
|
||||||
const name = `C${k + (j % 2 === 1 ? 0 : 3)}`;
|
const name = `C${k + (j % 2 === 1 ? 0 : 3)}`;
|
||||||
if (sp) {
|
if (sp) {
|
||||||
addPath(name, 0, [[xk, z - hookTop, n], [xk, z, n], [splitX + half, z, n]], NOMINAL_DIAMETER.H29, COLOR.coping);
|
tPath(name, 0, [[xk, z - hookTop, n], [xk, z, n], [splitX + half, z, n]], NOMINAL_DIAMETER.H29, COLOR.coping);
|
||||||
addPath(name, 0, [[splitX - half, z, n], [L - xk, z, n], [L - xk, z - hookTop, n]], NOMINAL_DIAMETER.H29, COLOR.coping);
|
tPath(name, 0, [[splitX - half, z, n], [L - xk, z, n], [L - xk, z - hookTop, n]], NOMINAL_DIAMETER.H29, COLOR.coping);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pts: [number, number, number][] = [
|
const pts: [number, number, number][] = [
|
||||||
@@ -199,7 +214,7 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
|
|||||||
[L - xk, z, n],
|
[L - xk, z, n],
|
||||||
[L - xk, z - hookTop, n],
|
[L - xk, z - hookTop, n],
|
||||||
];
|
];
|
||||||
addPath(name, 0, pts, NOMINAL_DIAMETER.H29, COLOR.coping);
|
tPath(name, 0, pts, NOMINAL_DIAMETER.H29, COLOR.coping);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Bottom bars C7 / C8 (코핑철근.md §5.2–5.3): they follow the sloped underside, 127 above it, with a vertical leg up
|
// Bottom bars C7 / C8 (코핑철근.md §5.2–5.3): they follow the sloped underside, 127 above it, with a vertical leg up
|
||||||
@@ -218,7 +233,7 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
|
|||||||
const right = left.map(([x, y, zz]): [number, number, number] => [L - x, y, zz]);
|
const right = left.map(([x, y, zz]): [number, number, number] => [L - x, y, zz]);
|
||||||
const name = j % 2 === 1 ? 'C7' : 'C8';
|
const name = j % 2 === 1 ? 'C7' : 'C8';
|
||||||
for (const pts of [left, right]) {
|
for (const pts of [left, right]) {
|
||||||
addPath(name, 0, pts, NOMINAL_DIAMETER.H29, COLOR.coping);
|
tPath(name, 0, pts, NOMINAL_DIAMETER.H29, COLOR.coping);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Shear loops C9 / C10 / C11 and the gaps between columns: a closed U at each position.
|
// Shear loops C9 / C10 / C11 and the gaps between columns: a closed U at each position.
|
||||||
@@ -244,12 +259,12 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
|
|||||||
// The three 폐합 stack to the right, so no U meets another loop's U or 삿갓 (SHEAR_LOOP_STEP).
|
// The three 폐합 stack to the right, so no U meets another loop's U or 삿갓 (SHEAR_LOOP_STEP).
|
||||||
const xu = xm + mm(i * SHEAR_LOOP_STEP);
|
const xu = xm + mm(i * SHEAR_LOOP_STEP);
|
||||||
const xc = xu + mm(dShear);
|
const xc = xu + mm(dShear);
|
||||||
addBar(name, 0, [xu, bot, a], [xu, bot, b], dShear, COLOR.shear);
|
tBar(name, 0, [xu, bot, a], [xu, bot, b], dShear, COLOR.shear);
|
||||||
addBar(name, 0, [xu, bot, a], [xu, top, a], dShear, COLOR.shear);
|
tBar(name, 0, [xu, bot, a], [xu, top, a], dShear, COLOR.shear);
|
||||||
addBar(name, 0, [xu, bot, b], [xu, top, b], dShear, COLOR.shear);
|
tBar(name, 0, [xu, bot, b], [xu, top, b], dShear, COLOR.shear);
|
||||||
addBar(name, 0, [xc, top, a], [xc, top, b], dShear, COLOR.shear);
|
tBar(name, 0, [xc, top, a], [xc, top, b], dShear, COLOR.shear);
|
||||||
addBar(name, 0, [xc, top, a], [xc, capBot, a], dShear, COLOR.shear);
|
tBar(name, 0, [xc, top, a], [xc, capBot, a], dShear, COLOR.shear);
|
||||||
addBar(name, 0, [xc, top, b], [xc, capBot, b], dShear, COLOR.shear);
|
tBar(name, 0, [xc, top, b], [xc, capBot, b], dShear, COLOR.shear);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -262,14 +277,14 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
|
|||||||
for (const [name, rows] of [['C12', side.c12], ['C13', side.c13]] as const) {
|
for (const [name, rows] of [['C12', side.c12], ['C13', side.c13]] as const) {
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
const xw = mm(r.xw);
|
const xw = mm(r.xw);
|
||||||
addBar(name, 0, [xw, r.z, -sideFace], [L - xw, r.z, -sideFace], dSide, COLOR.shear);
|
tBar(name, 0, [xw, r.z, -sideFace], [L - xw, r.z, -sideFace], dSide, COLOR.shear);
|
||||||
addBar(name, 0, [xw, r.z, sideFace], [L - xw, r.z, sideFace], dSide, COLOR.shear);
|
tBar(name, 0, [xw, r.z, sideFace], [L - xw, r.z, sideFace], dSide, COLOR.shear);
|
||||||
const zh = r.z + mm(dSide);
|
const zh = r.z + mm(dSide);
|
||||||
for (const x0 of [xw, L - xw]) {
|
for (const x0 of [xw, L - xw]) {
|
||||||
const leg = mm(SIDE_HOOK_LEG) * Math.sign(L / 2 - x0);
|
const leg = mm(SIDE_HOOK_LEG) * Math.sign(L / 2 - x0);
|
||||||
addBar(name, 0, [x0, zh, -sideFace], [x0, zh, sideFace], dSide, COLOR.shear);
|
tBar(name, 0, [x0, zh, -sideFace], [x0, zh, sideFace], dSide, COLOR.shear);
|
||||||
addBar(name, 0, [x0, zh, -sideFace], [x0 + leg, zh, -sideFace], dSide, COLOR.shear);
|
tBar(name, 0, [x0, zh, -sideFace], [x0 + leg, zh, -sideFace], dSide, COLOR.shear);
|
||||||
addBar(name, 0, [x0, zh, sideFace], [x0 + leg, zh, sideFace], dSide, COLOR.shear);
|
tBar(name, 0, [x0, zh, sideFace], [x0 + leg, zh, sideFace], dSide, COLOR.shear);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -280,31 +295,24 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
|
|||||||
const c14Nb = B / 2 - mm(C14_INSET);
|
const c14Nb = B / 2 - mm(C14_INSET);
|
||||||
for (const b of c14Bars(L * 1000, topEl, [...side.c13, ...side.c12], (x) => undersideAt(L * 1000, x, m.endBottom, m.levelBottom, m.copingGeom.levelWidth), m.copingRebar.mainLayerGap / 1000)) {
|
for (const b of c14Bars(L * 1000, topEl, [...side.c13, ...side.c12], (x) => undersideAt(L * 1000, x, m.endBottom, m.levelBottom, m.copingGeom.levelWidth), m.copingRebar.mainLayerGap / 1000)) {
|
||||||
const xm = mm(b.x);
|
const xm = mm(b.x);
|
||||||
addBar('C14', 0, [xm, b.z, c14Na], [xm, b.z, c14Nb], NOMINAL_DIAMETER.H19, COLOR.coping);
|
tBar('C14', 0, [xm, b.z, c14Na], [xm, b.z, c14Nb], NOMINAL_DIAMETER.H19, COLOR.coping);
|
||||||
// 연직 발 below the coping underside is left out (코핑철근.md §8).
|
// 연직 발 below the coping underside is left out (코핑철근.md §8).
|
||||||
if (b.legs) {
|
if (b.legs) {
|
||||||
addBar('C14', 0, [xm, b.z - c14Hook, c14Na], [xm, b.z, c14Na], NOMINAL_DIAMETER.H19, COLOR.coping);
|
tBar('C14', 0, [xm, b.z - c14Hook, c14Na], [xm, b.z, c14Na], NOMINAL_DIAMETER.H19, COLOR.coping);
|
||||||
addBar('C14', 0, [xm, b.z, c14Nb], [xm, b.z - c14Hook, c14Nb], NOMINAL_DIAMETER.H19, COLOR.coping);
|
tBar('C14', 0, [xm, b.z, c14Nb], [xm, b.z - c14Hook, c14Nb], NOMINAL_DIAMETER.H19, COLOR.coping);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// C15 (거꾸로 된 U) and C16 (L형) on the stair (코핑철근.md §7): 11 bars each across the width.
|
// C15 (⊓) and C16 (L형) on the model's own steps (결정 2026-11-10, src/core/rebar/step-bars.ts).
|
||||||
const stairs = stairBars(L * 1000, B * 1000, topEl, mCoping, 2 * m.copingRebar.mainSpacing);
|
for (const b of tStepBars(m, mCoping).bars) {
|
||||||
for (const s of stairs.c15) {
|
const n = b.n;
|
||||||
const n = mm(s.n);
|
for (let i = 1; i < b.pts.length; i++) {
|
||||||
const ld = mm(s.leg);
|
tBar(b.name, 0, [mm(b.pts[i - 1][0]), b.pts[i - 1][1], n], [mm(b.pts[i][0]), b.pts[i][1], n], NOMINAL_DIAMETER.H19, COLOR.coping);
|
||||||
const x0 = mm(s.x0);
|
|
||||||
const x1 = mm(s.x1);
|
|
||||||
addBar('C15', 0, [x0, s.z - ld, n], [x0, s.z, n], NOMINAL_DIAMETER.H19, COLOR.coping);
|
|
||||||
addBar('C15', 0, [x0, s.z, n], [x1, s.z, n], NOMINAL_DIAMETER.H19, COLOR.coping);
|
|
||||||
addBar('C15', 0, [x1, s.z, n], [x1, s.z - ld, n], NOMINAL_DIAMETER.H19, COLOR.coping);
|
|
||||||
}
|
}
|
||||||
for (const s of stairs.c16) {
|
}
|
||||||
const n = mm(s.n);
|
// 전·후열 단차 300 이상 블록 보강 C17 · C18 (결정 2026-11-10, step-bars.ts).
|
||||||
const ld = mm(s.leg);
|
const blockLd = Math.round(developmentLength(NOMINAL_DIAMETER.H16, fyOf(mCoping, 'H16'), mCoping.fck));
|
||||||
const x0 = mm(s.x0);
|
for (const b of stepBlockBars(m, { cover: COPING_REBAR.cover, d: NOMINAL_DIAMETER.H16, ld: blockLd, tieSpacing: m.copingRebar.spacingS, skinSpacing: m.copingRebar.sideSpacing })) {
|
||||||
const x1 = mm(s.x1);
|
for (let i = 1; i < b.pts.length; i++) tBar(b.name, 0, b.pts[i - 1], b.pts[i], NOMINAL_DIAMETER.H16, COLOR.coping);
|
||||||
addBar('C16', 0, [x0, s.z - ld, n], [x0, s.z, n], NOMINAL_DIAMETER.H19, COLOR.coping);
|
|
||||||
addBar('C16', 0, [x0, s.z, n], [x1, s.z, n], NOMINAL_DIAMETER.H19, COLOR.coping);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,7 +433,17 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Footing bars B1–B8 (교각축 · 교각축 직각), the grids from §3 and §6 of 확대기초철근 (1).md.
|
// Footing bars B1–B8 (교각축 · 교각축 직각), the grids from §3 and §6 of 확대기초철근 (1).md.
|
||||||
for (const f of m.footings) {
|
// One unit per real footing (footing-units.ts); a bent footing is drawn in unfolded g and folded at the mitre.
|
||||||
|
for (const f of footingUnits(m)) {
|
||||||
|
const fold = f.bent ? copingFold(m) : null;
|
||||||
|
const fBar = (name: string, a: [number, number, number], b: [number, number, number], dMm: number, color: number) => {
|
||||||
|
if (!fold) return addBar(name, f.k, a, b, dMm, color);
|
||||||
|
for (const p of foldBar(fold, [a, b]).pieces) for (let i = 1; i < p.pts.length; i++) addBar(name, p.k, p.pts[i - 1], p.pts[i], dMm, color);
|
||||||
|
};
|
||||||
|
const fPath = (name: string, pts: [number, number, number][], dMm: number, color: number) => {
|
||||||
|
if (!fold) return addPath(name, f.k, pts, dMm, color);
|
||||||
|
for (const p of foldBar(fold, pts).pieces) addPath(name, p.k, p.pts, dMm, color);
|
||||||
|
};
|
||||||
const Lf = f.length;
|
const Lf = f.length;
|
||||||
const Wf = f.width;
|
const Wf = f.width;
|
||||||
const Hf = (f.top - f.bottom) * 1000;
|
const Hf = (f.top - f.bottom) * 1000;
|
||||||
@@ -445,7 +463,7 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
|
|||||||
const off = legOffsets();
|
const off = legOffsets();
|
||||||
// One name, several cylinders: each segment of the path is one bar piece.
|
// One name, several cylinders: each segment of the path is one bar piece.
|
||||||
const path = (name: string, pts: [number, number, number][], dMm: number) => {
|
const path = (name: string, pts: [number, number, number][], dMm: number) => {
|
||||||
addPath(name, 0, pts, dMm, COLOR.footing);
|
fPath(name, pts, dMm, COLOR.footing);
|
||||||
};
|
};
|
||||||
// B1–B4: along s, at W position n. legTo (absolute EL, m) is where both ends turn up or down (§5.1); undefined = straight.
|
// B1–B4: along s, at W position n. legTo (absolute EL, m) is where both ends turn up or down (§5.1); undefined = straight.
|
||||||
const along = (name: string, n: number, y: number, legTo: number | undefined, dMm: number) => {
|
const along = (name: string, n: number, y: number, legTo: number | undefined, dMm: number) => {
|
||||||
@@ -485,21 +503,21 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
|
|||||||
// Each hook tail (6·d) turns toward the footing centre along the pier axis (Q-B19).
|
// Each hook tail (6·d) turns toward the footing centre along the pier axis (Q-B19).
|
||||||
const dB9 = NOMINAL_DIAMETER.H16;
|
const dB9 = NOMINAL_DIAMETER.H16;
|
||||||
const tail9 = (6 * dB9) / 1000;
|
const tail9 = (6 * dB9) / 1000;
|
||||||
const colX = m.columns.filter((c) => Math.abs(c.s - f.s) <= Lf / 2).map((c) => (c.s - f.s) * 1000);
|
const colX = f.columnX;
|
||||||
const yB9Bot = bottom + mm(z.B24);
|
const yB9Bot = bottom + mm(z.B24);
|
||||||
const yB9Top = bottom + mm(z.B5);
|
const yB9Top = bottom + mm(z.B5);
|
||||||
for (const b of b9Bars(Lf * 1000, Wf * 1000, colX, 2000, fs).bars) {
|
for (const b of b9Bars(Lf * 1000, Wf * 1000, colX, 2000, fs).bars) {
|
||||||
const x = mm(b.x);
|
const x = mm(b.x);
|
||||||
const n = mm(b.n);
|
const n = mm(b.n);
|
||||||
const tailDir = x >= 0 ? -1 : 1;
|
const tailDir = x >= 0 ? -1 : 1;
|
||||||
addBar('B9', 0, [f.s + x, yB9Bot, n], [f.s + x, yB9Top, n], dB9, COLOR.footing);
|
fBar('B9', [f.s + x, yB9Bot, n], [f.s + x, yB9Top, n], dB9, COLOR.footing);
|
||||||
addBar('B9', 0, [f.s + x, yB9Top, n], [f.s + x + tailDir * tail9, yB9Top, n], dB9, COLOR.footing);
|
fBar('B9', [f.s + x, yB9Top, n], [f.s + x + tailDir * tail9, yB9Top, n], dB9, COLOR.footing);
|
||||||
addBar('B9', 0, [f.s + x, yB9Bot, n], [f.s + x + tailDir * tail9, yB9Bot, n], dB9, COLOR.footing);
|
fBar('B9', [f.s + x, yB9Bot, n], [f.s + x + tailDir * tail9, yB9Bot, n], dB9, COLOR.footing);
|
||||||
}
|
}
|
||||||
|
|
||||||
// B10-1 / B10-2 (§7) at each B10 level, B11 / B12 (§8) between them. Ends turn 90° inward (B10) or down (B11, B12).
|
// B10-1 / B10-2 (§7) at each B10 level, B11 / B12 (§8) between them. Ends turn 90° inward (B10) or down (B11, B12).
|
||||||
const polyline = (name: string, pts: [number, number, number][], dMm: number) => {
|
const polyline = (name: string, pts: [number, number, number][], dMm: number) => {
|
||||||
addPath(name, 0, pts, dMm, COLOR.footing);
|
fPath(name, pts, dMm, COLOR.footing);
|
||||||
};
|
};
|
||||||
const hookB10 = mm(hookLength('main90', NOMINAL_DIAMETER.S19)); // 296
|
const hookB10 = mm(hookLength('main90', NOMINAL_DIAMETER.S19)); // 296
|
||||||
const hookAsm = mm(Math.round(hookLength('main90', NOMINAL_DIAMETER.S16))); // 246
|
const hookAsm = mm(Math.round(hookLength('main90', NOMINAL_DIAMETER.S16))); // 246
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import type { TerrainGrid } from '../core/ground-model';
|
import type { TerrainGrid } from '../core/ground-model';
|
||||||
|
|
||||||
|
export function hasTerrainSurface(grid: TerrainGrid): boolean {
|
||||||
|
for (let j = 0; j < grid.ny - 1; j++) for (let i = 0; i < grid.nx - 1; i++) {
|
||||||
|
const k = j * grid.nx + i;
|
||||||
|
if ([grid.z[k], grid.z[k + 1], grid.z[k + grid.nx], grid.z[k + grid.nx + 1]].every(h => h != null)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Terrain grid → mesh in scene coordinates. The scene origin is the pier centre (pivot), with y up:
|
* Terrain grid → mesh in scene coordinates. The scene origin is the pier centre (pivot), with y up:
|
||||||
* JSON 격자는 X 열(nx), Y 행(ny)이다. 내부 X/Y 축 교환을 적용해 x = rawY − pivot.e, z = −(rawX − pivot.n)로 놓는다.
|
* JSON 격자는 X 열(nx), Y 행(ny)이다. 내부 X/Y 축 교환을 적용해 x = rawY − pivot.e, z = −(rawX − pivot.n)로 놓는다.
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* ②·③ center workspace layout: model above the pane (stack) or beside it (side),
|
||||||
|
* with a draggable / keyboard splitter. A per-stage display preference kept in
|
||||||
|
* localStorage only — never part of the project file.
|
||||||
|
*/
|
||||||
|
export type WorkspaceMode = 'stack' | 'side';
|
||||||
|
export interface WorkspaceLayout { mode: WorkspaceMode; height: number | null; width: number; collapsed: boolean }
|
||||||
|
|
||||||
|
export const HEIGHT_MIN = 180, HEIGHT_MAX = 560, HEIGHT_STEP = 20;
|
||||||
|
export const WIDTH_MIN = 30, WIDTH_MAX = 70, WIDTH_STEP = 2, WIDTH_DEFAULT = 50;
|
||||||
|
/** Below this center-column width each side-by-side pane would be under ~440 px. */
|
||||||
|
export const SIDE_MIN_WIDTH = 900;
|
||||||
|
const DESKTOP = '(min-width: 1101px)';
|
||||||
|
|
||||||
|
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
|
||||||
|
const snap = (v: number, step: number) => Math.round(v / step) * step;
|
||||||
|
|
||||||
|
export function defaultLayout(stage: string): WorkspaceLayout {
|
||||||
|
return { mode: 'stack', height: stage === 'rebar' ? 240 : null, width: WIDTH_DEFAULT, collapsed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a stored value defensively; the old ③ key `pierzainer.review-layout` held {height, collapsed}. */
|
||||||
|
export function parseLayout(stage: string, raw: unknown): WorkspaceLayout {
|
||||||
|
const base = defaultLayout(stage);
|
||||||
|
if (!raw || typeof raw !== 'object') return base;
|
||||||
|
const v = raw as Record<string, unknown>;
|
||||||
|
const height = typeof v.height === 'number' && Number.isFinite(v.height) && v.height >= HEIGHT_MIN && v.height <= HEIGHT_MAX ? v.height : base.height;
|
||||||
|
const width = typeof v.width === 'number' && Number.isFinite(v.width) ? clamp(v.width, WIDTH_MIN, WIDTH_MAX) : base.width;
|
||||||
|
return { mode: v.mode === 'side' ? 'side' : 'stack', height, width, collapsed: v.collapsed === true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Side-by-side applies only when the user chose it and the column is wide enough on a desktop frame. */
|
||||||
|
export function effectiveMode(layout: WorkspaceLayout, columnWidth: number, desktop: boolean): { mode: WorkspaceMode; forced: boolean } {
|
||||||
|
if (layout.mode === 'side' && (!desktop || columnWidth < SIDE_MIN_WIDTH)) return { mode: 'stack', forced: true };
|
||||||
|
return { mode: layout.mode, forced: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const storageKey = (stage: string) => `pierzainer.workspace-layout.${stage}`;
|
||||||
|
|
||||||
|
function load(stage: string): WorkspaceLayout {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(storageKey(stage));
|
||||||
|
if (saved) return parseLayout(stage, JSON.parse(saved));
|
||||||
|
if (stage === 'rebar') return parseLayout(stage, JSON.parse(localStorage.getItem('pierzainer.review-layout') ?? 'null'));
|
||||||
|
} catch { /* A blocked or corrupt store falls back to defaults. */ }
|
||||||
|
return defaultLayout(stage);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initWorkspaceLayout(workspace: HTMLElement): void {
|
||||||
|
const stage = workspace.dataset.stage ?? 'plan';
|
||||||
|
const tools = workspace.querySelector<HTMLElement>('.workspace-tools')!;
|
||||||
|
const slot = workspace.querySelector<HTMLElement>('.viewer-slot')!;
|
||||||
|
const splitter = workspace.querySelector<HTMLElement>('.workspace-splitter')!;
|
||||||
|
const slider = tools.querySelector<HTMLInputElement>('input[type="range"]')!;
|
||||||
|
const output = tools.querySelector<HTMLOutputElement>('output')!;
|
||||||
|
const sizeLabel = tools.querySelector<HTMLElement>('[data-size-label]')!;
|
||||||
|
const toggle = tools.querySelector<HTMLButtonElement>('button[aria-controls]')!;
|
||||||
|
const note = tools.querySelector<HTMLElement>('.layout-note')!;
|
||||||
|
const modeButtons = Array.from(tools.querySelectorAll<HTMLButtonElement>('[data-layout]'));
|
||||||
|
const layout = load(stage);
|
||||||
|
let heightMax = HEIGHT_MAX;
|
||||||
|
let current: WorkspaceMode = 'stack';
|
||||||
|
|
||||||
|
const save = () => { try { localStorage.setItem(storageKey(stage), JSON.stringify(layout)); } catch { /* Display preference only. */ } };
|
||||||
|
/** Without a saved height ② keeps its original responsive band; show what is on screen. */
|
||||||
|
const shownHeight = () => layout.height ?? clamp(snap(slot.getBoundingClientRect().height || 400, HEIGHT_STEP), HEIGHT_MIN, heightMax);
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
const desktop = window.matchMedia(DESKTOP).matches;
|
||||||
|
const { mode, forced } = effectiveMode(layout, workspace.clientWidth, desktop);
|
||||||
|
current = mode;
|
||||||
|
workspace.classList.toggle('layout-side', mode === 'side');
|
||||||
|
workspace.classList.toggle('model-collapsed', layout.collapsed);
|
||||||
|
if (layout.height == null) workspace.style.removeProperty('--review-model-height');
|
||||||
|
else workspace.style.setProperty('--review-model-height', `${Math.min(layout.height, heightMax)}px`);
|
||||||
|
workspace.style.setProperty('--model-width', `${layout.width}%`);
|
||||||
|
for (const b of modeButtons) b.setAttribute('aria-pressed', String(b.dataset.layout === layout.mode));
|
||||||
|
note.textContent = forced ? (desktop ? '화면이 좁아 상하로 표시 중' : '좁은 화면은 상하로 표시') : '';
|
||||||
|
toggle.textContent = layout.collapsed ? '모델 펼치기' : '모델 접기';
|
||||||
|
toggle.setAttribute('aria-expanded', String(!layout.collapsed));
|
||||||
|
const side = mode === 'side';
|
||||||
|
sizeLabel.textContent = side ? '모델 폭' : '모델 높이';
|
||||||
|
slider.min = String(side ? WIDTH_MIN : HEIGHT_MIN);
|
||||||
|
slider.max = String(side ? WIDTH_MAX : heightMax);
|
||||||
|
slider.step = String(side ? WIDTH_STEP : HEIGHT_STEP);
|
||||||
|
const value = side ? layout.width : Math.min(shownHeight(), heightMax);
|
||||||
|
slider.value = String(value);
|
||||||
|
output.textContent = side ? `${value}%` : `${value} px`;
|
||||||
|
slider.disabled = layout.collapsed;
|
||||||
|
splitter.hidden = layout.collapsed;
|
||||||
|
splitter.setAttribute('aria-orientation', side ? 'vertical' : 'horizontal');
|
||||||
|
splitter.setAttribute('aria-valuemin', slider.min);
|
||||||
|
splitter.setAttribute('aria-valuemax', slider.max);
|
||||||
|
splitter.setAttribute('aria-valuenow', String(value));
|
||||||
|
splitter.setAttribute('aria-valuetext', output.textContent);
|
||||||
|
splitter.title = side ? '끌어서 모델 폭 조절 · 두 번 클릭하면 기본값' : '끌어서 모델 높이 조절 · 두 번 클릭하면 기본값';
|
||||||
|
};
|
||||||
|
const setSize = (value: number) => {
|
||||||
|
if (current === 'side') layout.width = clamp(snap(value, WIDTH_STEP), WIDTH_MIN, WIDTH_MAX);
|
||||||
|
else layout.height = clamp(snap(value, HEIGHT_STEP), HEIGHT_MIN, heightMax);
|
||||||
|
apply(); save();
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const b of modeButtons) b.addEventListener('click', () => { layout.mode = b.dataset.layout === 'side' ? 'side' : 'stack'; apply(); save(); });
|
||||||
|
slider.addEventListener('input', () => setSize(Number(slider.value)));
|
||||||
|
toggle.addEventListener('click', () => { layout.collapsed = !layout.collapsed; apply(); save(); });
|
||||||
|
|
||||||
|
splitter.addEventListener('pointerdown', (event) => {
|
||||||
|
if (event.button !== 0) return;
|
||||||
|
event.preventDefault();
|
||||||
|
splitter.setPointerCapture(event.pointerId);
|
||||||
|
workspace.classList.add('is-resizing');
|
||||||
|
const move = (e: PointerEvent) => {
|
||||||
|
if (current === 'side') {
|
||||||
|
const r = workspace.getBoundingClientRect();
|
||||||
|
setSize(((e.clientX - r.left) / r.width) * 100);
|
||||||
|
} else {
|
||||||
|
setSize(e.clientY - slot.getBoundingClientRect().top);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const end = () => {
|
||||||
|
workspace.classList.remove('is-resizing');
|
||||||
|
splitter.removeEventListener('pointermove', move);
|
||||||
|
splitter.removeEventListener('pointerup', end);
|
||||||
|
splitter.removeEventListener('pointercancel', end);
|
||||||
|
};
|
||||||
|
splitter.addEventListener('pointermove', move);
|
||||||
|
splitter.addEventListener('pointerup', end);
|
||||||
|
splitter.addEventListener('pointercancel', end);
|
||||||
|
});
|
||||||
|
splitter.addEventListener('keydown', (event) => {
|
||||||
|
const side = current === 'side';
|
||||||
|
const step = side ? WIDTH_STEP : HEIGHT_STEP;
|
||||||
|
const now = side ? layout.width : shownHeight();
|
||||||
|
const grow = side ? ['ArrowRight', 'ArrowDown'] : ['ArrowDown', 'ArrowRight'];
|
||||||
|
const shrink = side ? ['ArrowLeft', 'ArrowUp'] : ['ArrowUp', 'ArrowLeft'];
|
||||||
|
let next: number | null = null;
|
||||||
|
if (grow.includes(event.key)) next = now + step * (event.shiftKey ? 5 : 1);
|
||||||
|
else if (shrink.includes(event.key)) next = now - step * (event.shiftKey ? 5 : 1);
|
||||||
|
else if (event.key === 'Home') next = side ? WIDTH_MIN : HEIGHT_MIN;
|
||||||
|
else if (event.key === 'End') next = side ? WIDTH_MAX : heightMax;
|
||||||
|
if (next == null) return;
|
||||||
|
event.preventDefault();
|
||||||
|
setSize(next);
|
||||||
|
});
|
||||||
|
splitter.addEventListener('dblclick', () => {
|
||||||
|
if (current === 'side') layout.width = WIDTH_DEFAULT;
|
||||||
|
else layout.height = defaultLayout(stage).height;
|
||||||
|
apply(); save();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep the stacked model band within the visible column and re-evaluate the side-by-side width rule.
|
||||||
|
new ResizeObserver(() => {
|
||||||
|
if (workspace.clientHeight === 0) return;
|
||||||
|
if (window.matchMedia(DESKTOP).matches) {
|
||||||
|
const available = workspace.clientHeight - tools.offsetHeight - 264;
|
||||||
|
heightMax = Math.max(HEIGHT_MIN, Math.min(HEIGHT_MAX, Math.floor(available / HEIGHT_STEP) * HEIGHT_STEP));
|
||||||
|
} else heightMax = HEIGHT_MAX;
|
||||||
|
apply();
|
||||||
|
}).observe(workspace);
|
||||||
|
window.matchMedia(DESKTOP).addEventListener('change', apply);
|
||||||
|
apply();
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildPier } from '../src/core/geometry';
|
||||||
|
import { validateInput } from '../src/core/validate';
|
||||||
|
import { parseAbutments, toAbutInput } from '../src/core/abut/parse';
|
||||||
|
import { buildAbut } from '../src/core/abut/geometry';
|
||||||
|
import { abutSoles, resolveBearingRule } from '../src/core/abut/bearing-table';
|
||||||
|
import type { Issue, PierInput } from '../src/core/types';
|
||||||
|
import { loadInputWorkbook, loadP1, p1Pairs } from './helpers';
|
||||||
|
|
||||||
|
// 받침별 받침높이 (2026-11-10): h3 = 그 받침의 받침높이 H − T_ref, 거더 하면 = z + H.
|
||||||
|
const CTX = { pairs: p1Pairs() };
|
||||||
|
const withHeights = (input: PierInput, h: Record<number, number | undefined>): PierInput => ({
|
||||||
|
...input,
|
||||||
|
bearings: input.bearings.map((b) => ({ ...b, heightMm: b.no in h ? h[b.no] : b.heightMm })),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('교각 받침별 받침높이', () => {
|
||||||
|
it('reads 받침높이 from the bearing sheet (P1: 211 for every bearing, so h3 stays 181)', () => {
|
||||||
|
const p1 = loadP1();
|
||||||
|
expect(p1.bearings.map((b) => b.heightMm)).toEqual(Array(10).fill(211));
|
||||||
|
expect(buildPier(p1, CTX).blocks.every((b) => b.plan.h3 === 181)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each bearing gets h3 = its H − T_ref; the block (z − pad − drop) does not move', () => {
|
||||||
|
const base = buildPier(loadP1(), CTX);
|
||||||
|
const m = buildPier(withHeights(loadP1(), { 3: 174, 8: 188 }), CTX);
|
||||||
|
const h3 = (no: number) => m.blocks.find((b) => b.no === no)!.plan.h3;
|
||||||
|
expect(h3(3)).toBe(144);
|
||||||
|
expect(h3(8)).toBe(158);
|
||||||
|
expect(h3(1)).toBe(181);
|
||||||
|
for (const b of m.blocks) {
|
||||||
|
const o = base.blocks.find((x) => x.no === b.no)!;
|
||||||
|
expect(b.top).toBeCloseTo(o.top, 9);
|
||||||
|
expect(b.bottom).toBeCloseTo(o.bottom, 9);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('girder underside = z + H: pad top + h3 + T − (T − T_ref) − … equals z + H for every bearing', () => {
|
||||||
|
const input = withHeights(loadP1(), { 2: 176, 7: 183 });
|
||||||
|
const m = buildPier(input, CTX);
|
||||||
|
for (const b of m.blocks) {
|
||||||
|
const H = input.bearings.find((x) => x.no === b.no)!.heightMm!;
|
||||||
|
const z = input.bearings.find((x) => x.no === b.no)!.z;
|
||||||
|
expect(b.padTop + (b.plan.h3 + b.plan.T) / 1000).toBeCloseTo(z + H / 1000, 9);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a typed h3 (솔플레이트 입력) wins over the bearing heights', () => {
|
||||||
|
const input = { ...withHeights(loadP1(), { 3: 174 }), sole: { h3Mm: 200 } };
|
||||||
|
expect(buildPier(input, CTX).blocks.every((b) => b.plan.h3 === 200)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('without 받침높이 the default h3 181 is used', () => {
|
||||||
|
const input = { ...loadP1(), bearings: loadP1().bearings.map(({ heightMm: _h, ...b }) => b) };
|
||||||
|
expect(buildPier(input, CTX).blocks.every((b) => b.plan.h3 === 181)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('S01 (E) when a 받침높이 is not above T_ref, so no body could be built', () => {
|
||||||
|
const issues: Issue[] = [];
|
||||||
|
expect(validateInput(withHeights(loadP1(), { 4: 30 }), issues)).toBe(false);
|
||||||
|
expect(issues.filter((i) => i.code === 'S01').map((i) => i.message)).toEqual([expect.stringContaining('받침 4번')]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('교대 받침별 받침높이', () => {
|
||||||
|
const a1 = () => parseAbutments(loadInputWorkbook()).abutments.find((a) => a.name === 'A1')!;
|
||||||
|
|
||||||
|
it('different heights per bearing give different h3 and no AP07 warning', () => {
|
||||||
|
const wb = loadInputWorkbook();
|
||||||
|
wb.Sheets['받침']['I16'] = { t: 'n', v: 260 };
|
||||||
|
const { abutments, issues } = parseAbutments(wb);
|
||||||
|
expect(issues.some((i) => i.code === 'AP07')).toBe(false);
|
||||||
|
const rec = abutments.find((a) => a.name === 'A1')!;
|
||||||
|
const soles = abutSoles(buildAbut(toAbutInput(rec)), resolveBearingRule(), rec.bearingHeightMm);
|
||||||
|
expect(soles.map((s) => s.h3)).toEqual([245, 245, 230, 245, 245]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a typed h3 wins for every bearing', () => {
|
||||||
|
const rec = a1();
|
||||||
|
const soles = abutSoles(buildAbut(toAbutInput(rec)), resolveBearingRule({ h3Mm: 200 }), rec.bearingHeightMm);
|
||||||
|
expect(soles.every((s) => s.h3 === 200)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -134,12 +134,17 @@ describe('bent coping model (stage 2)', () => {
|
|||||||
expect(buildPier(bentInput('직접기초')).piles).toHaveLength(0);
|
expect(buildPier(bentInput('직접기초')).piles).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('2주형 bent with columns on both sides of the kink: separate footings, one per stretch', () => {
|
it('2주형 bent with columns on both sides of the kink: the overlapping footings become one bent footing (2026-11-10)', () => {
|
||||||
const m = buildPier(bentInput('말뚝기초', '2주형', 10));
|
const m = buildPier(bentInput('말뚝기초', '2주형', 10));
|
||||||
expect(m.footings.map((f) => f.k)).toEqual([0, 1]);
|
// The two 10.4 m footings overlap across the kink, so they are one bent footing, a part per stretch.
|
||||||
expect(m.footings.every((f) => Math.abs(f.length - 10.4) < 1e-9)).toBe(true);
|
expect(m.footings.map((f) => [f.k, f.bent?.part])).toEqual([[0, 0], [1, 1]]);
|
||||||
expect(m.piles).toHaveLength(40);
|
// Piles are laid once over the unfolded footing: both stretches get some, and none overlap (they used to: 2 × 20).
|
||||||
expect(m.piles.filter((p) => p.k === 1)).toHaveLength(20);
|
expect(m.piles.some((p) => p.k === 0) && m.piles.some((p) => p.k === 1)).toBe(true);
|
||||||
|
const kink = m.stretches[1].base;
|
||||||
|
const g = m.piles.map((p) => ({ g: p.k === 0 ? p.s : p.s + kink, n: p.n }));
|
||||||
|
for (let i = 0; i < g.length; i++) for (let j = i + 1; j < g.length; j++) {
|
||||||
|
expect(Math.hypot(g[i].g - g[j].g, g[i].n - g[j].n)).toBeGreaterThan(1.0);
|
||||||
|
}
|
||||||
const scene = buildPierScene(m);
|
const scene = buildPierScene(m);
|
||||||
const footings = scene.parts.footings.children as THREE.Mesh[];
|
const footings = scene.parts.footings.children as THREE.Mesh[];
|
||||||
expect(footings).toHaveLength(2);
|
expect(footings).toHaveLength(2);
|
||||||
|
|||||||
@@ -114,6 +114,22 @@ describe('지형 격자 (schema 2)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('형식 번호 검사', () => {
|
describe('형식 번호 검사', () => {
|
||||||
|
it('프로젝트 JSON에서는 내장 지반모델만 가져온다', () => {
|
||||||
|
const groundModel = { schema: 2, generated: '2026-11-09', piers: [{ id: 'P1', centerEl: 18.011, excelEl: null }], terrainGrid: { origin: [0, 0], step: 1, nx: 2, ny: 2, z: [1, 2, 3, 4] } };
|
||||||
|
const imported = parseGroundModel(JSON.stringify({ schemaVersion: 1, project: { name: '다른 프로젝트' }, piers: [{ input: { groundEl: 999 } }], groundModel }));
|
||||||
|
expect(imported).toEqual(groundModel);
|
||||||
|
expect(imported).not.toHaveProperty('project');
|
||||||
|
expect(imported.piers[0].centerEl).toBe(18.011);
|
||||||
|
});
|
||||||
|
it('프로젝트 내부의 잘못된 지반모델과 알 수 없는 프로젝트 형식을 거부한다', () => {
|
||||||
|
expect(() => parseGroundModel(JSON.stringify({ schemaVersion: 1, groundModel: { schema: 2, piers: [{ id: 'P1', centerEl: 'wrong', excelEl: null }] } }))).toThrow('값이 맞지 않습니다');
|
||||||
|
expect(() => parseGroundModel(JSON.stringify({ schemaVersion: 99, groundModel: { schema: 2, piers: [] } }))).toThrow('프로젝트 형식 번호 99');
|
||||||
|
});
|
||||||
|
it('구조가 다른 JSON을 형식 번호 undefined 대신 구체적인 파일 안내로 거부한다', () => {
|
||||||
|
expect(() => parseGroundModel(JSON.stringify({ drawing: '도면.dwg', output: 'output', piers: '입력.xlsx' }))).toThrow('지반 생성 설정 파일');
|
||||||
|
expect(() => parseGroundModel(JSON.stringify({ schemaVersion: 1, piers: [] }))).toThrow('프로젝트 열기');
|
||||||
|
expect(() => parseGroundModel(JSON.stringify({ status: 'ok', json: 'ground_model.json' }))).toThrow('결과 요약 JSON');
|
||||||
|
});
|
||||||
it('accepts a legacy file without a schema number when it has the standard pier structure', () => {
|
it('accepts a legacy file without a schema number when it has the standard pier structure', () => {
|
||||||
const m = parseGroundModel(JSON.stringify({ piers: [{ id: 'P1', ground: { center: 29.355, excel: 28.931 } }] }));
|
const m = parseGroundModel(JSON.stringify({ piers: [{ id: 'P1', ground: { center: 29.355, excel: 28.931 } }] }));
|
||||||
expect(m.schema).toBe(2);
|
expect(m.schema).toBe(2);
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import { parseWorkbook } from '../src/core/parseWorkbook';
|
||||||
|
import { buildPier, DIM, defaultLevelWidthMm } from '../src/core/geometry';
|
||||||
|
import type { PierInput } from '../src/core/types';
|
||||||
|
import { rebarSummary } from '../src/core/rebar/summary';
|
||||||
|
import { multicolCopingInfo } from '../src/core/rebar/multicol-coping';
|
||||||
|
import { copingFold, inWedge, isTransverse } from '../src/core/rebar/coping-fold';
|
||||||
|
import { buildRebar3D } from '../src/view/rebar3d';
|
||||||
|
import { shearB9 } from '../src/core/rebar/footing';
|
||||||
|
import { footingUnits } from '../src/core/rebar/footing-units';
|
||||||
|
import { loadP1 } from './helpers';
|
||||||
|
|
||||||
|
// 산외교 P11 (샘플01) with the coping bent 8.767° at IP4 (2026-11-10 검토): back ⑥ g 11.64, ⑦ g 18.49, kink g 15.14.
|
||||||
|
const SAMPLE = fileURLToPath(new URL('../project_data/01_엑셀/샘플01_산외교_교각입력데이터.xlsx', import.meta.url));
|
||||||
|
|
||||||
|
function p11(): PierInput {
|
||||||
|
const wb = XLSX.read(readFileSync(SAMPLE), { type: 'buffer' });
|
||||||
|
wb.Sheets['IP']['F46'] = { t: 'n', v: 8.767 };
|
||||||
|
const input = parseWorkbook(wb).piers.find((p) => p.name === 'P11')!;
|
||||||
|
return { ...input, levelWidthMm: input.levelWidthMm ?? defaultLevelWidthMm(input.type, 2000) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const kinkOf = (m: ReturnType<typeof buildPier>) => m.stretches[1].base;
|
||||||
|
const gOf = (m: ReturnType<typeof buildPier>, no: number) => m.bearings.find((b) => b.no === no)!.g;
|
||||||
|
|
||||||
|
describe('꺾임점 근처 계단 경계 (A, 2026-11-10)', () => {
|
||||||
|
it('a back boundary 0.07 m from the kink moves onto the kink (⑥|⑦)', () => {
|
||||||
|
const m = buildPier(p11());
|
||||||
|
const kink = kinkOf(m);
|
||||||
|
const own = (gOf(m, 6) + gOf(m, 7)) / 2;
|
||||||
|
expect(Math.abs(own - kink)).toBeLessThan(0.1);
|
||||||
|
expect(m.backSegments![2].s0).toBeCloseTo(kink, 9);
|
||||||
|
expect(m.backSegments![1].s1).toBeCloseTo(kink, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('boundaries far from the kink stay midway between their bearings', () => {
|
||||||
|
const m = buildPier(p11());
|
||||||
|
expect(m.backSegments![1].s0).toBeCloseTo((gOf(m, 5) + gOf(m, 6)) / 2, 9);
|
||||||
|
expect(m.backSegments![3].s0).toBeCloseTo((gOf(m, 7) + gOf(m, 8)) / 2, 9);
|
||||||
|
m.segments.slice(1).forEach((seg) => expect(Math.abs(seg.s0 - kinkOf(m))).toBeGreaterThan(DIM.blockSize / 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no snap when a bearing would end up closer than half a block to the kink', () => {
|
||||||
|
const input = p11();
|
||||||
|
const base = buildPier(input);
|
||||||
|
// ⑦ 0.4 m past the kink, ⑥ 0.6 m before it: midpoint 0.1 m before the kink, but ⑦ is within half a block.
|
||||||
|
const u1 = base.stretches[1].u;
|
||||||
|
const u0 = base.stretches[0].u;
|
||||||
|
const kink = kinkOf(base);
|
||||||
|
const d7 = kink + 0.4 - gOf(base, 7);
|
||||||
|
const d6 = kink - 0.6 - gOf(base, 6);
|
||||||
|
const bearings = input.bearings.map((b) =>
|
||||||
|
b.no === 7 ? { ...b, x: b.x + d7 * u1.e, y: b.y + d7 * u1.n } : b.no === 6 ? { ...b, x: b.x + d6 * u0.e, y: b.y + d6 * u0.n } : b);
|
||||||
|
const m = buildPier({ ...input, bearings });
|
||||||
|
const own = (gOf(m, 6) + gOf(m, 7)) / 2;
|
||||||
|
expect(kinkOf(m) - own).toBeCloseTo(0.1, 3);
|
||||||
|
expect(m.backSegments![2].s0).toBeCloseTo(own, 9);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('전·후열 나눔선 하나 (B, 2026-11-10)', () => {
|
||||||
|
const split = (m: ReturnType<typeof buildPier>, k: number) => {
|
||||||
|
const [front] = m.copingParts.filter((p) => p.k === k);
|
||||||
|
return Math.abs(front.nFrom) < Math.abs(front.nTo) ? front.nFrom : front.nTo;
|
||||||
|
};
|
||||||
|
const meanN = (m: ReturnType<typeof buildPier>, row: string, k?: number) => {
|
||||||
|
const rows = m.bearings.filter((b) => b.row === row && (k === undefined || b.k === k));
|
||||||
|
return rows.reduce((s, b) => s + b.n, 0) / rows.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('both stretches use the same line, the mean of the two rows over the whole pier (no jog at the kink)', () => {
|
||||||
|
const m = buildPier(p11());
|
||||||
|
expect(m.copingParts).toHaveLength(4);
|
||||||
|
const nAll = (meanN(m, '전열') + meanN(m, '후열')) / 2;
|
||||||
|
expect(split(m, 0)).toBeCloseTo(nAll, 9);
|
||||||
|
expect(split(m, 1)).toBeCloseTo(nAll, 9);
|
||||||
|
// Per stretch the lines would differ by about 0.16 m.
|
||||||
|
const per = (k: number) => (meanN(m, '전열', k) + meanN(m, '후열', k)) / 2;
|
||||||
|
expect(Math.abs(per(0) - per(1))).toBeGreaterThan(0.1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a line per stretch when a bearing would be closer than half a block to the common line', () => {
|
||||||
|
const input = p11();
|
||||||
|
const base = buildPier(input);
|
||||||
|
const nrm = base.stretches[1].nrm;
|
||||||
|
// Move back ⑦ towards the axis (+n side here is the front), to n ≈ +0.1.
|
||||||
|
const b7 = base.bearings.find((b) => b.no === 7)!;
|
||||||
|
const d = 0.1 - b7.n;
|
||||||
|
const bearings = input.bearings.map((b) => (b.no === 7 ? { ...b, x: b.x + d * nrm.e, y: b.y + d * nrm.n } : b));
|
||||||
|
const m = buildPier({ ...input, bearings });
|
||||||
|
const nAll = (meanN(m, '전열') + meanN(m, '후열')) / 2;
|
||||||
|
expect(m.bearings.some((b) => Math.abs(b.n - nAll) < DIM.blockSize / 2)).toBe(true);
|
||||||
|
for (const k of [0, 1]) expect(split(m, k)).toBeCloseTo((meanN(m, '전열', k) + meanN(m, '후열', k)) / 2, 9);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('꺾임점 계단면 = 미터면 (2026-11-10)', () => {
|
||||||
|
// Top of a stepped profile at s: the last top point at or before s.
|
||||||
|
const topAt = (m: ReturnType<typeof buildPier>, profile: [number, number][], s: number) =>
|
||||||
|
profile.slice(0, profile.findIndex(([ps]) => ps > s + 1e-9)).filter(([, z]) => z > m.endBottom + 1e-9).pop()![1];
|
||||||
|
|
||||||
|
it('over the whole mitre spread, stretch 0 keeps the top before the kink and stretch 1 the top after it', () => {
|
||||||
|
const m = buildPier(p11());
|
||||||
|
const kink = kinkOf(m);
|
||||||
|
const spread = (m.copingB / 2) * Math.tan((8.767 * Math.PI) / 360);
|
||||||
|
// The back part carries the ⑥|⑦ step at the kink: ⑥ top (back segment 1) before, ⑦ top (segment 2) after.
|
||||||
|
expect(m.backSegments![2].s0).toBeCloseTo(kink, 9);
|
||||||
|
const before = m.backSegments![1].top, after = m.backSegments![2].top;
|
||||||
|
expect(after - before).toBeGreaterThan(0.5);
|
||||||
|
const backPart = (k: number) => {
|
||||||
|
const parts = m.copingParts.filter((p) => p.k === k);
|
||||||
|
return parts.find((p) => p.profile.some(([, z]) => Math.abs(z - after) < 1e-9))!;
|
||||||
|
};
|
||||||
|
// Stretch 0 (local s = g): just short of the far edge of the mitre spread, still the ⑥ top.
|
||||||
|
expect(topAt(m, backPart(0).profile, kink + spread - 1e-6)).toBeCloseTo(before, 9);
|
||||||
|
// Stretch 1 (local s = g − kink): just past the near edge of the spread, already the ⑦ top.
|
||||||
|
expect(topAt(m, backPart(1).profile, -spread + 1e-6)).toBeCloseTo(after, 9);
|
||||||
|
// Away from the kink the profiles are unchanged: the ⑦ top in stretch 0 a block past the kink.
|
||||||
|
expect(topAt(m, backPart(0).profile, kink + DIM.blockSize / 2 + 0.1)).toBeCloseTo(after, 9);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('받침 번호가 축 반대 방향인 교각 (2026-11-10)', () => {
|
||||||
|
it('steps are built in g order: bounds rise and each step top is its own bearing seat', () => {
|
||||||
|
// P1 with the bearing numbers reversed in each row (⑤ … ① along IP1 → IP3).
|
||||||
|
const p1 = (() => {
|
||||||
|
const wb = XLSX.read(readFileSync(fileURLToPath(new URL('../도면추출/samples/운곡교P1입력.xlsx', import.meta.url))), { type: 'buffer' });
|
||||||
|
return parseWorkbook(wb).piers.find((p) => p.name === 'P1')!;
|
||||||
|
})();
|
||||||
|
const renumber = (no: number) => (no <= 5 ? 6 - no : 16 - no);
|
||||||
|
const reversed = { ...p1, bearings: p1.bearings.map((b) => ({ ...b, no: renumber(b.no) })) };
|
||||||
|
const a = buildPier(p1), b = buildPier(reversed);
|
||||||
|
for (const m of [a, b]) m.segments.forEach((s) => expect(s.s1).toBeGreaterThan(s.s0));
|
||||||
|
expect(b.segments.map((s) => s.top)).toEqual(a.segments.map((s) => s.top));
|
||||||
|
expect(b.segments.map((s) => s.s0)).toEqual(a.segments.map((s) => s.s0));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('꺾인 다주형 코핑의 철근표 (A, 2026-11-10)', () => {
|
||||||
|
it('uses the multi-column rows, not the one-column T형 rows', () => {
|
||||||
|
const m = buildPier(p11());
|
||||||
|
const rows = rebarSummary(m).rows.filter((r) => r.member === 'coping');
|
||||||
|
const info = multicolCopingInfo(m);
|
||||||
|
for (const name of ['C9', 'C10', 'C11', 'C1', 'C7']) {
|
||||||
|
expect(rows.find((r) => r.name === name)!.count, name).toBe(info.bars.filter((b) => b.name === name).length);
|
||||||
|
}
|
||||||
|
expect(rows.every((r) => r.note.includes('다주형'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('꺾인 코핑 철근 접기 (B·C, 2026-11-10)', () => {
|
||||||
|
const m = buildPier(p11());
|
||||||
|
const f = copingFold(m)!;
|
||||||
|
const bars = multicolCopingInfo(m).bars;
|
||||||
|
const unfolded = (pts: [number, number, number][]) => pts.slice(1).reduce((s, p, i) => s + Math.hypot(p[0] - pts[i][0], p[1] - pts[i][1], p[2] - pts[i][2]), 0) * 1000;
|
||||||
|
|
||||||
|
it('a run across the kink turns at its mitre point: length = unfolded + a0(n) − a1(n), outer longer, inner shorter', () => {
|
||||||
|
const crossing = bars.filter((b) => b.name === 'C7' && b.pieces!.length === 2);
|
||||||
|
expect(crossing.length).toBeGreaterThan(0);
|
||||||
|
for (const b of crossing) {
|
||||||
|
const n = b.pts[1][2];
|
||||||
|
expect(b.length).toBeCloseTo(unfolded(b.pts) + (f.a0(n) - f.a1(n)) * 1000, 6);
|
||||||
|
}
|
||||||
|
const byN = [...crossing].sort((a, b) => a.pts[1][2] - b.pts[1][2]);
|
||||||
|
const d = (b: (typeof crossing)[number]) => b.length - unfolded(b.pts);
|
||||||
|
expect(Math.sign(d(byN[0]))).toBe(-Math.sign(d(byN.at(-1)!)));
|
||||||
|
expect(Math.abs(d(byN[0]))).toBeGreaterThan(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no bar across the width cuts the mitre, except the one shear set laid on it', () => {
|
||||||
|
const transverse = bars.filter((b) => isTransverse(b.pts));
|
||||||
|
const onIt = transverse.filter((b) => b.pieces!.length === 1 && b.pieces![0].pts.every((p) => Math.abs(p[0] - (f.kinkG + f.a0(p[2]))) < 0.2));
|
||||||
|
expect(new Set(onIt.map((b) => b.name)).size).toBeGreaterThan(0);
|
||||||
|
for (const b of transverse.filter((x) => !onIt.includes(x))) expect(inWedge(f, b.pts)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the top-bar laps keep l_s/2 + the wedge away from the kink', () => {
|
||||||
|
const c1 = bars.filter((b) => b.name === 'C1');
|
||||||
|
const ends = c1.flatMap((b) => [Math.min(...b.pts.map((p) => p[0])), Math.max(...b.pts.map((p) => p[0]))]);
|
||||||
|
const inner = ends.filter((x) => x > 0.2 && x < m.copingL - 0.2);
|
||||||
|
expect(inner.length).toBeGreaterThan(0);
|
||||||
|
for (const x of inner) expect(Math.abs(x - f.kinkG)).toBeGreaterThan(f.wedge - 1e-9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a straight coping is unchanged (no pieces)', () => {
|
||||||
|
const straight = buildPier({ ...p11(), ips: p11().ips.map((q) => (q.name === 'IP4' ? { ...q, angleDeg: 0 } : q)) });
|
||||||
|
expect(multicolCopingInfo(straight).bars.every((b) => b.pieces === undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('꺾인 코핑 3D 철근 (E, 2026-11-10)', () => {
|
||||||
|
const straightOf = (input: PierInput) => ({ ...input, ips: input.ips.map((q) => (q.name === 'IP4' ? { ...q, angleDeg: 0 } : q)) });
|
||||||
|
const count = (rb: ReturnType<typeof buildRebar3D>, name: string) => rb.byName.get(name)?.length ?? 0;
|
||||||
|
|
||||||
|
it('a bent multi-column coping now draws its coping bars, runs across the kink in two pieces', () => {
|
||||||
|
const bent = buildRebar3D(buildPier(p11()));
|
||||||
|
for (const name of ['C1', 'C7', 'C9', 'C10', 'C12', 'C13', 'C15', 'C16']) expect(count(bent, name), name).toBeGreaterThan(0);
|
||||||
|
const straight = buildRebar3D(buildPier(straightOf(p11())));
|
||||||
|
expect(count(bent, 'C7')).toBeGreaterThan(count(straight, 'C7'));
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
it('a bent T형 coping draws its coping bars through the same fold', () => {
|
||||||
|
const t = { ...p11(), type: 'T형' as const, columnCount: 1 };
|
||||||
|
const bent = buildRebar3D(buildPier(t));
|
||||||
|
const straight = buildRebar3D(buildPier(straightOf(t)));
|
||||||
|
expect(count(bent, 'C1')).toBeGreaterThan(0);
|
||||||
|
expect(count(bent, 'C1')).toBeGreaterThan(count(straight, 'C1'));
|
||||||
|
}, 60000);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('꺾인 기초 (2026-11-10)', () => {
|
||||||
|
// 3주형 as in the project (산외교 P11): footings ① ② (−0.10 … 17.95) and ③ (15.20 … 25.60) overlap 2.75 m across the kink.
|
||||||
|
const m = buildPier({ ...p11(), type: '3주형', columnCount: 3 });
|
||||||
|
const kink = m.stretches[1].base;
|
||||||
|
const spread = (m.footings[0].width / 2) * Math.tan((8.767 * Math.PI) / 360);
|
||||||
|
|
||||||
|
it('the two footings become one bent footing: each part reaches past the mitre spread, one unfolded extent', () => {
|
||||||
|
expect(m.footings.map((f) => [f.k, f.bent?.part])).toEqual([[0, 0], [1, 1]]);
|
||||||
|
const [p0, p1] = m.footings;
|
||||||
|
expect(p0.bent!.gFrom).toBeCloseTo(-0.1, 9);
|
||||||
|
expect(p0.bent!.gTo).toBeCloseTo(kink + m.columns[2].s + 5.2, 9);
|
||||||
|
expect(p0.s + p0.length / 2).toBeGreaterThanOrEqual(kink + spread);
|
||||||
|
expect(p1.s - p1.length / 2).toBeLessThanOrEqual(-spread);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rebar is one footing of the unfolded length with all three columns (no double count of the overlap)', () => {
|
||||||
|
const units = footingUnits(m);
|
||||||
|
expect(units).toHaveLength(1);
|
||||||
|
expect(units[0].length).toBeCloseTo(m.footings[0].bent!.gTo - m.footings[0].bent!.gFrom, 9);
|
||||||
|
expect(units[0].columnX).toHaveLength(3);
|
||||||
|
const rows = rebarSummary(m).rows;
|
||||||
|
const u = units[0];
|
||||||
|
expect(rows.find((r) => r.name === 'B9')!.count).toBe(shearB9(u.length * 1000, u.width * 1000, (u.top - u.bottom) * 1000, u.columnX, 2000, m.footingRebar).count);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('3D draws the bent footing in both stretches, bars along the footing turning at the mitre', () => {
|
||||||
|
const rb = buildRebar3D(m);
|
||||||
|
const footing = rb.records.filter((r) => /^B\d/.test(r.name));
|
||||||
|
expect(new Set(footing.map((r) => r.k))).toEqual(new Set([0, 1]));
|
||||||
|
// Every footing bar point stays within the unfolded footing (no part drawn past its own stretch end).
|
||||||
|
for (const r of footing) for (const p of r.pts) {
|
||||||
|
const g = r.k === 0 ? p[0] : p[0] + kink;
|
||||||
|
expect(g).toBeGreaterThan(-0.1 - spread - 1e-6);
|
||||||
|
expect(g).toBeLessThan(m.footings[0].bent!.gTo + spread + 1e-6);
|
||||||
|
}
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
it('footings apart across the kink stay separate (2주형 sample: 10.4 m footings 4.9 m apart)', () => {
|
||||||
|
const two = buildPier(p11());
|
||||||
|
expect(two.footings.every((f) => f.bent === undefined)).toBe(true);
|
||||||
|
expect(footingUnits(two)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('정척 초과 확대기초 철근의 2조각 (2026-11-10)', () => {
|
||||||
|
it('P1 B6 (13646 > 12000): 31 bars of 2 pieces 7776 each, so the weight counts 62 pieces', () => {
|
||||||
|
const b6 = rebarSummary(buildPier(loadP1())).rows.find((r) => r.name === 'B6')!;
|
||||||
|
expect(b6.count).toBe(31);
|
||||||
|
expect(b6.length!).toBeCloseTo(7776, 0);
|
||||||
|
expect(b6.pieces).toEqual([{ length: b6.length, count: 62 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { abutGroundFromModel, applyProjectGround, groundInputChanged, sampleGroundGrid } from '../src/core/project-ground';
|
||||||
|
import type { GroundModelFile } from '../src/core/ground-model';
|
||||||
|
import { loadP1 } from './helpers';
|
||||||
|
|
||||||
|
const model: GroundModelFile = { schema: 2, generated: '', piers: [
|
||||||
|
{ id: 'P1', centerEl: -2, excelEl: 3, layers: [{ name: '암반', bottomEl: -10 }] },
|
||||||
|
{ id: 'P2', centerEl: null, excelEl: 5 },
|
||||||
|
] };
|
||||||
|
describe('프로젝트 지반 선택 적용', () => {
|
||||||
|
it('교대 IP2에서 4점을 보간하고 외곽 경계도 처리한다', () => {
|
||||||
|
const grid = { origin: [100, 200] as [number, number], step: 10, nx: 2, ny: 2, z: [10, 20, 30, 40] };
|
||||||
|
expect(sampleGroundGrid(grid, { n: 105, e: 205 })).toBe(25);
|
||||||
|
expect(sampleGroundGrid(grid, { n: 110, e: 210 })).toBe(40);
|
||||||
|
expect(sampleGroundGrid(grid, { n: 111, e: 205 })).toBeNull();
|
||||||
|
expect(sampleGroundGrid({ ...grid, z: [10, null, 30, 40] }, { n: 105, e: 205 })).toBeNull();
|
||||||
|
const result = abutGroundFromModel({ ...model, terrainGrid: grid }, { name: 'A1', ip2: { n: 105, e: 205 } });
|
||||||
|
expect(result.centerEl).toBe(25); expect(result.source).toBe('IP2 격자 보간'); expect(result.layers).toBeUndefined();
|
||||||
|
});
|
||||||
|
it('교대 개별 모델값이 있으면 null도 보간으로 대체하지 않는다', () => {
|
||||||
|
const result = abutGroundFromModel({ ...model, piers: [{ id: 'A1', centerEl: null, excelEl: 10, layers: [{ name: '암반', bottomEl: -10 }] }] }, { name: 'A1', ip2: { n: 0, e: 0 } });
|
||||||
|
expect(result.centerEl).toBeNull(); expect(result.source).toBe('JSON 개별값'); expect(result.layers).toHaveLength(1);
|
||||||
|
});
|
||||||
|
it('지반고만 적용하면 기존 지층과 N값을 보존한다', () => {
|
||||||
|
const p = { ...loadP1(), strataRows: [{ name: '기존', bottomEl: -20, nValue: '50/5' }] };
|
||||||
|
const next = applyProjectGround(p, model, { ground: true, layers: false });
|
||||||
|
expect(next.groundEl).toBe(-2); expect(next.strataRows).toEqual(p.strataRows);
|
||||||
|
expect(p.groundEl).not.toBe(-2); expect(groundInputChanged(p, next)).toBe(true);
|
||||||
|
});
|
||||||
|
it('지층만 적용하면 지반고와 다른 입력을 보존하고 모델을 복사한다', () => {
|
||||||
|
const p = loadP1(), next = applyProjectGround(p, model, { ground: false, layers: true });
|
||||||
|
expect(next.groundEl).toBe(p.groundEl); expect(next.copingLengthMm).toBe(p.copingLengthMm);
|
||||||
|
expect(next.strataRows).toEqual(model.piers[0].layers); expect(next.strataRows![0]).not.toBe(model.piers[0].layers![0]);
|
||||||
|
});
|
||||||
|
it('모델 누락과 범위 밖은 기존 값을 보존한다', () => {
|
||||||
|
for (const name of ['P2', 'P3']) {
|
||||||
|
const p = { ...loadP1(), name, strataRows: [{ name: '기존', bottomEl: -20 }] };
|
||||||
|
expect(applyProjectGround(p, model, { ground: true, layers: true })).toEqual(p);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it('빈 지층으로 기존 지층을 지우지 않는다', () => {
|
||||||
|
const p = { ...loadP1(), strataRows: [{ name: '기존', bottomEl: -20 }] };
|
||||||
|
const empty = { ...model, piers: [{ ...model.piers[0], layers: [] }] };
|
||||||
|
expect(applyProjectGround(p, empty, { ground: false, layers: true })).toEqual(p);
|
||||||
|
});
|
||||||
|
it('지반고·지층이 같으면 지반 재생성 대상이 아니다', () => {
|
||||||
|
const p = loadP1(); expect(groundInputChanged(p, { ...p, copingLengthMm: p.copingLengthMm + 100 })).toBe(false);
|
||||||
|
expect(groundInputChanged(p, { ...p, strataRows: [{ name: '층', bottomEl: -5 }] })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import { buildQuantity } from '../src/core/rebar/quantity';
|
||||||
|
import { DEFAULT_MATERIAL } from '../src/core/rebar/common';
|
||||||
|
import type { RebarRow } from '../src/core/rebar/summary';
|
||||||
|
import { createQuantityWorkbook, quantityExportIssues, quantityPieceRows } from '../src/core/quantity-workbook';
|
||||||
|
import { projectRecords } from '../수량산출/project-inputs';
|
||||||
|
import { buildPier } from '../src/core/geometry';
|
||||||
|
import { loadP1 } from './helpers';
|
||||||
|
|
||||||
|
const row: RebarRow = {member:'coping',name:'C9',size:'H19',count:2,length:null,pieces:[{length:1234.567,count:2},{length:765.432,count:4}],note:'',spacing:'',actual:'',position:'',direction:'',shape:'가공 조각',basis:'calc'};
|
||||||
|
const q = buildQuantity([row], {...DEFAULT_MATERIAL,waste:10});
|
||||||
|
const records = [{name:'P1',quantity:q,dirty:false}];
|
||||||
|
const meta = {project:'검증',bridge:'검증교',generatedAt:'2026-11-06T00:00:00.000Z',abutmentCount:2};
|
||||||
|
const values = (sheet: XLSX.WorkSheet) => XLSX.utils.sheet_to_json<(string|number)[]>(sheet,{header:1});
|
||||||
|
|
||||||
|
describe('재료표 Excel과 가공 상세', () => {
|
||||||
|
it('실제 파일 왕복 후 길이별 본수·중량 합이 반올림 없이 집계와 일치한다', () => {
|
||||||
|
const wb = XLSX.read(XLSX.write(createQuantityWorkbook(records,meta),{type:'buffer',bookType:'xlsx'}),{type:'buffer'});
|
||||||
|
const pieces = values(wb.Sheets['가공 상세']).slice(1);
|
||||||
|
expect(pieces.reduce((n,r)=>n+Number(r[7]),0)).toBe(6);
|
||||||
|
expect(pieces.reduce((n,r)=>n+Number(r[8]),0)).toBeCloseTo(q.groups[0].diameters[0].totalM,12);
|
||||||
|
expect(pieces.reduce((n,r)=>n+Number(r[9]),0)).toBeCloseTo(q.kg,12);
|
||||||
|
expect(values(wb.Sheets.P1).find(r=>r[1]==='C9')).toMatchObject({4:2,9:6});
|
||||||
|
expect(values(wb.Sheets['합계'])[1][2]).toBeCloseTo(q.kgWithWaste,12);
|
||||||
|
expect(values(wb.Sheets['생성 정보']).find(r=>r[0]==='범위')![1]).toContain('교대 2기 제외');
|
||||||
|
});
|
||||||
|
it('미재생성·미생성 교각이 있으면 Excel 생성을 막는다', () => {
|
||||||
|
const bad=[{...records[0],dirty:true},{name:'P2',quantity:null,dirty:false}];
|
||||||
|
expect(quantityExportIssues(bad)).toEqual(['P1: 형상 재생성 필요','P2: 형상 없음']);
|
||||||
|
expect(()=>createQuantityWorkbook(bad,meta)).toThrow('재생성 필요');
|
||||||
|
});
|
||||||
|
it('부분 소계와 미계산 사유를 파일에 보존한다', () => {
|
||||||
|
const partial=buildQuantity([row],DEFAULT_MATERIAL,['저부 P6 미확정']);
|
||||||
|
const wb=createQuantityWorkbook([{name:'P1',quantity:partial,dirty:false}],meta);
|
||||||
|
expect(values(wb.Sheets['검토 항목'])[1]).toEqual(['P1','저부 P6 미확정']);
|
||||||
|
expect(values(wb.Sheets['합계'])[1][4]).toContain('검토용');
|
||||||
|
});
|
||||||
|
it('예약명·대소문자·절단·문자치환으로 시트명이 충돌해도 모두 출력한다', () => {
|
||||||
|
const names=['합계','가공 상세','P1','p1','A/B','A:B','긴이름'.repeat(20)+'1','긴이름'.repeat(20)+'2'];
|
||||||
|
const wb=createQuantityWorkbook(names.map(name=>({...records[0],name})),meta);
|
||||||
|
expect(new Set(wb.SheetNames.map(n=>n.toLowerCase())).size).toBe(names.length+5);
|
||||||
|
expect(wb.SheetNames.every(n=>n.length<=31)).toBe(true);
|
||||||
|
expect(quantityPieceRows(names.map(name=>({...records[0],name})))).toHaveLength(1+names.length*2);
|
||||||
|
});
|
||||||
|
it('공종 수량에 수동 중량이 있어도 미확정 철근을 계산 완료로 처리하지 않는다', () => {
|
||||||
|
const input={...loadP1(),quantityTakeoff:{values:{rebar:123},reviewed:true}},model=buildPier(input);
|
||||||
|
const result=projectRecords({project:{name:'',bridge:''},selected:'P1',piers:[{input,model,dirty:false,rebarTon:null,rebarIssues:['저부 미확정']}]})[0].result!;
|
||||||
|
expect(result.issues).toContain('철근재료표 일부 계산: 저부 미확정');
|
||||||
|
expect(result.complete).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
+22
-18
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { buildPier } from '../src/core/geometry';
|
import { buildPier } from '../src/core/geometry';
|
||||||
import { c14Bars, c14Length, copingSideBars, stairBars, undersideAt } from '../src/core/rebar/coping';
|
import { c14Bars, c14Length, copingSideBars, tStepBars, undersideAt } from '../src/core/rebar/coping';
|
||||||
import { loadP1 } from './helpers';
|
import { loadP1 } from './helpers';
|
||||||
|
|
||||||
const m = buildPier(loadP1());
|
const m = buildPier(loadP1());
|
||||||
@@ -32,27 +32,31 @@ describe('C14 / C15 / C16 (코핑철근.md §7–8)', () => {
|
|||||||
expect(c14Length(B)).toBeCloseTo(3447.5, 0);
|
expect(c14Length(B)).toBeCloseTo(3447.5, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('C15: 11 bars, z 43.598, length 3852 (2088 + 2 × 882)', () => {
|
// P1 문서값 (§7) = 일반 규칙의 기준 (결정 2026-11-10): 모델의 계단 72/144/215/287 → C15 5구간, C16 3·4구간.
|
||||||
const { c15 } = stairBars(L, B, m.copingRefTop);
|
const steps = tStepBars(m).bars;
|
||||||
|
const c15 = steps.filter((b) => b.name === 'C15');
|
||||||
|
const c16 = steps.filter((b) => b.name === 'C16');
|
||||||
|
|
||||||
|
it('C15: 11 bars, z 43.598, length 3852 (2088 + 2 × 882), from 9.812 to 11.900', () => {
|
||||||
expect(c15.length).toBe(11);
|
expect(c15.length).toBe(11);
|
||||||
expect(c15[0].z).toBeCloseTo(43.598, 3);
|
expect(c15[0].z).toBeCloseTo(43.598, 3);
|
||||||
expect(c15[0].length).toBeCloseTo(3852, 0);
|
expect(c15[0].length).toBe(3852);
|
||||||
expect(c15[0].x0).toBeCloseTo(9812, 0);
|
expect([c15[0].x0, c15[0].x1]).toEqual([9812, 11900]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('C16: 22 bars (3·4구간), z 43.436 / 43.507, lengths 4070 / 4069', () => {
|
it('C16: 22 bars (3·4구간), z 43.436 / 43.507, lengths 4070 / 4069, leg at the low side', () => {
|
||||||
const { c16 } = stairBars(L, B, m.copingRefTop);
|
|
||||||
expect(c16.length).toBe(22);
|
expect(c16.length).toBe(22);
|
||||||
expect(c16[0].z).toBeCloseTo(43.436, 3);
|
expect(c16[0].z).toBeCloseTo(43.436, 3);
|
||||||
expect(c16[11].z).toBeCloseTo(43.507, 3);
|
expect(c16[11].z).toBeCloseTo(43.507, 3);
|
||||||
expect(c16[0].length).toBeCloseTo(4070, 0);
|
expect(c16[0].length).toBe(4070);
|
||||||
expect(c16[11].length).toBeCloseTo(4069, 0);
|
expect(c16[11].length).toBe(4069);
|
||||||
|
expect([c16[0].x0, c16[0].x1, c16[11].x0, c16[11].x1]).toEqual([4801, 7989, 7307, 10494]);
|
||||||
|
expect(c16.every((b) => b.legs === 'start')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('C15 / C16 across positions: 137 from the cover line, then @250, 11 bars', () => {
|
it('C15 / C16 across positions: 137.5 from the cover line (문서 137), then @250, 11 bars', () => {
|
||||||
const { c15 } = stairBars(L, B, m.copingRefTop);
|
expect(c15[0].n * 1000).toBeCloseTo(-B / 2 + 237.5, 6);
|
||||||
expect(c15[0].n).toBeCloseTo(-B / 2 + 237, 0);
|
expect((c15[10].n - c15[0].n) * 1000).toBeCloseTo(2500, 6);
|
||||||
expect(c15[10].n - c15[0].n).toBeCloseTo(2500, 0);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -67,10 +71,10 @@ describe('C14 연직 발 (코핑철근.md §8)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('C15 간격 = 2 × 주철근 기본간격 (승인 2026-10-26)', () => {
|
describe('C15 간격 = 2 × 주철근 기본간격 (승인 2026-10-26)', () => {
|
||||||
it('the default pitch is 250, and a larger pitch moves the 11 C15 bars apart', () => {
|
it('the default pitch is 250, and a 140 기본간격 moves the C15 bars to 280', () => {
|
||||||
const { c15 } = stairBars(L, B, m.copingRefTop);
|
const base = tStepBars(m).bars.filter((b) => b.name === 'C15');
|
||||||
expect(c15[1].n - c15[0].n).toBe(250);
|
expect(Math.round((base[1].n - base[0].n) * 1000)).toBe(250);
|
||||||
const wider = stairBars(L, B, m.copingRefTop, undefined, 280).c15;
|
const wider = tStepBars(buildPier({ ...loadP1(), copingRebar: { mainSpacing: 140 } })).bars.filter((b) => b.name === 'C15');
|
||||||
expect(wider[1].n - wider[0].n).toBe(280);
|
expect(Math.round((wider[1].n - wider[0].n) * 1000)).toBe(280);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -57,7 +57,8 @@ describe('③ 철근표 요약 (P1)', () => {
|
|||||||
expect(rows.find((r) => r.name === 'C12')!.pieces!.some((p) => p.length === 11746)).toBe(true);
|
expect(rows.find((r) => r.name === 'C12')!.pieces!.some((p) => p.length === 11746)).toBe(true);
|
||||||
expect(rows.find((r) => r.name === 'C14')!.length).toBe(3447.5);
|
expect(rows.find((r) => r.name === 'C14')!.length).toBe(3447.5);
|
||||||
expect(rows.find((r) => r.name === 'C15')!.length).toBe(3852);
|
expect(rows.find((r) => r.name === 'C15')!.length).toBe(3852);
|
||||||
expect(rows.find((r) => r.name === 'C16')!.length).toBe(4070);
|
// C16 has two lengths (3·4구간), so the row lists them as pieces (2026-11-10).
|
||||||
|
expect(rows.find((r) => r.name === 'C16')!.pieces).toEqual([{ length: 4070, count: 11 }, { length: 4069, count: 11 }]);
|
||||||
expect(rows.find((r) => r.name === 'P5')!.length).toBeCloseTo(5657.7, 0);
|
expect(rows.find((r) => r.name === 'P5')!.length).toBeCloseTo(5657.7, 0);
|
||||||
expect(rows.find((r) => r.name === 'P3')!.length!).toBeCloseTo(5657.7 + 1146.6, 0);
|
expect(rows.find((r) => r.name === 'P3')!.length!).toBeCloseTo(5657.7 + 1146.6, 0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,4 +55,37 @@ describe('교좌면 EL 입력 (2026-11-03)', () => {
|
|||||||
validatePier(input, m, issues);
|
validatePier(input, m, issues);
|
||||||
expect(issues.filter((i) => i.code === 'W10')).toEqual([]);
|
expect(issues.filter((i) => i.code === 'W10')).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('an input 교좌면 EL also sets the coping top of that step, so the block sits on the coping (2026-11-10)', () => {
|
||||||
|
const m = buildPier(withSeat(loadP1(), [43.4, ...SEAT.slice(1)]), P1CTX);
|
||||||
|
const b1 = m.blocks.find((x) => x.no === 1 && x.row === '전열')!;
|
||||||
|
const seg = m.segments.find((s) => b1.g >= s.s0 && b1.g < s.s1)!;
|
||||||
|
expect(seg.top).toBeCloseTo(43.4, 6);
|
||||||
|
expect(b1.bottom).toBeCloseTo(seg.top, 9);
|
||||||
|
// seatCalc keeps the slope value (z − h1) as the W10 reference.
|
||||||
|
expect(b1.seatCalc).toBeCloseTo(43.411, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('W10 is information (I), not a warning, since the input builds the geometry', () => {
|
||||||
|
const input = withSeat(loadP1(), [43.5, ...SEAT.slice(1)]);
|
||||||
|
const m = buildPier(input, P1CTX);
|
||||||
|
const issues: Issue[] = [];
|
||||||
|
validatePier(input, m, issues);
|
||||||
|
expect(issues.filter((i) => i.code === 'W10').map((i) => i.level)).toEqual(['I']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('W11 when a block is thinner than 63 mm (피복 50 + D13), from its own 교좌면 EL; none for P1', () => {
|
||||||
|
const p1 = withSeat(loadP1(), SEAT);
|
||||||
|
const ok: Issue[] = [];
|
||||||
|
validatePier(p1, buildPier(p1, P1CTX), ok);
|
||||||
|
expect(ok.filter((i) => i.code === 'W11')).toEqual([]);
|
||||||
|
// Bearing 1: z 43.561 − pad 50 − 50 mm block → 교좌면 43.461.
|
||||||
|
const thin = withSeat(loadP1(), [43.461, ...SEAT.slice(1)]);
|
||||||
|
const m = buildPier(thin, P1CTX);
|
||||||
|
const issues: Issue[] = [];
|
||||||
|
validatePier(thin, m, issues);
|
||||||
|
const w = issues.filter((i) => i.code === 'W11');
|
||||||
|
expect(w.length).toBe(1);
|
||||||
|
expect(w[0].message).toContain('받침 1번');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildPier } from '../src/core/geometry';
|
||||||
|
import { tStepBars } from '../src/core/rebar/coping';
|
||||||
|
import { multicolCopingInfo } from '../src/core/rebar/multicol-coping';
|
||||||
|
import { stepBlockBars } from '../src/core/rebar/step-bars';
|
||||||
|
import { rebarSummary } from '../src/core/rebar/summary';
|
||||||
|
import type { PierInput } from '../src/core/types';
|
||||||
|
import { loadP1 } from './helpers';
|
||||||
|
|
||||||
|
// 계단부 C15 · C16 일반 규칙 (결정 2026-11-10, src/core/rebar/step-bars.ts). P1 문서값은 rebar-stairs.test.ts.
|
||||||
|
|
||||||
|
/** P1 with the 교좌면 EL (and z with it) of bearings 1–5 / 6–10 set to `seats` (m), both rows alike. */
|
||||||
|
function withSeats(seats: number[]): PierInput {
|
||||||
|
const p1 = loadP1();
|
||||||
|
return {
|
||||||
|
...p1,
|
||||||
|
bearings: p1.bearings.map((b) => {
|
||||||
|
const s = seats[(b.no - 1) % 5];
|
||||||
|
return { ...b, z: b.z - b.seatEl! + s, seatEl: s };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const SEAT = [43.411, 43.483, 43.555, 43.626, 43.698];
|
||||||
|
|
||||||
|
describe('계단부 철근 일반 규칙', () => {
|
||||||
|
it('steps that fall towards the end: C15 on the first step (leg at the coping start + c), C16 legs at the low (end) side', () => {
|
||||||
|
const m = buildPier(withSeats([...SEAT].reverse()));
|
||||||
|
const bars = tStepBars(m).bars;
|
||||||
|
const c15 = bars.filter((b) => b.name === 'C15');
|
||||||
|
const c16 = bars.filter((b) => b.name === 'C16');
|
||||||
|
expect(c15.length).toBe(11);
|
||||||
|
expect(c15[0].x0).toBe(100);
|
||||||
|
expect(c16.length).toBe(22);
|
||||||
|
expect(c16.every((b) => b.legs === 'end')).toBe(true);
|
||||||
|
// Mirror of P1: the run reaches l_d into the higher (start-side) step.
|
||||||
|
expect(c16.map((b) => b.length).sort()).toEqual([...Array(11).fill(4069), ...Array(11).fill(4070)].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every bar stays inside the coping length (the P1 positions no longer leak past a shorter coping)', () => {
|
||||||
|
const m = buildPier({ ...withSeats([...SEAT].reverse()), copingLengthMm: 12000 });
|
||||||
|
const L = m.copingL * 1000;
|
||||||
|
for (const b of tStepBars(m).bars) {
|
||||||
|
expect(b.x0).toBeGreaterThanOrEqual(100);
|
||||||
|
expect(b.x1).toBeLessThanOrEqual(L - 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no step bar when no step is h_16 (100 mm) above the lowest top', () => {
|
||||||
|
const m = buildPier(withSeats([43.411, 43.45, 43.48, 43.5, 43.51]));
|
||||||
|
expect(tStepBars(m).bars).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a step with both neighbours higher gets no bar and a warning (RB-S01 규칙 밖)', () => {
|
||||||
|
const m = buildPier(withSeats([43.698, 43.561, 43.711, 43.411, 43.411]));
|
||||||
|
const { bars, warnings } = tStepBars(m);
|
||||||
|
expect(warnings).toHaveLength(1);
|
||||||
|
expect(warnings[0]).toContain('양쪽이 더 높아');
|
||||||
|
// The two peaks each get C15; the valley step none.
|
||||||
|
expect(bars.filter((b) => b.name === 'C15').length).toBe(22);
|
||||||
|
expect(bars.filter((b) => b.name === 'C16')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with a lateral step, front and back rows are separate and stay in their own part of the width', () => {
|
||||||
|
const p1 = loadP1();
|
||||||
|
const bearings = p1.bearings.map((b) => (b.row === '후열' && b.no === 7 ? { ...b, z: b.z + 0.3, seatEl: b.seatEl! + 0.3 } : b));
|
||||||
|
const m = buildPier({ ...p1, bearings });
|
||||||
|
const [front, back] = m.copingParts.filter((p) => p.k === 0);
|
||||||
|
const bars = tStepBars(m).bars;
|
||||||
|
expect(new Set(bars.map((b) => b.row))).toEqual(new Set(['전열', '후열']));
|
||||||
|
for (const b of bars) {
|
||||||
|
const part = b.row === '전열' ? front : back;
|
||||||
|
expect(b.n).toBeGreaterThanOrEqual(part.nFrom - 1e-9);
|
||||||
|
expect(b.n).toBeLessThanOrEqual(part.nTo + 1e-9);
|
||||||
|
}
|
||||||
|
// The raised back segment 2 (⑦) is a peak: C15 there, on the back row only.
|
||||||
|
const backC15 = bars.filter((b) => b.row === '후열' && b.name === 'C15');
|
||||||
|
expect(new Set(backC15.map((b) => b.x0))).toEqual(new Set([2296, 9812]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multi-column copings use the same rule: a peak gets C15, a lower step C16 (no more RB-M04)', () => {
|
||||||
|
const m = buildPier({ ...withSeats(SEAT), type: '2주형', columnCount: 2, foundationType: '말뚝기초', groundEl: 30, pileLengthMm: 24000 });
|
||||||
|
const info = multicolCopingInfo(m);
|
||||||
|
const names = info.bars.map((b) => b.name);
|
||||||
|
expect(names).toContain('C15');
|
||||||
|
expect(names).toContain('C16');
|
||||||
|
expect(info.warnings.some((w) => w.code === 'RB-M04')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('전·후열 단차 블록 보강 C17 · C18 (결정 2026-11-10)', () => {
|
||||||
|
const raised = (dz: number, extra: Partial<PierInput> = {}) => {
|
||||||
|
const p1 = loadP1();
|
||||||
|
return buildPier({ ...p1, ...extra, bearings: p1.bearings.map((b) => (b.row === '후열' && b.no === 7 ? { ...b, z: b.z + dz, seatEl: b.seatEl! + dz } : b)) });
|
||||||
|
};
|
||||||
|
const opts = (m: ReturnType<typeof buildPier>) => ({ cover: 100, d: 15.9, ld: 600, tieSpacing: m.copingRebar.spacingS, skinSpacing: m.copingRebar.sideSpacing });
|
||||||
|
|
||||||
|
it('a 400 mm step: C17 ㄷ ties across the higher (back) block @전단간격, legs l_d below the lower top; one C18 row', () => {
|
||||||
|
const m = raised(0.4);
|
||||||
|
const bars = stepBlockBars(m, opts(m));
|
||||||
|
const c17 = bars.filter((b) => b.name === 'C17'), c18 = bars.filter((b) => b.name === 'C18');
|
||||||
|
expect(c17.length).toBe(10);
|
||||||
|
const [, back] = m.copingParts.filter((p) => p.k === 0);
|
||||||
|
const hi = m.backSegments![1].top, lo = m.segments[1].top;
|
||||||
|
for (const b of c17) {
|
||||||
|
for (const p of b.pts) { expect(p[2]).toBeGreaterThanOrEqual(back.nFrom - 1e-9); expect(p[2]).toBeLessThanOrEqual(back.nTo + 1e-9); }
|
||||||
|
expect(b.pts[1][1]).toBeCloseTo(hi - 0.1, 9);
|
||||||
|
expect(b.pts[0][1]).toBeCloseTo(lo - 0.6, 9);
|
||||||
|
expect(b.pts[0][0]).toBeGreaterThan(2.196 + 0.1 - 1e-6);
|
||||||
|
expect(b.pts[0][0]).toBeLessThan(4.701 - 0.1 + 1e-6);
|
||||||
|
}
|
||||||
|
expect(Math.abs(c17[1].pts[0][0] - c17[0].pts[0][0])).toBeCloseTo(0.25, 9);
|
||||||
|
expect(c18.length).toBe(1);
|
||||||
|
expect(c18[0].pts[0][1]).toBeCloseTo(lo + 0.25, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('below 300 mm no block bars', () => {
|
||||||
|
const m = raised(0.25);
|
||||||
|
expect(stepBlockBars(m, opts(m))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('they reach the T형 철근표 and the multi-column bars', () => {
|
||||||
|
const rows = rebarSummary(raised(0.4)).rows;
|
||||||
|
expect(rows.find((r) => r.name === 'C17')!.count).toBe(10);
|
||||||
|
expect(rows.find((r) => r.name === 'C18')!.count).toBe(1);
|
||||||
|
const multi = multicolCopingInfo(raised(0.4, { type: '2주형', columnCount: 2, foundationType: '말뚝기초', groundEl: 30, pileLengthMm: 24000 }));
|
||||||
|
expect(multi.bars.some((b) => b.name === 'C17')).toBe(true);
|
||||||
|
expect(multi.bars.some((b) => b.name === 'C18')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -214,20 +214,68 @@ describe('횡방향 단차 (spec §4.3, Q1·Q2 추천안)', () => {
|
|||||||
|
|
||||||
it('a 20 mm step in one segment splits the coping into two parts at the bearing-row midline', () => {
|
it('a 20 mm step in one segment splits the coping into two parts at the bearing-row midline', () => {
|
||||||
const p1 = loadP1();
|
const p1 = loadP1();
|
||||||
const bearings = p1.bearings.map((b) => (b.row === '후열' && b.no === 7 ? { ...b, z: b.z + 0.02 } : b));
|
// The P1 sheet gives 교좌면 EL, which sets the step top (2026-11-10), so it is raised with z.
|
||||||
|
const bearings = p1.bearings.map((b) => (b.row === '후열' && b.no === 7 ? { ...b, z: b.z + 0.02, seatEl: b.seatEl! + 0.02 } : b));
|
||||||
const m = buildPier({ ...p1, bearings });
|
const m = buildPier({ ...p1, bearings });
|
||||||
expect(m.copingParts).toHaveLength(2);
|
expect(m.copingParts).toHaveLength(2);
|
||||||
const [front, back] = m.copingParts;
|
const [front, back] = m.copingParts;
|
||||||
expect(front.nTo).toBeCloseTo(back.nFrom, 9);
|
// P1's front row is on the + side of the axis (n ≈ +0.70, back ≈ −0.60), so the front part is the + half (2026-11-10 fix).
|
||||||
expect(front.nFrom).toBeCloseTo(-m.copingB / 2, 9);
|
expect(meanN(m, '전열')).toBeGreaterThan(meanN(m, '후열'));
|
||||||
expect(back.nTo).toBeCloseTo(m.copingB / 2, 9);
|
expect(front.nFrom).toBeCloseTo(back.nTo, 9);
|
||||||
expect(front.nTo).toBeCloseTo((meanN(m, '전열') + meanN(m, '후열')) / 2, 9);
|
expect(front.nTo).toBeCloseTo(m.copingB / 2, 9);
|
||||||
|
expect(back.nFrom).toBeCloseTo(-m.copingB / 2, 9);
|
||||||
|
expect(front.nFrom).toBeCloseTo((meanN(m, '전열') + meanN(m, '후열')) / 2, 9);
|
||||||
// Segment 2 (bearing no. 7) is the one raised by 20 mm on the back row only.
|
// Segment 2 (bearing no. 7) is the one raised by 20 mm on the back row only.
|
||||||
const topAt = (part: typeof front, s: number) => part.profile.find(([ps, pz]) => Math.abs(ps - s) < 1e-9 && pz > m.levelBottom)![1];
|
const topAt = (part: typeof front, s: number) => part.profile.find(([ps, pz]) => Math.abs(ps - s) < 1e-9 && pz > m.levelBottom)![1];
|
||||||
const s2 = m.segments[2].s0;
|
const s2 = m.segments[2].s0;
|
||||||
expect(topAt(back, s2) - topAt(front, s2)).toBeCloseTo(0.02, 6);
|
expect(topAt(back, s2) - topAt(front, s2)).toBeCloseTo(0.02, 6);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('a 20 mm higher 교좌면 EL alone (z unchanged) also makes the step (2026-11-10)', () => {
|
||||||
|
const p1 = loadP1();
|
||||||
|
const bearings = p1.bearings.map((b) => (b.row === '후열' && b.no === 7 ? { ...b, seatEl: b.seatEl! + 0.02 } : b));
|
||||||
|
expect(buildPier({ ...p1, bearings }).copingParts).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each block sits on the coping part under it, whichever side of the axis the front row is on (2026-11-10)', () => {
|
||||||
|
const p1 = loadP1();
|
||||||
|
const raise = (b: (typeof p1.bearings)[number]) => (b.row === '후열' && b.no === 7 ? { ...b, z: b.z + 0.3, seatEl: b.seatEl! + 0.3 } : b);
|
||||||
|
const swapRows = (b: (typeof p1.bearings)[number]) => ({ ...b, row: (b.row === '전열' ? '후열' : '전열') as typeof b.row });
|
||||||
|
// P1 as given (front on +n) and with the row labels swapped (front on −n).
|
||||||
|
for (const bearings of [p1.bearings.map(raise), p1.bearings.map(raise).map(swapRows)]) {
|
||||||
|
const m = buildPier({ ...p1, bearings });
|
||||||
|
expect(m.copingParts).toHaveLength(2);
|
||||||
|
for (const b of m.blocks) {
|
||||||
|
const part = m.copingParts.find((q) => q.k === b.k && b.n >= q.nFrom && b.n <= q.nTo)!;
|
||||||
|
// The last top point at or before s on the stepped outline is the step top under the block.
|
||||||
|
const before = part.profile.slice(0, part.profile.findIndex(([ps]) => ps > b.s + 1e-9));
|
||||||
|
const top = before.filter(([, pz]) => pz > m.endBottom + 1e-9).pop()![1];
|
||||||
|
expect(b.bottom).toBeCloseTo(top, 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('slightly staggered rows (P1, < half a block) keep the front bounds for the back steps', () => {
|
||||||
|
const m = buildPier(loadP1());
|
||||||
|
expect(m.backSegments!.map((x) => x.s0)).toEqual(m.segments.map((x) => x.s0));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('back steps break midway between back bearings, even when they are not opposite the front ones (2026-11-10)', () => {
|
||||||
|
const p1 = loadP1();
|
||||||
|
// Move back bearing 7 by 1.5 m along the row: its bounds follow it, the front bounds do not.
|
||||||
|
const m0 = buildPier(p1);
|
||||||
|
const u = m0.u;
|
||||||
|
const bearings = p1.bearings.map((b) => (b.no === 7 ? { ...b, x: b.x + 1.5 * u.e, y: b.y + 1.5 * u.n } : b));
|
||||||
|
const m = buildPier({ ...p1, bearings });
|
||||||
|
const back = m.bearings.filter((b) => b.row === '후열').sort((a, b) => a.g - b.g);
|
||||||
|
const bs = m.backSegments!.map((x) => x.s0);
|
||||||
|
// ⑥|⑦ and ⑦|⑧ move 0.75 m (> half a block): own midpoints. ⑧|⑨ and ⑨|⑩ stay on the front bounds.
|
||||||
|
expect(bs[1]).toBeCloseTo((back[0].g + back[1].g) / 2, 9);
|
||||||
|
expect(bs[2]).toBeCloseTo((back[1].g + back[2].g) / 2, 9);
|
||||||
|
expect(bs.slice(3)).toEqual(m.segments.slice(3).map((x) => x.s0));
|
||||||
|
expect(m.segments.map((x) => x.s0)).toEqual(m0.segments.map((x) => x.s0));
|
||||||
|
});
|
||||||
|
|
||||||
it('a 3 mm difference is below the 5 mm tolerance: no step', () => {
|
it('a 3 mm difference is below the 5 mm tolerance: no step', () => {
|
||||||
const p1 = loadP1();
|
const p1 = loadP1();
|
||||||
const bearings = p1.bearings.map((b) => (b.row === '후열' && b.no === 7 ? { ...b, z: b.z + 0.003 } : b));
|
const bearings = p1.bearings.map((b) => (b.row === '후열' && b.no === 7 ? { ...b, z: b.z + 0.003 } : b));
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { defaultLayout, effectiveMode, parseLayout, SIDE_MIN_WIDTH } from '../src/view/workspace-layout';
|
||||||
|
|
||||||
|
describe('workspace layout preference', () => {
|
||||||
|
it('defaults: stacked, ③ model 240 px, ② keeps its responsive band', () => {
|
||||||
|
expect(defaultLayout('rebar')).toEqual({ mode: 'stack', height: 240, width: 50, collapsed: false });
|
||||||
|
expect(defaultLayout('plan').height).toBeNull();
|
||||||
|
});
|
||||||
|
it('migrates the old ③ {height, collapsed} value', () => {
|
||||||
|
expect(parseLayout('rebar', { height: 360, collapsed: true })).toEqual({ mode: 'stack', height: 360, width: 50, collapsed: true });
|
||||||
|
});
|
||||||
|
it('rejects out-of-range or corrupt values', () => {
|
||||||
|
expect(parseLayout('rebar', { mode: 'grid', height: 9999, width: 95 })).toEqual({ mode: 'stack', height: 240, width: 70, collapsed: false });
|
||||||
|
expect(parseLayout('plan', 'x')).toEqual(defaultLayout('plan'));
|
||||||
|
});
|
||||||
|
it('side-by-side only on a wide desktop column, otherwise stacked and marked forced', () => {
|
||||||
|
const side = { ...defaultLayout('plan'), mode: 'side' as const };
|
||||||
|
expect(effectiveMode(side, SIDE_MIN_WIDTH, true)).toEqual({ mode: 'side', forced: false });
|
||||||
|
expect(effectiveMode(side, SIDE_MIN_WIDTH - 1, true)).toEqual({ mode: 'stack', forced: true });
|
||||||
|
expect(effectiveMode(side, 1400, false)).toEqual({ mode: 'stack', forced: true });
|
||||||
|
expect(effectiveMode(defaultLayout('plan'), 400, true)).toEqual({ mode: 'stack', forced: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
+3
-2
@@ -5,6 +5,7 @@ import { existsSync } from 'node:fs';
|
|||||||
import { mkdir,readFile,writeFile } from 'node:fs/promises';
|
import { mkdir,readFile,writeFile } from 'node:fs/promises';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { resolvePython } from '../pierzainer_ground/python-runtime.ts';
|
||||||
export const DWG_API='/__pierzainer/general-arrangement/dwg';
|
export const DWG_API='/__pierzainer/general-arrangement/dwg';
|
||||||
|
|
||||||
/** A narrow local export route; no project state or existing drawing-extractor endpoints are changed. */
|
/** A narrow local export route; no project state or existing drawing-extractor endpoints are changed. */
|
||||||
@@ -21,8 +22,8 @@ export function generalArrangementDwgPlugin(root:string):Plugin{
|
|||||||
for await(const chunk of req){const b=Buffer.from(chunk);size+=b.length;if(size>20*1024*1024)throw new Error('도면 크기가 20MB를 넘습니다.');chunks.push(b);}
|
for await(const chunk of req){const b=Buffer.from(chunk);size+=b.length;if(size>20*1024*1024)throw new Error('도면 크기가 20MB를 넘습니다.');chunks.push(b);}
|
||||||
const body=JSON.parse(Buffer.concat(chunks).toString('utf8')) as {dxf?:unknown};
|
const body=JSON.parse(Buffer.concat(chunks).toString('utf8')) as {dxf?:unknown};
|
||||||
if(typeof body.dxf!=='string'||!body.dxf.includes('SECTION')||!body.dxf.trim().endsWith('EOF'))throw new Error('유효한 DXF 도면 데이터가 필요합니다.');
|
if(typeof body.dxf!=='string'||!body.dxf.includes('SECTION')||!body.dxf.trim().endsWith('EOF'))throw new Error('유효한 DXF 도면 데이터가 필요합니다.');
|
||||||
const python=path.join(root,'pierzainer_ground','.venv','Scripts','python.exe'),script=path.join(root,'도면작성','dwg_export.py');
|
const python=resolvePython(root),script=path.join(root,'도면작성','dwg_export.py');
|
||||||
if(!existsSync(python))throw new Error('DWG 출력용 기존 Python 실행 환경을 찾을 수 없습니다.');
|
if(path.isAbsolute(python)&&!existsSync(python))throw new Error('DWG 출력용 기존 Python 실행 환경을 찾을 수 없습니다.');
|
||||||
const folder=path.join(root,'도면작성','output',randomUUID());await mkdir(folder,{recursive:true});
|
const folder=path.join(root,'도면작성','output',randomUUID());await mkdir(folder,{recursive:true});
|
||||||
const source=path.join(folder,'source.dxf'),output=path.join(folder,'general.dwg');await writeFile(source,body.dxf,'utf8');
|
const source=path.join(folder,'source.dxf'),output=path.join(folder,'general.dwg');await writeFile(source,body.dxf,'utf8');
|
||||||
const result=await new Promise<{code:number;stdout:string;stderr:string}>((resolve,reject)=>{
|
const result=await new Promise<{code:number;stdout:string;stderr:string}>((resolve,reject)=>{
|
||||||
|
|||||||
+5
-1
@@ -9,7 +9,7 @@ import { createSheetEditor } from './sheet-editor';
|
|||||||
import { shortenedBounds } from './breaks';
|
import { shortenedBounds } from './breaks';
|
||||||
import { createQualityPanel } from './quality-panel';
|
import { createQualityPanel } from './quality-panel';
|
||||||
|
|
||||||
export function createDrawingWorkspace(host: HTMLElement,read:()=>DrawingSnapshot,select:(id:string)=>void): { update:()=>void } {
|
export function createDrawingWorkspace(host: HTMLElement,read:()=>DrawingSnapshot,select:(id:string)=>void,openMaterials?:()=>void): { update:()=>void } {
|
||||||
host.className='ga-workspace';
|
host.className='ga-workspace';
|
||||||
host.innerHTML=`<aside class="ga-settings"><div class="ga-sidebar-title">도면 설정 <span>GENERAL ARRANGEMENT</span></div>
|
host.innerHTML=`<aside class="ga-settings"><div class="ga-sidebar-title">도면 설정 <span>GENERAL ARRANGEMENT</span></div>
|
||||||
<label>교각 선택<select id="ga-pier" aria-label="도면 교각 선택"></select></label>
|
<label>교각 선택<select id="ga-pier" aria-label="도면 교각 선택"></select></label>
|
||||||
@@ -29,6 +29,10 @@ export function createDrawingWorkspace(host: HTMLElement,read:()=>DrawingSnapsho
|
|||||||
<footer class="ga-status"><span id="ga-object-info">휠 확대·축소 / 드래그 이동 / 객체 클릭</span><span id="ga-count">모델 mm · EL m</span></footer></section>
|
<footer class="ga-status"><span id="ga-object-info">휠 확대·축소 / 드래그 이동 / 객체 클릭</span><span id="ga-count">모델 mm · EL m</span></footer></section>
|
||||||
<dialog class="ga-print-dialog" id="ga-print-dialog" aria-label="일반도 출력 미리보기"><div class="ga-print-toolbar"><strong>출력 미리보기</strong><span id="ga-print-paper"></span><button id="ga-print" class="ga-primary" type="button">인쇄 / PDF</button><button id="ga-print-close" type="button">닫기</button></div><div id="ga-print-sheet"></div></dialog>`;
|
<dialog class="ga-print-dialog" id="ga-print-dialog" aria-label="일반도 출력 미리보기"><div class="ga-print-toolbar"><strong>출력 미리보기</strong><span id="ga-print-paper"></span><button id="ga-print" class="ga-primary" type="button">인쇄 / PDF</button><button id="ga-print-close" type="button">닫기</button></div><div id="ga-print-sheet"></div></dialog>`;
|
||||||
const node=<T extends HTMLElement=HTMLElement>(id:string)=>host.querySelector<T>(`#${id}`)!;
|
const node=<T extends HTMLElement=HTMLElement>(id:string)=>host.querySelector<T>(`#${id}`)!;
|
||||||
|
if(openMaterials){
|
||||||
|
const button=window.document.createElement('button');button.type='button';button.id='ga-materials';button.textContent='재료표';button.addEventListener('click',openMaterials);
|
||||||
|
host.querySelector('.ga-toolbar-actions')!.prepend(button);
|
||||||
|
}
|
||||||
const pier=node<HTMLSelectElement>('ga-pier'),view=node<HTMLSelectElement>('ga-view'),paper=node<HTMLSelectElement>('ga-paper'),scale=node<HTMLSelectElement>('ga-scale');
|
const pier=node<HTMLSelectElement>('ga-pier'),view=node<HTMLSelectElement>('ga-view'),paper=node<HTMLSelectElement>('ga-paper'),scale=node<HTMLSelectElement>('ga-scale');
|
||||||
const bearing=node<HTMLSelectElement>('ga-bearing');
|
const bearing=node<HTMLSelectElement>('ga-bearing');
|
||||||
const canvas=node('ga-canvas'),empty=node('ga-empty'),warnings=node('ga-warnings'),note=node('ga-data-note');
|
const canvas=node('ga-canvas'),empty=node('ga-empty'),warnings=node('ga-warnings'),note=node('ga-data-note');
|
||||||
|
|||||||
@@ -0,0 +1,664 @@
|
|||||||
|
// Generated by scripts/promote-landing-hero.py from actual model projection.
|
||||||
|
export const heroCapture = {
|
||||||
|
"width": 2172,
|
||||||
|
"height": 870,
|
||||||
|
"elevation": 0.85,
|
||||||
|
"along": 0,
|
||||||
|
"outline": {
|
||||||
|
"method": "inward-alpha-envelope-quadratic",
|
||||||
|
"retainedTerrainRatio": 0.9268103107372453,
|
||||||
|
"addedOutsideSourcePixels": 0
|
||||||
|
},
|
||||||
|
"endMarginM": 30,
|
||||||
|
"cornerRadiusM": 18,
|
||||||
|
"terrainHalfWidthM": 56,
|
||||||
|
"roads": [
|
||||||
|
{
|
||||||
|
"name": "A1–P1",
|
||||||
|
"widthM": 8,
|
||||||
|
"validSections": 81,
|
||||||
|
"missingSections": 0,
|
||||||
|
"triangles": 2560,
|
||||||
|
"visualizationOnly": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "P6–A2",
|
||||||
|
"widthM": 8,
|
||||||
|
"validSections": 81,
|
||||||
|
"missingSections": 0,
|
||||||
|
"triangles": 2560,
|
||||||
|
"visualizationOnly": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"points": [
|
||||||
|
{
|
||||||
|
"name": "A1",
|
||||||
|
"kind": "abutment",
|
||||||
|
"x": 264.03,
|
||||||
|
"y": 399.71
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "P1",
|
||||||
|
"kind": "pier",
|
||||||
|
"x": 462.89,
|
||||||
|
"y": 407.62
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "P2",
|
||||||
|
"kind": "pier",
|
||||||
|
"x": 712.6,
|
||||||
|
"y": 401.43
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "P3",
|
||||||
|
"kind": "pier",
|
||||||
|
"x": 962.31,
|
||||||
|
"y": 395.46
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "P4",
|
||||||
|
"kind": "pier",
|
||||||
|
"x": 1213.33,
|
||||||
|
"y": 391.75
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "P5",
|
||||||
|
"kind": "pier",
|
||||||
|
"x": 1464.3,
|
||||||
|
"y": 394.12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "P6",
|
||||||
|
"kind": "pier",
|
||||||
|
"x": 1713.67,
|
||||||
|
"y": 407.08
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "A2",
|
||||||
|
"kind": "abutment",
|
||||||
|
"x": 1911.22,
|
||||||
|
"y": 410.06
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"terrainFlow": [
|
||||||
|
{
|
||||||
|
"x": 264.033,
|
||||||
|
"y": 448.436
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 275.731,
|
||||||
|
"y": 448.555
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 287.428,
|
||||||
|
"y": 448.674
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 299.126,
|
||||||
|
"y": 449.022
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 310.824,
|
||||||
|
"y": 448.307
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 322.521,
|
||||||
|
"y": 447.275
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 334.219,
|
||||||
|
"y": 446.007
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 345.917,
|
||||||
|
"y": 445.252
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 357.614,
|
||||||
|
"y": 444.752
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 369.312,
|
||||||
|
"y": 443.105
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 381.009,
|
||||||
|
"y": 441.457
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 392.707,
|
||||||
|
"y": 439.355
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 404.405,
|
||||||
|
"y": 437.296
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 416.102,
|
||||||
|
"y": 435.21
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 427.8,
|
||||||
|
"y": 432.427
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 439.498,
|
||||||
|
"y": 429.689
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 451.195,
|
||||||
|
"y": 426.961
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 462.893,
|
||||||
|
"y": 424.25
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 474.784,
|
||||||
|
"y": 421.577
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 486.675,
|
||||||
|
"y": 419.082
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 498.565,
|
||||||
|
"y": 416.661
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 510.456,
|
||||||
|
"y": 414.622
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 522.347,
|
||||||
|
"y": 415.821
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 534.238,
|
||||||
|
"y": 416.946
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 546.129,
|
||||||
|
"y": 419.229
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 558.02,
|
||||||
|
"y": 423.307
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 569.911,
|
||||||
|
"y": 427.384
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 581.802,
|
||||||
|
"y": 431.453
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 593.692,
|
||||||
|
"y": 435.523
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 605.583,
|
||||||
|
"y": 439.146
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 617.474,
|
||||||
|
"y": 440.666
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 629.365,
|
||||||
|
"y": 441.616
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 641.256,
|
||||||
|
"y": 441.504
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 653.147,
|
||||||
|
"y": 441.125
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 665.038,
|
||||||
|
"y": 440.763
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 676.928,
|
||||||
|
"y": 440.769
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 688.819,
|
||||||
|
"y": 440.77
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 700.71,
|
||||||
|
"y": 440.772
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 712.601,
|
||||||
|
"y": 440.775
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 724.492,
|
||||||
|
"y": 440.777
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 736.383,
|
||||||
|
"y": 440.72
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 748.274,
|
||||||
|
"y": 440.623
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 760.165,
|
||||||
|
"y": 440.506
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 772.057,
|
||||||
|
"y": 440.384
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 783.948,
|
||||||
|
"y": 440.263
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 795.839,
|
||||||
|
"y": 440.153
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 807.73,
|
||||||
|
"y": 440.075
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 819.621,
|
||||||
|
"y": 439.997
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 831.512,
|
||||||
|
"y": 439.889
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 843.403,
|
||||||
|
"y": 439.753
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 855.294,
|
||||||
|
"y": 439.604
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 867.186,
|
||||||
|
"y": 439.365
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 879.077,
|
||||||
|
"y": 439.223
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 890.968,
|
||||||
|
"y": 439.22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 902.859,
|
||||||
|
"y": 439.363
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 914.75,
|
||||||
|
"y": 439.208
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 926.641,
|
||||||
|
"y": 438.641
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 938.532,
|
||||||
|
"y": 438.027
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 950.423,
|
||||||
|
"y": 437.98
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 962.315,
|
||||||
|
"y": 439.693
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 974.268,
|
||||||
|
"y": 441.41
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 986.221,
|
||||||
|
"y": 444.7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 998.174,
|
||||||
|
"y": 448.696
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1010.127,
|
||||||
|
"y": 452.659
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1022.08,
|
||||||
|
"y": 455.006
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1034.033,
|
||||||
|
"y": 457.171
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1045.986,
|
||||||
|
"y": 458.569
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1057.939,
|
||||||
|
"y": 458.814
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1069.892,
|
||||||
|
"y": 459.134
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1081.845,
|
||||||
|
"y": 459.621
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1093.798,
|
||||||
|
"y": 460.055
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1105.751,
|
||||||
|
"y": 460.438
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1117.704,
|
||||||
|
"y": 460.085
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1129.657,
|
||||||
|
"y": 459.728
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1141.61,
|
||||||
|
"y": 459.372
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1153.563,
|
||||||
|
"y": 459.015
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1165.516,
|
||||||
|
"y": 458.66
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1177.469,
|
||||||
|
"y": 457.484
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1189.422,
|
||||||
|
"y": 456.209
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1201.375,
|
||||||
|
"y": 455.301
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1213.328,
|
||||||
|
"y": 455
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1225.279,
|
||||||
|
"y": 454.84
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1237.23,
|
||||||
|
"y": 454.627
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1249.181,
|
||||||
|
"y": 454.385
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1261.132,
|
||||||
|
"y": 454.143
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1273.083,
|
||||||
|
"y": 453.906
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1285.034,
|
||||||
|
"y": 453.693
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1296.985,
|
||||||
|
"y": 453.477
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1308.936,
|
||||||
|
"y": 453.261
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1320.887,
|
||||||
|
"y": 453.048
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1332.838,
|
||||||
|
"y": 452.917
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1344.789,
|
||||||
|
"y": 452.794
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1356.74,
|
||||||
|
"y": 452.12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1368.691,
|
||||||
|
"y": 450.348
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1380.642,
|
||||||
|
"y": 448.577
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1392.593,
|
||||||
|
"y": 448.16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1404.544,
|
||||||
|
"y": 447.954
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1416.495,
|
||||||
|
"y": 447.746
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1428.446,
|
||||||
|
"y": 447.807
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1440.397,
|
||||||
|
"y": 447.996
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1452.348,
|
||||||
|
"y": 446.603
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1464.299,
|
||||||
|
"y": 443.94
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1476.174,
|
||||||
|
"y": 441.755
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1488.049,
|
||||||
|
"y": 441.693
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1499.924,
|
||||||
|
"y": 441.701
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1511.799,
|
||||||
|
"y": 441.422
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1523.674,
|
||||||
|
"y": 440.908
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1535.549,
|
||||||
|
"y": 440.562
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1547.424,
|
||||||
|
"y": 437.6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1559.299,
|
||||||
|
"y": 434.638
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1571.173,
|
||||||
|
"y": 431.58
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1583.048,
|
||||||
|
"y": 428.343
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1594.923,
|
||||||
|
"y": 425.105
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1606.798,
|
||||||
|
"y": 421.806
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1618.673,
|
||||||
|
"y": 418.513
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1630.548,
|
||||||
|
"y": 416.153
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1642.423,
|
||||||
|
"y": 414.67
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1654.298,
|
||||||
|
"y": 413.145
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1666.173,
|
||||||
|
"y": 414.583
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1678.048,
|
||||||
|
"y": 415.925
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1689.923,
|
||||||
|
"y": 418.147
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1701.798,
|
||||||
|
"y": 421.257
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1713.672,
|
||||||
|
"y": 424.538
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1725.293,
|
||||||
|
"y": 428.715
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1736.913,
|
||||||
|
"y": 432.916
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1748.534,
|
||||||
|
"y": 435.788
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1760.154,
|
||||||
|
"y": 437.518
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1771.774,
|
||||||
|
"y": 439.215
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1783.395,
|
||||||
|
"y": 440.178
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1795.015,
|
||||||
|
"y": 441.136
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1806.635,
|
||||||
|
"y": 442.009
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1818.256,
|
||||||
|
"y": 442.838
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1829.876,
|
||||||
|
"y": 443.654
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1841.496,
|
||||||
|
"y": 444.383
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1853.117,
|
||||||
|
"y": 445.119
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1864.737,
|
||||||
|
"y": 445.637
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1876.357,
|
||||||
|
"y": 446.014
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1887.978,
|
||||||
|
"y": 446.453
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1899.598,
|
||||||
|
"y": 447.172
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1911.219,
|
||||||
|
"y": 447.903
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"camera": {
|
||||||
|
"position": [
|
||||||
|
-1142.5706300600066,
|
||||||
|
1298.0918545724896,
|
||||||
|
1012.9959736070584
|
||||||
|
],
|
||||||
|
"target": [
|
||||||
|
77.50014114379883,
|
||||||
|
2.7950143814086914,
|
||||||
|
99.96070098876953
|
||||||
|
],
|
||||||
|
"left": -220.05126480649932,
|
||||||
|
"right": 219.07033276742936,
|
||||||
|
"top": 95.74252164639043,
|
||||||
|
"bottom": -80.1487260006252
|
||||||
|
},
|
||||||
|
"piers": 6,
|
||||||
|
"abutments": 2,
|
||||||
|
"sourceSha256": "59f2e09500dc55dbd8ca588d99ad8dd8daa92fd55e34b4eeb1daf10a89c3d4c8"
|
||||||
|
} as const;
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 261 KiB |
Binary file not shown.
@@ -0,0 +1,67 @@
|
|||||||
|
1
|
||||||
|
00:00:00,200 --> 00:00:01,700
|
||||||
|
더 빠른 설계를 넘어,
|
||||||
|
|
||||||
|
2
|
||||||
|
00:00:01,700 --> 00:00:03,200
|
||||||
|
더 나은 설계 경험으로,
|
||||||
|
|
||||||
|
3
|
||||||
|
00:00:03,200 --> 00:00:05,000
|
||||||
|
그리고 새로운 가치로.
|
||||||
|
|
||||||
|
4
|
||||||
|
00:00:04,650 --> 00:00:09,150
|
||||||
|
Pierzainer는 교량 설계 플랫폼의 하부구조 설계 모듈입니다
|
||||||
|
|
||||||
|
5
|
||||||
|
00:00:08,800 --> 00:00:13,800
|
||||||
|
하부구조 설계 흐름
|
||||||
|
|
||||||
|
6
|
||||||
|
00:00:13,450 --> 00:00:17,850
|
||||||
|
기설계 교량 라이브러리에서 시작
|
||||||
|
|
||||||
|
7
|
||||||
|
00:00:17,500 --> 00:00:20,500
|
||||||
|
시트 1장 = 교각 1기
|
||||||
|
|
||||||
|
8
|
||||||
|
00:00:20,500 --> 00:00:23,700
|
||||||
|
지형 위에 3D 모델 자동 생성
|
||||||
|
|
||||||
|
9
|
||||||
|
00:00:23,350 --> 00:00:28,150
|
||||||
|
형상은 고르고, 값만 수정
|
||||||
|
|
||||||
|
10
|
||||||
|
00:00:27,800 --> 00:00:31,500
|
||||||
|
철근표가 자동으로 계산됩니다
|
||||||
|
|
||||||
|
11
|
||||||
|
00:00:31,500 --> 00:00:33,800
|
||||||
|
고른 철근만 모델에 표시
|
||||||
|
|
||||||
|
12
|
||||||
|
00:00:33,800 --> 00:00:36,800
|
||||||
|
근거와 함께 알려주는 알림
|
||||||
|
|
||||||
|
13
|
||||||
|
00:00:36,450 --> 00:00:40,050
|
||||||
|
일반도·단면도 자동 작성
|
||||||
|
|
||||||
|
14
|
||||||
|
00:00:40,050 --> 00:00:42,650
|
||||||
|
DWG·DXF로 바로 내보내기
|
||||||
|
|
||||||
|
15
|
||||||
|
00:00:42,300 --> 00:00:47,500
|
||||||
|
교각 전체 재료표·수량 집계
|
||||||
|
|
||||||
|
16
|
||||||
|
00:00:47,150 --> 00:00:53,750
|
||||||
|
설계 자동화에서, 새로운 가치를 만드는 플랫폼으로
|
||||||
|
|
||||||
|
17
|
||||||
|
00:00:53,400 --> 00:00:56,000
|
||||||
|
Pierzainer
|
||||||
@@ -54,7 +54,7 @@ export function createLandingGuide(root: HTMLElement): {close:(restoreFocus?:boo
|
|||||||
'종평면도·받침배치도·ELEVATION DWG가 들어 있는 폴더를 선택하고 자동 추출 시작을 누르세요. 진행 내용이 팝업에 표시됩니다.',
|
'종평면도·받침배치도·ELEVATION DWG가 들어 있는 폴더를 선택하고 자동 추출 시작을 누르세요. 진행 내용이 팝업에 표시됩니다.',
|
||||||
'결과 목록의 교각 불러오기를 눌러 Excel 인식 결과를 확인하고 형상을 생성합니다. Excel 저장으로 파일만 받을 수도 있습니다.'],
|
'결과 목록의 교각 불러오기를 눌러 Excel 인식 결과를 확인하고 형상을 생성합니다. Excel 저장으로 파일만 받을 수도 있습니다.'],
|
||||||
sample:['파일 준비 없이 실제 교각과 지반 모델을 살펴보려면 샘플 프로젝트를 선택하세요.',
|
sample:['파일 준비 없이 실제 교각과 지반 모델을 살펴보려면 샘플 프로젝트를 선택하세요.',
|
||||||
'설계 라이브러리 팝업에서 산외교·운곡교 등 12개 교량의 이름과 교각 수를 확인하고 원하는 교량을 선택합니다.',
|
'아카이브 팝업에서 산외교·운곡교 등 12개 교량의 이름과 교각 수를 확인하고 원하는 교량을 선택합니다.',
|
||||||
`현재 ${sampleProjects.filter(s=>s.pierCount>0).length}개 교량은 교각과 원도면 지반면, 위성사진, 2D 지도를 함께 엽니다. 교각 데이터가 없는 승천천교는 지반·지도 자료를 표시합니다. 설계 지반고는 Excel 원래 값을 유지하며, 기존 검증 알림은 확인이 필요합니다.`]
|
`현재 ${sampleProjects.filter(s=>s.pierCount>0).length}개 교량은 교각과 원도면 지반면, 위성사진, 2D 지도를 함께 엽니다. 교각 데이터가 없는 승천천교는 지반·지도 자료를 표시합니다. 설계 지반고는 Excel 원래 값을 유지하며, 기존 검증 알림은 확인이 필요합니다.`]
|
||||||
};
|
};
|
||||||
const facts: Record<StartMode,string[]>={
|
const facts: Record<StartMode,string[]>={
|
||||||
|
|||||||
+25
-13
@@ -1,25 +1,37 @@
|
|||||||
/** Decorative geometry uses the PNG's exact 2172×724 pixel coordinate system. */
|
import { heroCapture } from './assets/okseong-hero-capture';
|
||||||
export const heroMotionMarkup = (imageUrl: string): string => `
|
|
||||||
<div class="landing-scan-mask" style="mask-image:url('${imageUrl}');-webkit-mask-image:url('${imageUrl}')">
|
const flowPath = (() => {
|
||||||
<div class="landing-terrain-grid"></div><div class="landing-scan-beam"></div>
|
let connected = false;
|
||||||
</div>
|
return heroCapture.terrainFlow.map(point => {
|
||||||
<svg class="landing-model-motion" viewBox="0 0 2172 724" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
|
if (point === null) { connected = false; return ''; }
|
||||||
|
const command = connected ? 'L' : 'M'; connected = true;
|
||||||
|
return `${command}${point.x} ${point.y}`;
|
||||||
|
}).join(' ');
|
||||||
|
})();
|
||||||
|
const frame = {
|
||||||
|
left: Math.min(...heroCapture.points.map(p=>p.x))-50,
|
||||||
|
right: Math.max(...heroCapture.points.map(p=>p.x))+50,
|
||||||
|
top: Math.min(...heroCapture.points.map(p=>p.y))-60,
|
||||||
|
bottom: Math.max(...heroCapture.points.map(p=>p.y))+70,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Markers share the exact camera projection used to capture the model. */
|
||||||
|
export const heroMotionMarkup = (): string => `
|
||||||
|
<svg class="landing-model-motion" viewBox="0 0 ${heroCapture.width} ${heroCapture.height}" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
|
||||||
<defs>
|
<defs>
|
||||||
<filter id="landing-energy-glow" x="-30%" y="-200%" width="160%" height="500%"><feGaussianBlur stdDeviation="3"/></filter>
|
<filter id="landing-energy-glow" x="-30%" y="-200%" width="160%" height="500%"><feGaussianBlur stdDeviation="3"/></filter>
|
||||||
<linearGradient id="landing-flow-fade"><stop offset="0" stop-color="#087fcb" stop-opacity="0"/><stop offset=".12" stop-color="#087fcb"/><stop offset=".88" stop-color="#087fcb"/><stop offset="1" stop-color="#087fcb" stop-opacity="0"/></linearGradient>
|
<linearGradient id="landing-flow-fade"><stop offset="0" stop-color="#ffffff" stop-opacity="0"/><stop offset=".12" stop-color="#ffffff"/><stop offset=".88" stop-color="#ffffff"/><stop offset="1" stop-color="#ffffff" stop-opacity="0"/></linearGradient>
|
||||||
<!-- Trace only the clearly visible pale alignment. Fade the uncertain ends instead of inventing bridge geometry. -->
|
<path id="landing-alignment-path" pathLength="1000" d="${flowPath}"/>
|
||||||
<path id="landing-alignment-path" pathLength="1000" d="M600 335 C650 341 700 367 750 368.5 S850 370 950 370.5 S1020 379 1050 382 S1120 398 1150 398 S1220 395 1250 395.5 S1320 396 1350 395 S1420 392 1450 390 S1520 384 1550 378.5 S1610 352 1650 350"/>
|
|
||||||
</defs>
|
</defs>
|
||||||
<use href="#landing-alignment-path" class="landing-energy-track"/>
|
<use href="#landing-alignment-path" class="landing-energy-track"/>
|
||||||
<use href="#landing-alignment-path" class="landing-energy-haze" filter="url(#landing-energy-glow)"/>
|
<use href="#landing-alignment-path" class="landing-energy-haze" filter="url(#landing-energy-glow)"/>
|
||||||
<use href="#landing-alignment-path" class="landing-energy-flow"/>
|
<use href="#landing-alignment-path" class="landing-energy-flow"/>
|
||||||
${[[546,331],[774,335],[996,344],[1238,346],[1478,334],[1745,332]].map(([x,y],i)=>`
|
${heroCapture.points.map(({name,x,y},i)=>`
|
||||||
<g class="landing-pier-beacon" style="--beacon-delay:${(i*.72+1.6).toFixed(2)}s" transform="translate(${x} ${y})">
|
<g class="landing-pier-beacon" data-structure="${name}" style="--beacon-delay:${(i*.72+1.6).toFixed(2)}s" transform="translate(${x} ${y})">
|
||||||
<line x1="0" y1="-31" x2="0" y2="-8"/>
|
<line x1="0" y1="-31" x2="0" y2="-8"/>
|
||||||
<circle class="landing-pier-pulse" r="11"/><circle class="landing-pier-point" r="3.5"/>
|
<circle class="landing-pier-pulse" r="11"/><circle class="landing-pier-point" r="3.5"/>
|
||||||
<circle class="landing-pier-spark" cx="8" cy="-15" r="1.5"/>
|
|
||||||
</g>`).join('')}
|
</g>`).join('')}
|
||||||
<path class="landing-hud-bracket" d="M460 260h-24v22 M1775 260h24v22 M460 485h-24v-22 M1775 485h24v-22"/>
|
<path class="landing-hud-bracket" d="M${frame.left} ${frame.top}h-24v22 M${frame.right} ${frame.top}h24v22 M${frame.left} ${frame.bottom}h-24v-22 M${frame.right} ${frame.bottom}h24v-22"/>
|
||||||
</svg>`;
|
</svg>`;
|
||||||
|
|
||||||
export function createHeroMotion(root: HTMLElement): void {
|
export function createHeroMotion(root: HTMLElement): void {
|
||||||
|
|||||||
+2
-10
@@ -308,10 +308,7 @@ html:has(.landing-overlay:not([hidden])), body:has(.landing-overlay:not([hidden]
|
|||||||
.landing-visual { animation: landing-premium-float 10s ease-in-out infinite; }
|
.landing-visual { animation: landing-premium-float 10s ease-in-out infinite; }
|
||||||
.landing-artwork { position: absolute; left: 0; top: 40%; width: 100%; aspect-ratio: 2172 / 724; transform: translateY(-50%); }
|
.landing-artwork { position: absolute; left: 0; top: 40%; width: 100%; aspect-ratio: 2172 / 724; transform: translateY(-50%); }
|
||||||
.landing-artwork .landing-promo-image { display: block; position: static; width: 100%; height: auto; transform: none; filter: brightness(1.12) contrast(1.04) saturate(1.04); }
|
.landing-artwork .landing-promo-image { display: block; position: static; width: 100%; height: auto; transform: none; filter: brightness(1.12) contrast(1.04) saturate(1.04); }
|
||||||
.landing-model-motion, .landing-scan-mask { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
|
.landing-model-motion { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
|
||||||
.landing-scan-mask { mask-size: 100% 100%; mask-repeat: no-repeat; -webkit-mask-size: 100% 100%; -webkit-mask-repeat: no-repeat; overflow: hidden; }
|
|
||||||
.landing-terrain-grid { position: absolute; inset: 0; opacity: .11; background-image: linear-gradient(#89eaff80 1px,transparent 1px),linear-gradient(90deg,#89eaff80 1px,transparent 1px); background-size: 30px 24px; transform: perspective(650px) rotateX(48deg) scale(1.6); animation: landing-grid-breathe 9.7s ease-in-out infinite; }
|
|
||||||
.landing-scan-beam { position: absolute; top: -10%; left: -18%; width: 18%; height: 120%; background: linear-gradient(90deg,transparent,#88efff1a 35%,#c1f8ff66 49%,#78dfff17 65%,transparent); transform: translateX(0); animation: landing-scan-pass 14s ease-in-out 1.8s infinite; }
|
|
||||||
.landing-energy-track { fill: none; stroke: #70dfff; stroke-width: 1.4; opacity: .18; }
|
.landing-energy-track { fill: none; stroke: #70dfff; stroke-width: 1.4; opacity: .18; }
|
||||||
.landing-energy-flow, .landing-energy-haze { fill: none; stroke: url(#landing-flow-fade); stroke-width: 2.5; stroke-linecap: round; stroke-dasharray: 90 910; animation: landing-energy-run 8.3s linear 1.5s infinite; opacity: .8; }
|
.landing-energy-flow, .landing-energy-haze { fill: none; stroke: url(#landing-flow-fade); stroke-width: 2.5; stroke-linecap: round; stroke-dasharray: 90 910; animation: landing-energy-run 8.3s linear 1.5s infinite; opacity: .8; }
|
||||||
.landing-energy-haze { stroke-width: 8; opacity: .48; }
|
.landing-energy-haze { stroke-width: 8; opacity: .48; }
|
||||||
@@ -319,7 +316,6 @@ html:has(.landing-overlay:not([hidden])), body:has(.landing-overlay:not([hidden]
|
|||||||
.landing-pier-beacon line { stroke: currentColor; stroke-width: 1; opacity: .42; stroke-dasharray: 3 4; }
|
.landing-pier-beacon line { stroke: currentColor; stroke-width: 1; opacity: .42; stroke-dasharray: 3 4; }
|
||||||
.landing-pier-point { fill: #d3fdff; filter: drop-shadow(0 0 5px #62ddff); animation: landing-beacon-point 7.9s ease-in-out var(--beacon-delay) infinite; }
|
.landing-pier-point { fill: #d3fdff; filter: drop-shadow(0 0 5px #62ddff); animation: landing-beacon-point 7.9s ease-in-out var(--beacon-delay) infinite; }
|
||||||
.landing-pier-pulse { fill: none; stroke: #8decff; stroke-width: 1.2; opacity: 0; transform-box: fill-box; transform-origin: center; animation: landing-beacon-pulse 7.9s ease-out var(--beacon-delay) infinite; }
|
.landing-pier-pulse { fill: none; stroke: #8decff; stroke-width: 1.2; opacity: 0; transform-box: fill-box; transform-origin: center; animation: landing-beacon-pulse 7.9s ease-out var(--beacon-delay) infinite; }
|
||||||
.landing-pier-spark { fill: #c1faff; opacity: 0; animation: landing-beacon-spark 7.9s ease-out var(--beacon-delay) infinite; }
|
|
||||||
.landing-hud-bracket { fill: none; stroke: #8dcfff; stroke-width: 1; opacity: .28; }
|
.landing-hud-bracket { fill: none; stroke: #8dcfff; stroke-width: 1; opacity: .28; }
|
||||||
.landing-showcase-label { position: absolute; left: 50%; top: 56%; transform: translateX(-50%); color: #84c4dd; font-size: 9px; letter-spacing: .23em; white-space: nowrap; }
|
.landing-showcase-label { position: absolute; left: 50%; top: 56%; transform: translateX(-50%); color: #84c4dd; font-size: 9px; letter-spacing: .23em; white-space: nowrap; }
|
||||||
.landing-showcase-label > span { display: block; margin-top: 8px; font-size: 8px; letter-spacing: .06em; color: #729db8; text-align: center; }
|
.landing-showcase-label > span { display: block; margin-top: 8px; font-size: 8px; letter-spacing: .06em; color: #729db8; text-align: center; }
|
||||||
@@ -387,19 +383,15 @@ html:has(.landing-overlay:not([hidden])), body:has(.landing-overlay:not([hidden]
|
|||||||
.landing-paused .landing-light-stage *, .landing-inactive .landing-light-stage *,
|
.landing-paused .landing-light-stage *, .landing-inactive .landing-light-stage *,
|
||||||
.landing-paused .landing-visual, .landing-inactive .landing-visual,
|
.landing-paused .landing-visual, .landing-inactive .landing-visual,
|
||||||
.landing-paused .landing-model-motion *, .landing-inactive .landing-model-motion *,
|
.landing-paused .landing-model-motion *, .landing-inactive .landing-model-motion *,
|
||||||
.landing-paused .landing-scan-mask *, .landing-inactive .landing-scan-mask *,
|
|
||||||
.landing-paused .landing-hero::before, .landing-inactive .landing-hero::before { animation-play-state: paused !important; }
|
.landing-paused .landing-hero::before, .landing-inactive .landing-hero::before { animation-play-state: paused !important; }
|
||||||
|
|
||||||
@keyframes landing-atmosphere { from { transform: translate3d(-1%,-1%,0) scale(1); opacity: .6; } to { transform: translate3d(2%,1%,0) scale(1.06); opacity: 1; } }
|
@keyframes landing-atmosphere { from { transform: translate3d(-1%,-1%,0) scale(1); opacity: .6; } to { transform: translate3d(2%,1%,0) scale(1.06); opacity: 1; } }
|
||||||
@keyframes landing-glow-breathe { from { opacity: .65; scale: .96; } to { opacity: 1; scale: 1.08; } }
|
@keyframes landing-glow-breathe { from { opacity: .65; scale: .96; } to { opacity: 1; scale: 1.08; } }
|
||||||
@keyframes landing-title-shimmer { 0%,55% { background-position: 110% 50%; } 85%,100% { background-position: -90% 50%; } }
|
@keyframes landing-title-shimmer { 0%,55% { background-position: 110% 50%; } 85%,100% { background-position: -90% 50%; } }
|
||||||
@keyframes landing-premium-float { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-5px); } }
|
@keyframes landing-premium-float { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-5px); } }
|
||||||
@keyframes landing-scan-pass { 0%,12% { transform: translateX(0); opacity: 0; } 20% { opacity: .7; } 75% { opacity: .7; } 88%,100% { transform: translateX(740%); opacity: 0; } }
|
|
||||||
@keyframes landing-grid-breathe { 0%,100% { opacity: .06; } 45% { opacity: .17; } }
|
|
||||||
@keyframes landing-energy-run { 0% { stroke-dashoffset: 1000; opacity: 0; } 12% { opacity: .8; } 88% { opacity: .8; } 100% { stroke-dashoffset: -1000; opacity: 0; } }
|
@keyframes landing-energy-run { 0% { stroke-dashoffset: 1000; opacity: 0; } 12% { opacity: .8; } 88% { opacity: .8; } 100% { stroke-dashoffset: -1000; opacity: 0; } }
|
||||||
@keyframes landing-beacon-point { 0%,55%,100% { opacity: .35; } 12%,27% { opacity: 1; } }
|
@keyframes landing-beacon-point { 0%,55%,100% { opacity: .35; } 12%,27% { opacity: 1; } }
|
||||||
@keyframes landing-beacon-pulse { 0% { opacity: 0; transform: scale(.4); } 12% { opacity: .85; } 42%,100% { opacity: 0; transform: scale(2.3); } }
|
@keyframes landing-beacon-pulse { 0% { opacity: 0; transform: scale(.4); } 12% { opacity: .85; } 42%,100% { opacity: 0; transform: scale(2.3); } }
|
||||||
@keyframes landing-beacon-spark { 0%,8% { opacity: 0; transform: translateY(0); } 18% { opacity: .8; } 40%,100% { opacity: 0; transform: translateY(-18px); } }
|
|
||||||
@keyframes landing-guide-enter { from { opacity: 0; translate: 0 8px; } to { opacity: 1; translate: 0 0; } }
|
@keyframes landing-guide-enter { from { opacity: 0; translate: 0 8px; } to { opacity: 1; translate: 0 0; } }
|
||||||
@keyframes landing-guide-icon-float { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-3px); } }
|
@keyframes landing-guide-icon-float { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-3px); } }
|
||||||
@keyframes landing-guide-dot { 0%,100% { opacity: .2; } 50% { opacity: 1; } }
|
@keyframes landing-guide-dot { 0%,100% { opacity: .2; } 50% { opacity: 1; } }
|
||||||
@@ -441,7 +433,7 @@ html:has(.landing-overlay:not([hidden])), body:has(.landing-overlay:not([hidden]
|
|||||||
.landing-artwork *, .landing-guide *, .landing-guide[open] { animation: none !important; transition: none !important; }
|
.landing-artwork *, .landing-guide *, .landing-guide[open] { animation: none !important; transition: none !important; }
|
||||||
.landing-title { background: none; color: #f2fbff; -webkit-text-fill-color: #f2fbff; }
|
.landing-title { background: none; color: #f2fbff; -webkit-text-fill-color: #f2fbff; }
|
||||||
.landing-title > span { -webkit-text-fill-color: #7ff1ff; }
|
.landing-title > span { -webkit-text-fill-color: #7ff1ff; }
|
||||||
.landing-scan-beam, .landing-pier-pulse, .landing-pier-spark, .landing-energy-haze { opacity: 0; }
|
.landing-pier-pulse, .landing-energy-haze { opacity: 0; }
|
||||||
.landing-energy-flow { stroke-dasharray: none; opacity: .35; }
|
.landing-energy-flow { stroke-dasharray: none; opacity: .35; }
|
||||||
.landing-light-stage { transform: none; }
|
.landing-light-stage { transform: none; }
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-9
@@ -2,7 +2,9 @@ import '../pierzainer_ground/ground-link';
|
|||||||
import { loadSampleIntoApp } from './sample-loader';
|
import { loadSampleIntoApp } from './sample-loader';
|
||||||
import { createSampleExplorer, sampleExplorerMarkup, type Sample } from './sample-explorer';
|
import { createSampleExplorer, sampleExplorerMarkup, type Sample } from './sample-explorer';
|
||||||
import { createLandingGuide } from './landing-guide';
|
import { createLandingGuide } from './landing-guide';
|
||||||
|
import { createIntroVideo, introVideoTriggerMarkup } from './landing-video';
|
||||||
import { createHeroMotion, heroMotionMarkup } from './landing-motion';
|
import { createHeroMotion, heroMotionMarkup } from './landing-motion';
|
||||||
|
import { heroCapture } from './assets/okseong-hero-capture';
|
||||||
import './landing-white.css';
|
import './landing-white.css';
|
||||||
import './landing-typography.css';
|
import './landing-typography.css';
|
||||||
import './sample-library-popup.css';
|
import './sample-library-popup.css';
|
||||||
@@ -10,7 +12,7 @@ import { createDrawingPopup, drawingPopupMarkup } from './drawing-popup';
|
|||||||
|
|
||||||
const appRoot = document.querySelector<HTMLDivElement>('.app');
|
const appRoot = document.querySelector<HTMLDivElement>('.app');
|
||||||
const homeButton = document.querySelector<HTMLButtonElement>('#home-from-app');
|
const homeButton = document.querySelector<HTMLButtonElement>('#home-from-app');
|
||||||
const promoImageUrl = new URL('./assets/pierzainer-terrain-promo.png', import.meta.url).href;
|
const promoImageUrl = new URL('./assets/okseong-model-hero.webp', import.meta.url).href;
|
||||||
const companyLogosUrl = new URL('./assets/family-company-logos.png', import.meta.url).href;
|
const companyLogosUrl = new URL('./assets/family-company-logos.png', import.meta.url).href;
|
||||||
|
|
||||||
const landingOverlay = document.createElement('div');
|
const landingOverlay = document.createElement('div');
|
||||||
@@ -36,21 +38,21 @@ const landingPanelMarkup = `
|
|||||||
<div class="landing-hero-copy">
|
<div class="landing-hero-copy">
|
||||||
<div class="landing-title-group">
|
<div class="landing-title-group">
|
||||||
<h1 class="landing-title">PIER <span>&</span> ABUT</h1>
|
<h1 class="landing-title">PIER <span>&</span> ABUT</h1>
|
||||||
<h2 class="landing-headline">교량 하부구조 3D 통합모델링</h2>
|
<h2 class="landing-headline">교량 하부구조 설계 플랫폼</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="landing-brand-meta">
|
<div class="landing-brand-meta">
|
||||||
<span class="landing-kicker"><span class="landing-status-light"></span> PIERZAINER <span class="landing-kicker-divider">/</span><span class="landing-kicker-detail">BRIDGE DESIGN STUDIO</span></span>
|
<span class="landing-kicker"><span class="landing-status-light"></span> PIERZAINER <span class="landing-kicker-divider">/</span><span class="landing-kicker-detail">BRIDGE DESIGN STUDIO</span></span>
|
||||||
<p class="landing-subtitle">
|
<p class="landing-subtitle">
|
||||||
설계정보 입력부터 3D 모델링, 구조검토, 철근배근, 도면작성 및 수량산출까지 하나의 플랫폼에서
|
설계정보 입력부터 3D 모델링, 구조검토, 철근배근, 도면작성 및 수량산출까지 하나의 플랫폼에서
|
||||||
</p>
|
</p>
|
||||||
<button class="landing-guide-trigger" type="button" aria-haspopup="dialog"><span aria-hidden="true">?</span>사용 가이드 <span class="landing-guide-trigger-arrow" aria-hidden="true">↗</span></button>
|
<button class="landing-guide-trigger" type="button" aria-haspopup="dialog"><span aria-hidden="true">?</span>사용 가이드 <span class="landing-guide-trigger-arrow" aria-hidden="true">↗</span></button>${introVideoTriggerMarkup}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="landing-hero-visual" aria-hidden="true">
|
<div class="landing-hero-visual" aria-hidden="true">
|
||||||
<div class="landing-visual">
|
<div class="landing-visual">
|
||||||
<div class="landing-artwork">
|
<div class="landing-artwork" style="aspect-ratio:${heroCapture.width}/${heroCapture.height}">
|
||||||
<img class="landing-promo-image" src="${promoImageUrl}" alt="" draggable="false" fetchpriority="high" />
|
<img class="landing-promo-image" src="${promoImageUrl}" width="${heroCapture.width}" height="${heroCapture.height}" alt="" draggable="false" fetchpriority="high" />
|
||||||
${heroMotionMarkup(promoImageUrl)}
|
${heroMotionMarkup()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,8 +85,8 @@ const landingPanelMarkup = `
|
|||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="landing-card" data-card="sample">
|
<article class="landing-card" data-card="sample">
|
||||||
<div><div class="landing-card-top"><div class="landing-card-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 20V8l6-4 6 4 6-4v16H3zM9 4v16M15 8v12"/></svg></div><span class="landing-card-number">03 / EXPLORE</span></div><span class="landing-card-eyebrow">Design Library</span><h3>기설계교량 라이브러리</h3><p>기존 설계 프로젝트의 교각·지형·지도를 살펴보고 불러옵니다.</p></div>
|
<div><div class="landing-card-top"><div class="landing-card-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 20V8l6-4 6 4 6-4v16H3zM9 4v16M15 8v12"/></svg></div><span class="landing-card-number">03 / EXPLORE</span></div><span class="landing-card-eyebrow">Design Library</span><h3>설계 DATA 아카이브</h3><p>기존 설계 프로젝트의 교각·지형·지도를 살펴보고 불러옵니다.</p></div>
|
||||||
<div class="landing-card-actions"><span class="landing-card-note">12개 설계 프로젝트</span><button class="landing-primary" type="button" aria-haspopup="dialog">설계 라이브러리 불러오기</button></div>
|
<div class="landing-card-actions"><span class="landing-card-note">12개 설계 프로젝트</span><button class="landing-primary" type="button" aria-haspopup="dialog">둘러보기</button></div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
@@ -94,7 +96,7 @@ const landingPanelMarkup = `
|
|||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<dialog class="sample-library-popup" aria-label="기설계교량 라이브러리"><button type="button" class="sample-library-close" aria-label="설계 라이브러리 닫기">×</button>${sampleExplorerMarkup}</dialog>
|
<dialog class="sample-library-popup" aria-label="설계 DATA 아카이브"><button type="button" class="sample-library-close" aria-label="설계 DATA 아카이브 닫기">×</button>${sampleExplorerMarkup}</dialog>
|
||||||
<section id="landing-excel-panel" class="landing-panel" aria-label="엑셀 입력 패널">
|
<section id="landing-excel-panel" class="landing-panel" aria-label="엑셀 입력 패널">
|
||||||
<div class="landing-panel-head">
|
<div class="landing-panel-head">
|
||||||
<h3>엑셀 입력</h3>
|
<h3>엑셀 입력</h3>
|
||||||
@@ -122,6 +124,7 @@ const landingPanelMarkup = `
|
|||||||
landingOverlay.innerHTML = landingPanelMarkup;
|
landingOverlay.innerHTML = landingPanelMarkup;
|
||||||
document.body.prepend(landingOverlay);
|
document.body.prepend(landingOverlay);
|
||||||
const landingGuide = createLandingGuide(landingOverlay);
|
const landingGuide = createLandingGuide(landingOverlay);
|
||||||
|
const introVideo = createIntroVideo(landingOverlay, promoImageUrl);
|
||||||
createHeroMotion(landingOverlay);
|
createHeroMotion(landingOverlay);
|
||||||
|
|
||||||
const selectPanel = (panelId: string): void => {
|
const selectPanel = (panelId: string): void => {
|
||||||
@@ -142,6 +145,7 @@ const closePanels = (): void => {
|
|||||||
const showApp = (): void => {
|
const showApp = (): void => {
|
||||||
landingOverlay.querySelector<HTMLDialogElement>('.sample-library-popup')?.close();
|
landingOverlay.querySelector<HTMLDialogElement>('.sample-library-popup')?.close();
|
||||||
landingGuide.close(false);
|
landingGuide.close(false);
|
||||||
|
introVideo.close(false);
|
||||||
if (appRoot) {
|
if (appRoot) {
|
||||||
appRoot.hidden = false;
|
appRoot.hidden = false;
|
||||||
appRoot.setAttribute('aria-hidden', 'false');
|
appRoot.setAttribute('aria-hidden', 'false');
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/* Intro video trigger + modal. Shares the guide trigger/dialog visual language (landing-white). */
|
||||||
|
.landing-video-trigger {
|
||||||
|
display: inline-flex; align-items: center; gap: 9px; min-height: 36px; margin: 13px 0 0 8px; padding: 7px 12px;
|
||||||
|
border: 1px solid #d6e3ed; border-radius: 999px; background: #ffffffeb; color: #203b54;
|
||||||
|
font-size: 11px !important; box-shadow: 0 3px 10px #27496207; cursor: pointer;
|
||||||
|
transition: box-shadow .25s, background .25s, border-color .25s; animation: landing-enter .7s 1.45s both;
|
||||||
|
}
|
||||||
|
.landing-video-trigger > span:first-child {
|
||||||
|
display: grid; place-items: center; width: 16px; height: 16px; border-radius: 50%;
|
||||||
|
background: #087fcb; color: #fff; font-size: 7px; padding-left: 1px;
|
||||||
|
}
|
||||||
|
.landing-video-trigger-time { color: #64748b; font-variant-numeric: tabular-nums; }
|
||||||
|
.landing-video-trigger:hover { background: #eff6ff; border-color: #9dc6e2; box-shadow: 0 4px 16px #267daa10; }
|
||||||
|
.landing-video-trigger:focus-visible { outline: 2px solid #087fcb; outline-offset: 2px; }
|
||||||
|
|
||||||
|
.landing-video {
|
||||||
|
width: min(1120px, calc(100vw - 32px)); max-height: calc(100dvh - 32px); padding: 18px 20px 16px;
|
||||||
|
border: 1px solid #d1e2ee; border-radius: 18px; color-scheme: light; color: #14263d;
|
||||||
|
background: linear-gradient(145deg, #fff, #f8fbfe); box-shadow: 0 24px 90px #2247652b, inset 0 1px #fff;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.landing-video::backdrop { background: #253f5966; backdrop-filter: blur(5px); }
|
||||||
|
.landing-video-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 12px; }
|
||||||
|
.landing-video-kicker { display: block; color: #36779d; font-size: 11px; letter-spacing: .14em; font-weight: 600; }
|
||||||
|
.landing-video-head h2 { margin: 4px 0 0; font-size: 18px; color: #14263d; }
|
||||||
|
.landing-video-close {
|
||||||
|
flex: none; width: 44px; height: 44px; border: 1px solid #d5e5ef; border-radius: 10px;
|
||||||
|
background: #f5f9fc; color: #456478; font-size: 22px; line-height: 1; cursor: pointer;
|
||||||
|
}
|
||||||
|
.landing-video-close:hover { background: #eaf3f9; }
|
||||||
|
.landing-video-close:focus-visible { outline: 2px solid #087fcb; outline-offset: 2px; }
|
||||||
|
.landing-video-frame {
|
||||||
|
position: relative; aspect-ratio: 16 / 9; max-height: calc(100dvh - 190px); margin: 0 auto;
|
||||||
|
border-radius: 12px; overflow: hidden; background: #f4f6f8; border: 1px solid #e2ebf2;
|
||||||
|
}
|
||||||
|
.landing-video-frame video { display: block; width: 100%; height: 100%; object-fit: contain; background: #f4f6f8; }
|
||||||
|
.landing-video-desc { margin: 10px 2px 0; color: #64748b; font-size: 12.5px; line-height: 1.5; }
|
||||||
|
|
||||||
|
/* Different icon glyphs give the two inline-flex pills different baselines; align their boxes instead. */
|
||||||
|
.landing-brand-meta .landing-guide-trigger, .landing-video-trigger { vertical-align: middle; }
|
||||||
|
/* Mirror the guide trigger's responsive margins (landing-page.css). */
|
||||||
|
@media (max-width: 1100px) and (min-width: 761px) { .landing-video-trigger { margin-top: 8px; } }
|
||||||
|
@media (max-height: 560px) { .landing-brand-meta .landing-video-trigger { margin-top: 6px; } }
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.landing-video-trigger { min-height: 30px; font-size: 9px !important; margin: 8px 0 0 6px; padding: 5px 9px; }
|
||||||
|
.landing-video { padding: 12px; border-radius: 14px; }
|
||||||
|
.landing-video-head h2 { font-size: 15px; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.landing-video-trigger { animation: none; transition: none; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import './landing-video.css';
|
||||||
|
|
||||||
|
/** Self-hosted intro video (56 s, 1920×1080 H.264, no audio track). Fetched only when opened. */
|
||||||
|
export const introVideoUrl = new URL('./assets/pierzainer-intro.mp4', import.meta.url).href;
|
||||||
|
|
||||||
|
export const introVideoTriggerMarkup = `<button class="landing-video-trigger" type="button" aria-haspopup="dialog" aria-controls="landing-video"><span aria-hidden="true">▶</span>소개 영상 <span class="landing-video-trigger-time">1분</span></button>`;
|
||||||
|
|
||||||
|
/** Native dialog: Esc/backdrop close, pause + rewind on close, focus returns to the opener. */
|
||||||
|
export function createIntroVideo(root: HTMLElement, poster: string): { open: () => void; close: (restoreFocus?: boolean) => void } {
|
||||||
|
const trigger = root.querySelector<HTMLButtonElement>('.landing-video-trigger')!;
|
||||||
|
const dialog = document.createElement('dialog');
|
||||||
|
dialog.className = 'landing-video';
|
||||||
|
dialog.id = 'landing-video';
|
||||||
|
dialog.setAttribute('aria-labelledby', 'landing-video-title');
|
||||||
|
dialog.innerHTML = `
|
||||||
|
<div class="landing-video-head">
|
||||||
|
<div><span class="landing-video-kicker">PRODUCT TOUR / PIER & ABUT</span><h2 id="landing-video-title">Pierzainer 소개 영상</h2></div>
|
||||||
|
<button type="button" class="landing-video-close" aria-label="소개 영상 닫기">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="landing-video-frame">
|
||||||
|
<video controls playsinline muted preload="none" poster="${poster}" aria-describedby="landing-video-desc"></video>
|
||||||
|
</div>
|
||||||
|
<p id="landing-video-desc" class="landing-video-desc">설계정보 입력부터 3D 모델링, 철근배근, 도면작성, 수량산출까지의 흐름을 1분 동안 소개합니다. (음성 없음)</p>`;
|
||||||
|
root.append(dialog);
|
||||||
|
const video = dialog.querySelector('video')!;
|
||||||
|
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)');
|
||||||
|
|
||||||
|
const open = (): void => {
|
||||||
|
if (dialog.open) return;
|
||||||
|
if (!video.currentSrc && !video.querySelector('source')) {
|
||||||
|
const source = document.createElement('source');
|
||||||
|
source.src = introVideoUrl;
|
||||||
|
source.type = 'video/mp4';
|
||||||
|
video.append(source);
|
||||||
|
video.load();
|
||||||
|
}
|
||||||
|
dialog.showModal();
|
||||||
|
dialog.querySelector<HTMLButtonElement>('.landing-video-close')!.focus();
|
||||||
|
if (!reducedMotion?.matches) void video.play().catch(() => undefined);
|
||||||
|
};
|
||||||
|
const stop = (): void => {
|
||||||
|
video.pause();
|
||||||
|
if (video.readyState > 0) video.currentTime = 0;
|
||||||
|
};
|
||||||
|
const close = (restoreFocus = true): void => {
|
||||||
|
if (!dialog.open) return;
|
||||||
|
dialog.close();
|
||||||
|
if (restoreFocus && trigger.isConnected && !root.hidden) trigger.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
trigger.addEventListener('click', open);
|
||||||
|
dialog.querySelector('.landing-video-close')!.addEventListener('click', () => close());
|
||||||
|
dialog.addEventListener('close', stop);
|
||||||
|
dialog.addEventListener('cancel', (event) => { event.preventDefault(); close(); });
|
||||||
|
// Clicking the backdrop (outside the panel box) closes the dialog.
|
||||||
|
dialog.addEventListener('click', (event) => {
|
||||||
|
if (event.target !== dialog) return;
|
||||||
|
const box = dialog.getBoundingClientRect();
|
||||||
|
const inside = event.clientX >= box.left && event.clientX <= box.right && event.clientY >= box.top && event.clientY <= box.bottom;
|
||||||
|
if (!inside) close();
|
||||||
|
});
|
||||||
|
return { open, close };
|
||||||
|
}
|
||||||
+9
-10
@@ -1,4 +1,10 @@
|
|||||||
/* Premium White UI. Theme selectors never reach the application outside this overlay. */
|
/* Premium White UI. Theme selectors never reach the application outside this overlay. */
|
||||||
|
@media (min-width: 761px) and (max-width: 1400px) {
|
||||||
|
.landing-white .landing-guide-hint { top: 20px; }
|
||||||
|
}
|
||||||
|
@media (max-width: 760px) and (min-height: 760px) {
|
||||||
|
.landing-white .landing-guide-hint { position: fixed; top: auto; bottom: 16px; }
|
||||||
|
}
|
||||||
.landing-overlay.landing-white {
|
.landing-overlay.landing-white {
|
||||||
--landing-bg: #f8fafc;
|
--landing-bg: #f8fafc;
|
||||||
--landing-panel: rgba(255, 255, 255, .84);
|
--landing-panel: rgba(255, 255, 255, .84);
|
||||||
@@ -85,21 +91,14 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.landing-white .landing-artwork .landing-promo-image { position: relative; inset: auto; z-index: 1; filter: brightness(1.02) contrast(1.02); }
|
.landing-white .landing-artwork .landing-promo-image { position: relative; inset: auto; z-index: 1; filter: brightness(1.02) contrast(1.02); }
|
||||||
.landing-white .landing-model-motion, .landing-white .landing-scan-mask { z-index: 2; }
|
.landing-white .landing-model-motion { z-index: 2; }
|
||||||
.landing-white .landing-terrain-grid {
|
.landing-white .landing-energy-track { stroke: #ffffff; stroke-width: 1.2; opacity: .16; }
|
||||||
background-image: linear-gradient(#167eae47 1px, transparent 1px), linear-gradient(90deg, #167eae47 1px, transparent 1px);
|
|
||||||
}
|
|
||||||
.landing-white .landing-scan-beam {
|
|
||||||
background: linear-gradient(90deg, transparent, #3b9fd31a 36%, #6bc6ed47 49%, #59abd917 64%, transparent);
|
|
||||||
}
|
|
||||||
.landing-white .landing-energy-track { stroke: #087fcb; stroke-width: 1.2; opacity: .25; }
|
|
||||||
.landing-white .landing-energy-flow { stroke-width: 2; }
|
.landing-white .landing-energy-flow { stroke-width: 2; }
|
||||||
.landing-white .landing-energy-haze { stroke-width: 5; opacity: .14; }
|
.landing-white .landing-energy-haze { stroke-width: 5; opacity: .4; }
|
||||||
.landing-white .landing-pier-beacon { color: #087fcb; }
|
.landing-white .landing-pier-beacon { color: #087fcb; }
|
||||||
.landing-white .landing-pier-beacon line { stroke: #167ead; opacity: .58; }
|
.landing-white .landing-pier-beacon line { stroke: #167ead; opacity: .58; }
|
||||||
.landing-white .landing-pier-point { fill: #087fcb; stroke: #f6fcff; stroke-width: 1.5; filter: drop-shadow(0 1px 2px #087fcb33); }
|
.landing-white .landing-pier-point { fill: #087fcb; stroke: #f6fcff; stroke-width: 1.5; filter: drop-shadow(0 1px 2px #087fcb33); }
|
||||||
.landing-white .landing-pier-pulse { stroke: #087fcb; stroke-width: 1.5; }
|
.landing-white .landing-pier-pulse { stroke: #087fcb; stroke-width: 1.5; }
|
||||||
.landing-white .landing-pier-spark { fill: #1284bf; }
|
|
||||||
.landing-white .landing-hud-bracket { stroke: #538bb0; opacity: .35; }
|
.landing-white .landing-hud-bracket { stroke: #538bb0; opacity: .35; }
|
||||||
|
|
||||||
/* White glass surfaces with consistent cobalt actions. */
|
/* White glass surfaces with consistent cobalt actions. */
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { createSampleMap } from './sample-map';
|
|||||||
import './sample-explorer.css';
|
import './sample-explorer.css';
|
||||||
export type Sample=typeof sampleProjects[number];
|
export type Sample=typeof sampleProjects[number];
|
||||||
export const sampleExplorerMarkup=`<aside class="sample-explorer" aria-label="기설계 교량" data-guide-target="sample">
|
export const sampleExplorerMarkup=`<aside class="sample-explorer" aria-label="기설계 교량" data-guide-target="sample">
|
||||||
<header class="sample-explorer-head"><div><span>EXPLORE PROJECTS</span><h2>교각·교대 설계 라이브러리</h2></div><b>12<span> PROJECTS</span></b></header>
|
<header class="sample-explorer-head"><div><span>EXPLORE PROJECTS</span><h2>교각·교대 아카이브</h2></div><b>12<span> PROJECTS</span></b></header>
|
||||||
<section class="sample-map-section"><div class="sample-section-title"><h3>설계 프로젝트 위치</h3><div class="sample-map-modes" role="group" aria-label="지도 종류"><button type="button" data-map-mode="street" aria-pressed="true">일반</button><button type="button" data-map-mode="satellite" aria-pressed="false">위성</button></div></div><div class="sample-map" aria-label="대한민국 기설계 교량 지도"><div class="sample-location"><div class="sample-location-heading"><strong>📍 <span class="sample-location-name"></span> 위치정보</strong></div><p class="sample-address"></p><dl><dt>좌표계</dt><dd class="sample-crs"></dd><dt>조사좌표</dt><dd class="sample-survey"></dd><dt>위도·경도</dt><dd class="sample-wgs84"></dd></dl><small class="sample-coordinate-basis"></small></div><div class="sample-map-tiles"></div><div class="sample-map-markers"></div><div class="sample-map-zoom"><button type="button" data-zoom="1" aria-label="지도 확대">+</button><button type="button" data-zoom="-1" aria-label="지도 축소">−</button><button type="button" data-zoom="0" aria-label="남한 중심 기본 지도">⌂</button></div><span class="sample-map-attribution"></span><span class="sample-map-error" hidden>배경지도 연결 대기 · 좌표 마커 표시</span></div></section>
|
<section class="sample-map-section"><div class="sample-section-title"><h3>설계 프로젝트 위치</h3><div class="sample-map-modes" role="group" aria-label="지도 종류"><button type="button" data-map-mode="street" aria-pressed="true">일반</button><button type="button" data-map-mode="satellite" aria-pressed="false">위성</button></div></div><div class="sample-map" aria-label="대한민국 기설계 교량 지도"><div class="sample-location"><div class="sample-location-heading"><strong>📍 <span class="sample-location-name"></span> 위치정보</strong></div><p class="sample-address"></p><dl><dt>좌표계</dt><dd class="sample-crs"></dd><dt>조사좌표</dt><dd class="sample-survey"></dd><dt>위도·경도</dt><dd class="sample-wgs84"></dd></dl><small class="sample-coordinate-basis"></small></div><div class="sample-map-tiles"></div><div class="sample-map-markers"></div><div class="sample-map-zoom"><button type="button" data-zoom="1" aria-label="지도 확대">+</button><button type="button" data-zoom="-1" aria-label="지도 축소">−</button><button type="button" data-zoom="0" aria-label="남한 중심 기본 지도">⌂</button></div><span class="sample-map-attribution"></span><span class="sample-map-error" hidden>배경지도 연결 대기 · 좌표 마커 표시</span></div></section>
|
||||||
<section class="sample-selection" aria-label="선택 교량 상세"><div class="sample-section-title"><h3>하부구조 3D 미리보기</h3><span class="sample-selection-index"></span></div>
|
<section class="sample-selection" aria-label="선택 교량 상세"><div class="sample-section-title"><h3>하부구조 3D 미리보기</h3><span class="sample-selection-index"></span></div>
|
||||||
<div class="sample-preview" role="region" aria-label="지도·지반·교각 통합 3D 미리보기"></div>
|
<div class="sample-preview" role="region" aria-label="지도·지반·교각 통합 3D 미리보기"></div>
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
|가설계단 / 워킹타워 / 워킹타워|받침 EL(Zb), 지반고 EL(Zs)|3.3 수량산출_P1!W61 · 원식은 ANALYSIS.md|자동 2 / 직접 0|
|
|가설계단 / 워킹타워 / 워킹타워|받침 EL(Zb), 지반고 EL(Zs)|3.3 수량산출_P1!W61 · 원식은 ANALYSIS.md|자동 2 / 직접 0|
|
||||||
|교량받침(2800kN) / 양방향 H=166 / 양방향 H=166|받침 수(Nb)|3.3 수량산출_P1!W62 · 원식은 ANALYSIS.md|자동 1 / 직접 0|
|
|교량받침(2800kN) / 양방향 H=166 / 양방향 H=166|받침 수(Nb)|3.3 수량산출_P1!W62 · 원식은 ANALYSIS.md|자동 1 / 직접 0|
|
||||||
|무수축몰탈 / T=300mm / T=300mm|받침 길이(Lb), 받침 폭(Bb), 받침 수(Nb)|3.3 수량산출_P1!W63 · 원식은 ANALYSIS.md|자동 3 / 직접 0|
|
|무수축몰탈 / T=300mm / T=300mm|받침 길이(Lb), 받침 폭(Bb), 받침 수(Nb)|3.3 수량산출_P1!W63 · 원식은 ANALYSIS.md|자동 3 / 직접 0|
|
||||||
|솔플레이트|받침 길이(Lb), 받침 폭(Bb), 받침 수(Nb), 솔플레이트 두께(Ts)|3.3 수량산출_P1!W64 · 원식은 ANALYSIS.md|자동 4 / 직접 0|
|
|솔플레이트||3.3 수량산출_P1!W64 · 원식은 ANALYSIS.md|자동 0 / 직접 0|
|
||||||
|스페이서 / 슬래브 및 기초용 / 슬래브 및 기초용|저판 길이(Lf), 저판 폭(Bf), 저판 높이(Hf)|3.3 수량산출_P1!W65 · 원식은 ANALYSIS.md|자동 3 / 직접 0|
|
|스페이서 / 슬래브 및 기초용 / 슬래브 및 기초용|저판 길이(Lf), 저판 폭(Bf), 저판 높이(Hf)|3.3 수량산출_P1!W65 · 원식은 ANALYSIS.md|자동 3 / 직접 0|
|
||||||
|스페이서 / 벽체용 / 벽체용|받침 EL(Zb), 지반고 EL(Zs), 두부보 길이(Lc), 두부보 폭(Bc), 두부보 하부높이(H1), 두부보 상부높이(H2), 횡경사(%)(s), 두부보 돌출부 폭(a), 기둥 직경(Dc), 저판 저면 EL(Zf)|3.3 수량산출_P1!W66 · 원식은 ANALYSIS.md|자동 10 / 직접 0|
|
|스페이서 / 벽체용 / 벽체용|받침 EL(Zb), 지반고 EL(Zs), 두부보 길이(Lc), 두부보 폭(Bc), 두부보 하부높이(H1), 두부보 상부높이(H2), 횡경사(%)(s), 두부보 돌출부 폭(a), 기둥 직경(Dc), 저판 저면 EL(Zf)|3.3 수량산출_P1!W66 · 원식은 ANALYSIS.md|자동 10 / 직접 0|
|
||||||
|철근가공조립 / 매우복잡 / 매우복잡||3.3 수량산출_P1!W67 · 원식은 ANALYSIS.md|자동 0 / 직접 0|
|
|철근가공조립 / 매우복잡 / 매우복잡||3.3 수량산출_P1!W67 · 원식은 ANALYSIS.md|자동 0 / 직접 0|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type {QuantityInputs} from './model';
|
||||||
|
import type {QuantitySnapshot} from './project-inputs';
|
||||||
|
import {projectRecords} from './project-inputs';
|
||||||
|
|
||||||
|
export type ExportScope='selected'|'all';
|
||||||
|
export function exportRecords(snapshot:QuantitySnapshot,scope:ExportScope){
|
||||||
|
return projectRecords(snapshot).filter(r=>scope==='all'||r.pier.input.name===snapshot.selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preserve edits made while PNG/Excel generation awaited IO, even across project replacement. */
|
||||||
|
export function finishQuantityExport(start:QuantitySnapshot,scope:ExportScope,live:QuantitySnapshot,save:(name:string,data:QuantityInputs)=>void):string[]{
|
||||||
|
const current=projectRecords(live),changed:string[]=[];
|
||||||
|
for(const before of exportRecords(start,scope)){
|
||||||
|
const after=current.find(r=>r.pier.input.name===before.pier.input.name);
|
||||||
|
if(!after||JSON.stringify(start.project)!==JSON.stringify(live.project)||after.pier.dirty||!after.result||after.pier.model!==before.pier.model||after.result.signature!==before.result?.signature||JSON.stringify(after.stored)!==JSON.stringify(before.stored)){
|
||||||
|
changed.push(before.pier.input.name);continue;
|
||||||
|
}
|
||||||
|
save(after.pier.input.name,{...after.stored,lastSignature:after.result.signature});
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
+8
-1
@@ -2,10 +2,11 @@ import {straightCopingFormulas} from './coping-formulas.ts';
|
|||||||
import {FormulaBook,cellKey,type Overrides} from './formula.ts';
|
import {FormulaBook,cellKey,type Overrides} from './formula.ts';
|
||||||
import {template,templateHash} from './template-data.ts';
|
import {template,templateHash} from './template-data.ts';
|
||||||
import {DETAIL,INPUTS,extraNumbers,specifications} from './schema.ts';
|
import {DETAIL,INPUTS,extraNumbers,specifications} from './schema.ts';
|
||||||
export type ExportRequest={project:{name:string;bridge:string};pier:string;overrides:Overrides;specs:Record<string,unknown>;reviewed:boolean;copingMode?:'straight';templateHash:string;planPng?:string};
|
export type ExportRequest={project:{name:string;bridge:string};pier:string;overrides:Overrides;specs:Record<string,unknown>;reviewed:boolean;copingMode?:'straight';templateHash:string;planPng?:string;solplateMode?:'model'};
|
||||||
export function exportEdits(request:ExportRequest){
|
export function exportEdits(request:ExportRequest){
|
||||||
if(request.templateHash!==templateHash||!request.reviewed)throw new Error('원본 산식 적용 확인 후 다시 계산하세요.');
|
if(request.templateHash!==templateHash||!request.reviewed)throw new Error('원본 산식 적용 확인 후 다시 계산하세요.');
|
||||||
const edits:Record<string,Record<string,{value?:string|number|null;cached?:number|string;formula?:string}>>={},evaluation:Overrides={...request.overrides};
|
const edits:Record<string,Record<string,{value?:string|number|null;cached?:number|string;formula?:string}>>={},evaluation:Overrides={...request.overrides};
|
||||||
|
if(request.solplateMode!=='model'){delete evaluation[cellKey(DETAIL,'W64')];delete evaluation[cellKey(DETAIL,'K64')];}
|
||||||
const value=(sheet:string,cell:string,v:string|number|null)=>{(edits[sheet]??={})[cell]={value:v};evaluation[cellKey(sheet,cell)]=v??0;};
|
const value=(sheet:string,cell:string,v:string|number|null)=>{(edits[sheet]??={})[cell]={value:v};evaluation[cellKey(sheet,cell)]=v??0;};
|
||||||
for(let r=18;r<=46;r++){
|
for(let r=18;r<=46;r++){
|
||||||
const raw=request.overrides[cellKey(INPUTS,'N'+r)];if(raw!==null&&(typeof raw!=='number'||!Number.isFinite(raw)))throw new Error('입력값 누락 N'+r);
|
const raw=request.overrides[cellKey(INPUTS,'N'+r)];if(raw!==null&&(typeof raw!=='number'||!Number.isFinite(raw)))throw new Error('입력값 누락 N'+r);
|
||||||
@@ -13,6 +14,12 @@ export function exportEdits(request:ExportRequest){
|
|||||||
}
|
}
|
||||||
for(const v of extraNumbers){const n=request.overrides[cellKey(DETAIL,v.cell)];if(typeof n!=='number'||!Number.isFinite(n)||n<0)throw new Error(v.name+' 입력을 확인하세요.');value(DETAIL,v.cell,n);}
|
for(const v of extraNumbers){const n=request.overrides[cellKey(DETAIL,v.cell)];if(typeof n!=='number'||!Number.isFinite(n)||n<0)throw new Error(v.name+' 입력을 확인하세요.');value(DETAIL,v.cell,n);}
|
||||||
for(const v of specifications){const s=request.specs[v.key];if(typeof s!=='string'||!s.trim())throw new Error(v.name+'을 확인하세요.');value(DETAIL,v.cell,s);}
|
for(const v of specifications){const s=request.specs[v.key];if(typeof s!=='string'||!s.trim())throw new Error(v.name+'을 확인하세요.');value(DETAIL,v.cell,s);}
|
||||||
|
if(request.solplateMode==='model'){
|
||||||
|
const ton=request.overrides[cellKey(DETAIL,'W64')];
|
||||||
|
if(typeof ton!=='number'||!Number.isFinite(ton)||ton<0)throw new Error('모델 솔플레이트 중량을 확인하세요.');
|
||||||
|
value(DETAIL,'W64',ton);
|
||||||
|
value(DETAIL,'K64','3D 모델 판별 체적 합계 × 7.85 (앱 재생성 값)');
|
||||||
|
}
|
||||||
for(let i=2;i<=6;i++){
|
for(let i=2;i<=6;i++){
|
||||||
const sheet='3.3 수량산출_P'+i;
|
const sheet='3.3 수량산출_P'+i;
|
||||||
for(const [cell,c]of Object.entries(template[sheet]))if(typeof c.value==='number'||typeof c.value==='string'&&c.value.startsWith('='))value(sheet,cell,null);
|
for(const [cell,c]of Object.entries(template[sheet]))if(typeof c.value==='number'||typeof c.value==='string'&&c.value.startsWith('='))value(sheet,cell,null);
|
||||||
|
|||||||
+1
-1
@@ -39,6 +39,6 @@ export class FormulaBook{
|
|||||||
const args=n.args.flatMap(a=>this.evaluate(a,sheet));const f:Record<string,()=>number>={SUM:()=>args.reduce((a,b)=>a+b,0),MAX:()=>Math.max(...args),MIN:()=>Math.min(...args),SQRT:()=>Math.sqrt(args[0]),PI:()=>Math.PI,ASIN:()=>Math.asin(args[0]),SIN:()=>Math.sin(args[0])};if(!f[n.name])throw new FormulaError('지원하지 않는 함수 '+n.name);return f[n.name]();
|
const args=n.args.flatMap(a=>this.evaluate(a,sheet));const f:Record<string,()=>number>={SUM:()=>args.reduce((a,b)=>a+b,0),MAX:()=>Math.max(...args),MIN:()=>Math.min(...args),SQRT:()=>Math.sqrt(args[0]),PI:()=>Math.PI,ASIN:()=>Math.asin(args[0]),SIN:()=>Math.sin(args[0])};if(!f[n.name])throw new FormulaError('지원하지 않는 함수 '+n.name);return f[n.name]();
|
||||||
}
|
}
|
||||||
substitution(sheet:string,formula:string):string{return formula.replace(/'(?:[^']|'')+'!\$?[A-Z]+\$?\d+|\$?[A-Z]+\$?\d+/g,t=>{const p=t.lastIndexOf('!');try{return String(this.cell(p>=0?t.slice(1,p-1).replace(/''/g,"'"):sheet,t.slice(p+1).replace(/\$/g,'')));}catch{return '미입력';}});}
|
substitution(sheet:string,formula:string):string{return formula.replace(/'(?:[^']|'')+'!\$?[A-Z]+\$?\d+|\$?[A-Z]+\$?\d+/g,t=>{const p=t.lastIndexOf('!');try{return String(this.cell(p>=0?t.slice(1,p-1).replace(/''/g,"'"):sheet,t.slice(p+1).replace(/\$/g,'')));}catch{return '미입력';}});}
|
||||||
references(sheet:string,cell:string):string[]{const result=new Set<string>(),visiting=new Set<string>();const walk=(s:string,c:string)=>{const key=cellKey(s,c);if(visiting.has(key))return;visiting.add(key);result.add(key);const f=this.formulas[key]??this.data[s]?.[c]?.value;if(typeof f!=='string'||(!f.startsWith('=')||f.length===1))return;
|
references(sheet:string,cell:string):string[]{const result=new Set<string>(),visiting=new Set<string>();const walk=(s:string,c:string)=>{const key=cellKey(s,c);if(visiting.has(key))return;visiting.add(key);result.add(key);if(Object.hasOwn(this.overrides,key))return;const f=this.formulas[key]??this.data[s]?.[c]?.value;if(typeof f!=='string'||(!f.startsWith('=')||f.length===1))return;
|
||||||
for(const t of f.slice(1).match(/'(?:[^']|'')+'!\$?[A-Z]+\$?\d+|\$?[A-Z]+\$?\d+/g)??[]){const p=t.lastIndexOf('!');walk(p>=0?t.slice(1,p-1).replace(/''/g,"'"):s,t.slice(p+1).replace(/\$/g,''));}};walk(sheet,cell);return [...result];}
|
for(const t of f.slice(1).match(/'(?:[^']|'')+'!\$?[A-Z]+\$?\d+|\$?[A-Z]+\$?\d+/g)??[]){const p=t.lastIndexOf('!');walk(p>=0?t.slice(1,p-1).replace(/''/g,"'"):s,t.slice(p+1).replace(/\$/g,''));}};walk(sheet,cell);return [...result];}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import {describe,it,expect} from 'vitest';
|
||||||
|
import {buildPier} from '../src/core/geometry';
|
||||||
|
import {solplateGeometry} from '../src/core/solplate';
|
||||||
|
import {loadP1} from '../tests/helpers';
|
||||||
|
import {calculate,withTemplateDefaults,DETAIL,type QuantityInputs} from './model';
|
||||||
|
import {exportEdits} from './export';
|
||||||
|
import {template,templateHash} from './template-data';
|
||||||
|
import {cellKey} from './formula';
|
||||||
|
import {exportRecords,finishQuantityExport} from './export-state';
|
||||||
|
import type {QuantitySnapshot} from './project-inputs';
|
||||||
|
|
||||||
|
const base=loadP1();
|
||||||
|
const input={...base,sole:{h3Mm:181,marginMm:100,tByRow:{전열:35,후열:35},slopePct:Object.fromEntries(base.bearings.map(b=>[String(b.no),0]))}};
|
||||||
|
const model=buildPier(input);
|
||||||
|
const stored:QuantityInputs={values:{r20:0,r21:2.8,r22:0,r23:0},reviewed:true};
|
||||||
|
const raw=()=>calculate(input,model,stored,1.25);
|
||||||
|
const snapshot=():QuantitySnapshot=>({project:{name:'프로젝트',bridge:'교량'},selected:input.name,piers:[{input:{...input,quantityTakeoff:stored},model,dirty:false,rebarTon:1.25}]});
|
||||||
|
|
||||||
|
describe('W64 모델 기준 수량·Excel',()=>{
|
||||||
|
it('800×740×35mm 10개는 1.62652t이며 Excel에도 같은 숫자를 전달한다',()=>{
|
||||||
|
const result=raw(),row=result.results.find(r=>r.cell==='W64')!;
|
||||||
|
expect(model.blocks).toHaveLength(10);
|
||||||
|
expect(row.value).toBeCloseTo(1.62652,10);
|
||||||
|
expect(row.sourceFormula).toBe(template[DETAIL].W64.value);
|
||||||
|
expect(row.formula).toContain('3D 모델');
|
||||||
|
const payload=exportEdits({project:{name:'',bridge:''},pier:input.name,overrides:result.overrides,specs:withTemplateDefaults(stored).values,reviewed:true,copingMode:result.copingMode,solplateMode:result.solplateMode,templateHash});
|
||||||
|
expect(payload.edits[DETAIL].W64.value).toBeCloseTo(row.value!,12);
|
||||||
|
expect(payload.edits[DETAIL].K64.value).toContain('3D 모델');
|
||||||
|
expect(template[DETAIL].W64.value).toBe('=(BG28+0.1)*(BG29+0.1)*BG34*BG30*7.85');
|
||||||
|
});
|
||||||
|
it('구형 요청은 모델 값을 원식의 캐시로 섞지 않고 기존 계산을 유지한다',()=>{
|
||||||
|
const result=raw();
|
||||||
|
const payload=exportEdits({project:{name:'',bridge:''},pier:input.name,overrides:result.overrides,specs:withTemplateDefaults(stored).values,reviewed:true,copingMode:result.copingMode,templateHash});
|
||||||
|
expect(payload.edits[DETAIL].W64.cached).toBeCloseTo(1.23088,10);
|
||||||
|
expect(payload.edits[DETAIL].W64.value).toBeUndefined();
|
||||||
|
});
|
||||||
|
it('받침별 크기·경사·두께가 달라도 개별 모델 체적으로 합산한다',()=>{
|
||||||
|
const next={...input,sole:{...input.sole,plates:{[String(base.bearings[0].no)]:{aMm:900,bMm:800}},slopePct:{[String(base.bearings[0].no)]:2.1}}};
|
||||||
|
const m=buildPier(next),r=calculate(next,m,{...stored,values:{...stored.values,r40:999,r41:999,r46:999}},1.25);
|
||||||
|
const actual=m.blocks.reduce((sum,b)=>sum+solplateGeometry(b,b.plan).volume*7.85,0);
|
||||||
|
expect(r.results.find(x=>x.cell==='W64')!.value).toBeCloseTo(actual,12);
|
||||||
|
expect(r.results.find(x=>x.cell==='W64')!.dependencies).toEqual([cellKey(DETAIL,'W64')]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Excel 비동기 생성 후 입력 보존·범위',()=>{
|
||||||
|
it('선택 출력은 해당 교각만, 전체 출력은 미생성 교각도 검사 대상으로 반환한다',()=>{
|
||||||
|
const s=snapshot();s.piers.push({...s.piers[0],input:{...input,name:'P2'},model:null});
|
||||||
|
expect(exportRecords(s,'selected').map(r=>r.pier.input.name)).toEqual([input.name]);
|
||||||
|
expect(exportRecords(s,'all').map(r=>r.pier.input.name)).toEqual([input.name,'P2']);
|
||||||
|
});
|
||||||
|
it('생성 중 수정한 입력·검토 체크를 이전 스냅샷으로 저장하지 않는다',()=>{
|
||||||
|
const before=snapshot(),live=snapshot(),saved:unknown[]=[];
|
||||||
|
live.piers[0].input={...live.piers[0].input,quantityTakeoff:{values:{...stored.values,r21:6},reviewed:false}};
|
||||||
|
expect(finishQuantityExport(before,'all',live,(...args)=>saved.push(args))).toEqual([input.name]);
|
||||||
|
expect(saved).toEqual([]);
|
||||||
|
expect(live.piers[0].input.quantityTakeoff).toMatchObject({values:{r21:6},reviewed:false});
|
||||||
|
});
|
||||||
|
it('변경이 없으면 현재 입력에 계산 서명만 기록한다',()=>{
|
||||||
|
const s=snapshot(),saved:{name:string;data:QuantityInputs}[]=[];
|
||||||
|
expect(finishQuantityExport(s,'selected',s,(name,data)=>saved.push({name,data}))).toEqual([]);
|
||||||
|
expect(saved[0].data).toMatchObject({values:stored.values,reviewed:true,lastSignature:raw().signature});
|
||||||
|
});
|
||||||
|
it('다른 프로젝트로 바뀌거나 모델이 재생성되면 이전 출력의 서명을 저장하지 않는다',()=>{
|
||||||
|
const before=snapshot(),live=snapshot();live.project={...live.project,name:'다른 프로젝트'};
|
||||||
|
expect(finishQuantityExport(before,'all',live,()=>{throw new Error('과거 입력 저장');})).toEqual([input.name]);
|
||||||
|
live.project=before.project;live.piers[0].model=buildPier(input);
|
||||||
|
expect(finishQuantityExport(before,'all',live,()=>{throw new Error('과거 입력 저장');})).toEqual([input.name]);
|
||||||
|
});
|
||||||
|
});
|
||||||
+8
-2
@@ -2,6 +2,7 @@ import {bearingDimensions} from './bearing-dimensions';
|
|||||||
import {straightCopingFormulas} from './coping-formulas';
|
import {straightCopingFormulas} from './coping-formulas';
|
||||||
import type {PierInput} from '../src/core/types';
|
import type {PierInput} from '../src/core/types';
|
||||||
import {DIM,type PierModel} from '../src/core/geometry';
|
import {DIM,type PierModel} from '../src/core/geometry';
|
||||||
|
import {solplateGeometry} from '../src/core/solplate';
|
||||||
import {variables,quantityRows,template,templateHash} from './template-data';
|
import {variables,quantityRows,template,templateHash} from './template-data';
|
||||||
import {FormulaBook,cellKey,type Overrides} from './formula';
|
import {FormulaBook,cellKey,type Overrides} from './formula';
|
||||||
import {DETAIL,INPUTS,extraNumbers,specifications} from './schema';
|
import {DETAIL,INPUTS,extraNumbers,specifications} from './schema';
|
||||||
@@ -34,9 +35,13 @@ export function calculate(input:PierInput,m:PierModel,stored:QuantityInputs,reba
|
|||||||
stored=withTemplateDefaults(stored);
|
stored=withTemplateDefaults(stored);
|
||||||
const mapped=automaticInputs(input,m),overrides=overridesFor(mapped,stored),copingMode='straight' as const,formulas=straightCopingFormulas,book=new FormulaBook(overrides,template,formulas);
|
const mapped=automaticInputs(input,m),overrides=overridesFor(mapped,stored),copingMode='straight' as const,formulas=straightCopingFormulas,book=new FormulaBook(overrides,template,formulas);
|
||||||
if(rebarTon!=null&&Number.isFinite(rebarTon))overrides[cellKey(DETAIL,'W67')]=rebarTon;
|
if(rebarTon!=null&&Number.isFinite(rebarTon))overrides[cellKey(DETAIL,'W67')]=rebarTon;
|
||||||
|
const solplateMode='model' as const;
|
||||||
|
const solplateTon=m.blocks.reduce((sum,b)=>sum+solplateGeometry(b,b.plan).volume*7.85,0);
|
||||||
|
overrides[cellKey(DETAIL,'W64')]=solplateTon;
|
||||||
|
overrides[cellKey(DETAIL,'K64')]='3D 모델 판별 체적 합계 × 7.85 (앱 재생성 값)';
|
||||||
const results=quantityRows.map(row=>{const dependencies=book.references(DETAIL,row.cell),missing=dependencies.filter(k=>Object.hasOwn(overrides,k)&&overrides[k]===null);let value:number|null=null,error='';
|
const results=quantityRows.map(row=>{const dependencies=book.references(DETAIL,row.cell),missing=dependencies.filter(k=>Object.hasOwn(overrides,k)&&overrides[k]===null);let value:number|null=null,error='';
|
||||||
try{if(!missing.length)value=Number(book.cell(DETAIL,row.cell));}catch(e){error=(e as Error).message;}
|
try{if(!missing.length)value=Number(book.cell(DETAIL,row.cell));}catch(e){error=(e as Error).message;}
|
||||||
return {...row,value,error,missing,dependencies,formula:formulas[cellKey(DETAIL,row.cell)]??template[DETAIL][row.cell]?.value,sourceFormula:template[DETAIL][row.cell]?.value};});
|
return {...row,value,error,missing,dependencies,description:row.cell==='W64'?'3D 모델 판별 체적 합계 × 7.85':row.description,formula:row.cell==='W64'?'3D 모델 솔플레이트 체적 합계 × 7.85 t/m³':formulas[cellKey(DETAIL,row.cell)]??template[DETAIL][row.cell]?.value,sourceFormula:template[DETAIL][row.cell]?.value};});
|
||||||
const needed=new Set(results.flatMap(r=>r.dependencies));
|
const needed=new Set(results.flatMap(r=>r.dependencies));
|
||||||
const missing=mapped.filter(v=>!v.automatic&&needed.has(cellKey(INPUTS,'N'+v.key.slice(1)))&&overrides[cellKey(INPUTS,'N'+v.key.slice(1))]===null).map(v=>v.name);
|
const missing=mapped.filter(v=>!v.automatic&&needed.has(cellKey(INPUTS,'N'+v.key.slice(1)))&&overrides[cellKey(INPUTS,'N'+v.key.slice(1))]===null).map(v=>v.name);
|
||||||
for(const v of extraNumbers)if(overrides[cellKey(DETAIL,v.cell)]===null)missing.push(v.name);
|
for(const v of extraNumbers)if(overrides[cellKey(DETAIL,v.cell)]===null)missing.push(v.name);
|
||||||
@@ -45,6 +50,7 @@ export function calculate(input:PierInput,m:PierModel,stored:QuantityInputs,reba
|
|||||||
|
|
||||||
for(const [key,v]of Object.entries(overrides))if(typeof v==='number'&&(/!N(21|22|23|24|25|26|27|29|30|31|33|34|35|36|37|38|39|40|41|42|43|44|45|46)$/.test(key)||key.startsWith(DETAIL+'!'))&&v<0)issues.push(`${key}: 음수 입력을 확인하세요.`);
|
for(const [key,v]of Object.entries(overrides))if(typeof v==='number'&&(/!N(21|22|23|24|25|26|27|29|30|31|33|34|35|36|37|38|39|40|41|42|43|44|45|46)$/.test(key)||key.startsWith(DETAIL+'!'))&&v<0)issues.push(`${key}: 음수 입력을 확인하세요.`);
|
||||||
const warnings=['원본 W51의 실제 기둥 거푸집식은 설명 Hcol과 다릅니다(Zb-H1-H2-Zs+Zf). 원식을 유지합니다.','원본 W50 코핑 거푸집식의 계수와 W63 몰탈 0.05m는 표시 설명/규격과 다릅니다.','현재 코핑은 직선/단차 형상: a=0, R 미적용이며 W50/W58의 원본 원호항에 직선 극한(R→∞)을 적용합니다. s는 단차를 포함한 양단 등가경사입니다.','원본 고정 기준: 터파기 여유 각 1m, 버림 두께 0.1m·치수여유 0.2m, 몰탈 두께 0.05m, 솔플레이트 여유 0.1m, 강재비중 7.85.'];
|
const warnings=['원본 W51의 실제 기둥 거푸집식은 설명 Hcol과 다릅니다(Zb-H1-H2-Zs+Zf). 원식을 유지합니다.','원본 W50 코핑 거푸집식의 계수와 W63 몰탈 0.05m는 표시 설명/규격과 다릅니다.','현재 코핑은 직선/단차 형상: a=0, R 미적용이며 W50/W58의 원본 원호항에 직선 극한(R→∞)을 적용합니다. s는 단차를 포함한 양단 등가경사입니다.','원본 고정 기준: 터파기 여유 각 1m, 버림 두께 0.1m·치수여유 0.2m, 몰탈 두께 0.05m, 솔플레이트 여유 0.1m, 강재비중 7.85.'];
|
||||||
return {mapped,overrides,book,results,copingMode,formulas,missing,issues,warnings,complete:!missing.length&&!issues.length&&!results.some(r=>r.value===null||r.error)&&stored.reviewed,signature:geometrySignature(input,m)+'|rebar:'+String(rebarTon??'manual')} ;
|
warnings.push('W64 솔플레이트는 생성 모델의 판별 체적 합계×7.85t/m³를 사용합니다. 원본 Excel 식은 비교 근거로 보존하며 출력 W64는 모델 계산값입니다.');
|
||||||
|
return {mapped,overrides,book,results,copingMode,solplateMode,formulas,missing,issues,warnings,complete:!missing.length&&!issues.length&&!results.some(r=>r.value===null||r.error)&&stored.reviewed,signature:geometrySignature(input,m)+'|rebar:'+String(rebarTon??'manual')+'|solplate-model:'+solplateTon} ;
|
||||||
}
|
}
|
||||||
export function aggregate(results:ReturnType<typeof calculate>[]){return quantityRows.map((r,i)=>({...r,value:results.every(x=>x.complete)?results.reduce((s,x)=>s+x.results[i].value!,0):null}));}
|
export function aggregate(results:ReturnType<typeof calculate>[]){return quantityRows.map((r,i)=>({...r,value:results.every(x=>x.complete)?results.reduce((s,x)=>s+x.results[i].value!,0):null}));}
|
||||||
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user