Compare commits
28
Commits
2cf82b248a
...
1ecd7542ee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ecd7542ee | ||
|
|
7861bc3fdf | ||
|
|
278d19faa5 | ||
|
|
fb09c693c4 | ||
|
|
5f3ea8d56f | ||
|
|
7314da639b | ||
|
|
70fa0be86f | ||
|
|
9a338614a6 | ||
|
|
0dbd66d807 | ||
|
|
3a16ee59da | ||
|
|
ccc1f494bc | ||
|
|
d8b383423c | ||
|
|
42ae591eba | ||
|
|
4fc02afb1a | ||
|
|
60e50b1186 | ||
|
|
d2ebce026d | ||
|
|
12e9828a3a | ||
|
|
b0a71fc73f | ||
|
|
844177b40e | ||
|
|
2caed5b87f | ||
|
|
e5d8002c79 | ||
|
|
765384cd71 | ||
|
|
a2ad0f2baf | ||
|
|
ab04c19afb | ||
|
|
df96991c9b | ||
|
|
cf8410e450 | ||
|
|
0e9d89951a | ||
|
|
d3c761dcd1 |
No files matched your search
+65
-79
@@ -46,21 +46,21 @@
|
||||
<label>형식
|
||||
<select id="type-filter"></select>
|
||||
</label>
|
||||
<button id="select-all" class="lnk" type="button">전체 선택</button>
|
||||
</div>
|
||||
<div class="phead" aria-hidden="true"><span></span><span>교각</span><span>상태</span></div>
|
||||
<div class="phead" aria-hidden="true"><span>교각</span><span>상태</span></div>
|
||||
<ul id="pier-items" class="pier-items"></ul>
|
||||
<p id="pier-empty" class="muted">불러온 교각이 없습니다.</p>
|
||||
<p class="pnote">시트 1장 = 교각 1기입니다. 오류가 난 교각만 건너뛰고 나머지는 생성합니다.</p>
|
||||
<div class="bulk">
|
||||
<b id="bulk-count">선택 0기</b>
|
||||
<span class="grow"></span>
|
||||
<button id="bulk-copy" class="btn-o" type="button" disabled title="2단계에서 구현">속성 복사…</button>
|
||||
<button id="bulk-regen" class="btn-o" type="button" disabled>선택 교각 다시 생성</button>
|
||||
</div>
|
||||
|
||||
<section class="pier-input" aria-labelledby="input-title">
|
||||
<p id="input-title" class="lbl">입력값</p>
|
||||
<div class="pier-table-wrap">
|
||||
<table id="inputs-all" class="grid-table compact">
|
||||
<thead><tr><th>교각</th><th>코핑 상면</th><th>기초 하단</th><th>지반고</th><th>말뚝 (mm)</th><th>코핑 폭×길이</th><th>받침</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="lbl small" id="inputs-active"></p>
|
||||
<dl id="inputs" class="kv wide"></dl>
|
||||
<section class="edit-card" aria-labelledby="edit-title">
|
||||
<h3 id="edit-title">코핑·말뚝 입력 수정</h3>
|
||||
@@ -81,6 +81,7 @@
|
||||
<summary>부재 표시 <span id="parts-count" class="panel-state"></span></summary>
|
||||
<div id="parts"></div>
|
||||
</details>
|
||||
<button id="all-toggle" class="btn" type="button" aria-pressed="false" title="6기를 콘크리트만 한 화면에 (① 형상 생성)">전체 보기</button>
|
||||
<div class="views-menu" id="views-menu">
|
||||
<button id="views-toggle" class="btn views-toggle" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="views">시점</button>
|
||||
<div id="views" class="views views-list" role="group" aria-label="시점" hidden></div>
|
||||
@@ -130,7 +131,7 @@
|
||||
<main class="grid plan" id="view-plan" hidden>
|
||||
<aside class="panel piers" aria-labelledby="plan-pier-title">
|
||||
<p id="plan-pier-title" class="lbl">교각 목록</p>
|
||||
<div class="phead" aria-hidden="true"><span></span><span>교각</span><span>변경</span></div>
|
||||
<div class="phead" aria-hidden="true"><span>교각</span><span>변경</span></div>
|
||||
<ul id="plan-pier-items" class="pier-items"></ul>
|
||||
<p class="lbl members-title">부재 · 5개</p>
|
||||
<ul id="members" class="members"></ul>
|
||||
@@ -147,6 +148,45 @@
|
||||
<div id="member-fields"></div>
|
||||
<p id="member-note" class="muted small"></p>
|
||||
<button id="member-regen" class="btn primary" type="button" disabled>다시 생성 · 변경 0</button>
|
||||
<section id="copy-panel" class="copy-panel" aria-labelledby="cp-title">
|
||||
<header class="copy-head">
|
||||
<h3 id="cp-title">속성 복사</h3>
|
||||
<p class="num" id="cp-source"></p>
|
||||
<p class="muted small">기준 교각의 설정을 고른 교각에만 적용합니다 · 현재 입력값 기준 (미반영 변경 포함)</p>
|
||||
</header>
|
||||
<div class="dlg-body">
|
||||
<div class="dlg-col">
|
||||
<section>
|
||||
<h3>1 · 대상 교각</h3>
|
||||
<div class="cp-tools">
|
||||
<button id="cp-clear" class="btn-o small" type="button">선택 해제</button>
|
||||
</div>
|
||||
<div id="cp-targets" class="cp-chips"></div>
|
||||
<p class="muted small">고른 교각에만 적용합니다. 고르지 않은 교각은 바뀌지 않습니다. 모델이 없는 교각은 고를 수 없습니다.</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>2 · 복사할 항목</h3>
|
||||
<div class="cp-tools">
|
||||
<button id="cp-items-reset" class="btn-o small" type="button">변경 항목으로 선택</button>
|
||||
</div>
|
||||
<div id="cp-items" class="cp-chips"></div>
|
||||
<p class="muted small">복사하지 않는 값: 좌표 · IP · 받침 EL · 코핑 상면 EL · 기초 하단 EL · 지반고 · 기둥 높이 · 말뚝 선단 EL · 계단 구간</p>
|
||||
</section>
|
||||
</div>
|
||||
<div class="dlg-col">
|
||||
<section>
|
||||
<h3>3 · 변경 요약 (고른 교각만)</h3>
|
||||
<div id="cp-preview" class="cp-lines"></div>
|
||||
<label class="chk"><input id="cp-regen" type="checkbox" checked />적용 후 고른 교각만 바로 다시 생성</label>
|
||||
<p class="muted small">적용하면 대상 교각마다 "변경 n건"이 표시되고, 입력 수정 카드에서 항목별로 되돌릴 수 있습니다.</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="copy-foot">
|
||||
<span id="cp-summary" class="muted"></span>
|
||||
<button id="cp-apply" class="btn-p" type="button" disabled>대상을 고르세요</button>
|
||||
</footer>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -161,6 +201,7 @@
|
||||
<main class="grid quantity" id="view-quantity" hidden>
|
||||
<aside class="panel piers" aria-labelledby="quantity-pier-title">
|
||||
<p id="quantity-pier-title" class="lbl">교각 목록</p>
|
||||
<button id="quantity-all" class="pier-name" type="button" aria-pressed="true">전체</button>
|
||||
<ul id="quantity-pier-items" class="pier-items"></ul>
|
||||
<p class="pnote" id="quantity-note"></p>
|
||||
</aside>
|
||||
@@ -176,13 +217,8 @@
|
||||
<main class="grid rebar" id="view-rebar" hidden>
|
||||
<aside class="panel piers" aria-labelledby="rebar-pier-title">
|
||||
<p id="rebar-pier-title" class="lbl">교각 목록</p>
|
||||
<div class="phead" aria-hidden="true"><span></span><span>교각</span><span>배근</span></div>
|
||||
<div class="phead" aria-hidden="true"><span>교각</span><span>배근</span></div>
|
||||
<ul id="rebar-pier-items" class="pier-items"></ul>
|
||||
<div class="bulk">
|
||||
<b id="rebar-bulk-count">선택 0기</b>
|
||||
<span class="grow"></span>
|
||||
<button id="rebar-bulk-copy" class="btn-o" type="button" disabled>속성 복사…</button>
|
||||
</div>
|
||||
<p id="rebar-member-title" class="lbl members-title">부재 · 배근 타입</p>
|
||||
<ul id="rebar-tabs" class="members"></ul>
|
||||
<p class="pnote">형상은 ② 형상계획 기준입니다. 형상이 바뀌면 "형상 바뀜"으로 표시합니다.</p>
|
||||
@@ -290,17 +326,21 @@
|
||||
<h3>미리보기</h3>
|
||||
<p id="preview-title" class="muted"></p>
|
||||
<h4>교각 정보</h4>
|
||||
<table id="pv-pier" class="grid-table"><tbody></tbody></table>
|
||||
<div class="pv-scroll"><table id="pv-pier" class="grid-table"></table></div>
|
||||
<h4>받침</h4>
|
||||
<table id="pv-bearings" class="grid-table">
|
||||
<thead><tr><th>번호</th><th>x (m)</th><th>y (m)</th><th>z (m)</th><th>방위각</th><th>위치</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<div class="pv-scroll">
|
||||
<table id="pv-bearings" class="grid-table">
|
||||
<thead><tr><th>교각</th><th>번호</th><th>x (m)</th><th>y (m)</th><th>z (m)</th><th>방위각</th><th>위치</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<h4>IP</h4>
|
||||
<table id="pv-ips" class="grid-table">
|
||||
<thead><tr><th>점</th><th>x (m)</th><th>y (m)</th><th>꺾임각</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<div class="pv-scroll">
|
||||
<table id="pv-ips" class="grid-table">
|
||||
<thead><tr><th>교각</th><th>점</th><th>x (m)</th><th>y (m)</th><th>꺾임각</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -361,61 +401,7 @@
|
||||
</footer>
|
||||
</dialog>
|
||||
|
||||
<dialog id="copy-dialog" aria-labelledby="cp-title">
|
||||
<header class="dlg-head">
|
||||
<h2 id="cp-title">속성 복사</h2>
|
||||
<p class="muted small">한 교각의 설정을 여러 교각에 한 번에 적용합니다</p>
|
||||
<button id="cp-close" class="btn small" type="button">닫기</button>
|
||||
</header>
|
||||
<div class="dlg-body">
|
||||
<div class="dlg-col">
|
||||
<section>
|
||||
<h3>1 · 원본 교각</h3>
|
||||
<p id="cp-source" class="num"></p>
|
||||
<p class="muted small">현재 입력값 기준 (미반영 변경 포함)</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>2 · 복사할 항목</h3>
|
||||
<ul class="cp-items">
|
||||
<li><span class="cp-group">코핑</span>
|
||||
<label class="chk"><input type="checkbox" data-item="coping-width" />폭 B</label>
|
||||
<label class="chk"><input type="checkbox" data-item="coping-length" />길이 L <span class="muted small">교각마다 다름 · 기본 해제</span></label>
|
||||
</li>
|
||||
<li><span class="cp-group">확대기초 · 말뚝</span>
|
||||
<label class="chk"><input type="checkbox" data-item="pile-length" />말뚝 길이 <span class="muted small">지반마다 다름 · 기본 해제</span></label>
|
||||
<label class="chk"><input type="checkbox" data-item="pile-rows" />열별 본수 <span class="muted small">기초 크기마다 다름 · 기본 해제</span></label>
|
||||
<label class="chk disabled"><input type="checkbox" disabled />기초 형상 · 치수 · 버림 <span class="muted small">예정</span></label>
|
||||
</li>
|
||||
<li><span class="cp-group">기둥 · 받침 · 철근배근</span>
|
||||
<label class="chk disabled"><input type="checkbox" disabled />기둥 단면 · 지름 D, 받침·받침블럭, 배근 <span class="muted small">예정</span></label>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="muted small">복사하지 않는 값: 좌표 · IP · 받침 EL · 코핑 상면 EL · 기초 하단 EL · 지반고 · 기둥 높이 · 말뚝 선단 EL · 계단 구간</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>3 · 대상 교각</h3>
|
||||
<label class="chk"><input id="cp-same-type" type="checkbox" checked />원본과 같은 형식만 보기</label>
|
||||
<ul id="cp-targets" class="cp-targets"></ul>
|
||||
</section>
|
||||
</div>
|
||||
<div class="dlg-col">
|
||||
<section>
|
||||
<h3>4 · 적용 미리보기</h3>
|
||||
<table id="cp-preview" class="grid-table">
|
||||
<thead><tr><th>대상</th><th>형식</th><th>바뀌는 값</th><th>건너뜀</th><th>내용</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<label class="chk"><input id="cp-regen" type="checkbox" checked />적용 후 대상 교각 형상까지 바로 다시 생성</label>
|
||||
<p class="muted small">적용하면 대상 교각마다 "변경 n건"이 표시되고, 입력 수정 카드에서 항목별로 되돌릴 수 있습니다.</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="dlg-foot">
|
||||
<span id="cp-summary" class="muted"></span>
|
||||
<button id="cp-cancel" class="btn" type="button">취소</button>
|
||||
<button id="cp-apply" class="btn-p" type="button" disabled>대상 0기에 적용</button>
|
||||
</footer>
|
||||
</dialog>
|
||||
|
||||
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { COPY_ITEMS, type CopyItem } from './copy';
|
||||
import { overrideKeys, type PierValues } from './changes';
|
||||
import type { MemberId } from './shape-options';
|
||||
|
||||
/** One pier as a target of 속성 복사. */
|
||||
export interface CopyCandidate {
|
||||
name: string;
|
||||
type: string;
|
||||
/** A pier without a generated model cannot be regenerated, so it is listed but not selectable. */
|
||||
hasModel: boolean;
|
||||
}
|
||||
|
||||
export interface TargetCandidate {
|
||||
name: string;
|
||||
type: string;
|
||||
selectable: boolean;
|
||||
}
|
||||
|
||||
/** The piers offered as targets: every other pier. The type is shown so a different type is visible. */
|
||||
export function copyCandidates(piers: CopyCandidate[], sourceName: string): TargetCandidate[] {
|
||||
return piers
|
||||
.filter((p) => p.name !== sourceName)
|
||||
.map((p) => ({ name: p.name, type: p.type, selectable: p.hasModel }));
|
||||
}
|
||||
|
||||
/** The targets that get the change: ticked by the user, selectable, and visible under the current filter. */
|
||||
export function selectedTargets(piers: CopyCandidate[], sourceName: string, ticked: Set<string>): string[] {
|
||||
return copyCandidates(piers, sourceName)
|
||||
.filter((c) => c.selectable && ticked.has(c.name))
|
||||
.map((c) => c.name);
|
||||
}
|
||||
|
||||
/** 부재 → 복사 항목. A member with no copy items (기둥, 받침) copies nothing yet. */
|
||||
export const MEMBER_COPY_ITEMS: Record<MemberId, CopyItem[]> = {
|
||||
bearing: [],
|
||||
coping: ['coping-width', 'coping-length', 'coping-h1', 'coping-h2', 'level-width'],
|
||||
column: [],
|
||||
footing: ['pile-length', 'pile-rows'],
|
||||
ground: ['ground-position'],
|
||||
};
|
||||
|
||||
/** Whether the 속성 복사 panel is shown for the 부재: only when it has copy items. */
|
||||
export function hasCopyItems(member: MemberId): boolean {
|
||||
return copyItemsFor(member).length > 0;
|
||||
}
|
||||
|
||||
/** The copy items for the selected 부재. */
|
||||
export function copyItemsFor(member: MemberId): CopyItem[] {
|
||||
return MEMBER_COPY_ITEMS[member];
|
||||
}
|
||||
|
||||
/**
|
||||
* The copy items whose values the 기준 pier has changed from the imported default (되돌리기가 보이는 항목).
|
||||
* They are ticked by default; the user can change the ticks afterwards.
|
||||
*/
|
||||
export function copyItemsWithOverrides(source: PierValues): CopyItem[] {
|
||||
const changed = new Set<string>(overrideKeys(source));
|
||||
return (Object.keys(COPY_ITEMS) as CopyItem[]).filter((item) => COPY_ITEMS[item].keys.some((k) => changed.has(k)));
|
||||
}
|
||||
+4
-1
@@ -5,7 +5,7 @@ import type { PierInput } from './types';
|
||||
* 속성 복사 (spec §9 · plan C7). Copies chosen attributes from one pier to others.
|
||||
* Values the spec never copies (coordinates, IP, elevations, names) are not in any item, so they cannot be copied.
|
||||
*/
|
||||
export type CopyItem = 'coping-width' | 'coping-length' | 'pile-length' | 'pile-rows' | 'ground-position';
|
||||
export type CopyItem = 'coping-width' | 'coping-length' | 'coping-h1' | 'coping-h2' | 'level-width' | 'pile-length' | 'pile-rows' | 'ground-position';
|
||||
|
||||
interface ItemRule {
|
||||
label: string;
|
||||
@@ -19,6 +19,9 @@ interface ItemRule {
|
||||
export const COPY_ITEMS: Record<CopyItem, ItemRule> = {
|
||||
'coping-width': { label: '코핑 폭', keys: ['copingWidthMm'], defaultOn: true, pileItem: false },
|
||||
'coping-length': { label: '코핑 길이 L', keys: ['copingLengthMm'], defaultOn: false, pileItem: false },
|
||||
'coping-h1': { label: '끝단 높이 h1', keys: ['copingH1Mm'], defaultOn: false, pileItem: false },
|
||||
'coping-h2': { label: '테이퍼 높이 h2', keys: ['copingH2Mm'], defaultOn: false, pileItem: false },
|
||||
'level-width': { label: 'Level 폭', keys: ['levelWidthMm'], defaultOn: false, pileItem: false },
|
||||
'pile-length': { label: '말뚝 길이', keys: ['pileLengthMm'], defaultOn: false, pileItem: true },
|
||||
'pile-rows': { label: '열별 본수', keys: ['pileRows'], defaultOn: false, pileItem: true },
|
||||
'ground-position': { label: '지반 위치·지하수위', keys: ['groundPosition', 'groundWaterOn', 'groundWaterEl', 'floodLevelEl', 'highWaterEl', 'lowWaterEl'], defaultOn: false, pileItem: false },
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/** One pier as the 재료표 scope sees it. */
|
||||
export interface QuantityPier {
|
||||
name: string;
|
||||
hasModel: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The piers the ④ 재료표 shows: every pier with a model when 전체 is on, otherwise only the chosen pier
|
||||
* (교각 목록에서 고른 교각). A chosen pier without a model shows nothing.
|
||||
*/
|
||||
export function quantityTargetNames(piers: QuantityPier[], all: boolean, active: string | null): string[] {
|
||||
if (all) return piers.filter((p) => p.hasModel).map((p) => p.name);
|
||||
return piers.filter((p) => p.hasModel && p.name === active).map((p) => p.name);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Quantity } from './rebar/quantity';
|
||||
|
||||
/** One line of the bridge total: a 강종 and 직경 summed over the piers. */
|
||||
export interface TotalRow {
|
||||
grade: number;
|
||||
size: string;
|
||||
count: number;
|
||||
totalM: number;
|
||||
kg: number;
|
||||
}
|
||||
|
||||
export interface QuantityTotal {
|
||||
rows: TotalRow[];
|
||||
/** Totals by 강종, in the same order as the rows. */
|
||||
gradeKg: { grade: number; kg: number }[];
|
||||
kg: number;
|
||||
kgWithWaste: number;
|
||||
ton: number;
|
||||
couplers: { name: string; count: number }[];
|
||||
/** Bar names with no length, left out of the totals (summed over the piers). */
|
||||
missingCount: number;
|
||||
}
|
||||
|
||||
/** 교량 전체 집계: sums the per-pier 재료표 by 강종 and 직경 (the per-pier values are not changed). */
|
||||
export function aggregateQuantities(items: Quantity[]): QuantityTotal {
|
||||
const byKey = new Map<string, TotalRow>();
|
||||
const couplers = new Map<string, number>();
|
||||
let kg = 0;
|
||||
let kgWithWaste = 0;
|
||||
let missingCount = 0;
|
||||
for (const q of items) {
|
||||
for (const g of q.groups) {
|
||||
for (const d of g.diameters) {
|
||||
const key = `${g.grade}|${d.size}`;
|
||||
const row = byKey.get(key) ?? { grade: g.grade, size: d.size, count: 0, totalM: 0, kg: 0 };
|
||||
row.count += d.lines.reduce((n, l) => n + l.count, 0);
|
||||
row.totalM += d.totalM;
|
||||
row.kg += d.kg;
|
||||
byKey.set(key, row);
|
||||
}
|
||||
}
|
||||
for (const c of q.couplers) couplers.set(c.name, (couplers.get(c.name) ?? 0) + c.count);
|
||||
kg += q.kg;
|
||||
kgWithWaste += q.kgWithWaste;
|
||||
missingCount += q.missing.length;
|
||||
}
|
||||
const rows = [...byKey.values()].sort((a, b) => a.grade - b.grade || parseInt(a.size.slice(1), 10) - parseInt(b.size.slice(1), 10));
|
||||
const gradeKg = new Map<number, number>();
|
||||
for (const r of rows) gradeKg.set(r.grade, (gradeKg.get(r.grade) ?? 0) + r.kg);
|
||||
return {
|
||||
rows,
|
||||
gradeKg: [...gradeKg].map(([grade, value]) => ({ grade, kg: value })),
|
||||
kg,
|
||||
kgWithWaste,
|
||||
ton: kgWithWaste / 1000,
|
||||
couplers: [...couplers].map(([name, count]) => ({ name, count })),
|
||||
missingCount,
|
||||
};
|
||||
}
|
||||
@@ -91,6 +91,15 @@ export const FCK_MEMBERS: readonly FckMember[] = ['column', 'coping', 'footing']
|
||||
/** Per-member fck (MPa). A member without an entry uses the ③ 공통 설정 fck. */
|
||||
export type FckByMember = Partial<Record<FckMember, number>>;
|
||||
|
||||
/** 교각도 기준 부재별 기본 fck (MPa). 되돌리기는 이 값으로 돌아가고, 이 값과 다르면 주황색으로 표시합니다. */
|
||||
export const IMPORTED_FCK: Record<FckMember, number> = { column: 40, coping: 40, footing: 27 };
|
||||
|
||||
/** True when the member's fck is set and differs from its imported default. An unset member is not changed. */
|
||||
export function fckChanged(byMember: FckByMember, member: FckMember): boolean {
|
||||
const value = byMember[member];
|
||||
return value !== undefined && value !== IMPORTED_FCK[member];
|
||||
}
|
||||
|
||||
/** The material a member is designed with: its own fck when set, otherwise the 공통 value. fy stays common. */
|
||||
export function materialFor(common: Material, byMember: FckByMember, member: FckMember): Material {
|
||||
return { ...common, fck: byMember[member] ?? common.fck };
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { PierModel } from '../geometry';
|
||||
import { hookLength, NOMINAL_DIAMETER } from './common';
|
||||
import { assemblyBars, bottomBars, crossBars, layers, legB2, legLength, sideBars } from './footing';
|
||||
import { DEFAULT_FOOTING_REBAR } from './footing-settings';
|
||||
|
||||
/** One row of the 다리 배치 상세 tab (확대기초철근 (1).md §5–§8). Lengths in mm, heights in mm from the 하면. */
|
||||
export interface FootingLegRow {
|
||||
bar: string;
|
||||
size: string;
|
||||
shape: string;
|
||||
legLength: number | null;
|
||||
legTop: number | null;
|
||||
total: number;
|
||||
splice: boolean;
|
||||
}
|
||||
|
||||
const STANDARD = 12000;
|
||||
|
||||
/** Leg of every footing bar family: where it turns and how long the leg is (12d rule, B2/B6 to the B1/B5 height). */
|
||||
export function footingLegRows(model: PierModel): FootingLegRow[] {
|
||||
const foot = model.footings[0];
|
||||
if (!foot) return [];
|
||||
const fs = model.footingRebar ?? DEFAULT_FOOTING_REBAR;
|
||||
const L = foot.length * 1000;
|
||||
const W = foot.width * 1000;
|
||||
const H = (foot.top - foot.bottom) * 1000;
|
||||
const z = layers(H, fs);
|
||||
const lenOf = (name: string, list: { name: string; length: number }[]) => list.find((b) => b.name === name)!.length;
|
||||
const bottom = bottomBars(L, W, H, fs);
|
||||
const cross = crossBars(L, W, H, fs);
|
||||
const side = sideBars(L, W, H, fs);
|
||||
const asm = assemblyBars(L, W, H, fs);
|
||||
const row = (bar: string, size: string, shape: string, leg: number | null, legTop: number | null, total: number): FootingLegRow =>
|
||||
({ bar, size, shape, legLength: leg, legTop, total, splice: total > STANDARD });
|
||||
const d = (size: keyof typeof NOMINAL_DIAMETER) => NOMINAL_DIAMETER[size];
|
||||
return [
|
||||
row('B1', 'S25', 'ㅡ 다리 아래로 (측면 피복 c_s)', legLength('S25'), z.B1 - legLength('S25'), lenOf('B1', bottom)),
|
||||
row('B2', 'S25', '⊔ 다리 B1 높이까지', legB2(H, fs), z.B1, lenOf('B2', bottom)),
|
||||
row('B3', 'S19', '⊔ 다리 위로', legLength('S19'), z.B3 + legLength('S19'), lenOf('B3', bottom)),
|
||||
row('B4', 'S25', '직선', null, null, lenOf('B4', bottom)),
|
||||
row('B5', 'S29', '⊓ 다리 아래로 (측면 피복 c_s)', legLength('S29'), z.B5 - legLength('S29'), lenOf('B5', cross)),
|
||||
row('B6', 'S29', '⊔ 다리 B5 높이까지', z.B5 - z.B68, z.B5, lenOf('B6', cross)),
|
||||
row('B7', 'S19', '⊔ 다리 위로', legLength('S19'), z.B7 + legLength('S19'), lenOf('B7', cross)),
|
||||
row('B8', 'S29', '직선', null, null, lenOf('B8', cross)),
|
||||
row('B10-1', 'S19', '양끝 90° 갈고리 (옆 면으로)', hookLength('main90', d('S19')), null, lenOf('B10-1', side)),
|
||||
row('B10-2', 'S19', '양끝 90° 갈고리 (옆 면으로)', hookLength('main90', d('S19')), null, lenOf('B10-2', side)),
|
||||
row('B11', 'S16', 'U 양끝 아래로', Math.round(hookLength('main90', d('S16'))), null, lenOf('B11', asm)),
|
||||
row('B12', 'S16', 'U 양끝 아래로', Math.round(hookLength('main90', d('S16'))), null, lenOf('B12', asm)),
|
||||
];
|
||||
}
|
||||
+375
-180
@@ -8,12 +8,15 @@ import { NOT_IMPLEMENTED_RULES, validateInput, validatePier } from './core/valid
|
||||
import { buildTemplateWorkbook } from './core/template';
|
||||
import { EDITABLE_FIELDS, FIELD_LABELS, overrideKeys, pendingKeys, revertDraft, type EditableKey, type PierValues } from './core/changes';
|
||||
import { adjustRow, layoutText, resolveRowCounts, rowLayout, totalPiles } from './core/piles';
|
||||
import { applyCopy, DEFAULT_COPY_ITEMS, planCopy, type CopyItem } from './core/copy';
|
||||
import { applyCopy, COPY_ITEMS, planCopy, type CopyItem } from './core/copy';
|
||||
import { copyCandidates, copyItemsFor, copyItemsWithOverrides, hasCopyItems, selectedTargets, type CopyCandidate } from './core/copy-targets';
|
||||
import { quantityTargetNames } from './core/quantity-scope';
|
||||
import { aggregateQuantities, type QuantityTotal } from './core/quantity-total';
|
||||
import { pileGeometry } from './core/geometry';
|
||||
import { rebarSummary, type RebarRow } from './core/rebar/summary';
|
||||
import { shearSegments } from './core/rebar/coping';
|
||||
import { buildRebar3D, type RebarColorMode, type RebarScene } from './view/rebar3d';
|
||||
import { DEFAULT_MATERIAL, FCK_MEMBERS, fyOf, GRADE_CHOICES, isAssumedMaterial, 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, 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 { parseDrawingRows } from './core/drawing-import';
|
||||
import { groundElFor, parseGroundModel, strataRowsFor, type GroundModelFile } from './core/ground-model';
|
||||
@@ -33,6 +36,9 @@ import { columnInputFor, columnRebar } from './core/rebar/column';
|
||||
import { DEFAULT_FOOTING_REBAR, FOOTING_REBAR_RANGES, type FootingRebarSettings, footingSettingsIssues } from './core/rebar/footing-settings';
|
||||
import { footingRuleValues } from './core/rebar/footing';
|
||||
import { footingSectionSvg } from './view/footing-section-svg';
|
||||
import { footingSectionLSvg } from './view/footing-section-l-svg';
|
||||
import { footingCornerSvg } from './view/footing-corner-svg';
|
||||
import { footingLegRows } from './core/rebar/footing-legs';
|
||||
import { columnElevationSvg } from './view/column-elevation-svg';
|
||||
import { type ColumnSection, columnSectionSvg } from './view/column-section-svg';
|
||||
import { groundSectionSvg } from './view/ground-svg';
|
||||
@@ -52,12 +58,13 @@ let bearingDrawing: Record<string, DrawingBearing[]> = {};
|
||||
|
||||
/** 지반 모델 (ground_model.json) for the project. Its values reach a pier's 지반고 only when the user applies them. */
|
||||
let groundModel: GroundModelFile | null = null;
|
||||
import { allVisibleChecked, setVisibleChecked, 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 { parseProject, SCHEMA_VERSION, serializeProject, type ProjectFile, type ProjectInfo } from './core/project';
|
||||
import { clearBackup, readBackup, writeBackup, type Backup } from './view/backup';
|
||||
import type { Issue, PierInput, PierType, StrataRow } from './core/types';
|
||||
import { buildPierScene, setRebarView, viewPosition, type PartName, type PierScene, type ViewKind } from './view/scene';
|
||||
import { buildAllPiers, type AllPiers } from './view/all-piers';
|
||||
|
||||
/** One pier as shown in the list. `checked` is the multi-select state (시안 ①). */
|
||||
interface Pier extends PierValues {
|
||||
@@ -104,7 +111,6 @@ interface Pending {
|
||||
fileName: string;
|
||||
entries: Pier[];
|
||||
sheets: SheetReport[];
|
||||
preview: string | null;
|
||||
}
|
||||
|
||||
const state = {
|
||||
@@ -116,10 +122,14 @@ const state = {
|
||||
project: { name: '', bridge: '', client: '', author: '', crs: CRS, memo: '' } as ProjectInfo,
|
||||
/** Which stage screen is shown: ① 형상 생성 or ② 형상계획 (plan.md C1). */
|
||||
stage: 'shape' as Stage,
|
||||
/** 전체 보기 on ① 형상 생성: every pier's concrete in one frame (콘크리트만). On by default (승인 2026-10-27). */
|
||||
allView: true,
|
||||
/** ④ 재료표 scope: true = every pier (전체), false = the pier chosen in the list. */
|
||||
quantityAll: true,
|
||||
/** ③ 공통 설정 (fck · fy). */
|
||||
material: { ...DEFAULT_MATERIAL } as Material,
|
||||
/** ② 부재별 fck. A member without an entry uses the 공통 fck (③). */
|
||||
fckByMember: {} as FckByMember,
|
||||
/** ② 부재별 fck. A member without an entry uses the 공통 fck (③). Initial values from 교각도 (025·026, 승인 2026-10-27): 기둥·코핑 40, 기초 27. */
|
||||
fckByMember: { ...IMPORTED_FCK } as FckByMember,
|
||||
/** Bar highlighted on ③ after [보기] (시안 ③ 알림). */
|
||||
/** Bars selected on ③ (철근표 checkboxes, 3D click, [보기]). */
|
||||
rebarSelection: new Set<string>(),
|
||||
@@ -144,6 +154,8 @@ const state = {
|
||||
let pendingBackup: Backup | null = null;
|
||||
/** 3D rebar of the current pier (③); its bars are named as in the 철근표. */
|
||||
let rebar3d: RebarScene | null = null;
|
||||
/** 전체 보기: every pier's concrete in one frame (null in single-pier view). */
|
||||
let allPiers: AllPiers | null = null;
|
||||
let backupTimer: number | undefined;
|
||||
|
||||
const $ = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
|
||||
@@ -288,7 +300,7 @@ function buildPartToggles(): void {
|
||||
input.addEventListener('change', () => {
|
||||
for (const p of group.parts) {
|
||||
state.visible[p] = input.checked;
|
||||
if (state.scene) state.scene.parts[p].visible = input.checked;
|
||||
setPartVisible(p, input.checked);
|
||||
}
|
||||
});
|
||||
label.append(input, document.createTextNode(group.label));
|
||||
@@ -308,6 +320,22 @@ function buildViewButtons(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Scenes whose parts follow the 부재 표시 checkboxes: every pier in 전체 보기, else the active pier. */
|
||||
function partScenes(): PierScene[] {
|
||||
if (allPiers) return [...allPiers.scenes.values()];
|
||||
return state.scene ? [state.scene] : [];
|
||||
}
|
||||
|
||||
function setPartVisible(part: PartName, on: boolean): void {
|
||||
if (part === 'ground' && allPiers) {
|
||||
// With terrain only the shared grid follows the checkbox; without it each pier's 지반고 line does.
|
||||
if (allPiers.terrain) allPiers.terrain.visible = on;
|
||||
else for (const s of allPiers.scenes.values()) s.parts.ground.visible = on;
|
||||
return;
|
||||
}
|
||||
for (const s of partScenes()) s.parts[part].visible = on;
|
||||
}
|
||||
|
||||
function setView(kind: ViewKind): void {
|
||||
state.view = kind;
|
||||
document.querySelectorAll<HTMLButtonElement>('#views button').forEach((b, i) => {
|
||||
@@ -316,20 +344,54 @@ function setView(kind: ViewKind): void {
|
||||
// The band shows the current view only; choosing one closes the menu (승인 2026-10-23).
|
||||
$('views-toggle').textContent = `${VIEWS.find((v) => v.kind === kind)?.label ?? ''} ▾`;
|
||||
setMenuOpen('views', false);
|
||||
if (!state.scene) return;
|
||||
const scene = allPiers ? allPiers.view : state.scene;
|
||||
if (!scene) return;
|
||||
const pier = activePier();
|
||||
if (!pier?.model) return;
|
||||
const { position, target } = viewPosition(state.scene, kind, pier.model);
|
||||
const { position, target } = viewPosition(scene, kind, pier.model);
|
||||
camera.position.copy(position);
|
||||
controls.target.copy(target);
|
||||
controls.update();
|
||||
}
|
||||
|
||||
/** 전체 보기 applies on ① 형상 생성 only; ③ and the others keep the single-pier scene. */
|
||||
/** The piers the 3D view shows together: all generated piers in ① (전체 보기), the 기준 pier alone in ②. */
|
||||
function shownPiers(): Pier[] {
|
||||
if (state.stage === 'shape') return state.piers.filter((p) => p.model);
|
||||
const active = activePier();
|
||||
return active?.model ? [active] : [];
|
||||
}
|
||||
|
||||
/** Several piers in one frame: only ① with 전체 보기 on. ② shows the 기준 pier alone. */
|
||||
function multiSceneOn(): boolean {
|
||||
return state.stage === 'shape' && state.allView;
|
||||
}
|
||||
|
||||
/** Builds the shown piers in one frame. Returns false when none has a model. */
|
||||
function buildAllScene(): boolean {
|
||||
const entries = shownPiers().flatMap((p) => (p.model ? [{ name: p.input.name, model: p.model }] : []));
|
||||
if (entries.length === 0) return false;
|
||||
allPiers = buildAllPiers(entries, groundModel?.terrainGrid);
|
||||
world.add(allPiers.piers);
|
||||
if (allPiers.terrain) world.add(allPiers.terrain);
|
||||
for (const key of Object.keys(state.visible) as PartName[]) setPartVisible(key, state.visible[key]);
|
||||
return true;
|
||||
}
|
||||
|
||||
function setupScene(): void {
|
||||
if (state.scene) world.remove(state.scene.root);
|
||||
state.scene = null;
|
||||
if (allPiers) {
|
||||
world.remove(allPiers.piers);
|
||||
if (allPiers.terrain) world.remove(allPiers.terrain);
|
||||
allPiers = null;
|
||||
}
|
||||
if (rebar3d) world.remove(rebar3d.group);
|
||||
rebar3d = null;
|
||||
if (multiSceneOn() && buildAllScene()) {
|
||||
setView(state.view);
|
||||
return;
|
||||
}
|
||||
const pier = activePier();
|
||||
if (pier?.model) {
|
||||
rebar3d = buildRebar3D(pier.model, state.material, state.fckByMember);
|
||||
@@ -365,7 +427,6 @@ function renderPierList(): void {
|
||||
const visible = state.piers.filter((p) => state.filter === 'all' || p.input.type === state.filter);
|
||||
$<HTMLParagraphElement>('pier-empty').hidden = state.piers.length > 0;
|
||||
|
||||
$<HTMLButtonElement>('select-all').textContent = allVisibleChecked(state.piers, state.filter) ? '선택 해제' : '전체 선택';
|
||||
}
|
||||
|
||||
/** Rows of the pier list. ① shows the type and warnings; ② shows the count of changes from the defaults. */
|
||||
@@ -374,21 +435,13 @@ function renderPierRows(list: HTMLUListElement, mode: Stage): void {
|
||||
const visible = state.piers.filter((p) => state.filter === 'all' || p.input.type === state.filter);
|
||||
for (const pier of visible) {
|
||||
const li = el('li');
|
||||
const check = el('input');
|
||||
check.type = 'checkbox';
|
||||
check.checked = pier.checked;
|
||||
check.disabled = !pier.model;
|
||||
check.setAttribute('aria-label', `${pier.input.name} 선택`);
|
||||
check.addEventListener('change', () => {
|
||||
pier.checked = check.checked;
|
||||
renderAll();
|
||||
});
|
||||
// The pier name and type are shown here, since the 3D title band is gone on wide screens (승인 2026-10-23).
|
||||
const name = el('button', `${pier.input.name} · ${pier.input.type}`, 'pier-name');
|
||||
name.type = 'button';
|
||||
name.setAttribute('aria-pressed', String(pier.input.name === state.active));
|
||||
name.addEventListener('click', () => {
|
||||
state.active = pier.input.name;
|
||||
state.quantityAll = false;
|
||||
state.rebarSelection = new Set();
|
||||
setupScene();
|
||||
renderAll();
|
||||
@@ -400,7 +453,7 @@ function renderPierRows(list: HTMLUListElement, mode: Stage): void {
|
||||
: pier.model
|
||||
? el('span', `${pier.input.type}${pier.issues.some((i) => i.level !== 'I') ? ' · 경고' : ''}`, 'badge')
|
||||
: el('span', '오류', 'badge error');
|
||||
li.append(check, name, badge);
|
||||
li.append(name, badge);
|
||||
list.appendChild(li);
|
||||
}
|
||||
}
|
||||
@@ -414,19 +467,53 @@ function renderHeader(): void {
|
||||
$('step1-status').textContent = pier?.model ? '생성됨' : '미생성';
|
||||
renderSaveStatus();
|
||||
const total = pendingTotal();
|
||||
const checked = state.piers.filter((p) => p.checked).length;
|
||||
const targets = generationTargets();
|
||||
$('step2-status').textContent = total ? `변경 ${total}건 · 재생성 필요` : '예정';
|
||||
const regen = $<HTMLButtonElement>('regen');
|
||||
regen.disabled = checked === 0;
|
||||
regen.textContent = total ? `선택 교각 다시 생성 · 변경 ${total}` : '선택 교각 형상 생성';
|
||||
$('bulk-count').textContent = `선택 ${checked}기`;
|
||||
$<HTMLButtonElement>('bulk-regen').disabled = checked === 0;
|
||||
$<HTMLButtonElement>('bulk-copy').disabled = !activePier();
|
||||
regen.disabled = targets.length === 0;
|
||||
regen.textContent = targets.length ? `변경 교각 다시 생성 · ${targets.length}기` : '교각 형상 생성';
|
||||
renderCopyPanel();
|
||||
}
|
||||
|
||||
// --- results panel (시안 ① 입력값 · 계산 결과) ----------------------------------------
|
||||
type Pair = [string, string];
|
||||
|
||||
/** 입력값 for every pier in one table; the row of the active pier is marked, and a row click makes that pier active. */
|
||||
function renderInputTable(): void {
|
||||
const table = $<HTMLTableElement>('inputs-all');
|
||||
const body = table.tBodies[0];
|
||||
body.replaceChildren();
|
||||
for (const p of state.piers) {
|
||||
const inp = p.input;
|
||||
const front = inp.bearings.filter((b) => b.row === '전열').length;
|
||||
const back = inp.bearings.filter((b) => b.row === '후열').length;
|
||||
const tr = el('tr', undefined, p.input.name === state.active ? 'active' : undefined);
|
||||
tr.tabIndex = 0;
|
||||
tr.addEventListener('click', () => selectPier(p.input.name));
|
||||
tr.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') selectPier(p.input.name);
|
||||
});
|
||||
const cells = [
|
||||
inp.name,
|
||||
f3(inp.copingTopEl),
|
||||
f3(inp.footingBottomEl),
|
||||
f3(inp.groundEl),
|
||||
String(inp.pileLengthMm),
|
||||
`${inp.copingWidthMm}×${inp.copingLengthMm}`,
|
||||
`${front}·${back}`,
|
||||
];
|
||||
for (const v of cells) tr.appendChild(el('td', v));
|
||||
body.appendChild(tr);
|
||||
}
|
||||
}
|
||||
|
||||
function selectPier(name: string): void {
|
||||
state.active = name;
|
||||
state.rebarSelection = new Set();
|
||||
setupScene();
|
||||
renderAll();
|
||||
}
|
||||
|
||||
function fillKv(dl: HTMLElement, pairs: Pair[]): void {
|
||||
dl.replaceChildren();
|
||||
for (const [k, v] of pairs) {
|
||||
@@ -446,6 +533,10 @@ function renderPanels(): void {
|
||||
$('viewer-title').textContent = pier ? `${pier.input.name} · ${pier.input.type} 교각 형상` : '교각 3D 뷰';
|
||||
$('origin').textContent = m ? `원점 = 교각 중심 (${pt(m.pivot)})` : '';
|
||||
|
||||
renderInputTable();
|
||||
$('inputs-active').textContent = pier ? `활성 교각 ${pier.input.name} 상세 (표의 행을 누르면 바뀜)` : '';
|
||||
$('edit-title').textContent = pier ? `코핑·말뚝 입력 수정 · 기준 ${pier.input.name}` : '코핑·말뚝 입력 수정';
|
||||
|
||||
if (!pier) {
|
||||
fillKv($('inputs'), []);
|
||||
fillKv($('res-axis'), []);
|
||||
@@ -523,6 +614,9 @@ let lastScrolledPier: string | null = null;
|
||||
|
||||
function renderAll(): void {
|
||||
updatePartsCount();
|
||||
const allToggle = $<HTMLButtonElement>('all-toggle');
|
||||
allToggle.setAttribute('aria-pressed', String(state.allView));
|
||||
allToggle.disabled = state.stage !== 'shape' || !state.piers.some((p) => p.model);
|
||||
if (state.active !== lastScrolledPier) {
|
||||
lastScrolledPier = state.active;
|
||||
document.querySelector<HTMLButtonElement>('.pier-items [aria-pressed="true"]')?.scrollIntoView({ block: 'nearest' });
|
||||
@@ -540,50 +634,87 @@ function renderAll(): void {
|
||||
}
|
||||
|
||||
// --- 속성 복사 (시안 ⑥, plan C8) ---------------------------------------------------------
|
||||
/** Names of the pier targets ticked in the copy dialog. */
|
||||
/** Names of the pier targets ticked in the copy panel. */
|
||||
let copySelection = new Set<string>();
|
||||
|
||||
function copyTargets(): Pier[] {
|
||||
const src = activePier();
|
||||
const sameType = $<HTMLInputElement>('cp-same-type').checked;
|
||||
return state.piers.filter((p) => p !== src && (!sameType || p.input.type === src?.input.type));
|
||||
function copyCandidateList(): CopyCandidate[] {
|
||||
return state.piers.map((p) => ({ name: p.input.name, type: p.input.type, hasModel: p.model !== null }));
|
||||
}
|
||||
|
||||
/** Targets that are both visible in the dialog and ticked. Hidden targets are never changed. */
|
||||
/** The piers that get the change: only those ticked in the panel (기본 0기). */
|
||||
function selectedCopyTargets(): Pier[] {
|
||||
return copyTargets().filter((t) => copySelection.has(t.input.name));
|
||||
const src = activePier();
|
||||
if (!src) return [];
|
||||
const names = new Set(selectedTargets(copyCandidateList(), src.input.name, copySelection));
|
||||
return state.piers.filter((p) => names.has(p.input.name));
|
||||
}
|
||||
|
||||
/** Copy items ticked by the user, limited to the items of the selected 부재. */
|
||||
function chosenCopyItems(): CopyItem[] {
|
||||
return Array.from(document.querySelectorAll<HTMLInputElement>('#copy-dialog input[data-item]'))
|
||||
.filter((i) => i.checked)
|
||||
.map((i) => i.dataset.item as CopyItem);
|
||||
return copyItemsFor(state.member).filter((item) => copyItemChoice.has(item));
|
||||
}
|
||||
|
||||
/** The items ticked by the user. Reset to the 기준 pier's changed items when the 기준 pier or 부재 changes. */
|
||||
let copyItemChoice = new Set<CopyItem>();
|
||||
let copyItemKey = '';
|
||||
|
||||
/** Shows the copy items of the selected 부재 only. A 부재 without items shows a note instead. */
|
||||
function renderCopyItems(): void {
|
||||
const src = activePier();
|
||||
const key = `${src?.input.name ?? ''}|${state.member}`;
|
||||
if (key !== copyItemKey) {
|
||||
copyItemKey = key;
|
||||
copyItemChoice = new Set(src ? copyItemsWithOverrides(src) : []);
|
||||
}
|
||||
const box = $('cp-items');
|
||||
box.replaceChildren();
|
||||
const items = copyItemsFor(state.member);
|
||||
for (const item of items) {
|
||||
const label = el('label', undefined, 'chk');
|
||||
const check = el('input');
|
||||
check.type = 'checkbox';
|
||||
check.dataset.item = item;
|
||||
check.checked = copyItemChoice.has(item);
|
||||
check.addEventListener('change', () => {
|
||||
if (check.checked) copyItemChoice.add(item);
|
||||
else copyItemChoice.delete(item);
|
||||
renderCopyPreview();
|
||||
});
|
||||
label.append(check, document.createTextNode(COPY_ITEMS[item].label));
|
||||
box.appendChild(label);
|
||||
}
|
||||
// The panel is hidden for a 부재 without copy items (교량받침·기둥 are 예정).
|
||||
$('copy-panel').hidden = !hasCopyItems(state.member);
|
||||
}
|
||||
|
||||
function renderCopyTargets(): void {
|
||||
const list = $<HTMLUListElement>('cp-targets');
|
||||
list.replaceChildren();
|
||||
for (const t of copyTargets()) {
|
||||
const li = el('li');
|
||||
const box = $('cp-targets');
|
||||
box.replaceChildren();
|
||||
const src = activePier();
|
||||
for (const c of copyCandidates(copyCandidateList(), src?.input.name ?? '')) {
|
||||
const label = el('label', undefined, 'chk');
|
||||
const check = el('input');
|
||||
check.type = 'checkbox';
|
||||
check.checked = copySelection.has(t.input.name);
|
||||
check.setAttribute('aria-label', `${t.input.name} 대상`);
|
||||
check.disabled = !c.selectable;
|
||||
check.checked = c.selectable && copySelection.has(c.name);
|
||||
check.setAttribute('aria-label', `${c.name} 대상`);
|
||||
check.addEventListener('change', () => {
|
||||
if (check.checked) copySelection.add(t.input.name);
|
||||
else copySelection.delete(t.input.name);
|
||||
if (check.checked) copySelection.add(c.name);
|
||||
else copySelection.delete(c.name);
|
||||
renderCopyPreview();
|
||||
});
|
||||
li.append(check, el('span', `${t.input.name} · ${t.input.type} · ${t.input.foundationType}`));
|
||||
list.appendChild(li);
|
||||
const otherType = src !== undefined && c.type !== src.input.type ? ' · 형식 다름' : '';
|
||||
const text = c.selectable ? `${c.name} · ${c.type}${otherType}` : `${c.name} · ${c.type} (모델 없음, 제외)`;
|
||||
label.append(check, document.createTextNode(text));
|
||||
box.appendChild(label);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCopyPreview(): void {
|
||||
const src = activePier();
|
||||
const items = chosenCopyItems();
|
||||
const body = $<HTMLTableElement>('cp-preview').tBodies[0];
|
||||
body.replaceChildren();
|
||||
const box = $('cp-preview');
|
||||
box.replaceChildren();
|
||||
let applied = 0;
|
||||
let skippedTotal = 0;
|
||||
const selected = selectedCopyTargets();
|
||||
@@ -594,37 +725,27 @@ function renderCopyPreview(): void {
|
||||
const skipped = plan.skipped.map((k) => k.reason);
|
||||
skippedTotal += skipped.length;
|
||||
if (plan.changes.length) applied++;
|
||||
const row = el('tr');
|
||||
row.append(
|
||||
el('td', t.input.name),
|
||||
el('td', t.input.type),
|
||||
el('td', String(plan.changes.length)),
|
||||
el('td', String(skipped.length)),
|
||||
el('td', [...labels, ...skipped].join(' · ') || '바뀌는 값 없음'),
|
||||
);
|
||||
body.appendChild(row);
|
||||
const detail = [...labels, ...skipped.map((r) => `건너뜀: ${r}`)].join(' · ') || '바뀌는 값 없음';
|
||||
box.appendChild(el('p', `${t.input.name} · ${detail}`, 'cp-line'));
|
||||
}
|
||||
const n = selected.length;
|
||||
$('cp-summary').textContent = `${src?.input.name ?? ''} → ${n}기 · 항목 ${items.length}개 · 건너뜀 ${skippedTotal}건`;
|
||||
const apply = $<HTMLButtonElement>('cp-apply');
|
||||
apply.textContent = `대상 ${applied}기에 적용`;
|
||||
apply.textContent = selected.length === 0 ? '대상을 고르세요' : `${selected.map((t) => t.input.name).join(', ')} ${selected.length}기에만 적용`;
|
||||
apply.disabled = applied === 0;
|
||||
if (items.length === 0) apply.textContent = '복사 항목 없음';
|
||||
}
|
||||
|
||||
function openCopyDialog(): void {
|
||||
/** The inline 속성 복사 panel: source is the 기준 pier; targets are ticked inside the panel only. */
|
||||
function renderCopyPanel(): void {
|
||||
const src = activePier();
|
||||
if (!src) return;
|
||||
$('cp-source').textContent = `${src.input.name} · ${src.input.type} · ${src.input.foundationType}`;
|
||||
for (const i of document.querySelectorAll<HTMLInputElement>('#copy-dialog input[data-item]')) {
|
||||
i.checked = (DEFAULT_COPY_ITEMS as string[]).includes(i.dataset.item ?? '');
|
||||
}
|
||||
copySelection = new Set(state.piers.filter((p) => p.checked && p !== src).map((p) => p.input.name));
|
||||
$('cp-source').textContent = src ? `${src.input.name} · ${src.input.type} · ${src.input.foundationType}` : '교각을 선택하세요';
|
||||
renderCopyItems();
|
||||
renderCopyTargets();
|
||||
renderCopyPreview();
|
||||
$<HTMLDialogElement>('copy-dialog').showModal();
|
||||
}
|
||||
|
||||
function applyCopyDialog(): void {
|
||||
function applyCopyPanel(): void {
|
||||
const src = activePier();
|
||||
if (!src) return;
|
||||
const items = chosenCopyItems();
|
||||
@@ -641,7 +762,6 @@ function applyCopyDialog(): void {
|
||||
const next = { ...t, draft: applyCopy(t.draft, plan) };
|
||||
return regen ? regenerateEntry(next) : next;
|
||||
});
|
||||
$<HTMLDialogElement>('copy-dialog').close();
|
||||
setupScene();
|
||||
renderAll();
|
||||
setStatus(`${applied}기에 속성을 복사했습니다. 변경 ${changes}건${regen ? ' (형상 다시 생성)' : ' · 재생성 필요'}.`);
|
||||
@@ -649,7 +769,7 @@ function applyCopyDialog(): void {
|
||||
|
||||
// --- input cards and change tracking (plan B1 · C2) -------------------------------------
|
||||
function pendingTotal(): number {
|
||||
return state.piers.filter((p) => p.checked).reduce((n, p) => n + pendingKeys(p).length, 0);
|
||||
return state.piers.reduce((n, p) => n + pendingKeys(p).length, 0);
|
||||
}
|
||||
|
||||
function fieldLabel(key: EditableKey): string {
|
||||
@@ -916,6 +1036,32 @@ function memberOfBar(name: string): RebarRow['member'] {
|
||||
return 'coping';
|
||||
}
|
||||
|
||||
/** Click on a pier in 전체 보기 makes it the active pier and returns to the single-pier view. */
|
||||
function wireAllPiersPicking(): void {
|
||||
const dom = renderer.domElement;
|
||||
let down: { x: number; y: number } | null = null;
|
||||
dom.addEventListener('pointerdown', (e) => {
|
||||
down = { x: e.clientX, y: e.clientY };
|
||||
});
|
||||
dom.addEventListener('pointerup', (e) => {
|
||||
if (!down || !allPiers) return;
|
||||
if (Math.hypot(e.clientX - down.x, e.clientY - down.y) > 5) return; // a drag rotates the view
|
||||
const rect = dom.getBoundingClientRect();
|
||||
const ndc = new THREE.Vector2(((e.clientX - rect.left) / rect.width) * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1);
|
||||
const ray = new THREE.Raycaster();
|
||||
ray.setFromCamera(ndc, camera);
|
||||
const hit = ray.intersectObjects(allPiers.piers.children, true).find((h) => h.object.visible);
|
||||
let node: THREE.Object3D | null = hit?.object ?? null;
|
||||
while (node && !node.userData.pier) node = node.parent;
|
||||
if (!node) return;
|
||||
state.active = node.userData.pier as string;
|
||||
state.allView = false;
|
||||
state.rebarSelection = new Set();
|
||||
setupScene();
|
||||
renderAll();
|
||||
});
|
||||
}
|
||||
|
||||
/** Click on a 3D bar selects its 철근표 row (on ③ only). */
|
||||
function wireRebarPicking(): void {
|
||||
const dom = renderer.domElement;
|
||||
@@ -940,7 +1086,11 @@ function wireRebarPicking(): void {
|
||||
}
|
||||
|
||||
function setStage(stage: Stage): void {
|
||||
// ① opens on 전체 보기 (default). The button is gone, so 전체 보기 returns each time ① is entered.
|
||||
if (stage === 'shape' && state.stage !== 'shape') state.allView = true;
|
||||
state.stage = stage;
|
||||
// Switching 전체 보기 on or off in ① needs the scene rebuilt for the new layout.
|
||||
if ((allPiers !== null) !== multiSceneOn()) setupScene();
|
||||
if (state.scene) setRebarView(state.scene, stage === 'rebar');
|
||||
$<HTMLElement>('view-shape').hidden = stage !== 'shape';
|
||||
$<HTMLElement>('view-plan').hidden = stage !== 'plan';
|
||||
@@ -1373,25 +1523,50 @@ function renderFootingSettings(box: HTMLDivElement, pier: Pier): void {
|
||||
applyFootingRebar(pier.input.name, next);
|
||||
});
|
||||
wrap.append(msg, apply);
|
||||
// 확대기초 단면 탭 (승인 2026-10-26, 추천안 2단계): 첫 탭 "교각축 직각 단면 (폭 W)"만 작동하고, 나머지 탭은 다음 단계 자리만 둡니다.
|
||||
// 확대기초 단면 탭 (승인 2026-10-26): 교각축 직각 단면 (폭 W) and 교각축 단면 (길이 L) work; the other tabs are next steps.
|
||||
const footingModel = pier.model;
|
||||
let footingView: 'W' | 'L' | 'D' | 'B10' = 'W';
|
||||
const tabs = el('div');
|
||||
tabs.style.display = 'flex';
|
||||
tabs.style.flexWrap = 'wrap';
|
||||
tabs.style.gap = '6px';
|
||||
for (const [label, enabled] of [['교각축 직각 단면 (폭 W)', true], ['교각축 단면 (길이 L)', false], ['다리 배치 상세', false], ['B10 모서리', false]] as const) {
|
||||
const b = el('button', label, 'btn small');
|
||||
b.type = 'button';
|
||||
b.setAttribute('aria-pressed', String(enabled));
|
||||
b.disabled = !enabled;
|
||||
if (!enabled) b.title = '다음 단계에서 추가';
|
||||
tabs.appendChild(b);
|
||||
}
|
||||
wrap.appendChild(tabs);
|
||||
// 교각축 직각 단면 (폭 W): a bubble click highlights that bar family; a second click clears it.
|
||||
const sectionFig = el('figure', undefined, 'ground-fig');
|
||||
sectionFig.innerHTML = footingSectionSvg(pier.model);
|
||||
sectionFig.appendChild(el('figcaption', '교각축 직각 단면 (폭 W) · 실제 비율 · 다리 생략', 'muted small'));
|
||||
wrap.appendChild(sectionFig);
|
||||
const figBox = el('div');
|
||||
const drawFootingFigure = (): void => {
|
||||
tabs.replaceChildren();
|
||||
for (const [view, label, enabled] of [['W', '교각축 직각 단면 (폭 W)', true], ['L', '교각축 단면 (길이 L)', true], ['D', '다리 배치 상세', true], ['B10', 'B10 모서리', true]] as const) {
|
||||
const b = el('button', label, 'btn small');
|
||||
b.type = 'button';
|
||||
b.setAttribute('aria-pressed', String(view === footingView));
|
||||
b.disabled = !enabled;
|
||||
if (!enabled) b.title = '다음 단계에서 추가';
|
||||
if (enabled) b.addEventListener('click', () => {
|
||||
footingView = view as 'W' | 'L' | 'D' | 'B10';
|
||||
drawFootingFigure();
|
||||
});
|
||||
tabs.appendChild(b);
|
||||
}
|
||||
figBox.replaceChildren();
|
||||
if (footingView === 'D') {
|
||||
// 다리 배치 상세 (승인 2026-10-27): leg shape, leg length and leg top per bar; splices where the bar exceeds the 12000 standard.
|
||||
const table = el('table', undefined, 'grid-table');
|
||||
for (const h of ['번호', '규격', '다리 형태', '다리 길이 (mm)', '다리 끝 높이 (하면 기준, mm)', '전체 길이 (mm)', '이음']) table.appendChild(el('th', h));
|
||||
for (const r of footingLegRows(footingModel)) {
|
||||
const tr = el('tr');
|
||||
tr.append(el('td', r.bar), el('td', r.size), el('td', r.shape), el('td', r.legLength === null ? '—' : r.legLength.toFixed(0)), el('td', r.legTop === null ? '—' : r.legTop.toFixed(0)), el('td', r.total.toFixed(0)), el('td', r.splice ? '겹침 필요 (12000 초과)' : '—'));
|
||||
table.appendChild(tr);
|
||||
}
|
||||
figBox.appendChild(table);
|
||||
figBox.appendChild(el('p', '다리 길이는 12d 기준 (Q-B5 확인 필요). B11·B12 갈고리는 l_h 246.', 'muted small'));
|
||||
return;
|
||||
}
|
||||
const sectionFig = el('figure', undefined, 'ground-fig');
|
||||
const captions = { W: '교각축 직각 단면 (폭 W) · 실제 비율 · 다리 생략', L: '교각축 단면 (길이 L) · 실제 비율 · 다리 생략', B10: 'B10 모서리 · 개략도 (단면 비율 아님)' } as const;
|
||||
sectionFig.innerHTML = footingView === 'W' ? footingSectionSvg(footingModel) : footingView === 'L' ? footingSectionLSvg(footingModel) : footingCornerSvg(footingModel);
|
||||
sectionFig.appendChild(el('figcaption', captions[footingView], 'muted small'));
|
||||
figBox.appendChild(sectionFig);
|
||||
};
|
||||
drawFootingFigure();
|
||||
wrap.append(tabs, figBox);
|
||||
box.appendChild(wrap);
|
||||
}
|
||||
|
||||
@@ -1438,9 +1613,6 @@ function renderRebar(): void {
|
||||
const pier = activePier();
|
||||
// 교각 목록 with the 배근 status (시안 ③). The checks are the same as in ① and ②.
|
||||
renderPierRows($<HTMLUListElement>('rebar-pier-items'), 'rebar');
|
||||
const checked = state.piers.filter((p) => p.checked).length;
|
||||
$('rebar-bulk-count').textContent = `선택 ${checked}기`;
|
||||
$<HTMLButtonElement>('rebar-bulk-copy').disabled = !pier;
|
||||
const tabs = $<HTMLUListElement>('rebar-tabs');
|
||||
tabs.replaceChildren();
|
||||
for (const m of REBAR_MEMBERS) {
|
||||
@@ -1599,11 +1771,10 @@ function renderQuantityLink(box: HTMLElement, rows: RebarRow[]): void {
|
||||
}
|
||||
|
||||
/** ④ 재료표: the 철근재료표 of each target pier, bars by 강종 → 직경 → 번호, with subtotals and the totals. */
|
||||
/** The piers the 재료표 shows: 전체, or the pier chosen in the list. */
|
||||
function quantityTargets(): Pier[] {
|
||||
const checked = state.piers.filter((p) => p.checked && p.model);
|
||||
if (checked.length) return checked;
|
||||
const active = activePier();
|
||||
return active?.model ? [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));
|
||||
}
|
||||
|
||||
/** Length cell of a 재료표 line: the length, or 여러 길이 with the pieces on hover. */
|
||||
@@ -1642,26 +1813,51 @@ function renderQuantityScreen(): void {
|
||||
const body = $<HTMLDivElement>('quantity-body');
|
||||
body.replaceChildren();
|
||||
renderPierRows($<HTMLUListElement>('quantity-pier-items'), 'quantity');
|
||||
const allButton = $<HTMLButtonElement>('quantity-all');
|
||||
const generated = state.piers.filter((p) => p.model).length;
|
||||
allButton.textContent = `전체 합계 (${generated}기)`;
|
||||
allButton.setAttribute('aria-pressed', String(state.quantityAll));
|
||||
const targets = quantityTargets();
|
||||
$('step4-status').textContent = targets.length ? `${targets.length}기` : '예정';
|
||||
$('step4-status').textContent = targets.length ? (state.quantityAll ? `${targets.length}기` : targets[0].input.name) : '예정';
|
||||
$('quantity-note').textContent = targets.length
|
||||
? `${targets.length}기 · 할증 ${state.material.waste}% (기본 0%, 가정). 삿갓 길이는 h − 100으로 가정(Q-K5). 체크한 교각 기준이고, 체크가 없으면 현재 교각입니다.`
|
||||
? `${state.quantityAll ? `전체 ${targets.length}기` : targets[0].input.name} · 할증 ${state.material.waste}% (기본 0%, 가정). 삿갓 길이는 h − 100으로 가정(Q-K5). 교각 목록에서 고른 교각만 보며, 전체를 누르면 모든 교각을 보여 줍니다.`
|
||||
: '계산된 교각이 없습니다. 교각을 생성하세요.';
|
||||
let kg = 0;
|
||||
let kgWithWaste = 0;
|
||||
for (const pier of targets) {
|
||||
if (!pier.model) continue;
|
||||
const q = buildQuantity(rebarSummary(pier.model, state.material, state.fckByMember).rows, state.material);
|
||||
kg += q.kg;
|
||||
kgWithWaste += q.kgWithWaste;
|
||||
const entries = targets.flatMap((pier) =>
|
||||
pier.model ? [{ pier, q: buildQuantity(rebarSummary(pier.model, state.material, state.fckByMember).rows, state.material) }] : [],
|
||||
);
|
||||
// 전체 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));
|
||||
for (const { pier, q } of entries) {
|
||||
body.appendChild(el('h3', `${pier.input.name} · ${pier.input.type}`));
|
||||
if (q.missing.length) body.appendChild(el('p', `길이 미산정 ${q.missing.length}개는 합계에서 뺐습니다: ${q.missing.join(', ')}`, 'muted small'));
|
||||
body.appendChild(quantityTable(q));
|
||||
body.appendChild(el('p', `합계 ${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'));
|
||||
}
|
||||
if (targets.length > 1) {
|
||||
body.appendChild(el('p', `전체 ${targets.length}기 합계 ${kg.toFixed(1)} kg · 할증 포함 ${kgWithWaste.toFixed(1)} kg (${(kgWithWaste / 1000).toFixed(3)} ton)`, 'lbl'));
|
||||
}
|
||||
|
||||
/** 교량 전체 집계: the 강종·직경 totals over every pier on ④ (전체). */
|
||||
function bridgeTotalBlock(total: QuantityTotal, pierCount: number): HTMLElement {
|
||||
const wrap = el('section', undefined, 'quantity-total');
|
||||
wrap.appendChild(el('h3', `교량 전체 집계 · ${pierCount}기`));
|
||||
const table = el('table', undefined, 'grid-table rebar-table');
|
||||
const head = el('thead');
|
||||
const hr = el('tr');
|
||||
for (const h of ['강종', '직경', '개수', '총길이 (m)', '총중량 (kg)']) hr.appendChild(el('th', h));
|
||||
head.appendChild(hr);
|
||||
table.appendChild(head);
|
||||
const body = el('tbody');
|
||||
for (const r of total.rows) {
|
||||
body.appendChild(el('tr')).append(el('td', `SD${r.grade}`), el('td', r.size), el('td', String(r.count)), el('td', r.totalM.toFixed(1)), el('td', r.kg.toFixed(1)));
|
||||
}
|
||||
for (const g of total.gradeKg) {
|
||||
body.appendChild(el('tr', undefined, 'subtotal')).append(el('td', `SD${g.grade} 소계`), el('td', ''), el('td', ''), el('td', ''), el('td', g.kg.toFixed(1)));
|
||||
}
|
||||
table.appendChild(body);
|
||||
wrap.appendChild(table);
|
||||
const couplers = total.couplers.map((c) => `${c.name} ${c.count}개`).join(', ') || '없음';
|
||||
const missing = total.missingCount ? ` · 길이 미산정 ${total.missingCount}개 제외` : '';
|
||||
wrap.appendChild(el('p', `합계 ${total.kg.toFixed(1)} kg · 할증 포함 ${total.kgWithWaste.toFixed(1)} kg (${total.ton.toFixed(3)} ton) · 커플러 ${couplers}${missing}`, 'pnote'));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/** Excel: one sheet per pier and a 합계 sheet (승인 2026-10-23). */
|
||||
@@ -1681,6 +1877,10 @@ function exportQuantityWorkbook(): void {
|
||||
XLSX.writeFile(wb, `${state.project.bridge || '교량'}_철근재료표.xlsx`);
|
||||
}
|
||||
|
||||
$<HTMLButtonElement>('quantity-all').addEventListener('click', () => {
|
||||
state.quantityAll = true;
|
||||
renderAll();
|
||||
});
|
||||
$<HTMLButtonElement>('quantity-export').addEventListener('click', exportQuantityWorkbook);
|
||||
$<HTMLButtonElement>('quantity-print').addEventListener('click', () => window.print());
|
||||
|
||||
@@ -1741,7 +1941,8 @@ function renderMaterial(): void {
|
||||
function setMemberFck(member: FckMember, raw: string): void {
|
||||
const next = { ...state.fckByMember };
|
||||
if (raw.trim() === '') {
|
||||
delete next[member];
|
||||
// 되돌리기: back to the imported default, not to the 공통 value.
|
||||
next[member] = IMPORTED_FCK[member];
|
||||
} else {
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
@@ -1759,8 +1960,9 @@ function setMemberFck(member: FckMember, raw: string): void {
|
||||
/** fck row on a ② card for a member that has its own fck: value, the 공통 value beside it, and a revert when set. */
|
||||
function renderMemberFck(box: HTMLDivElement, member: FckMember): void {
|
||||
const own = state.fckByMember[member];
|
||||
// Not the orange "changed" style: fck changes only the rebar, so it is not a pending shape change (변경 n건).
|
||||
const row = el('div', undefined, 'edit-row');
|
||||
// Orange = differs from the imported default. fck changes only the rebar, so it is not counted in 변경 n건.
|
||||
const changed = fckChanged(state.fckByMember, member);
|
||||
const row = el('div', undefined, changed ? 'edit-row changed' : 'edit-row');
|
||||
const id = `member-fck-${member}`;
|
||||
const label = el('label', 'fck (MPa)');
|
||||
label.htmlFor = id;
|
||||
@@ -1770,9 +1972,8 @@ function renderMemberFck(box: HTMLDivElement, member: FckMember): void {
|
||||
input.step = 'any';
|
||||
input.value = String(own ?? state.material.fck);
|
||||
input.addEventListener('change', () => setMemberFck(member, input.value));
|
||||
const note = own === undefined ? `공통 ${state.material.fck}` : `공통 ${state.material.fck} · 배근에만 반영`;
|
||||
row.append(label, input, el('span', note, 'muted small'));
|
||||
if (own !== undefined) {
|
||||
row.append(label, input, el('span', `기본 ${IMPORTED_FCK[member]} · 배근에만 반영`, 'muted small'));
|
||||
if (changed) {
|
||||
const revert = el('button', '되돌리기', 'btn small');
|
||||
revert.type = 'button';
|
||||
revert.addEventListener('click', () => setMemberFck(member, ''));
|
||||
@@ -2119,16 +2320,6 @@ function setStrataRows(name: string, rows: StrataRow[]): void {
|
||||
renderAll();
|
||||
}
|
||||
|
||||
/** 일괄 적용: the source's 위치 and 지하수위 go to the checked piers, which are then regenerated. */
|
||||
function applyGroundPositionTo(source: Pier, targets: Pier[]): void {
|
||||
for (const target of targets) {
|
||||
target.draft = applyCopy(target.draft, planCopy(source.draft, target.draft, ['ground-position']));
|
||||
}
|
||||
regenerateAllPiers();
|
||||
renderAll();
|
||||
setStatus(`${targets.length}기에 위치와 지하수위를 적용했습니다.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* ② 지반·지층 card (시안 2026-10-26; 승인 2026-10-26: 지반고는 계산에 쓰고, 지층은 행으로 입력하며, 지하수위는 직접 입력).
|
||||
* The ground model import and the 지반고 apply stay at the bottom, as before.
|
||||
@@ -2285,15 +2476,6 @@ function renderGroundModel(box: HTMLDivElement, pier: Pier): void {
|
||||
wrap.appendChild(fig);
|
||||
}
|
||||
|
||||
// 일괄 적용: copies the 위치 and 지하수위 to the other checked piers (속성 복사 item 'ground-position').
|
||||
const others = state.piers.filter((q) => q.checked && q.input.name !== name);
|
||||
const batch = el('button', `${name} 외 일괄 대상 ${others.length}기에 적용`, 'btn small');
|
||||
batch.type = 'button';
|
||||
batch.title = '위치와 지하수위 설정을 체크한 다른 교각에 적용합니다.';
|
||||
batch.disabled = others.length === 0;
|
||||
batch.addEventListener('click', () => applyGroundPositionTo(pier, others));
|
||||
wrap.appendChild(batch);
|
||||
|
||||
// ② 지반 모델 (승인 2026-10-23): the pier-centre ground EL from ground_model.json. Applied only when the user presses the button.
|
||||
|
||||
wrap.appendChild(el('p', '지반 모델 (지반 모델 결과 · ground_model.json)', 'lbl'));
|
||||
@@ -2736,7 +2918,7 @@ function renderDialog(): void {
|
||||
$('file-name').textContent = p ? `선택된 파일: ${p.fileName}` : '';
|
||||
if (!p) {
|
||||
sheetsBox.appendChild(el('li', '파일을 선택하면 시트별 인식 결과가 표시됩니다.'));
|
||||
renderPreview(null);
|
||||
renderPreview([]);
|
||||
updateApply();
|
||||
return;
|
||||
}
|
||||
@@ -2767,38 +2949,36 @@ function renderDialog(): void {
|
||||
entry.checked = check.checked;
|
||||
updateApply();
|
||||
});
|
||||
const name = el('button', `${entry.input.name} · ${entry.input.type}`, 'pier-name');
|
||||
name.type = 'button';
|
||||
name.setAttribute('aria-pressed', String(p.preview === entry.input.name));
|
||||
name.addEventListener('click', () => {
|
||||
p.preview = entry.input.name;
|
||||
renderDialog();
|
||||
});
|
||||
const name = el('span', `${entry.input.name} · ${entry.input.type}`, 'pier-name');
|
||||
row.append(check, name);
|
||||
li.appendChild(row);
|
||||
}
|
||||
sheetsBox.appendChild(li);
|
||||
}
|
||||
const preview = p.entries.find((e) => e.input.name === p.preview) ?? p.entries.find((e) => selectable(p, e)) ?? null;
|
||||
renderPreview(preview ? preview.input : null);
|
||||
renderPreview(p ? p.entries.map((e) => ({ input: e.input, checked: e.checked })) : []);
|
||||
updateApply();
|
||||
}
|
||||
|
||||
function renderPreview(input: PierInput | null): void {
|
||||
/** Preview of every pier in the file: one column per pier for the 교각 정보, all 받침·IP rows with a 교각 column. */
|
||||
function renderPreview(items: { input: PierInput; checked: boolean }[]): void {
|
||||
const title = $('preview-title');
|
||||
const pierTable = $<HTMLTableElement>('pv-pier').tBodies[0];
|
||||
const pierTable = $<HTMLTableElement>('pv-pier');
|
||||
const bearingBody = $<HTMLTableElement>('pv-bearings').tBodies[0];
|
||||
const ipBody = $<HTMLTableElement>('pv-ips').tBodies[0];
|
||||
pierTable.replaceChildren();
|
||||
bearingBody.replaceChildren();
|
||||
ipBody.replaceChildren();
|
||||
if (!input) {
|
||||
if (items.length === 0) {
|
||||
title.textContent = '미리볼 교각이 없습니다.';
|
||||
return;
|
||||
}
|
||||
title.textContent = `${input.name} · 시트 ${input.sheet}`;
|
||||
const pairs: Pair[] = [
|
||||
['교각명', input.name],
|
||||
const sheets = [...new Set(items.map((i) => i.input.sheet))].join(', ');
|
||||
title.textContent = `${items.length}기 · 시트 ${sheets}`;
|
||||
|
||||
const head = pierTable.createTHead().insertRow();
|
||||
head.appendChild(el('th', '항목'));
|
||||
for (const { input } of items) head.appendChild(el('th', input.name));
|
||||
const rowsOf = (input: PierInput): Pair[] => [
|
||||
['교각형식', input.type],
|
||||
['두부보 EL (코핑 상면)', f3(input.copingTopEl)],
|
||||
['기초 하단 EL', f3(input.footingBottomEl)],
|
||||
@@ -2807,22 +2987,31 @@ function renderPreview(input: PierInput | null): void {
|
||||
['말뚝길이 (mm)', String(input.pileLengthMm)],
|
||||
['두부보 폭 × 길이 (mm)', `${input.copingWidthMm} × ${input.copingLengthMm}`],
|
||||
['기둥갯수', String(input.columnCount)],
|
||||
['받침 · IP', `${input.bearings.length} · ${input.ips.length}`],
|
||||
];
|
||||
for (const [k, v] of pairs) {
|
||||
const tr = el('tr');
|
||||
tr.append(el('th', k), el('td', v));
|
||||
pierTable.appendChild(tr);
|
||||
}
|
||||
for (const b of input.bearings) {
|
||||
const tr = el('tr');
|
||||
const azimuth = Number.isNaN(b.azimuthDeg) ? '오류' : `${b.azimuthDeg.toFixed(4)}°`;
|
||||
for (const v of [String(b.no), b.x.toFixed(3), b.y.toFixed(3), f3(b.z), azimuth, b.row]) tr.appendChild(el('td', v));
|
||||
bearingBody.appendChild(tr);
|
||||
}
|
||||
for (const ip of input.ips) {
|
||||
const tr = el('tr');
|
||||
for (const v of [ip.name, ip.x.toFixed(3), ip.y.toFixed(3), `${ip.angleDeg}°`]) tr.appendChild(el('td', v));
|
||||
ipBody.appendChild(tr);
|
||||
const labels = rowsOf(items[0].input).map(([k]) => k);
|
||||
const body = pierTable.createTBody();
|
||||
labels.forEach((label, r) => {
|
||||
const tr = body.insertRow();
|
||||
tr.appendChild(el('th', label));
|
||||
for (const { input } of items) tr.appendChild(el('td', rowsOf(input)[r][1]));
|
||||
});
|
||||
const included = body.insertRow();
|
||||
included.appendChild(el('th', '불러오기'));
|
||||
for (const { checked } of items) included.appendChild(el('td', checked ? '예' : '아니오'));
|
||||
|
||||
for (const { input } of items) {
|
||||
for (const b of input.bearings) {
|
||||
const tr = el('tr');
|
||||
const azimuth = Number.isNaN(b.azimuthDeg) ? '오류' : `${b.azimuthDeg.toFixed(4)}°`;
|
||||
for (const v of [input.name, String(b.no), b.x.toFixed(3), b.y.toFixed(3), f3(b.z), azimuth, b.row]) tr.appendChild(el('td', v));
|
||||
bearingBody.appendChild(tr);
|
||||
}
|
||||
for (const ip of input.ips) {
|
||||
const tr = el('tr');
|
||||
for (const v of [input.name, ip.name, ip.x.toFixed(3), ip.y.toFixed(3), `${ip.angleDeg}°`]) tr.appendChild(el('td', v));
|
||||
ipBody.appendChild(tr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2846,9 +3035,8 @@ function loadBytes(bytes: Uint8Array, fileName: string): void {
|
||||
for (const e of own) sheet.issues.push(...e.issues.filter((i) => i.level === 'E'));
|
||||
sheet.ok = sheet.ok && own.every((e) => e.model !== null);
|
||||
}
|
||||
const pending: Pending = { fileName, entries, sheets, preview: null };
|
||||
const pending: Pending = { fileName, entries, sheets };
|
||||
for (const e of entries) e.checked = selectable(pending, e);
|
||||
pending.preview = entries.find((e) => selectable(pending, e))?.input.name ?? entries[0]?.input.name ?? null;
|
||||
state.pending = pending;
|
||||
renderDialog();
|
||||
}
|
||||
@@ -2884,19 +3072,23 @@ async function fetchSample(): Promise<Uint8Array> {
|
||||
return new Uint8Array(await res.arrayBuffer());
|
||||
}
|
||||
|
||||
function regenerateChecked(): void {
|
||||
/** Piers that need a (re)build: never generated, or with changes not yet in the 3D model. */
|
||||
function generationTargets(): Pier[] {
|
||||
return state.piers.filter((p) => !p.model || pendingKeys(p).length > 0);
|
||||
}
|
||||
|
||||
function regenerateChanged(): void {
|
||||
const names = new Set(generationTargets().map((p) => p.input.name));
|
||||
let errors = 0;
|
||||
let count = 0;
|
||||
state.piers = state.piers.map((p) => {
|
||||
if (!p.checked) return p;
|
||||
count++;
|
||||
if (!names.has(p.input.name)) return p;
|
||||
const next = regenerateEntry(p);
|
||||
if (!next.model) errors++;
|
||||
return next;
|
||||
});
|
||||
setupScene();
|
||||
renderAll();
|
||||
setStatus(`${count}기 형상을 생성했습니다.${errors ? ` 오류 ${errors}기는 생성되지 않았습니다.` : ''}`);
|
||||
setStatus(`${names.size}기 형상을 생성했습니다.${errors ? ` 오류 ${errors}기는 생성되지 않았습니다.` : ''}`);
|
||||
}
|
||||
|
||||
// --- events -----------------------------------------------------------------------------
|
||||
@@ -2904,10 +3096,7 @@ $<HTMLButtonElement>('open-import').addEventListener('click', openImport);
|
||||
$<HTMLButtonElement>('dlg-close').addEventListener('click', () => $<HTMLDialogElement>('import-dialog').close());
|
||||
$<HTMLButtonElement>('dlg-cancel').addEventListener('click', () => $<HTMLDialogElement>('import-dialog').close());
|
||||
$<HTMLButtonElement>('dlg-apply').addEventListener('click', applyImport);
|
||||
$<HTMLButtonElement>('regen').addEventListener('click', regenerateChecked);
|
||||
$<HTMLButtonElement>('bulk-regen').addEventListener('click', regenerateChecked);
|
||||
$<HTMLButtonElement>('bulk-copy').addEventListener('click', openCopyDialog);
|
||||
$<HTMLButtonElement>('rebar-bulk-copy').addEventListener('click', openCopyDialog);
|
||||
$<HTMLButtonElement>('regen').addEventListener('click', regenerateChanged);
|
||||
$<HTMLButtonElement>('nav-shape').addEventListener('click', () => setStage('shape'));
|
||||
$<HTMLButtonElement>('nav-plan').addEventListener('click', () => setStage('plan'));
|
||||
$<HTMLButtonElement>('nav-rebar').addEventListener('click', () => setStage('rebar'));
|
||||
@@ -2924,17 +3113,19 @@ $<HTMLButtonElement>('member-regen').addEventListener('click', () => {
|
||||
const pier = activePier();
|
||||
if (pier) regenerateOne(pier.input.name);
|
||||
});
|
||||
$<HTMLInputElement>('cp-same-type').addEventListener('change', () => {
|
||||
$<HTMLButtonElement>('cp-items-reset').addEventListener('click', () => {
|
||||
const src = activePier();
|
||||
copyItemChoice = new Set(src ? copyItemsWithOverrides(src) : []);
|
||||
renderCopyItems();
|
||||
renderCopyPreview();
|
||||
});
|
||||
$<HTMLButtonElement>('cp-clear').addEventListener('click', () => {
|
||||
copySelection = new Set();
|
||||
renderCopyTargets();
|
||||
renderCopyPreview();
|
||||
});
|
||||
for (const i of document.querySelectorAll<HTMLInputElement>('#copy-dialog input[data-item]')) {
|
||||
i.addEventListener('change', renderCopyPreview);
|
||||
}
|
||||
$<HTMLInputElement>('cp-regen').addEventListener('change', renderCopyPreview);
|
||||
$<HTMLButtonElement>('cp-apply').addEventListener('click', applyCopyDialog);
|
||||
$<HTMLButtonElement>('cp-cancel').addEventListener('click', () => $<HTMLDialogElement>('copy-dialog').close());
|
||||
$<HTMLButtonElement>('cp-close').addEventListener('click', () => $<HTMLDialogElement>('copy-dialog').close());
|
||||
$<HTMLButtonElement>('cp-apply').addEventListener('click', applyCopyPanel);
|
||||
$<HTMLButtonElement>('sv-save').addEventListener('click', confirmSaveDialog);
|
||||
$<HTMLButtonElement>('sv-cancel').addEventListener('click', () => $<HTMLDialogElement>('save-dialog').close());
|
||||
$<HTMLButtonElement>('sv-close').addEventListener('click', () => $<HTMLDialogElement>('save-dialog').close());
|
||||
@@ -2942,6 +3133,7 @@ $<HTMLButtonElement>('edit-regen').addEventListener('click', () => {
|
||||
const pier = activePier();
|
||||
if (pier) regenerateOne(pier.input.name);
|
||||
});
|
||||
|
||||
$<HTMLInputElement>('project-name').addEventListener('input', updateApply);
|
||||
|
||||
$<HTMLButtonElement>('pick-file').addEventListener('click', () => $<HTMLInputElement>('file').click());
|
||||
@@ -2998,10 +3190,6 @@ $<HTMLSelectElement>('type-filter').addEventListener('change', (ev) => {
|
||||
state.filter = (ev.target as HTMLSelectElement).value as 'all' | PierType;
|
||||
renderPierList();
|
||||
});
|
||||
$<HTMLButtonElement>('select-all').addEventListener('click', () => {
|
||||
state.piers = setVisibleChecked(state.piers, state.filter, !allVisibleChecked(state.piers, state.filter));
|
||||
renderAll();
|
||||
});
|
||||
|
||||
$<HTMLButtonElement>('save-project').addEventListener('click', saveProject);
|
||||
$<HTMLButtonElement>('open-project').addEventListener('click', () => $<HTMLInputElement>('project-file').click());
|
||||
@@ -3039,6 +3227,7 @@ $<HTMLButtonElement>('discard-backup').addEventListener('click', () => {
|
||||
// --- start ------------------------------------------------------------------------------
|
||||
captureViewerHome();
|
||||
wireRebarPicking();
|
||||
wireAllPiersPicking();
|
||||
buildPartToggles();
|
||||
buildViewButtons();
|
||||
renderDefaults();
|
||||
@@ -3092,6 +3281,12 @@ function restorePanel(id: string): void {
|
||||
restorePanel('parts-info');
|
||||
restorePanel('color-info');
|
||||
|
||||
$<HTMLButtonElement>('all-toggle').addEventListener('click', () => {
|
||||
state.allView = !state.allView;
|
||||
setupScene();
|
||||
renderAll();
|
||||
});
|
||||
|
||||
$<HTMLButtonElement>('views-toggle').addEventListener('click', () => setMenuOpen('views', $<HTMLDivElement>('views').hidden === true));
|
||||
$<HTMLButtonElement>('origin-toggle').addEventListener('click', () => setMenuOpen('origin', $<HTMLSpanElement>('origin').hidden === true));
|
||||
document.addEventListener('click', (ev) => {
|
||||
|
||||
+31
-3
@@ -629,10 +629,10 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
|
||||
.piers { display: flex; flex-direction: column; gap: 6px; }
|
||||
.pier-tools { display: flex; align-items: center; gap: 8px; padding: 0 4px 8px; }
|
||||
.pier-tools select { border: 1px solid #c3cbd4; border-radius: 6px; background: #ffffff; min-height: 36px; }
|
||||
.phead { display: grid; grid-template-columns: 36px 1fr auto; gap: 8px; padding: 0 10px 6px; font-size: 12px; color: #55606e; border-bottom: 1px solid #d5dbe2; }
|
||||
.pier-items li { display: grid; grid-template-columns: 36px 1fr auto; align-items: center; gap: 8px; border-radius: 6px; padding-right: 6px; }
|
||||
.phead { display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 0 10px 6px; font-size: 12px; color: #55606e; border-bottom: 1px solid #d5dbe2; }
|
||||
.pier-items li { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 8px; border-radius: 6px; padding-right: 6px; }
|
||||
.pier-items li:has(.pier-name[aria-pressed='true']) { background: #e3edf8; }
|
||||
.pier-name { border: 0; background: transparent; color: #18212b; text-align: left; font-size: 13px; min-height: 44px; padding: 0 6px; }
|
||||
.pier-name { width: 100%; border: 0; background: transparent; color: #18212b; text-align: left; font-size: 13px; min-height: 44px; padding: 0 6px; }
|
||||
.pier-name[aria-pressed='true'] { color: #123e70; font-weight: 700; }
|
||||
.badge { font-size: 12px; border-radius: 10px; padding: 2px 8px; background: #e2f2e7; color: #1d6b3a; white-space: nowrap; }
|
||||
.badge.error { background: #fde2e2; color: #9b2c2c; }
|
||||
@@ -810,3 +810,31 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
|
||||
@media (max-width: 800px) {
|
||||
.footing-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* 입력값: one row per pier; the table scrolls sideways in the narrow left panel. */
|
||||
.pier-table-wrap { overflow-x: auto; margin-bottom: 8px; }
|
||||
#inputs-all { font-size: 12px; width: 100%; }
|
||||
#inputs-all th, #inputs-all td { white-space: nowrap; padding: 4px 6px; }
|
||||
#inputs-all tbody tr { cursor: pointer; }
|
||||
#inputs-all tbody tr.active td { background: #e3edf8; font-weight: 700; }
|
||||
|
||||
/* Import preview: all piers' 받침·IP rows scroll inside the dialog. */
|
||||
.pv-scroll { max-height: 240px; overflow: auto; }
|
||||
|
||||
/* 속성 복사: one line per target in the change summary. */
|
||||
.copy-panel .cp-line { margin: 2px 0; font-size: 12px; }
|
||||
|
||||
/* 속성 복사: an inline panel at the bottom of the 부재별 편집창 (was a modal dialog). */
|
||||
.copy-panel { margin-top: 12px; border-top: 1px solid #d5dde6; padding-top: 10px; }
|
||||
.copy-panel .copy-head h3 { margin: 0 0 4px; font-size: 13px; }
|
||||
.copy-panel .dlg-body { display: block; }
|
||||
.copy-panel .dlg-col + .dlg-col { margin-top: 10px; }
|
||||
.copy-panel .grid-table { font-size: 12px; display: block; overflow-x: auto; }
|
||||
.copy-panel .copy-foot { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; margin-top: 10px; }
|
||||
.copy-panel .cp-tools { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-bottom: 6px; }
|
||||
.copy-panel .cp-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 4px; }
|
||||
.copy-panel .cp-chips .chk { border: 1px solid #d5dde6; border-radius: 6px; padding: 4px 8px; background: #fff; }
|
||||
.copy-panel .cp-chips .chk:has(input:checked) { border-color: #1f5fa8; background: #e3edf8; }
|
||||
.copy-panel .cp-chips .chk:has(input:disabled) { color: #8a94a3; background: #f4f6f9; }
|
||||
|
||||
.quantity-total { margin-bottom: 16px; }
|
||||
@@ -0,0 +1,53 @@
|
||||
import * as THREE from 'three';
|
||||
import type { PierModel, Vec2 } from '../core/geometry';
|
||||
import type { TerrainGrid } from '../core/ground-model';
|
||||
import { buildPierScene, type PierScene } from './scene';
|
||||
import { terrainGeometry } from './terrain';
|
||||
|
||||
/**
|
||||
* Scene offset of a pier's group inside the shared frame of the reference pier. Each pier's scene is centred on its own
|
||||
* pivot, so moving it by the pivot difference puts it at its national-grid place (x = E, z = −N, as in stretchMatrix).
|
||||
*/
|
||||
export function pierOffset(pivot: Vec2, reference: Vec2): { x: number; z: number } {
|
||||
return { x: pivot.e - reference.e, z: reference.n - pivot.n };
|
||||
}
|
||||
|
||||
export interface AllPiers {
|
||||
/** Holds every pier's group, placed by pierOffset. Used for the camera fit and picking. */
|
||||
piers: THREE.Group;
|
||||
/** Shared terrain grid in the reference frame, or null without a terrain model. */
|
||||
terrain: THREE.Mesh | null;
|
||||
/** Scene of each pier, keyed by the pier name. Parts are shown per pier. */
|
||||
scenes: Map<string, PierScene>;
|
||||
/** Scene with the same shape the single-pier view expects (root = pier group, size = whole span). */
|
||||
view: PierScene;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the concrete members of several piers in one frame (전체 보기, 콘크리트만: no rebar). The first entry is the
|
||||
* reference. With a terrain grid it is drawn once and the per-pier ground lines are hidden.
|
||||
*/
|
||||
export function buildAllPiers(entries: { name: string; model: PierModel }[], terrain?: TerrainGrid): AllPiers {
|
||||
const piers = new THREE.Group();
|
||||
const scenes = new Map<string, PierScene>();
|
||||
const reference = entries[0].model.pivot;
|
||||
for (const { name, model } of entries) {
|
||||
const scene = buildPierScene(model);
|
||||
// Without terrain each pier keeps its own 지반고 line; with terrain the shared grid replaces them.
|
||||
scene.parts.ground.visible = terrain === undefined;
|
||||
const { x, z } = pierOffset(model.pivot, reference);
|
||||
scene.root.position.set(x, 0, z);
|
||||
scene.root.userData.pier = name;
|
||||
piers.add(scene.root);
|
||||
scenes.set(name, scene);
|
||||
}
|
||||
let terrainMesh: THREE.Mesh | null = null;
|
||||
if (terrain) {
|
||||
const material = new THREE.MeshStandardMaterial({ color: 0xb8c7b0, roughness: 0.9, metalness: 0, transparent: true, opacity: 0.35, side: THREE.DoubleSide });
|
||||
terrainMesh = new THREE.Mesh(terrainGeometry(terrain, reference), material);
|
||||
}
|
||||
const box = new THREE.Box3().setFromObject(piers);
|
||||
const size = box.getSize(new THREE.Vector3()).length();
|
||||
const view: PierScene = { root: piers, parts: scenes.get(entries[0].name)!.parts, size };
|
||||
return { piers, terrain: terrainMesh, scenes, view };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { PierModel } from '../core/geometry';
|
||||
import { NOMINAL_DIAMETER, hookLength } from '../core/rebar/common';
|
||||
import { b10Levels, layers, sideDepth } from '../core/rebar/footing';
|
||||
import { DEFAULT_FOOTING_REBAR } from '../core/rebar/footing-settings';
|
||||
|
||||
/**
|
||||
* 확대기초 · B10 모서리 (plan corner, 확대기초철근 (1).md §7, 승인 2026-10-27). Schematic of the corner where the B10-2 bar (L face)
|
||||
* bends 90° into the B10-1 bar (W face), with the depths and the 19.1 offset from the table. Lowest level removed (B2·B4 take its place).
|
||||
*/
|
||||
const COLOR = { outline: '#55606E', b10: '#2E7D32', b14: '#B4531F', dim: '#B42318' };
|
||||
|
||||
export function footingCornerSvg(model: PierModel): string {
|
||||
const foot = model.footings[0];
|
||||
const fs = model.footingRebar ?? DEFAULT_FOOTING_REBAR;
|
||||
if (!foot) return '<svg viewBox="0 0 520 300" width="520" height="300"><text x="20" y="150" font-size="12">확대기초 없음</text></svg>';
|
||||
const H = (foot.top - foot.bottom) * 1000;
|
||||
const z = layers(H, fs);
|
||||
const levels = b10Levels(H, fs);
|
||||
const depth2 = sideDepth(NOMINAL_DIAMETER.S25, NOMINAL_DIAMETER.S19, fs);
|
||||
const depth1 = sideDepth(NOMINAL_DIAMETER.S29, NOMINAL_DIAMETER.S19, fs);
|
||||
const hook = hookLength('main90', NOMINAL_DIAMETER.S19);
|
||||
const k = 1 / 6;
|
||||
const ox = 120;
|
||||
const oy = 230;
|
||||
const cornerX = ox;
|
||||
const cornerY = oy;
|
||||
const parts: string[] = [];
|
||||
const text = (xx: number, yy: number, s: string, anchor = 'start', fill = COLOR.outline) =>
|
||||
`<text x="${xx.toFixed(1)}" y="${yy.toFixed(1)}" font-size="10" font-family="JetBrains Mono, monospace" fill="${fill}" stroke="#fff" stroke-width="3" paint-order="stroke" text-anchor="${anchor}">${s}</text>`;
|
||||
// Plan corner: the L face (horizontal) and the W face (vertical) of the footing.
|
||||
parts.push(`<line x1="${cornerX}" y1="${cornerY}" x2="${cornerX + 300}" y2="${cornerY}" stroke="${COLOR.outline}" stroke-width="1.6"/>`);
|
||||
parts.push(`<line x1="${cornerX}" y1="${cornerY}" x2="${cornerX}" y2="${cornerY - 200}" stroke="${COLOR.outline}" stroke-width="1.6"/>`);
|
||||
// B10-2 (L face) at depth2 from the L face, bending 90° at the corner into the B10-1 (W face) at depth1 from the W face.
|
||||
const xL = cornerX + depth2 * k;
|
||||
const yW = cornerY - depth1 * k;
|
||||
parts.push(`<polyline points="${(cornerX + hook * k).toFixed(1)},${(cornerY - depth2 * k).toFixed(1)} ${xL.toFixed(1)},${(cornerY - depth2 * k).toFixed(1)} ${xL.toFixed(1)},${(cornerY - depth2 * k).toFixed(1)} ${(xL + 1).toFixed(1)},${cornerY.toFixed(1)}" fill="none" stroke="${COLOR.b10}" stroke-width="2.2"/>`);
|
||||
parts.push(`<polyline points="${(cornerX + depth1 * k).toFixed(1)},${(cornerY - hook * k).toFixed(1)} ${(cornerX + depth1 * k).toFixed(1)},${yW.toFixed(1)} ${(cornerX + depth1 * k).toFixed(1)},${yW.toFixed(1)} ${cornerX.toFixed(1)},${(yW - 1).toFixed(1)}" fill="none" stroke="${COLOR.b10}" stroke-width="2.2" stroke-dasharray="4 2"/>`);
|
||||
// B2·B4 at the 하면 1단 (replace the lowest B10 level): the orange bars along the corner.
|
||||
parts.push(`<circle cx="${(cornerX + depth1 * k).toFixed(1)}" cy="${(cornerY - depth2 * k).toFixed(1)}" r="3" fill="${COLOR.b14}"/>`);
|
||||
parts.push(text(cornerX + 6, cornerY + 14, `B10-2 ${depth2.toFixed(2)} (L면)`, 'start', COLOR.b10));
|
||||
parts.push(text(cornerX + 6, cornerY - 210, `B10-1 ${depth1.toFixed(2)} (W면)`, 'start', COLOR.b10));
|
||||
parts.push(text(cornerX + 120, cornerY + 14, `모서리 갈고리 l_h ${hook.toFixed(0)}`, 'start', COLOR.dim));
|
||||
parts.push(text(cornerX + 120, cornerY + 28, `B10-1 = B10-2 + 19.1 (높이)`, 'start', COLOR.dim));
|
||||
parts.push(text(cornerX + 120, cornerY + 42, `최하단 B10 제외 · 하면 1단 B2·B4가 대체 (z ${z.B24.toFixed(0)})`, 'start', COLOR.outline));
|
||||
parts.push(text(cornerX + 120, cornerY + 56, `단 수 ${levels - 1} (B10 9단 중 최하단 제외)`, 'start', COLOR.outline));
|
||||
return `<svg viewBox="0 0 520 300" width="520" height="300" role="img" aria-label="확대기초 B10 모서리">${parts.join('')}</svg>`;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { PierModel } from '../core/geometry';
|
||||
import { NOMINAL_DIAMETER } from '../core/rebar/common';
|
||||
import { assemblyGridIndices, assemblyJs, b10Levels, b9Bars, grid, gridPositions, layers, legLength, sideDepth } from '../core/rebar/footing';
|
||||
import { DEFAULT_FOOTING_REBAR } from '../core/rebar/footing-settings';
|
||||
|
||||
/**
|
||||
* 확대기초 · 교각축 단면 (길이 L) (승인 2026-10-27, 2단계 탭). A cut across the footing length: horizontal = L, vertical = height from the 하면.
|
||||
* Bars that run along W cross this plane as points; bars that run along L lie in it as lines (방향 교환 후 배치).
|
||||
* - B1–B4 (along W, spaced along L): points at the L grid (odd → B1·B2·B3, even → B4)
|
||||
* - B5–B8 (along L): one line per family from cover to cover
|
||||
* - B9: vertical lines at the L grid columns
|
||||
* - B10-2 (on the L faces): points per level (lowest removed); B10-1 (on the W faces, along L): lines per level (lowest removed)
|
||||
* - B11 (along W, spaced along L): points on the L grid per assembly level; B12 (along L, spaced along W): lines per assembly level
|
||||
* Every mark comes from the same functions as the rebar table and the 3D view.
|
||||
*/
|
||||
const COLOR = { outline: '#55606E', b5: '#0F5C66', b14: '#B4531F', b9: '#6B3FA0', b10: '#2E7D32', b11: '#55606E', b12: '#55606E', dim: '#B42318', column: '#B08D57', pile: '#8898aa' };
|
||||
const L_PX = 380;
|
||||
const X0 = 60;
|
||||
const Y0 = 230;
|
||||
|
||||
export function footingSectionLSvg(model: PierModel): string {
|
||||
const foot = model.footings[0];
|
||||
if (!foot) return '<svg viewBox="0 0 600 430" width="600" height="430" role="img" aria-label="확대기초 없음"><text x="20" y="180" font-size="12">확대기초 없음</text></svg>';
|
||||
const fs = model.footingRebar ?? DEFAULT_FOOTING_REBAR;
|
||||
const L = foot.length * 1000;
|
||||
const H = (foot.top - foot.bottom) * 1000;
|
||||
const k = L_PX / L;
|
||||
const cx = X0 + L_PX / 2;
|
||||
const x = (mm: number) => cx + mm * k;
|
||||
const y = (z: number) => Y0 - z * k;
|
||||
const z = layers(H, fs);
|
||||
const parts: string[] = [];
|
||||
const line = (cls: string, bar: string, x1: number, x2: number, yy: number, color: string, width = 1.4, dash = '') =>
|
||||
`<line class="${cls}" data-bar="${bar}" x1="${x1.toFixed(1)}" x2="${x2.toFixed(1)}" y1="${yy.toFixed(1)}" y2="${yy.toFixed(1)}" stroke="${color}" stroke-width="${width}"${dash ? ` stroke-dasharray="${dash}"` : ''}/>`;
|
||||
const dot = (cls: string, bar: string, xx: number, yy: number, color: string, r = 2.6) =>
|
||||
`<circle class="${cls}" data-bar="${bar}" cx="${xx.toFixed(1)}" cy="${yy.toFixed(1)}" r="${r}" fill="${color}"/>`;
|
||||
const vline = (cls: string, bar: string, xx: number, y1: number, y2: number, color: string) =>
|
||||
`<line class="${cls}" data-bar="${bar}" x1="${xx.toFixed(1)}" x2="${xx.toFixed(1)}" y1="${y1.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="${color}" stroke-width="1"/>`;
|
||||
const text = (xx: number, yy: number, s: string, anchor = 'start', fill = COLOR.outline) =>
|
||||
`<text x="${xx.toFixed(1)}" y="${yy.toFixed(1)}" font-size="10" font-family="JetBrains Mono, monospace" fill="${fill}" stroke="#fff" stroke-width="3" paint-order="stroke" text-anchor="${anchor}">${s}</text>`;
|
||||
const tick = (xx: number, yy: number) => `<line x1="${(xx - 3).toFixed(1)}" x2="${(xx + 3).toFixed(1)}" y1="${(yy + 3).toFixed(1)}" y2="${(yy - 3).toFixed(1)}" stroke="${COLOR.dim}" stroke-width="0.8"/>`;
|
||||
|
||||
// Context: the column (centred, above the footing) and the piles (below, one stub per distinct L position).
|
||||
const colD = model.columnDiameterMm;
|
||||
parts.push(`<rect class="column" x="${x(-colD / 2).toFixed(1)}" y="${y(H) - 36}" width="${(colD * k).toFixed(1)}" height="36" fill="#F2E8D5" stroke="${COLOR.column}" stroke-width="0.8"/>`);
|
||||
const piles = model.piles.filter((p) => Math.abs(p.s - foot.s) <= foot.length / 2 + 1e-6);
|
||||
for (const s of [...new Set(piles.map((p) => Math.round((p.s - foot.s) * 1000)))]) {
|
||||
const d = (piles.find((p) => Math.round((p.s - foot.s) * 1000) === s)?.diameter ?? 0.6) * 1000;
|
||||
parts.push(`<rect class="pile" x="${(x(s) - d * k / 2).toFixed(1)}" y="${Y0}" width="${Math.max(3, d * k).toFixed(1)}" height="26" fill="${COLOR.pile}" stroke="#3b4656" stroke-width="0.6"/>`);
|
||||
}
|
||||
|
||||
// Footing outline and the 하면 line.
|
||||
parts.push(`<rect x="${x(-L / 2).toFixed(1)}" y="${y(H).toFixed(1)}" width="${(L * k).toFixed(1)}" height="${(H * k).toFixed(1)}" fill="none" stroke="${COLOR.outline}" stroke-width="1.2"/>`);
|
||||
parts.push(line('base', '', x(-L / 2), x(L / 2), y(0), COLOR.outline, 1));
|
||||
|
||||
// Cover lines on the L faces (측면 피복 c_s).
|
||||
const xa = x(-L / 2 + fs.cSide);
|
||||
const xb = x(L / 2 - fs.cSide);
|
||||
|
||||
// B5–B8 (along L, 주철근): one line per family from cover to cover, with the legs at both ends
|
||||
// (B5 ⊓ down, B6 and B7 ⊔ up to the B5 height / up, B8 straight; 확대기초철근 (1).md §6).
|
||||
const legW = 1.6;
|
||||
parts.push(line('b5', 'B5', xa, xb, y(z.B5), COLOR.b5, 2));
|
||||
parts.push(line('b6', 'B6', xa, xb, y(z.B68), COLOR.b5, 2));
|
||||
parts.push(line('b7', 'B7', xa, xb, y(z.B7), COLOR.b5, 2));
|
||||
parts.push(line('b8', 'B8', xa, xb, y(z.B68), COLOR.b5, 2));
|
||||
const leg = (name: string, xx: number, y1: number, y2: number) =>
|
||||
`<line class="mainleg" data-bar="${name}" x1="${xx.toFixed(1)}" x2="${xx.toFixed(1)}" y1="${y1.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="${COLOR.b5}" stroke-width="${legW}"/>`;
|
||||
for (const xx of [xa, xb]) {
|
||||
parts.push(leg('B5', xx, y(z.B5), y(z.B5 - legLength('S29'))));
|
||||
parts.push(leg('B6', xx, y(z.B68), y(z.B5)));
|
||||
parts.push(leg('B7', xx, y(z.B7), y(z.B7 + legLength('S19'))));
|
||||
}
|
||||
|
||||
// B1–B4 (along W, spaced along L): points on the L grid. Odd positions carry B1, B2, B3; even carry B4 (as bottomBars).
|
||||
const xsL = gridPositions(L, fs, 'sL');
|
||||
xsL.forEach((mm, i) => {
|
||||
const xx = x(mm);
|
||||
if (i % 2 === 1) {
|
||||
parts.push(dot('b1', 'B1', xx, y(z.B1), COLOR.b14));
|
||||
parts.push(dot('b2', 'B2', xx, y(z.B24), COLOR.b14));
|
||||
parts.push(dot('b3', 'B3', xx, y(z.B3), COLOR.b14));
|
||||
} else {
|
||||
parts.push(dot('b4', 'B4', xx, y(z.B24), COLOR.b14));
|
||||
}
|
||||
});
|
||||
|
||||
// B9: one vertical line per L column of the B9 grid.
|
||||
const b9 = b9Bars(L, foot.width * 1000, [], 2000, fs);
|
||||
for (const xm of [...new Set(b9.bars.map((b) => b.x))]) parts.push(vline('b9', 'B9', x(xm), y(z.B24), y(z.B5), COLOR.b9));
|
||||
|
||||
// B10-2 (on the L faces, points) and B10-1 (on the W faces, lines along L), per level above the lowest.
|
||||
const levels = b10Levels(H, fs);
|
||||
const dB10 = NOMINAL_DIAMETER.S19;
|
||||
const depth2 = sideDepth(NOMINAL_DIAMETER.S25, NOMINAL_DIAMETER.S19, fs);
|
||||
for (let j = 1; j < levels; j++) {
|
||||
const zj = fs.cBottom + j * fs.sV;
|
||||
parts.push(dot('b10', 'B10-2', x(-L / 2 + depth2), y(zj), COLOR.b10, 2.2));
|
||||
parts.push(dot('b10', 'B10-2', x(L / 2 - depth2), y(zj), COLOR.b10, 2.2));
|
||||
parts.push(line('b10', 'B10-1', x(-L / 2 + depth2), x(L / 2 - depth2), y(zj + dB10), COLOR.b10, 1.2, '2 2'));
|
||||
}
|
||||
|
||||
// B11 (along W, spaced along L): points on the L grid, per assembly level. B12 (along L, spaced along W): lines per level.
|
||||
const js = assemblyJs(H, fs);
|
||||
const nL = grid(L, fs, 'sL').positions - 1;
|
||||
js.forEach((j, idx) => {
|
||||
const zA = fs.cBottom + j * fs.sV;
|
||||
for (const i of assemblyGridIndices(nL, fs.kB11, idx)) parts.push(dot('b11', 'B11', x(xsL[i]), y(zA), COLOR.b11, 2.2));
|
||||
parts.push(line('b12', 'B12', xa, xb, y(zA + dB10), COLOR.b12, 1.2, '6 3'));
|
||||
});
|
||||
|
||||
// Dimensions: the length line with ticks and the grid string, and the right-hand heights.
|
||||
const g = grid(L, fs, 'sL');
|
||||
const yDim = Y0 + 40;
|
||||
parts.push(`<line x1="${x(-L / 2).toFixed(1)}" x2="${x(L / 2).toFixed(1)}" y1="${yDim}" y2="${yDim}" stroke="${COLOR.dim}" stroke-width="0.8"/>`);
|
||||
parts.push(tick(x(-L / 2), yDim) + tick(x(L / 2), yDim));
|
||||
parts.push(text(x(-L / 2) - 5, yDim + 14, `${fs.cSide}`, 'end', COLOR.dim));
|
||||
parts.push(text(x(L / 2) + 5, yDim + 14, `${fs.cSide}`, 'start', COLOR.dim));
|
||||
parts.push(text(x(-L / 2 + fs.cSide) + 2, yDim + 14, `${g.e.toFixed(1)}`, 'start', COLOR.dim));
|
||||
parts.push(text(x(L / 2 - fs.cSide) - 2, yDim + 14, `${g.e.toFixed(1)}`, 'end', COLOR.dim));
|
||||
parts.push(text(cx + 150, yDim - 6, `${g.n}@${fs.sL}=${g.G}`, 'middle', COLOR.dim));
|
||||
parts.push(text(cx, 20, `교각축 단면 · 길이 ${L.toFixed(0)}`, 'middle', '#1F2937'));
|
||||
const xr = X0 + L_PX + 18;
|
||||
parts.push(`<line x1="${xr}" x2="${xr}" y1="${y(H).toFixed(1)}" y2="${y(0).toFixed(1)}" stroke="${COLOR.dim}" stroke-width="0.8"/>`);
|
||||
parts.push(tick(xr, y(H)) + tick(xr, y(0)));
|
||||
parts.push(text(xr + 6, y(H) + 12, `${fs.cTop}`, 'start', COLOR.dim));
|
||||
parts.push(text(xr + 6, (y(H) + y(0)) / 2, `${levels - 1}@${fs.sV}=${(levels - 1) * fs.sV}`, 'start', COLOR.dim));
|
||||
parts.push(text(xr + 6, y(0) - 4, `${fs.cBottom}`, 'start', COLOR.dim));
|
||||
|
||||
// Bubbles outside the outline with a leader to a point of their family.
|
||||
const bubble = (name: string, bx: number, by: number, tx: number, ty: number, dotColor: string) => {
|
||||
const edgeY = by < ty ? by + 11 : by - 11;
|
||||
return `<g class="bubble" data-bubble="${name}"><line class="leader" data-bar="${name}" x1="${bx.toFixed(1)}" x2="${tx.toFixed(1)}" y1="${edgeY.toFixed(1)}" y2="${ty.toFixed(1)}" stroke="${COLOR.outline}" stroke-width="0.7"/><circle class="leader-point" data-bar="${name}" cx="${tx.toFixed(1)}" cy="${ty.toFixed(1)}" r="2.6" fill="${dotColor}"/><circle cx="${bx.toFixed(1)}" cy="${by.toFixed(1)}" r="11" fill="#fff" stroke="${COLOR.outline}"/><text x="${bx.toFixed(1)}" y="${(by + 3).toFixed(1)}" font-size="9" text-anchor="middle" fill="${COLOR.outline}">${name}</text></g>`;
|
||||
};
|
||||
const yTop = 40;
|
||||
const zB11 = js.length > 0 ? fs.cBottom + js[0] * fs.sV : fs.cBottom;
|
||||
const zB12 = zB11 + dB10;
|
||||
parts.push(bubble('B5', xa + 12, yTop, xa + 12, y(z.B5), COLOR.b5));
|
||||
parts.push(bubble('B12', xb - 80, yTop, xb, y(zB12), COLOR.b12));
|
||||
parts.push(bubble('B11', xb - 12, yTop + 0, x(xsL[xsL.length - 2]), y(zB11), COLOR.b11));
|
||||
parts.push(bubble('B10-2', X0 - 18, y(fs.cBottom + (levels - 1) * fs.sV / 2), x(-L / 2 + depth2), y(fs.cBottom + (levels - 1) * fs.sV / 2), COLOR.b10));
|
||||
|
||||
// Legend and units note.
|
||||
const yLeg = 352;
|
||||
parts.push(`<line x1="20" x2="44" y1="${yLeg}" y2="${yLeg}" stroke="${COLOR.b5}" stroke-width="1.6"/>${text(50, yLeg + 3, '주철근 (B5–B8)', 'start', COLOR.outline)}`);
|
||||
parts.push(`<circle cx="180" cy="${yLeg}" r="2.6" fill="${COLOR.b14}"/>${text(188, yLeg + 3, '직각·보조 (B1–B4)', 'start', COLOR.outline)}`);
|
||||
parts.push(`<line x1="330" x2="330" y1="${yLeg - 8}" y2="${yLeg + 8}" stroke="${COLOR.b9}" stroke-width="1"/>${text(338, yLeg + 3, '전단·띠 (B9)', 'start', COLOR.outline)}`);
|
||||
parts.push(`<circle cx="20" cy="${yLeg + 22}" r="2.2" fill="${COLOR.b10}"/>${text(28, yLeg + 25, '측면 수평 (B10-1 · B10-2)', 'start', COLOR.outline)}`);
|
||||
parts.push(`<line x1="250" x2="274" y1="${yLeg + 22}" y2="${yLeg + 22}" stroke="${COLOR.b11}" stroke-width="1.2" stroke-dasharray="6 3"/>${text(280, yLeg + 25, '조립 (B11·B12)', 'start', COLOR.outline)}`);
|
||||
parts.push(text(20, yLeg + 48, '치수 mm · 높이 방향은 실제 비율 · 다리 생략 · 기준: 확대기초 하면 0', 'start', COLOR.outline));
|
||||
|
||||
return `<svg viewBox="0 0 600 430" width="600" height="430" role="img" aria-label="확대기초 교각축 단면 (길이 L)"><g transform="translate(0 20)">${parts.join('')}</g></svg>`;
|
||||
}
|
||||
+10
-6
@@ -20,6 +20,9 @@ export const PART_LABELS: Record<PartName, string> = {
|
||||
ground: '지반',
|
||||
};
|
||||
|
||||
/** Ground pad drawn without a terrain grid (m): a square of side = coping length + margin, thin. */
|
||||
const GROUND_PAD = { margin: 2, thickness: 0.05 } as const;
|
||||
|
||||
const COLORS: Record<PartName, number> = {
|
||||
coping: 0xb8c2cc,
|
||||
columns: 0x9aa7b4,
|
||||
@@ -173,17 +176,18 @@ export function buildPierScene(m: PierModel, terrain?: TerrainGrid): PierScene {
|
||||
}
|
||||
}
|
||||
|
||||
// Ground: the terrain grid around the piers when the ground model has one (수 m 떨어진 지형까지 보임),
|
||||
// otherwise a translucent plane at the ground EL, used only to judge soil cover (not a computed input).
|
||||
// Ground: the terrain grid around the piers when the ground model has one (수 m 떨어진 지형까지 보임).
|
||||
// Without a terrain, a thin square pad at this pier's 지반고, side = coping length + 2 m (승인 2026-10-27).
|
||||
// It is a local estimate, not a terrain surface: the pads are not joined to each other.
|
||||
if (terrain) {
|
||||
const material = new THREE.MeshStandardMaterial({ color: COLORS.ground, roughness: 0.9, metalness: 0, transparent: true, opacity: 0.35, side: THREE.DoubleSide });
|
||||
material.userData.baseOpacity = 0.35;
|
||||
parts.ground.add(new THREE.Mesh(terrainGeometry(terrain, m.pivot), material));
|
||||
} else {
|
||||
const ground = new THREE.PlaneGeometry(60, 60);
|
||||
ground.rotateX(-Math.PI / 2);
|
||||
ground.translate(m.copingL / 2, m.groundEl, 0);
|
||||
add('ground', ground, 0.35);
|
||||
const side = m.copingL + GROUND_PAD.margin;
|
||||
const pad = new THREE.BoxGeometry(side, GROUND_PAD.thickness, side);
|
||||
pad.translate(m.copingL / 2, m.groundEl, 0);
|
||||
add('ground', pad, 0.35);
|
||||
}
|
||||
|
||||
const root = new THREE.Group();
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as THREE from 'three';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildPier, type PierModel } from '../src/core/geometry';
|
||||
import { buildAllPiers, pierOffset } from '../src/view/all-piers';
|
||||
import { loadP1 } from './helpers';
|
||||
|
||||
const model = buildPier(loadP1());
|
||||
|
||||
describe('pierOffset', () => {
|
||||
it('is zero for the reference pier', () => {
|
||||
expect(pierOffset(model.pivot, model.pivot)).toEqual({ x: 0, z: 0 });
|
||||
});
|
||||
|
||||
it('moves east by the E difference and north by the N difference (z = −N)', () => {
|
||||
const ref = { e: 259865, n: 179138 };
|
||||
const other = { e: 260117, n: 179177 };
|
||||
expect(pierOffset(other, ref)).toEqual({ x: 252, z: -39 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAllPiers', () => {
|
||||
it('places a pier at the same world box whatever pivot it is built around', () => {
|
||||
// The same pier with its pivot shifted: the geometry shifts by the opposite amount, so the world box must not change.
|
||||
const shifted: PierModel = { ...model, pivot: { e: model.pivot.e + 50, n: model.pivot.n - 20 } };
|
||||
const all = buildAllPiers([{ name: 'P1', model }, { name: 'P2', model: shifted }]);
|
||||
all.piers.updateMatrixWorld(true);
|
||||
const a = new THREE.Box3().setFromObject(all.scenes.get('P1')!.root);
|
||||
const b = new THREE.Box3().setFromObject(all.scenes.get('P2')!.root);
|
||||
// Float32 geometry: allow 0.1 mm.
|
||||
expect(b.min.distanceTo(a.min)).toBeLessThan(1e-4);
|
||||
expect(b.max.distanceTo(a.max)).toBeLessThan(1e-4);
|
||||
});
|
||||
|
||||
it('keeps each pier 지반고 line without terrain, and hides it when the shared terrain is drawn', () => {
|
||||
const without = buildAllPiers([{ name: 'P1', model }]);
|
||||
expect([...without.scenes.keys()]).toEqual(['P1']);
|
||||
expect(without.scenes.get('P1')!.parts.ground.visible).toBe(true);
|
||||
expect(without.terrain).toBeNull();
|
||||
expect(without.view.size).toBeGreaterThan(0);
|
||||
|
||||
const withTerrain = buildAllPiers([{ name: 'P1', model }], { origin: [259815.676, 179088.094], step: 5, nx: 2, ny: 2, z: [30, 31, 32, 33] });
|
||||
expect(withTerrain.scenes.get('P1')!.parts.ground.visible).toBe(false);
|
||||
expect(withTerrain.terrain).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { copyCandidates, copyItemsFor, copyItemsWithOverrides, hasCopyItems, selectedTargets, type CopyCandidate } from '../src/core/copy-targets';
|
||||
import { loadP1 } from './helpers';
|
||||
|
||||
const piers: CopyCandidate[] = [
|
||||
{ name: 'P1', type: 'T형', hasModel: true },
|
||||
{ name: 'P2', type: 'T형', hasModel: true },
|
||||
{ name: 'P3', type: 'T형', hasModel: true },
|
||||
{ name: 'P4', type: 'Y형', hasModel: true },
|
||||
{ name: 'P5', type: 'T형', hasModel: false },
|
||||
];
|
||||
|
||||
describe('copyCandidates (대상 후보)', () => {
|
||||
it('excludes the source pier', () => {
|
||||
expect(copyCandidates(piers, 'P3').map((c) => c.name)).not.toContain('P3');
|
||||
});
|
||||
|
||||
it('offers piers of every type, so a different type is visible', () => {
|
||||
expect(copyCandidates(piers, 'P3').map((c) => c.type)).toEqual(['T형', 'T형', 'Y형', 'T형']);
|
||||
});
|
||||
|
||||
it('marks a pier without a model as not selectable', () => {
|
||||
const p5 = copyCandidates(piers, 'P3').find((c) => c.name === 'P5');
|
||||
expect(p5?.selectable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectedTargets (선택한 대상만)', () => {
|
||||
it('is empty when nothing is ticked', () => {
|
||||
expect(selectedTargets(piers, 'P3', new Set())).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns only ticked, selectable piers', () => {
|
||||
const picked = new Set(['P1', 'P4', 'P5']);
|
||||
expect(selectedTargets(piers, 'P3', picked)).toEqual(['P1', 'P4']);
|
||||
});
|
||||
|
||||
it('keeps a tick on a pier of another type', () => {
|
||||
expect(selectedTargets(piers, 'P3', new Set(['P4']))).toEqual(['P4']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('copyItemsFor (부재 선택에 해당하는 복사 항목)', () => {
|
||||
it('coping copies only the coping items', () => {
|
||||
expect(copyItemsFor('coping')).toEqual(['coping-width', 'coping-length', 'coping-h1', 'coping-h2', 'level-width']);
|
||||
});
|
||||
|
||||
it('확대기초 · 말뚝 copies only the pile items', () => {
|
||||
expect(copyItemsFor('footing')).toEqual(['pile-length', 'pile-rows']);
|
||||
});
|
||||
|
||||
it('지반 copies only the ground position', () => {
|
||||
expect(copyItemsFor('ground')).toEqual(['ground-position']);
|
||||
});
|
||||
|
||||
it('기둥 and 받침 have no copy items yet', () => {
|
||||
expect(copyItemsFor('column')).toEqual([]);
|
||||
expect(copyItemsFor('bearing')).toEqual([]);
|
||||
});
|
||||
|
||||
it('every copy item belongs to exactly one member', () => {
|
||||
const all = (['bearing', 'coping', 'column', 'footing', 'ground'] as const).flatMap((m) => copyItemsFor(m));
|
||||
expect([...all].sort()).toEqual(['coping-h1', 'coping-h2', 'coping-length', 'coping-width', 'ground-position', 'level-width', 'pile-length', 'pile-rows']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('copyItemsWithOverrides (기준 교각에서 되돌리기가 표시된 항목)', () => {
|
||||
const base = loadP1();
|
||||
const source = (draft: Partial<typeof base>) => ({ defaults: base, input: base, draft: { ...base, ...draft } });
|
||||
|
||||
it('is empty when the 기준 pier has no changed values', () => {
|
||||
expect(copyItemsWithOverrides(source({}))).toEqual([]);
|
||||
});
|
||||
|
||||
it('selects the item of a changed coping length', () => {
|
||||
expect(copyItemsWithOverrides(source({ copingLengthMm: base.copingLengthMm + 500 }))).toEqual(['coping-length']);
|
||||
});
|
||||
|
||||
it('selects the pile item when the pile length changed', () => {
|
||||
expect(copyItemsWithOverrides(source({ pileLengthMm: base.pileLengthMm + 100 }))).toEqual(['pile-length']);
|
||||
});
|
||||
|
||||
it('selects the coping h1, h2 and Level 폭 items when those values changed', () => {
|
||||
expect(copyItemsWithOverrides(source({ copingH1Mm: (base.copingH1Mm ?? 0) + 1, levelWidthMm: (base.levelWidthMm ?? 0) + 1 }))).toEqual(['coping-h1', 'level-width']);
|
||||
});
|
||||
|
||||
it('ignores a changed value that no copy item carries (groundEl)', () => {
|
||||
expect(copyItemsWithOverrides(source({ groundEl: base.groundEl + 0.5 }))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasCopyItems (속성 복사 패널을 보일지)', () => {
|
||||
it('shows the panel for coping, 확대기초·말뚝 and 지반', () => {
|
||||
expect(hasCopyItems('coping')).toBe(true);
|
||||
expect(hasCopyItems('footing')).toBe(true);
|
||||
expect(hasCopyItems('ground')).toBe(true);
|
||||
});
|
||||
|
||||
it('hides the panel for 교량받침 and 기둥 (no copy items yet)', () => {
|
||||
expect(hasCopyItems('bearing')).toBe(false);
|
||||
expect(hasCopyItems('column')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildPier } from '../src/core/geometry';
|
||||
import { footingLegRows } from '../src/core/rebar/footing-legs';
|
||||
import { footingCornerSvg } from '../src/view/footing-corner-svg';
|
||||
import { loadP1 } from './helpers';
|
||||
|
||||
describe('확대기초 다리 배치 상세 · B10 모서리 (승인 2026-10-27)', () => {
|
||||
const m = buildPier(loadP1());
|
||||
const rows = footingLegRows(m);
|
||||
const byBar = (name: string) => rows.find((r) => r.bar === name)!;
|
||||
|
||||
it('lists the 12 bar families of the footing', () => {
|
||||
expect(rows.map((r) => r.bar)).toEqual(['B1', 'B2', 'B3', 'B4', 'B5', 'B6', 'B7', 'B8', 'B10-1', 'B10-2', 'B11', 'B12']);
|
||||
});
|
||||
|
||||
it('B2 leg goes up to the B1 height (1723); straight bars have no leg', () => {
|
||||
expect(byBar('B2').legLength).toBeCloseTo(1723, 0);
|
||||
expect(byBar('B4').legLength).toBeNull();
|
||||
expect(byBar('B8').legLength).toBeNull();
|
||||
});
|
||||
|
||||
it('B6 (13646) needs a splice, B1 (under 12000) does not', () => {
|
||||
expect(byBar('B6').splice).toBe(true);
|
||||
expect(byBar('B1').splice).toBe(false);
|
||||
});
|
||||
|
||||
it('B10 corner shows the depths, the 296 hook, and the lowest level removed', () => {
|
||||
const svg = footingCornerSvg(m);
|
||||
expect(svg).toContain('B10-2 122.25');
|
||||
expect(svg).toContain('B10-1 123.85');
|
||||
expect(svg).toContain('l_h 296');
|
||||
expect(svg).toContain('단 수 8');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildPier } from '../src/core/geometry';
|
||||
import { assemblyBars, assemblyJs, b9Bars, bottomBars, crossBars, sideBars } from '../src/core/rebar/footing';
|
||||
import { DEFAULT_FOOTING_REBAR } from '../src/core/rebar/footing-settings';
|
||||
import { footingSectionLSvg } from '../src/view/footing-section-l-svg';
|
||||
import { loadP1 } from './helpers';
|
||||
|
||||
const countMark = (svg: string, cls: string, bar: string) => svg.split(`class="${cls}" data-bar="${bar}"`).length - 1;
|
||||
|
||||
describe('확대기초 교각축 단면 (길이 L) (승인 2026-10-27)', () => {
|
||||
const m = buildPier(loadP1());
|
||||
const foot = m.footings[0];
|
||||
const L = foot.length * 1000;
|
||||
const W = foot.width * 1000;
|
||||
const H = (foot.top - foot.bottom) * 1000;
|
||||
const fs = DEFAULT_FOOTING_REBAR;
|
||||
const svg = footingSectionLSvg(m);
|
||||
|
||||
it('point and line counts match the rebar table families', () => {
|
||||
const bottom = bottomBars(L, W, H, fs);
|
||||
expect(countMark(svg, 'b1', 'B1')).toBe(bottom.find((b) => b.name === 'B1')!.count);
|
||||
expect(countMark(svg, 'b2', 'B2')).toBe(bottom.find((b) => b.name === 'B2')!.count);
|
||||
expect(countMark(svg, 'b4', 'B4')).toBe(bottom.find((b) => b.name === 'B4')!.count);
|
||||
expect(countMark(svg, 'b10', 'B10-2')).toBe(sideBars(L, W, H, fs).find((b) => b.name === 'B10-2')!.count);
|
||||
expect(countMark(svg, 'b10', 'B10-1')).toBe(sideBars(L, W, H, fs).find((b) => b.name === 'B10-1')!.count / 2);
|
||||
expect(countMark(svg, 'b11', 'B11')).toBe(assemblyBars(L, W, H, fs).find((b) => b.name === 'B11')!.count);
|
||||
expect(countMark(svg, 'b12', 'B12')).toBe(assemblyJs(H, fs).length);
|
||||
expect(svg.split('class="b9"').length - 1).toBe(new Set(b9Bars(L, W, [], 2000, fs).bars.map((b) => b.x)).size);
|
||||
expect(crossBars(L, W, H, fs).length).toBe(4);
|
||||
});
|
||||
|
||||
it('주철근 B5–B8: 선 4개와 다리 6개 (B5 양끝 아래, B6·B7 양끝 위, B8 직선)', () => {
|
||||
expect(svg.split('class="mainleg"').length - 1).toBe(6);
|
||||
});
|
||||
|
||||
it('shows the length, the grid string, and the B10 levels as dimensions', () => {
|
||||
expect(svg).toContain('길이 10400');
|
||||
expect(svg).toContain('8@200=1600');
|
||||
});
|
||||
|
||||
it('말뚝은 길이 방향 5개 위치 (모델 s 1.5–10.5 m)로 모두 그려짐', () => {
|
||||
expect(svg.split('class="pile"').length - 1).toBe(new Set(m.piles.map((p) => p.s)).size);
|
||||
expect(new Set(m.piles.map((p) => p.s)).size).toBe(5);
|
||||
});
|
||||
|
||||
it('has bubbles for B5, B11, B12 and B10-2', () => {
|
||||
for (const name of ['B5', 'B11', 'B12', 'B10-2']) expect(svg).toContain(`data-bubble="${name}"`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as THREE from 'three';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildPier } from '../src/core/geometry';
|
||||
import type { TerrainGrid } from '../src/core/ground-model';
|
||||
import { buildPierScene } from '../src/view/scene';
|
||||
import { loadP1 } from './helpers';
|
||||
|
||||
const model = buildPier(loadP1());
|
||||
|
||||
const terrain: TerrainGrid = { origin: [259815.676, 179088.094], step: 5, nx: 2, ny: 2, z: [30, 31, 32, 33] };
|
||||
|
||||
describe('지반 표시 (지형 모델이 없을 때)', () => {
|
||||
it('draws a thin square pad at the pier 지반고, side = coping length + 2 m', () => {
|
||||
const scene = buildPierScene(model);
|
||||
const mesh = scene.parts.ground.children[0] as THREE.Mesh;
|
||||
mesh.updateMatrixWorld(true);
|
||||
const pos = mesh.geometry.getAttribute('position');
|
||||
const pts: THREE.Vector3[] = [];
|
||||
for (let i = 0; i < pos.count; i++) pts.push(new THREE.Vector3().fromBufferAttribute(pos, i).applyMatrix4(mesh.matrixWorld));
|
||||
const top = Math.max(...pts.map((p) => p.y));
|
||||
const corners = pts.filter((p) => Math.abs(p.y - top) < 1e-6);
|
||||
// Four corners of the top face, with a distinct set of positions.
|
||||
const unique = corners.filter((p, i) => corners.findIndex((q) => q.distanceTo(p) < 1e-6) === i);
|
||||
expect(unique).toHaveLength(4);
|
||||
const side = model.copingL + 2;
|
||||
const edges = unique.flatMap((a, i) => unique.slice(i + 1).map((b) => a.distanceTo(b))).sort((a, b) => a - b);
|
||||
// Four sides of length `side`, two diagonals of length side·√2.
|
||||
for (const e of edges.slice(0, 4)) expect(e).toBeCloseTo(side, 3);
|
||||
for (const e of edges.slice(4)) expect(e).toBeCloseTo(side * Math.SQRT2, 3);
|
||||
// Thin: a pad, not a solid block. Centred on the ground EL.
|
||||
const box = new THREE.Box3().setFromObject(scene.parts.ground);
|
||||
expect(box.getSize(new THREE.Vector3()).y).toBeLessThan(0.1);
|
||||
expect(box.getCenter(new THREE.Vector3()).y).toBeCloseTo(model.groundEl, 2);
|
||||
});
|
||||
|
||||
it('keeps the ground group out of the pier size', () => {
|
||||
const scene = buildPierScene(model);
|
||||
expect(scene.size).toBeLessThan(60);
|
||||
});
|
||||
|
||||
it('does not draw the line when the terrain grid is present (the terrain replaces it)', () => {
|
||||
const scene = buildPierScene(model, terrain);
|
||||
expect(scene.parts.ground.children.length).toBe(1);
|
||||
const mesh = scene.parts.ground.children[0] as THREE.Mesh;
|
||||
expect(mesh.geometry.type).toBe('BufferGeometry');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { fckChanged, IMPORTED_FCK } from '../src/core/rebar/common';
|
||||
|
||||
describe('IMPORTED_FCK (교각도 기준 부재별 기본 fck)', () => {
|
||||
it('is 40 for 기둥·코핑 and 27 for 확대기초', () => {
|
||||
expect(IMPORTED_FCK).toEqual({ column: 40, coping: 40, footing: 27 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('fckChanged (기본과 다르면 주황색)', () => {
|
||||
it('is false at the imported default', () => {
|
||||
expect(fckChanged({ column: 40, coping: 40, footing: 27 }, 'coping')).toBe(false);
|
||||
});
|
||||
|
||||
it('is true when the member differs from its imported default', () => {
|
||||
expect(fckChanged({ coping: 42 }, 'coping')).toBe(true);
|
||||
expect(fckChanged({ footing: 30 }, 'footing')).toBe(true);
|
||||
});
|
||||
|
||||
it('is false when the member has no value yet', () => {
|
||||
expect(fckChanged({}, 'column')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { quantityTargetNames } from '../src/core/quantity-scope';
|
||||
|
||||
const piers = [
|
||||
{ name: 'P1', hasModel: true },
|
||||
{ name: 'P2', hasModel: true },
|
||||
{ name: 'P3', hasModel: false },
|
||||
];
|
||||
|
||||
describe('quantityTargetNames (④ 재료표 범위)', () => {
|
||||
it('is every pier with a model when 전체 is on', () => {
|
||||
expect(quantityTargetNames(piers, true, 'P2')).toEqual(['P1', 'P2']);
|
||||
});
|
||||
|
||||
it('is only the chosen pier when 전체 is off', () => {
|
||||
expect(quantityTargetNames(piers, false, 'P2')).toEqual(['P2']);
|
||||
});
|
||||
|
||||
it('is empty when the chosen pier has no model', () => {
|
||||
expect(quantityTargetNames(piers, false, 'P3')).toEqual([]);
|
||||
});
|
||||
|
||||
it('is empty when no pier is chosen and 전체 is off', () => {
|
||||
expect(quantityTargetNames(piers, false, null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { aggregateQuantities } from '../src/core/quantity-total';
|
||||
import type { Quantity } from '../src/core/rebar/quantity';
|
||||
|
||||
/** A 재료표 with one 강종·직경 line; only the fields the aggregate reads are filled. */
|
||||
function quantity(grade: number, size: string, count: number, totalM: number, kg: number, extra: Partial<Quantity> = {}): Quantity {
|
||||
return {
|
||||
groups: [{ grade, kg, diameters: [{ size, dMm: 19, lines: [{ count } as never], totalM, kg }] }],
|
||||
couplers: [],
|
||||
missing: [],
|
||||
kg,
|
||||
wastePercent: 0,
|
||||
kgWithWaste: kg,
|
||||
ton: kg / 1000,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
describe('aggregateQuantities (교량 전체 집계)', () => {
|
||||
it('sums the same 강종·직경 over the piers', () => {
|
||||
const total = aggregateQuantities([quantity(400, 'H19', 10, 100, 225), quantity(400, 'H19', 5, 50, 112.5)]);
|
||||
expect(total.rows).toEqual([{ grade: 400, size: 'H19', count: 15, totalM: 150, kg: 337.5 }]);
|
||||
expect(total.kg).toBe(337.5);
|
||||
});
|
||||
|
||||
it('keeps different 강종 and 직경 apart and orders them by 강종 then 직경', () => {
|
||||
const total = aggregateQuantities([quantity(500, 'S19', 4, 40, 90), quantity(400, 'H25', 2, 20, 50), quantity(400, 'H19', 1, 10, 22.5)]);
|
||||
expect(total.rows.map((r) => `${r.grade}|${r.size}`)).toEqual(['400|H19', '400|H25', '500|S19']);
|
||||
expect(total.gradeKg).toEqual([{ grade: 400, kg: 72.5 }, { grade: 500, kg: 90 }]);
|
||||
});
|
||||
|
||||
it('adds the 할증 total and the couplers across the piers', () => {
|
||||
const total = aggregateQuantities([
|
||||
{ ...quantity(400, 'H19', 1, 1, 100, { kgWithWaste: 110, couplers: [{ name: '커플러 H19', count: 3 }] }) },
|
||||
{ ...quantity(400, 'H19', 1, 1, 200, { kgWithWaste: 220, couplers: [{ name: '커플러 H19', count: 2 }] }) },
|
||||
]);
|
||||
expect(total.kgWithWaste).toBe(330);
|
||||
expect(total.ton).toBeCloseTo(0.33, 6);
|
||||
expect(total.couplers).toEqual([{ name: '커플러 H19', count: 5 }]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user