Files
Pier_v1/src/main.ts
T
ypshimandClaude Opus 5.5 3d0d484524 feat(ui): 헤더 단계를 시안대로 ①②③으로, 철근재료표는 ③에서 여는 화면으로
시안(11-03·11-06)의 헤더는 ① 형상 생성 · ② 형상계획 · ③ 철근배근뿐이다 (사용자 결정 2026-11-06).
- 헤더의 ④ 재료 버튼을 없앰
- 철근재료표 화면(교각별 표·전체 합계·엑셀·인쇄)은 그대로 두고 ③의 [철근재료표 보기]로 연다
- 재료표 화면에 [← ③ 철근배근] 버튼, 열려 있는 동안 헤더는 ③을 강조
- 브라우저 검증 스크립트 4개가 ③을 거쳐 재료표를 열도록 수정

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:31:46 +09:00

6156 lines
311 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import './style.css';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { swapXYAzimuth } from './core/coordinate-frame';
import * as XLSX from 'xlsx';
import { parseWorkbook, type SheetReport } from './core/parseWorkbook';
import { parseAbutments, toAbutInput, type AbutRecord } from './core/abut/parse';
import { groupAbutImportIssues } from './core/abut/import-issues';
import { buildAbut } from './core/abut/geometry';
import { structureOrder } from './core/abut/order';
import { ABUT_DETAIL_LABELS, ABUT_SEGMENT_HEADER, abutInputRows, abutSegmentRows, abutSummaryRows } from './core/abut/summary';
import {
applyAbutEdit,
applyBearingEdit,
applyBearingRule,
applyShapeEdit,
applyShapeEdits,
applyWingDim,
ABUT_EDIT_FIELDS,
BEARING_RULE_FIELDS,
SHAPE_EDIT_FIELDS,
WING_DIM_FIELDS,
restoreAbutDefault,
copyWingDims,
setWingForm,
applySlabEdit,
setSlabForm,
SLAB_EDIT_FIELDS,
setAbutGround,
applyFootingRebar as applyAbutFootingRebar,
applyWallRebar,
applyParapetRebar,
applyWingRebar,
applySlabRebar,
applyBlockRebar,
} from './core/abut/edit';
import { ABUT_FOOTING_REBAR_DEFAULT, abutFootingGeom, abutFootingRebar, ABUT_FOOTING_SIZES, type AbutFootingGeom, type AbutFootingRebar, type AbutFootingRebarSettings } from './core/abut/rebar/footing';
import { lenText, skewAngle } from './core/abut/rebar/skew-layout';
import { ABUT_BLOCK_PAD_MM, ABUT_BLOCK_REBAR_DEFAULT, abutBlockRebar, type AbutBlockRebar, type AbutBlockSettings } from './core/abut/rebar/block';
import { ABUT_BLOCK_COLORS, abutBlockPlanSvg as abutBlockRebarPlanSvg, abutBlockSectionSvg as abutBlockRebarSectionSvg } from './view/abut-block-rebar-svg';
import { ABUT_SLAB_REBAR_DEFAULT, ABUT_SLAB_SIZES, abutSlabGeom, abutSlabRebar, type AbutSlabGeom, type AbutSlabRebar, type AbutSlabRebarSettings } from './core/abut/rebar/slab';
import { ABUT_SLAB_COLORS, abutSlabPlanSvg as abutSlabRebarPlanSvg, abutSlabSectionSvg as abutSlabRebarSectionSvg } from './view/abut-slab-rebar-svg';
import { ABUT_WING_REBAR_DEFAULT, ABUT_WING_SIZES, abutWingGeom, abutWingRebar, type AbutWingGeom, type AbutWingRebar, type AbutWingRebarSettings } from './core/abut/rebar/wing';
import { ABUT_WING_COLORS, abutWingCornerSvg as abutWingRebarCornerSvg, abutWingFaceSvg } from './view/abut-wing-rebar-svg';
import { ABUT_PARAPET_REBAR_DEFAULT, ABUT_PARAPET_SIZES, abutParapetGeom, abutParapetRebar, slabSpanAt, type AbutParapetGeom, type AbutParapetRebar, type AbutParapetRebarSettings } from './core/abut/rebar/parapet';
import { ABUT_PARAPET_COLORS, abutParapetDowelSvg, abutParapetElevationSvg, abutParapetSectionSvg } from './view/abut-parapet-rebar-svg';
import { ABUT_WALL_REBAR_DEFAULT, ABUT_WALL_SIZES, abutWallGeom, abutWallRebar, resolveWallRebar, type AbutWallGeom, type AbutWallRebar, type AbutWallRebarSettings } from './core/abut/rebar/wall';
import { ABUT_WALL_COLORS, abutWallElevationSvg as abutWallRebarElevationSvg, abutWallSectionSvg as abutWallRebarSectionSvg } from './view/abut-wall-rebar-svg';
import { lapLength } from './core/abut/rebar/splice';
import { ABUT_FOOTING_COLORS, abutFootingRebarPlanSvg, abutFootingSectionLSvg, abutFootingSectionWSvg, type AbutRebarPick } from './view/abut-footing-rebar-svg';
import { abutGroundIssues, supportTopEl, type AbutGround } from './core/abut/ground';
import { ABUT_SLAB_DEFAULT, resolveSlab, SLAB_FORM_LABEL, slabGradePct, slabSection, slabSlopeLabel, type SlabForm } from './core/abut/slab';
import { abutSlabPlanSvg, abutSlabSectionSvg } from './view/abut-slab-svg';
import { ABUT_SHAPE_DEFAULT, footingEnds, normalizeShape, pileRowsMm, resolveShape, wallThicknessMm, type AbutShape } from './core/abut/shape';
import { abutFootingPlanSvg } from './view/abut-footing-svg';
import { abutWallElevationSvg, abutWallSectionSvg, type AbutWallDrawing } from './view/abut-wall-svg';
import { abutShown, piersShown } from './core/abut/visibility';
import { abutBearingPlanSvg, abutBearingSectionSvg, abutSolplateSvg, type AbutBearingDetail } from './view/abut-bearing-svg';
import {
ABUT_BEARING_RULE_DEFAULT,
ABUT_BLOCK_MM,
ABUT_PAD_PLAN_MM,
abutBearingChecks,
abutElevationRows,
abutSolplatePlan,
abutSolplateRows,
resolveBearingRule,
} from './core/abut/bearing-table';
import { abutMemberRows, ABUT_MEMBER_EDITABLE, type AbutMemberId } from './core/abut/member-list';
import type { AbutModel, WingForm } from './core/abut/geometry';
import { buildMembers, type AbutMembers } from './core/abut/members';
import { buildWings, resolveWingDims, type WingSolid } from './core/abut/wings';
import { abutPlanOutline, abutSkewK, buildAbutGroup, cameraForBox } from './view/abut-scene';
import { abutWingCornerSvg, abutWingPlanSvg, abutWingSideSvg } from './view/abut-wing-svg';
import { fitCamera } from './view/fit';
import { buildPier, type PierModel } from './core/geometry';
import { NOT_IMPLEMENTED_RULES, validateInput, validatePier } from './core/validate';
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, 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 { defaultLevelWidthMm, 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 { buildAbutRebar3D } from './view/abut-rebar3d';
import { DEFAULT_MATERIAL, FCK_MEMBERS, fckChanged, fyOf, GRADE_CHOICES, IMPORTED_FCK, isAssumedMaterial, materialFor, materialFrom, type BendFactors, type FckByMember, type FckMember, type GradeKey, type Material } from './core/rebar/common';
import { buildQuantity, quantityRows, type Quantity, type QuantityLine } from './core/rebar/quantity';
import { groundElFor, parseGroundModel, strataRowsFor, type GroundMapLayer, type GroundModelFile } from './core/ground-model';
import { CORROSION_ALLOWANCE_MM, GROUND_POSITIONS, parseNValue, SEA_DATUM, STRATA_NAMES } from './core/ground';
import { bearingEdgeDistances, integratedCheck, PAIR_ECCENTRICITY_LIMIT_MM, pairEccentricities } from './core/bearing-layout';
import { bearingPlanSvg, PAIR_CHIP_LABELS } from './view/bearing-plan-svg';
import { bearingSectionSvg } from './view/bearing-section-svg';
import { blockRebarSectionSvg } from './view/block-rebar-svg';
import { blockRebarPlanSvg } from './view/block-rebar-plan-svg';
import { copingEndStation, copingFrontSvg, copingSectionSvg } from './view/coping-rebar-svg';
import { BLOCK_REBAR_RANGES } from './core/rebar/block-check';
import { DEFAULT_BLOCK_REBAR, type BlockRebarSettings } from './core/rebar/bearing-block';
import { COPING_REBAR_RANGES, copingDefaults, copingSettingsIssues, type CopingRebarSettings, DEFAULT_TOP_SPLICE, type TopSpliceSettings } from './core/rebar/coping-settings';
import { COLUMN_REBAR_RANGES, type ColumnRebarSettings, columnSettingsIssues, DEFAULT_COLUMN_REBAR } from './core/rebar/column-settings';
import { columnInputFor, columnRebar } from './core/rebar/column';
import { CAST_PART_FIELDS, castSettingsIssues, castRebarDefaults, type CastInPlaceRebar, type CastRebarPart } from './core/rebar/cast-in-place';
import { castRebarLayout } from './core/rebar/cast-layout';
import { castRebarSvg } from './view/cast-rebar-svg';
import { castShape, castShapeIssues, castLowerZone, type CastInPlaceShapeSettings } from './core/cast-in-place-shape';
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 { abutGroundSectionSvg, groundSectionSvg } from './view/ground-svg';
import { columnDiameterIssues } from './core/column-check';
import { findPileSpec, PHC_PILES, STEEL_PILES } from './core/pile-spec';
import { footingPlanSvg, footingSideSvg } from './view/footing-svg';
import { P1_COPING_GEOM } from './core/coping-geom';
import { SOLPLATE_MIN_T2_MM, SOLPLATE_RULE_DEFAULT, solplatePlan, solplateRuleIssues } from './core/solplate-rule';
import type { SolplateRule } from './core/types';
import { type BarSummary, elevationRows, solplateRows } from './core/bearing-table';
/** 지반 모델 (ground_model.json) for the project. Its values reach a pier's 지반고 only when the user applies them. */
let groundModel: GroundModelFile | null = null;
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, setRebarViewOn, viewPosition, type PartName, type PierScene, type ViewKind } from './view/scene';
import { buildAllPiers, type AllPiers } from './view/all-piers';
import { buildTerrainMapGroup, disposeTerrainMapGroup, setTerrainMapMode } from './view/terrain-map';
/** One pier as shown in the list. `checked` is the multi-select state (시안 ①). */
interface Pier extends PierValues {
model: PierModel | null;
issues: Issue[];
checked: boolean;
}
/** Part toggles in the order of 시안 ① "부재 표시". Each toggle controls one or more scene parts. */
/** The stages: ① 형상 생성, ② 형상계획, ③ 철근배근. 'quantity' is the 철근재료표 screen opened from ③ (시안: header ①②③, 2026-11-06). */
type Stage = 'shape' | 'plan' | 'rebar' | 'quantity';
/** 부재 표시 toggles (2026-11-03). `pier` parts follow the pier scenes; `abut` names follow the abutment groups (abut-scene.ts). */
const PART_GROUPS: { label: string; pier: PartName[]; abut: string[] }[] = [
{ label: '받침·받침블럭', pier: ['blocks', 'pads', 'bearings'], abut: ['blocks'] },
{ label: '솔플레이트', pier: ['solplates'], abut: [] },
{ label: '코핑', pier: ['coping'], abut: [] },
{ label: '기둥', pier: ['columns'], abut: [] },
{ label: '확대기초', pier: ['footings'], abut: ['footing'] },
{ label: '말뚝', pier: ['piles'], abut: ['piles'] },
{ label: '버림', pier: ['blinding'], abut: ['blinding'] },
{ label: '지반면', pier: ['ground'], abut: ['ground'] },
{ label: '벽체·흉벽', pier: [], abut: ['wall', 'parapet'] },
// 헌치 sits at the wing ends (wings.ts builds it per wing), so it follows the 날개벽 toggle (2026-11-03).
{ label: '날개벽', pier: [], abut: ['wings', 'haunches'] },
{ label: '접속슬래브', pier: [], abut: ['slab'] },
];
const VIEWS: { kind: ViewKind; label: string }[] = [
{ kind: 'iso', label: '아이소' },
{ kind: 'front', label: '정면' },
{ kind: 'side', label: '측면' },
{ kind: 'top', label: '평면' },
];
const TYPES: PierType[] = ['T형', '2주형', '3주형'];
const CRS = '국토좌표계 (x = 동, y = 북)';
/** 시안 ② 부재 목록 (spec §5.2). `keys` are the inputs that exist today; the rest show values only. */
const MEMBERS: { id: MemberId; label: string; keys: EditableKey[]; note: string }[] = [
{ id: 'bearing', label: '교량받침·받침블럭·솔플레이트', keys: ['bearingType', 'bearingLayout'], note: '받침 상세(C3)는 Q16 결정 후 추가합니다. 지금은 표시만 합니다.' },
{ id: 'coping', label: '코핑', keys: ['copingWidthMm', 'copingLengthMm', 'copingH1Mm', 'copingH2Mm', 'levelWidthMm'], note: '폭, 길이, 끝단 높이 h1, 테이퍼 높이 h2, Level 폭을 입력합니다. 계단 위치·단차선 입력은 다음 단계에서 추가합니다.' },
{ id: 'column', label: '기둥', keys: ['columnDiameterMm'], note: '기둥 직경 D를 입력합니다 (1200–3000 mm, 50 mm 단위). 높이와 위치는 계산값입니다.' },
{ id: 'footing', label: '확대기초 · 말뚝', keys: ['footingLengthMm', 'footingWidthMm', 'footingHeightMm', 'blindingMarginMm', 'blindingThicknessMm', 'pileLengthMm'], note: '확대기초 치수, 비림, 말뚝 길이와 규격을 입력합니다. 규칙 미정 항목(모따기, 경사, 복합 말뚝)은 다음 단계에서 추가합니다.' },
{ id: 'ground', label: '지반·지층', keys: ['groundEl', 'groundPosition', 'groundWaterOn', 'groundWaterEl', 'strataRows'], note: '지반고는 계산(흙덮개)에 쓰고, 지층은 행으로, 지하수위는 직접 입력합니다. 위치는 육상·하상·해상 중 고릅니다.' },
];
interface Pending {
fileName: string;
entries: Pier[];
sheets: SheetReport[];
/** 교대 기록 read from the same workbook (AB). */
abutments: AbutRecord[];
abutIssues: Issue[];
selectedAbutments: Set<string>;
}
const state = {
piers: [] as Pier[],
/** 교대 기록 of the project (AB, 2026-11-03). Not drawn yet: the 3D placement is a later step. */
abutments: [] as AbutRecord[],
/** 교대 편집 카드에서 고른 교대 (2026-11-03). */
abutActive: null as string | null,
/** The 부재 · 교대 row whose card is open in ② (2026-11-03). */
abutMember: 'bearing' as AbutMemberId,
/** The bearing the ② 교량받침 삽도 draw (상세 보기). */
abutBearingNo: 1,
/** The side the ② 날개벽 card edits (편집할 측). */
abutWingSide: 'left' as 'left' | 'right',
/** The editable part of the 교대 panel is open (2026-11-03). */
abutEditOpen: false,
/** 교대 기본값 (가져온 그대로), for 기본값 복원. */
abutDefaults: [] as AbutRecord[],
active: null as string | null,
view: 'iso' as ViewKind,
visible: Object.fromEntries(PART_GROUPS.flatMap((g) => g.pier).map((p) => [p, true])) as Record<PartName, boolean>,
/** Abutment part visibility by abut-scene.ts group name (2026-11-03). */
abutVisible: Object.fromEntries(PART_GROUPS.flatMap((g) => g.abut).map((n) => [n, true])) as Record<string, boolean>,
scene: null as PierScene | null,
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 (③). 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>(),
/** 선택만 보기: the bars not selected are hidden in 3D. */
rebarIsolate: false,
/** Colour basis of the 3D rebar on ③: role (default), diameter, or member. */
rebarColor: 'role' as RebarColorMode,
/** Member tab on ③ (철근표). */
rebarMember: 'coping' as RebarRow['member'],
/** ③ 교대 (2026-11-04): 부재, 보기 탭, 비고 펼치기. 선택한 철근은 교각과 같은 rebarSelection · rebarIsolate. */
abutRebarMember: 'foot' as AbutRebarMemberId,
abutRebarView: 'w' as 'w' | 'l' | 'plan',
/** ③ 교대 벽체 보기 탭 (교대축 직각 단면 / 교대축 단면 계단). */
abutWallView: 'sec' as 'sec' | 'elev',
/** ③ 교대 흉벽 보기 탭 (단면 / 교대축 단면 / P10 다웰 배치). */
abutParapetView: 'sec' as 'sec' | 'elev' | 'dw',
/** ③ 교대 날개벽 보기 탭 (전면도 / 배면도 / 헌치 · 모서리 평면). 측은 ② 날개벽 카드와 같은 abutWingSide. */
abutWingView: 'front' as 'front' | 'back' | 'corner',
/** ③ 교대 접속슬래브 보기 탭 (평면 / 단면). */
abutSlabView: 'plan' as 'plan' | 'sec',
/** ③ 교대 받침블럭 보기 탭 (평면 / 단면). */
abutBlockView: 'plan' as 'plan' | 'sec',
abutRebarNotes: false,
/** Member card selected on ② (시안 ② 부재 목록). */
member: 'coping' as MemberId,
/** True once the project has a file on disk (first save via 시안 ⑤, or an opened file). Later saves go straight to it. */
savedOnce: false,
filter: 'all' as 'all' | PierType,
pending: null as Pending | null,
source: { fileName: '', sheets: [] as string[] },
history: [] as ProjectFile['history'],
/** Snapshot of the inputs at the last save or open. null until the project is first saved. */
savedSnapshot: null as string | null,
};
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 abutGroups: THREE.Group[] = [];
/** The 3D group of each abutment by name, so that selecting it can move the camera (2026-11-03). */
const abutGroupByName = new Map<string, THREE.Group>();
let groundMapGroup: THREE.Group | null = null;
let groundMapMode: 'none' | 'ground' | 'satellite' = 'none';
let backupTimer: number | undefined;
const $ = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
function el<K extends keyof HTMLElementTagNameMap>(tag: K, text?: string, cls?: string): HTMLElementTagNameMap[K] {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (cls) node.className = cls;
return node;
}
/** Results and short notices: a toast that goes away after 4 s. */
function setStatus(msg: string): void {
showToast(msg, false);
}
/** Problems: a toast that stays until the user closes it. */
function setError(msg: string): void {
showToast(msg, true);
}
function showToast(msg: string, sticky: boolean): void {
const host = $<HTMLDivElement>('toast-host');
const t = el('div', undefined, sticky ? 'toast error' : 'toast');
t.appendChild(el('span', msg));
if (sticky) {
const close = el('button', '닫기', 'btn small');
close.type = 'button';
close.addEventListener('click', () => t.remove());
t.appendChild(close);
} else {
window.setTimeout(() => t.remove(), 4000);
}
host.appendChild(t);
while (host.childElementCount > 3) host.firstElementChild?.remove();
}
// --- 3D renderer -------------------------------------------------------------
const container = $<HTMLDivElement>('canvas');
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.localClippingEnabled = true; // mitre planes on the bent coping
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
container.appendChild(renderer.domElement);
const world = new THREE.Scene();
world.background = new THREE.Color(0xe9eef4);
const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 5000);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
world.add(new THREE.HemisphereLight(0xffffff, 0x8899aa, 1.6));
const sun = new THREE.DirectionalLight(0xffffff, 1.2);
sun.position.set(30, 60, 40);
world.add(sun);
function resize(): void {
const w = container.clientWidth;
const h = container.clientHeight;
if (w === 0 || h === 0) return;
renderer.setSize(w, h);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
window.addEventListener('resize', resize);
// The 3D view follows its container, which changes when the layout frame or a panel size changes (승인 2026-10-23).
new ResizeObserver(() => resize()).observe(container);
resize();
const coordinateAxes: { line: SVGLineElement; label: SVGTextElement; vector: THREE.Vector3 }[] = [
{ line: document.getElementById('axis-e-line') as unknown as SVGLineElement, label: document.getElementById('axis-e-label') as unknown as SVGTextElement, vector: new THREE.Vector3(1, 0, 0) },
{ line: document.getElementById('axis-n-line') as unknown as SVGLineElement, label: document.getElementById('axis-n-label') as unknown as SVGTextElement, vector: new THREE.Vector3(0, 0, -1) },
{ line: document.getElementById('axis-el-line') as unknown as SVGLineElement, label: document.getElementById('axis-el-label') as unknown as SVGTextElement, vector: new THREE.Vector3(0, 1, 0) },
];
function updateCoordinateAxis(): void {
camera.updateMatrixWorld();
const right = new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld, 0);
const up = new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld, 1);
const cx = 75;
const cy = 75;
const length = 52;
for (const axis of coordinateAxes) {
const screenX = axis.vector.dot(right);
const screenY = axis.vector.dot(up);
const x = cx + screenX * length;
const y = cy - screenY * length;
axis.line.setAttribute('x1', String(cx));
axis.line.setAttribute('y1', String(cy));
axis.line.setAttribute('x2', String(x));
axis.line.setAttribute('y2', String(y));
const dx = x - cx;
const dy = y - cy;
const lineLength = Math.hypot(dx, dy) || 1;
axis.label.setAttribute('x', String(x + (dx / lineLength) * 12));
axis.label.setAttribute('y', String(y + (dy / lineLength) * 12));
}
}
function animate(): void {
controls.update();
updateCoordinateAxis();
renderer.render(world, camera);
requestAnimationFrame(animate);
}
animate();
// --- pier model ----------------------------------------------------------------
/** Fills the coping heights and Level width with the P1 values when a file or sheet does not give them (1단계). */
function withCopingDefaults(input: PierInput): PierInput {
return {
...input,
columnDiameterMm: input.columnDiameterMm ?? 2000,
footingLengthMm: input.footingLengthMm ?? 10400,
footingWidthMm: input.footingWidthMm ?? 8150,
footingHeightMm: input.footingHeightMm ?? 2000,
blindingMarginMm: input.blindingMarginMm ?? 100,
blindingThicknessMm: input.blindingThicknessMm ?? 100,
pileKind: input.pileKind ?? '강관',
pileOuterMm: input.pileOuterMm ?? 609.6,
pileThicknessMm: input.pileThicknessMm ?? 12,
copingH1Mm: input.copingH1Mm ?? P1_COPING_GEOM.h1,
copingH2Mm: input.copingH2Mm ?? P1_COPING_GEOM.h2,
levelWidthMm: input.levelWidthMm ?? defaultLevelWidthMm(input.type, input.columnDiameterMm ?? 2000),
};
}
function buildEntry(rawInput: PierInput, checked: boolean): Pier {
const input = withCopingDefaults(rawInput);
const issues: Issue[] = [];
let model: PierModel | null = null;
// 솔플레이트 규칙 (C안, 2026-11-03): a rule that cannot make a solplate is reported, and no model is built from it.
const solIssues = solplateRuleIssues(input.solplate ?? SOLPLATE_RULE_DEFAULT);
for (const message of solIssues) issues.push({ code: 'S01', level: 'E', pier: input.name, message });
if (validateInput(input, issues) && solIssues.length === 0) {
try {
model = buildPier(input);
validatePier(input, model, issues);
for (const message of columnDiameterIssues(input.columnDiameterMm ?? 2000, model)) {
issues.push({ code: 'D01', level: 'E', pier: input.name, message });
}
} catch (err) {
issues.push({ code: '미실시', level: 'E', pier: input.name, message: (err as Error).message });
}
}
return { input, model, issues, checked, defaults: input, draft: input };
}
/** Builds the model from the draft (the cards' values) and keeps the imported defaults. */
function regenerateEntry(p: Pier): Pier {
return { ...buildEntry(p.draft, p.checked), defaults: p.defaults, draft: p.draft };
}
function sheetOk(sheets: SheetReport[], name: string): boolean {
return sheets.find((s) => s.name === name)?.ok === true;
}
function activePier(): Pier | undefined {
return state.piers.find((p) => p.input.name === state.active);
}
// --- parts and views -------------------------------------------------------------
/** Pier drawn in this view: all piers in 전체 보기, else the 기준 pier unless an abutment is selected in ②. */
function pierDrawn(): boolean {
return Boolean(allPiers) || Boolean(activePier()?.model && piersShown(state.stage, state.abutActive));
}
/** Any abutment group drawn in this view. */
function abutDrawn(): boolean {
return abutGroups.some((g) => g.visible);
}
const partToggles: { group: (typeof PART_GROUPS)[number]; input: HTMLInputElement }[] = [];
function buildPartToggles(): void {
const box = $<HTMLDivElement>('parts');
const row = el('div', undefined, 'parts-row');
for (const group of PART_GROUPS) {
const label = el('label');
const input = el('input');
input.type = 'checkbox';
input.addEventListener('change', () => {
for (const p of group.pier) {
state.visible[p] = input.checked;
setPartVisible(p, input.checked);
}
for (const n of group.abut) {
state.abutVisible[n] = input.checked;
setAbutNameVisible(n, input.checked);
}
});
partToggles.push({ group, input });
label.append(input, document.createTextNode(group.label));
row.appendChild(label);
}
box.appendChild(row);
updatePartToggles();
}
/** A toggle is live when an object it names is drawn; its value comes from that object (2026-11-03: 선택 대상만). */
function updatePartToggles(): void {
const pier = pierDrawn();
const abut = abutDrawn();
for (const { group, input } of partToggles) {
const pierLive = pier && group.pier.length > 0;
const abutLive = abut && group.abut.length > 0;
input.disabled = !(pierLive || abutLive);
input.checked = pierLive
? group.pier.every((p) => state.visible[p])
: abutLive && group.abut.every((n) => state.abutVisible[n]);
}
updatePartsCount();
}
/** Shows or hides one named part of every abutment (the names of abut-scene.ts). */
function setAbutNameVisible(name: string, on: boolean): void {
for (const g of abutGroups) {
const member = g.getObjectByName(name);
if (member) member.visible = on;
}
}
function buildViewButtons(): void {
const box = $<HTMLDivElement>('views');
for (const v of VIEWS) {
const btn = el('button', v.label, 'btn');
btn.type = 'button';
btn.setAttribute('aria-pressed', String(v.kind === state.view));
btn.addEventListener('click', () => setView(v.kind));
box.appendChild(btn);
}
}
/** 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 groundMapElevation(): number {
const gridValues = groundModel?.terrainGrid?.z.filter((value): value is number => value !== null) ?? [];
const pierValues = groundModel?.piers.flatMap((pier) => pier.centerEl === null ? [] : [pier.centerEl]) ?? [];
const groundValues = gridValues.length > 0 ? gridValues : pierValues;
return (groundValues.length > 0 ? Math.min(...groundValues) : 0) - 0.5;
}
function decodeGroundMapImage(layer: GroundMapLayer): Promise<void> {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve();
image.onerror = () => reject(new Error(`${layer.name} 내장 이미지를 읽지 못했습니다.`));
image.src = layer.image;
});
}
function addGroundMaps(pivot: { e: number; n: number }): void {
const layers = groundModel?.mapLayers;
if (!layers?.length) return;
groundMapGroup = buildTerrainMapGroup(layers, pivot, groundMapElevation(), groundMapMode, (layer) => {
setError(`${layer.name} 이미지를 3D 지도에 표시하지 못했습니다.`);
}, groundModel?.terrainGrid);
world.add(groundMapGroup);
}
function setGroundMapMode(mode: 'none' | 'ground' | 'satellite'): void {
groundMapMode = mode;
setTerrainMapMode(groundMapGroup, mode);
const panel = $<HTMLDetailsElement>('ground-map-info');
panel.hidden = !groundModel?.mapLayers?.length;
document.querySelectorAll<HTMLInputElement>('input[name="ground-map-mode"]').forEach((input) => {
input.checked = input.value === mode;
});
}
function setView(kind: ViewKind): void {
state.view = kind;
document.querySelectorAll<HTMLButtonElement>('#views button').forEach((b, i) => {
b.setAttribute('aria-pressed', String(VIEWS[i].kind === kind));
});
// 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);
const scene = allPiers ? allPiers.view : state.scene;
const pier = activePier();
if (!scene || !pier?.model) {
// No pier is drawn (an abutment is selected in ②): the views follow the abutment's own frame (2026-11-03).
abutView(kind);
return;
}
const framed = viewPosition(scene, kind, pier.model);
// Keep the chosen view direction but fit every model (piers and abutments) in the window (2026-11-03).
const fitted = fitToModels(framed.position, framed.target);
camera.position.copy(fitted.position);
controls.target.copy(fitted.target);
controls.update();
}
/** Camera for the drawn abutments alone: the directions come from the abutment group (local +x = 교대축 IP1→IP3, +z = 전면). */
function abutView(kind: ViewKind): void {
const shown = abutGroups.filter((g) => g.visible);
if (shown.length === 0) return;
const box = new THREE.Box3();
for (const g of shown) {
g.updateWorldMatrix(true, true);
box.expandByObject(g);
}
const sphere = box.getBoundingSphere(new THREE.Sphere());
const ref = shown[0];
const axisX = new THREE.Vector3(1, 0, 0).transformDirection(ref.matrixWorld);
const front = new THREE.Vector3(0, 0, 1).transformDirection(ref.matrixWorld);
const lift = new THREE.Vector3(0, 0.15, 0);
const dirs: Record<ViewKind, THREE.Vector3> = {
iso: new THREE.Vector3(1, 0.7, 1).normalize(),
front: front.clone().add(lift).normalize(),
side: axisX.clone().add(lift).normalize(),
top: new THREE.Vector3(0, 1, 0.001).normalize(),
};
const target = sphere.center.clone();
const dist = sphere.radius * 2.6;
camera.position.copy(target.clone().add(dirs[kind].multiplyScalar(dist)));
controls.target.copy(target);
controls.update();
}
/** Bounding sphere of everything drawn (the piers or the all-piers view, plus the abutments) fitted to the camera. */
function fitToModels(position: THREE.Vector3, target: THREE.Vector3): { position: THREE.Vector3; target: THREE.Vector3 } {
const box = new THREE.Box3();
const piersRoot = allPiers ? allPiers.piers : state.scene?.root;
if (piersRoot) box.expandByObject(piersRoot);
for (const g of abutGroups) if (g.visible) box.expandByObject(g);
if (box.isEmpty()) return { position, target };
const sphere = box.getBoundingSphere(new THREE.Sphere());
return fitCamera(position, target, sphere, camera.fov, camera.aspect);
}
/** 전체 보기 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) => {
if (!p.model) return [];
const importedPier = groundModel?.piers.find((q) => q.id === p.input.name);
return [{
name: p.input.name,
model: p.model,
...(groundModel && importedPier ? { groundEl: importedPier.centerEl } : {}),
}];
});
if (entries.length === 0) return false;
allPiers = buildAllPiers(entries, groundModel?.terrainGrid);
world.add(allPiers.piers);
if (allPiers.terrain) world.add(allPiers.terrain);
addGroundMaps(entries[0].model.pivot);
for (const key of Object.keys(state.visible) as PartName[]) setPartVisible(key, state.visible[key]);
return true;
}
function setupScene(): void {
// 교대 (AB, 2026-11-03): drawn in the frame of the first pier with a model, so they sit with the piers.
for (const g of abutGroups) world.remove(g);
abutGroups = [];
abutGroupByName.clear();
// The frame is the same one the piers use: the first shown pier in the all-piers view, otherwise the active pier.
const ref = multiSceneOn() ? shownPiers().find((q) => q.model)?.model : (activePier()?.model ?? state.piers.find((q) => q.model)?.model);
if (ref) {
for (const rec of state.abutments) {
try {
const model = buildAbut(toAbutInput(rec));
const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape);
const group = buildAbutGroup(model, members, buildWings(model, members, rec.wings, rec.wingDims), ref.pivot, rec.groundEl, groundModel?.terrainGrid, rec.slab);
group.visible = abutShown(state.stage, state.abutActive, rec.name);
// The 부재 표시 toggles hold for a new abutment group too.
for (const [name, on] of Object.entries(state.abutVisible)) {
const member = group.getObjectByName(name);
if (member) member.visible = on;
}
setRebarViewOn(group, state.stage === 'rebar');
abutGroups.push(group);
abutGroupByName.set(rec.name, group);
world.add(group);
} catch {
// 입력 오류는 가져오기 때 알림으로 표시한다. 여기서는 그리지 않는다.
}
}
}
if (groundMapGroup) {
world.remove(groundMapGroup);
disposeTerrainMapGroup(groundMapGroup);
groundMapGroup = null;
}
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();
// In ② an abutment selection draws the abutment alone, so the pier scene is not built (visibility.ts).
if (pier?.model && piersShown(state.stage, state.abutActive)) {
const importedPier = groundModel?.piers.find((q) => q.id === pier.input.name);
const importedGroundEl = groundModel && importedPier ? importedPier.centerEl : undefined;
const scene = buildPierScene(pier.model, groundModel?.terrainGrid, importedGroundEl);
for (const key of Object.keys(state.visible) as PartName[]) {
scene.parts[key].visible = state.visible[key];
}
world.add(scene.root);
addGroundMaps(pier.model.pivot);
setRebarView(scene, state.stage === 'rebar');
state.scene = scene;
setView(state.view);
if (state.stage === 'rebar') {
try {
rebar3d = buildRebar3D(pier.model, state.material, state.fckByMember);
rebar3d.setColorMode(state.rebarColor);
world.add(rebar3d.group);
} catch (err) {
setRebarView(scene, false);
setError(`철근 3D 생성 실패 · 콘크리트 형상은 유지합니다: ${err instanceof Error ? err.message : String(err)}`);
}
}
}
// ③ 교대 (2026-11-04): with the piers hidden, the selected abutment's 저판 · 벽체 · 흉벽 · 날개벽 · 접속슬래브 · 받침블럭 rebar is the ③ rebar (표 · 3D 선택 공통).
const abutRec = piersShown(state.stage, state.abutActive) ? undefined : state.abutments.find((a) => a.name === state.abutActive);
const abutGroup = abutRec ? abutGroupByName.get(abutRec.name) : undefined;
const footing = abutRec ? abutFootingBuilt(abutRec) : null;
if (abutRec && abutGroup && footing && footing.bottomEl !== undefined) {
const wall = abutWallBuilt(abutRec, footing.result);
const para = wall ? abutParapetBuilt(abutRec, wall) : null;
const wings = abutWingsBuilt(abutRec, footing.result).map((w) => w.result);
rebar3d = buildAbutRebar3D(footing.geom, footing.result, footing.skewK, footing.backM, footing.bottomEl, wall?.result, para?.result, wings, abutSlabBuilt(abutRec)?.result, abutBlockBuilt(abutRec)?.result);
rebar3d.group.applyMatrix4(abutGroup.matrix);
rebar3d.group.visible = state.stage === 'rebar';
rebar3d.setColorMode(state.rebarColor);
world.add(rebar3d.group);
}
}
// --- main list (시안 ① 교각 목록) ----------------------------------------------------
function renderPierList(): void {
const select = $<HTMLSelectElement>('type-filter');
if (select.options.length === 0) {
select.appendChild(new Option('전체', 'all'));
for (const t of TYPES) select.appendChild(new Option(t, t));
}
select.value = state.filter;
for (const opt of Array.from(select.options)) {
const count = opt.value === 'all' ? state.piers.length : state.piers.filter((p) => p.input.type === opt.value).length;
opt.textContent = `${opt.value === 'all' ? '전체' : opt.value} (${count})`;
}
renderPierRows($<HTMLUListElement>('pier-items'), 'shape');
renderPierRows($<HTMLUListElement>('plan-pier-items'), 'plan');
renderAbutEditor();
const visible = state.piers.filter((p) => state.filter === 'all' || p.input.type === state.filter);
$<HTMLParagraphElement>('pier-empty').hidden = state.piers.length > 0;
}
/** One 교대 row: the name and form, and a click shows its key values in the status line (the 3D view already has it). */
function abutRow(rec: AbutRecord, mode: Stage = 'shape'): HTMLLIElement {
const li = el('li');
const name = el('button', `${rec.name} · ${rec.type} 교대`, 'pier-name');
name.type = 'button';
name.setAttribute('aria-pressed', String(state.abutActive === rec.name));
name.addEventListener('click', () => {
state.abutActive = rec.name;
state.abutEditOpen = false;
state.rebarSelection = new Set();
setStatus(abutSummary(rec));
// Rebuilt first: the abutment and the camera frame are in the new scene (visibility.ts in ②).
setupScene();
focusAbut(rec.name);
renderAll();
});
li.append(name, el('span', '교대', 'badge'));
return li;
}
/** Moves the camera to the selected pier: its own scene, in the all-piers view or the single view (2026-11-03). */
function focusPier(name: string): void {
const root = allPiers ? allPiers.scenes.get(name)?.root : state.scene?.root;
if (!root) return;
root.updateWorldMatrix(true, true);
const { position, target } = cameraForBox(new THREE.Box3().setFromObject(root));
camera.position.copy(position);
controls.target.copy(target);
controls.update();
}
/** Moves the camera to the selected abutment (2026-11-03). */
function focusAbut(name: string): void {
const group = abutGroupByName.get(name);
if (!group) return;
group.updateWorldMatrix(true, true);
const { position, target } = cameraForBox(new THREE.Box3().setFromObject(group));
camera.position.copy(position);
controls.target.copy(target);
controls.update();
}
/** 교대 편집 카드: the values of the selected abutment. Each change is checked, rebuilds the abutment in the 3D view and marks the project as changed. */
/** The 교대 입력 panel slots of ① (replaces the pier info) and ② (under the list). */
/** Open questions of spec_abut that touch the 벽체·흉벽 shape (the 확인 필요 panel of the mockup). */
const ABUT_QA_NOTES = [
'QA-4 흉벽 level 2500의 기준점, Level 좌·우 450 / 995 (가정, 계산 미사용)',
'QA-5 말뚝 D600 / 609.6, 전면 열 본수 (가정)',
'QA-6 날개벽 외측면 = 교좌면 끝 skew 선 (가정)',
'QA-7 수축줄눈 개수 (10 m 기준)',
'QA-8 브라켓형·오목형·반일체식 벽체 생성 규칙 미정 (type-1만 생성)',
];
const ABUT_SLOTS = {
shape: { root: 'abut-panel', name: 'abut-panel-name', input: 'abut-panel-input', toggle: 'abut-panel-edit-toggle', editor: 'abut-panel-editor', summary: 'abut-panel-summary', basic: 'abut-panel-basic', bearings: 'abut-panel-bearings', shape: 'abut-panel-shape', msg: 'abut-panel-msg', prefix: 'abs' },
// ② has no 편집 toggle and no basic items: the card sits under the 3D view and shows the picked member (2026-11-03).
plan: { wing: 'abut-panel-plan-wing', slab: 'abut-panel-plan-slab', ground: 'abut-panel-plan-ground', footing: 'abut-panel-plan-footing', root: 'abut-panel-plan', name: 'abut-panel-plan-name', bearings: 'abut-panel-plan-bearings', shape: 'abut-panel-plan-shape', msg: 'abut-panel-plan-msg', prefix: 'abp' },
} as const;
/** Shows the 교대 입력 in place of the pier info when an abutment is selected (2026-11-03). */
function renderAbutEditor(): void {
const rec = state.abutments.find((a) => a.name === state.abutActive);
$<HTMLElement>('pier-info').hidden = Boolean(rec);
// ② hides the pier cards (부재 목록, 코핑 카드, 속성 복사) while an abutment is selected (style.css .abut-selected).
$<HTMLElement>('view-plan').classList.toggle('abut-selected', Boolean(rec));
for (const slot of [ABUT_SLOTS.shape, ABUT_SLOTS.plan]) {
const box = $<HTMLElement>(slot.root);
box.hidden = !rec;
if (rec) fillAbutSlot(slot, rec);
}
if (rec) {
renderAbutMembers(rec);
applyAbutMemberCard(rec);
} else {
$<HTMLUListElement>('abut-members').replaceChildren();
}
}
/** The 부재 · 교대 rows in ②: each row's value and the card it opens (2026-11-03, phase 1). */
function renderAbutMembers(rec: AbutRecord): void {
const box = $<HTMLUListElement>('abut-members');
const slabWidth = abutSummaryRows(rec).find(([label]) => label === '접속슬래브 폭')?.[1] ?? '—';
box.replaceChildren();
for (const row of abutMemberRows(rec, slabWidth)) {
const btn = el('button', undefined, 'pier-name');
btn.type = 'button';
btn.setAttribute('aria-pressed', String(row.id === state.abutMember));
btn.append(el('span', row.label), el('span', row.summary, 'muted small'));
if (row.changed) btn.append(el('span', '변경', 'badge'));
btn.addEventListener('click', () => {
state.abutMember = row.id;
state.abutEditOpen = true;
renderAll();
});
const li = el('li');
li.append(btn);
box.append(li);
}
}
/** One option of a 시안 tile row: the label, an optional icon (SVG inner markup), on/off, default badge, and a reason when disabled. */
interface TileOption {
label: string;
icon?: string;
on: boolean;
isDefault?: boolean;
disabled?: string;
pick?: () => void;
}
/** 시안 선택 줄 (.grp · .opts · .opt / .optt): icon tiles when the options have icons, text tiles otherwise, and a note below. */
function optionTiles(title: string, options: TileOption[], note?: string): HTMLDivElement {
const group = el('div', undefined, 'abut-grp');
group.setAttribute('role', 'group');
group.setAttribute('aria-label', title);
group.append(el('span', title, 'abut-grp-title'));
const row = el('div', undefined, 'abut-opts');
for (const o of options) {
const b = el('button', undefined, `${o.icon ? 'abut-opt' : 'abut-optt'}${o.on ? ' on' : ''}`);
b.type = 'button';
b.setAttribute('aria-pressed', String(o.on));
if (o.isDefault) b.append(el('span', '기본', 'abut-def'));
if (o.icon) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', '44');
svg.setAttribute('height', '24');
svg.setAttribute('viewBox', '0 0 48 24');
svg.setAttribute('aria-hidden', 'true');
svg.innerHTML = o.icon;
b.append(svg);
}
b.append(document.createTextNode(o.label));
if (o.disabled) {
b.disabled = true;
b.title = o.disabled;
} else if (o.pick) {
b.addEventListener('click', o.pick);
}
row.append(b);
}
group.append(row);
if (note) group.append(el('span', note, 'abut-tbdn'));
return group;
}
/** 시안 아이콘 (선 하나의 단면·평면 모식도): 벽체 형식 4개, 블럭 배치 2개. */
const ICON = {
wallBasic: '<path d="M12 22V6h3v4h2V2h4v12h12v8z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>',
wallBracket: '<path d="M12 22V9l4-3V2h4v12h13v8z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>',
wallConcave: '<path d="M12 22V8l5-2V2h4v6l-2 3v3h14v8z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>',
wallSemi: '<path d="M13 22V10h2v-2h2v6h16v8z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>',
blockSeparate:
'<rect x="3" y="7" width="8" height="10" fill="none" stroke="currentColor" stroke-width="1.6"/><rect x="15" y="7" width="8" height="10" fill="none" stroke="currentColor" stroke-width="1.6"/><rect x="27" y="7" width="8" height="10" fill="none" stroke="currentColor" stroke-width="1.6"/><rect x="39" y="7" width="6" height="10" fill="none" stroke="currentColor" stroke-width="1.6"/>',
blockOne: '<rect x="3" y="7" width="42" height="10" fill="none" stroke="currentColor" stroke-width="1.6"/>',
};
/** ② 교량받침·받침블럭·솔플레이트 카드 (시안): 블럭 배치, ELEVATION TABLE, 솔플레이트 TABLE, 공통 · 규칙 입력, 검사 줄. */
function fillAbutBearingCard(
box: HTMLDivElement,
rec: AbutRecord,
apply: (next: AbutRecord) => void,
fail: (err: unknown, input: HTMLInputElement, back: string) => void,
idOf: (suffix: string) => string,
numberInput: (id: string, value: string) => HTMLInputElement,
): void {
const rule = resolveBearingRule(rec.bearingRule);
const table = (head: string[], rows: string[][]) => {
const tbl = document.createElement('table');
tbl.className = 'abut-seg';
const tr = tbl.createTHead().insertRow();
for (const h of head) tr.append(el('th', h));
const body = tbl.createTBody();
for (const row of rows) {
const r = body.insertRow();
for (const cell of row) r.append(el('td', cell));
}
return tbl;
};
// 블럭 배치 (시안): 분리 고정, 일체는 규칙 미정이라 고를 수 없다.
box.append(
optionTiles(
'블럭 배치',
[
{ label: '분리', icon: ICON.blockSeparate, on: true, isDefault: true },
{ label: '일체', icon: ICON.blockOne, on: false, disabled: '교대 일체 블럭은 규칙 미정 (교좌면이 계단이라 분리 고정)' },
],
'교대 일체 블럭은 규칙 미정 (교좌면이 계단이라 분리 고정)',
),
);
let model: AbutModel;
let members: AbutMembers;
try {
model = buildAbut(toAbutInput(rec));
members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape);
} catch {
box.append(el('p', '교대 모델을 만들 수 없어 받침 표를 표시하지 않습니다.', 'muted small'));
return;
}
const m3 = (v: number) => v.toFixed(3);
const H = rec.bearingHeightMm;
const plan = abutSolplatePlan(rule, H);
const elev = abutElevationRows(model, rule, H);
const sol = abutSolplateRows(model, rule, H);
// 상세 보기 (받침 ①–⑤): the three 삽도 draw the picked bearing.
const pickNo = elev.some((r) => r.no === state.abutBearingNo) ? state.abutBearingNo : elev[0].no;
box.append(
optionTiles(
'상세 보기 (받침)',
elev.map((r) => ({
label: r.seg,
on: r.no === pickNo,
pick: () => {
state.abutBearingNo = r.no;
renderAll();
},
})),
),
);
const er = elev.find((r) => r.no === pickNo)!;
const sr = sol.find((r) => r.no === pickNo)!;
const detail: AbutBearingDetail = {
no: er.no, blockMm: ABUT_BLOCK_MM, padMm: ABUT_PAD_PLAN_MM, L: sr.L, W: sr.W,
h1: er.h1, h2: er.h2, h3: er.h3, h4: er.h4, T1: sr.T1, T2: sr.T2, rotDeg: er.rotDeg, S: sr.S, seatEl: er.seatEl, z: er.z,
};
const figs = el('div', undefined, 'abut-bearing-figs');
for (const [svg, caption] of [
[abutBearingPlanSvg(detail), '평면 · 점선 = 몰탈패드, 주황 = 솔플레이트 L × W'],
[abutBearingSectionSvg(detail), '단면 · 교축 방향 · E = h1 + h2 + h3 + h4'],
[abutSolplateSvg(detail), '솔플레이트 상세 · T = ⌈22 + L·|S|/200⌉'],
]) {
const fig = el('figure', undefined, 'abut-fig');
fig.innerHTML = svg;
fig.append(el('figcaption', caption, 'muted small'));
figs.append(fig);
}
box.append(figs);
box.append(el('p', `받침 ELEVATION TABLE (mm, EL·위치는 m) · 거리는 IP3(① 쪽)에서 · h3 = H − T, h4 = T (솔플레이트 규칙)`, 'lbl'));
box.append(
table(
['No.', '위치 s', '구간', '교좌면 EL', 'h1 블럭', 'h2 패드', 'z = EL.X', 'h3 받침', 'h4 솔플', 'E', '솔플 상면 EL', '블럭 ~ 계단 경계', '방위각 CAD / 진북 (°)', '블럭 회전 (°)'],
elev.map((r) => [
String(r.no), m3(r.sFromIp3M), r.seg, m3(r.seatEl), String(r.h1), String(r.h2), m3(r.z), String(r.h3), String(r.h4),
String(r.E), m3(r.solplateTopEl), r.stepGapM === null ? '—' : m3(r.stepGapM),
r.azimuthCadDeg === null || r.azimuthNorthDeg === null ? '입력 없음' : `${r.azimuthCadDeg.toFixed(3)} / ${r.azimuthNorthDeg.toFixed(3)}`,
r.rotDeg.toFixed(2),
]),
),
);
box.append(el('p', '솔플레이트 상세 TABLE (mm) · 솔플레이트 규칙', 'lbl'));
box.append(
table(
['No.', 'S (%)', 'L', 'W', 'T', 'T1', 'T2', '검산 T1 − T2 = L·|S|/100'],
sol.map((r) => [String(r.no), r.S.toFixed(3), String(r.L), String(r.W), String(r.T), r.T1.toFixed(1), r.T2.toFixed(1), r.check]),
),
);
// 솔플레이트 규칙 (교각 카드와 같은 형식, 2026-11-03): H는 교대 받침 시트 값으로 고정, L · W · S를 입력한다.
const d = ABUT_BEARING_RULE_DEFAULT;
box.append(el('p', `솔플레이트 규칙 (이 교대 · 기본 ${d.lengthMm} × ${d.widthMm} · ${d.slopePct} %) · 받침높이 H ${H} mm 고정 (솔플레이트 포함)`, 'lbl'));
for (const f of BEARING_RULE_FIELDS) {
const id = idOf(`rule-${f.key}`);
const lab = el('label', f.label);
lab.htmlFor = id;
const input = numberInput(id, String(rule[f.key]));
if (rule[f.key] !== ABUT_BEARING_RULE_DEFAULT[f.key]) input.classList.add('changed-input');
input.addEventListener('change', () => {
try {
apply(applyBearingRule(rec, f.key, Number(input.value)));
} catch (err) {
fail(err, input, String(rule[f.key]));
}
});
box.append(lab, input, el('span', `기본 ${ABUT_BEARING_RULE_DEFAULT[f.key]}`, 'muted small'));
}
box.append(
el(
'p',
`중심 두께 T = ${plan.T} mm (T2 ≥ ${SOLPLATE_MIN_T2_MM} mm) · 받침 본체 h3 = H − T = ${plan.h3} mm · T1 = ${plan.T1.toFixed(1)} mm · T2 = ${plan.T2.toFixed(1)} mm`,
'muted small',
),
);
box.append(el('p', `블럭 평면 ${ABUT_BLOCK_MM} × ${ABUT_BLOCK_MM} (기본값) · 몰탈패드 ${ABUT_PAD_PLAN_MM} × ${ABUT_PAD_PLAN_MM} × 50 (기본값) · 블럭 높이 = min(z) − 교좌면 (계산)`, 'muted small'));
box.append(el('p', `검사 · ${abutBearingChecks(model, members, rule, H).join(' · ')}`, 'muted small abut-checks'));
}
/**
* 날개벽 타일 그림 (2026-11-04, 48 × 24, 선 색 = 글자 색). 평면 그림은 위가 흉벽 · 벽체, 아래로 날개벽이 뻗는다.
* 날개벽 모양은 현재 형식을 따른다: 일반형 = 길게, 단축형 = 짧게(뒷굽 끝까지), 없음 = 흐린 점선.
*/
const WING_ICON = {
plan(left: WingForm, right: WingForm, focus?: 'left' | 'right', equal = false): string {
const stroke = (on: boolean) => (on ? 'stroke="currentColor" stroke-width="2.6"' : 'stroke="currentColor" stroke-width="1.6" stroke-dasharray="2 2" opacity="0.45"');
const wing = (side: 'left' | 'right', form: WingForm) => {
const x = side === 'left' ? 10 : 38;
const dx = side === 'left' ? -1 : 1;
const on = form !== 'none' && (focus === undefined || focus === side);
const len = form === 'general' ? 12 : 5;
const style = form === 'none' ? stroke(false) : stroke(on);
return `<path d="M${x} 8l${dx * (len / 4)} ${len}" fill="none" stroke-linecap="round" ${style}/>`;
};
const wall = '<path d="M8 4h32v4H8z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>';
const eq = equal ? '<path d="M21 14h6M21 17.5h6" stroke="currentColor" stroke-width="1.5"/>' : '';
return wall + wing('left', left) + wing('right', right) + eq;
},
side(form: WingForm): string {
const solid = 'fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"';
const faint = 'fill="none" stroke="currentColor" stroke-width="1.3" stroke-dasharray="2 2" opacity="0.45"';
if (form === 'general') return `<path d="M40 4v16h-8L8 13V4z" ${solid}/>`;
if (form === 'short') return `<path d="M40 4v16h-9V4z" ${solid}/>`;
return `<path d="M40 4v16" ${solid}/><path d="M40 4H8v9l24 7h8" ${faint}/>`;
},
};
/** skew (deg) as 95°02′52″. */
function dms(deg: number): string {
const total = Math.round(Math.abs(deg) * 3600);
const d = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
return `${d}°${String(m).padStart(2, '0')}′${String(s).padStart(2, '0')}″`;
}
/** ② 날개벽 카드 (시안, 2026-11-04): 형식 1행(좌 · 우) → 삽도 3개 → 편집 중인 측의 치수 · 반대쪽에도 적용 → 자동 값 → 생성되는 날개벽 → 메모. */
function fillAbutWingCard(
box: HTMLDivElement,
rec: AbutRecord,
apply: (next: AbutRecord) => void,
fail: (err: unknown, input: HTMLInputElement, back: string) => void,
idOf: (suffix: string) => string,
numberInput: (id: string, value: string) => HTMLInputElement,
): void {
const NAME: Record<WingForm, string> = { general: '일반형', short: '단축형', none: '없음' };
const forms = rec.wings ?? { left: 'general' as WingForm, right: 'general' as WingForm };
const side = state.abutWingSide;
const sideKo = (s: 'left' | 'right') => (s === 'left' ? '좌' : '우');
// 날개벽 형식 1행 (2026-11-04): 좌 · 우 묶음을 나란히. 타일을 누르면 그 측의 형식이 바뀌고 그 측이 편집 대상이 된다.
const formsRow = el('div', undefined, 'abut-wing-forms');
formsRow.append(el('span', '날개벽 형식', 'abut-grp-title'));
for (const s of ['left', 'right'] as const) {
const group = optionTiles(
sideKo(s),
(['general', 'short', 'none'] as const).map((f) => ({
label: NAME[f],
icon: WING_ICON.side(f),
on: forms[s] === f,
isDefault: f === 'general',
pick: () => {
state.abutWingSide = s;
if (forms[s] === f) renderAll();
else apply(setWingForm(rec, s, f));
},
})),
);
group.classList.add('compact');
if (s === side) group.classList.add('editing');
const title = group.querySelector<HTMLElement>('.abut-grp-title');
if (title) {
title.title = `${sideKo(s)} 날개벽 치수 편집`;
title.addEventListener('click', () => {
state.abutWingSide = s;
renderAll();
});
}
formsRow.append(group);
}
box.append(formsRow);
let model: AbutModel;
let members: AbutMembers;
let wings: WingSolid[];
try {
model = buildAbut(toAbutInput(rec));
members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape);
wings = buildWings(model, members, forms, rec.wingDims);
} catch {
box.append(el('p', '교대 모델을 만들 수 없어 날개벽을 그리지 않습니다.', 'muted small'));
return;
}
const shapeAll = { ...ABUT_SHAPE_DEFAULT, ...normalizeShape(rec.shape) };
const wing = wings.find((w) => w.side === side);
const leftWing = wings.find((w) => w.side === 'left');
// 뒷굽 끝까지의 날개벽 거리 (단축형 길이의 기본값); with no wing drawn, the 뒷굽 width is a fair stand-in.
const heelM = wing?.heelDistanceM ?? leftWing?.heelDistanceM ?? Math.abs(members.shape.footingBackM);
// 삽도 3개.
const figs = el('div', undefined, 'abut-wall-figs abut-wing-figs');
const addFig = (svg: string, caption: string) => {
const fig = el('figure', undefined, 'abut-fig');
fig.innerHTML = svg;
fig.append(el('figcaption', caption, 'muted small'));
figs.append(fig);
};
addFig(
abutWingPlanSvg({
lengthM: model.length,
outline: abutPlanOutline(model, members, wings, resolveSlab(rec.slab)),
leftLabel: `좌 · ${NAME[forms.left]}`,
rightLabel: `우 · ${NAME[forms.right]}`,
leftSEnd: leftWing?.sEnd ?? (model.bearings[0].s < model.length / 2 ? 0 : model.length),
slabWidthM: (model.wing?.slabWidthMm ?? 0) / 1000,
}),
'평면 · 좌우 날개벽과 접속슬래브 · 축척 같음',
);
if (wing) {
addFig(
abutWingSideSvg({
name: `${sideKo(side)} 날개벽`,
form: wing.form,
lengthM: wing.lengthM,
endHeightM: wing.endHeightM,
heelDistanceM: wing.heelDistanceM,
topLine: wing.topLine,
bottomLine: wing.bottomLine,
bands: wing.bands,
seatEl: rec.seatEl,
footingTopEl: members.footingTopEl,
parapetTopEl: wing.topStartEl,
bracketHeightM: members.shape.bracketHeightM,
wallBackM: members.shape.wallBackM,
parapetBackM: members.shape.parapetBackM,
wallFrontM: members.shape.wallFrontM,
footingFrontM: members.shape.footingFrontM,
footingBackM: members.shape.footingBackM,
footingThicknessM: members.shape.footingThicknessM,
}),
`${sideKo(side)} 날개벽 측면 · ${NAME[wing.form]} (날개벽 축 방향으로 잘라 본 모습) · 축척 같음`,
);
}
addFig(
abutWingCornerSvg({
parapetMm: shapeAll.parapetThicknessMm,
bracketMm: shapeAll.bracketWidthMm,
wingThicknessMm: Math.round((wing?.thicknessM ?? 0.45) * 1000),
haunchMm: Math.round((wing?.haunchM ?? 0.3) * 1000),
slabGapWingMm: resolveSlab(rec.slab).wingGapMm,
slabGapParapetMm: resolveSlab(rec.slab).parapetGapMm,
chamferMm: Math.round((wing?.haunchM ?? 0.3) * 1000),
}),
'날개벽–흉벽 모서리 평면 · 모식도 · 접속슬래브 모따기',
);
box.append(figs);
// 측별 치수 (선택한 측의 형식에 맞는 칸).
const form = forms[side];
box.append(el('p', `${sideKo(side)} 날개벽 · ${NAME[form]} (편집 중)`, 'lbl'));
if (form === 'none' || !wing) {
box.append(el('p', '입력 없음 · 접속슬래브 측면은 교좌면 끝선에 맞춥니다.', 'muted small'));
} else {
const dims = resolveWingDims(form, rec.wingDims?.[side], heelM);
const defaults = resolveWingDims(form, undefined, heelM);
for (const f of WING_DIM_FIELDS.filter((x) => (form === 'general' ? x.general : x.short))) {
const id = idOf(`wing-${side}-${f.key}`);
const lab = el('label', form === 'short' && f.key === 'lengthMm' ? '길이 (mm) · 기본 = 뒷굽 끝' : f.label);
lab.htmlFor = id;
const input = numberInput(id, String(dims[f.key]));
if (dims[f.key] !== defaults[f.key]) input.classList.add('changed-input');
input.addEventListener('change', () => {
try {
apply(applyWingDim(rec, side, f.key, Number(input.value)));
} catch (err) {
fail(err, input, String(dims[f.key]));
}
});
box.append(lab, input, el('span', `기본 ${defaults[f.key]}`, 'muted small'));
}
// 반대쪽에도 적용: one copy now (두께 · 끝 높이 · 헌치, and the 길이 only when both forms match).
const other = side === 'left' ? 'right' : 'left';
const copy = el('button', `반대쪽(${sideKo(other)})에도 적용`, 'btn small');
copy.type = 'button';
copy.disabled = forms[other] === 'none';
copy.title = forms[other] === 'none'
? '반대쪽 날개벽이 없습니다'
: forms[side] === forms[other]
? '길이 · 두께 · 끝 높이 · 헌치를 한 번 복사합니다 (이후 연결되지 않음)'
: '형식이 달라 길이는 복사하지 않습니다. 두께 · 끝 높이 · 헌치만 한 번 복사합니다';
copy.addEventListener('click', () => {
try {
apply(copyWingDims(rec, side));
} catch (err) {
$<HTMLElement>('abut-panel-plan-msg').textContent = (err as Error).message;
}
});
box.append(el('span', ''), copy, el('span', forms[side] === forms[other] ? '길이 포함' : '형식이 달라 길이 제외', 'muted small'));
const auto = (name: string, value: string, note: string) => box.append(el('label', name), el('span', value, 'num'), el('span', note, 'muted small'));
const slope = model.wing?.slopePct ?? 0;
auto(`경사 (자동, ${rec.type} = ${rec.type === 'A1' ? '−i' : '+i'})`, `${slope >= 0 ? '+' : ''}${slope.toFixed(2)} %`, '종단경사에서');
if (form === 'general') {
auto('등단면 · 변화단면 경계 (자동)', `${wing.heelDistanceM.toFixed(3)} m`, '뒷굽 끝');
auto('끝 EL 상단 / 하단', `${wing.topEndEl.toFixed(3)} / ${wing.bottomEndEl.toFixed(3)}`, '가정');
} else {
auto('하단 (자동)', `확대기초 상면 ${members.footingTopEl.toFixed(3)}`, wing.lengthM > wing.heelDistanceM + 1e-6 ? '뒷굽 밖은 하단이 떠 있음 (정보 AI02)' : '');
auto('끝 EL 상단', wing.topEndEl.toFixed(3), '가정');
}
}
const made = (s: 'left' | 'right') => (forms[s] === 'none' ? `${s === 'left' ? '좌' : '우'} 없음` : `${s === 'left' ? '좌' : '우'} ${NAME[forms[s]]}`);
box.append(el('p', `생성되는 날개벽: ${made('left')} · ${made('right')} · 각도는 좌우 모두 skew ${dms(rec.skewDeg)}`, 'muted small'));
box.append(
el(
'p',
'시안 메모 · 형식은 ②에서만 정합니다(엑셀 입력 없음). 좌 · 우 형식과 치수는 따로 정하고, "반대쪽에도 적용"으로 치수를 한 번 복사할 수 있습니다(길이는 형식이 같을 때만). 단축형은 하단이 모두 확대기초 상면이고 길이 기본값은 뒷굽 끝이며, 늘리면 뒷굽 밖 구간의 하단이 떠 있게 됩니다(정보 AI02). 두께는 접속슬래브 폭에, 헌치는 연직 헌치와 저판 상면 헌치, 접속슬래브 모따기에 함께 반영됩니다.',
'muted small abut-memo',
),
);
}
/**
* 접속슬래브 타일 그림 (2026-11-04, 48 × 24, 사용자 삽도 기준): 오른쪽이 흉벽 · 브라켓 단차, 왼쪽으로 슬래브가 뻗는다.
* 매립형 = 흉벽 상단 아래 일정 두께, 하향 4 % (왼쪽이 낮음). 노출형 = 상면이 흉벽 상단과 같고 끝에 헌치, 종단경사 (왼쪽이 높음).
*/
const SLAB_ICON = {
section(form: SlabForm): string {
const wall = '<path d="M31 22V12h3V2h4v20z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>';
const slab = form === 'buried' ? 'M4 11L30 8v4L4 15z' : 'M4 0.8L30 2v10L23 5.5L4 4.8z';
return `${wall}<path d="${slab}" fill="currentColor" fill-opacity="0.18" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>`;
},
};
/** ② 접속슬래브 카드 (시안, 2026-11-04): 형식 타일 (경사는 노트) → 평면 · 단면(접합 상세 합침) → 입력 → 자동 값 → 메모. */
function fillAbutSlabCard(
box: HTMLDivElement,
rec: AbutRecord,
apply: (next: AbutRecord) => void,
fail: (err: unknown, input: HTMLInputElement, back: string) => void,
idOf: (suffix: string) => string,
numberInput: (id: string, value: string) => HTMLInputElement,
): void {
const slab = resolveSlab(rec.slab);
const say = (err: unknown) => ($<HTMLElement>('abut-panel-plan-msg').textContent = (err as Error).message);
let model: AbutModel | null = null;
let members: AbutMembers | null = null;
let wings: WingSolid[] = [];
try {
model = buildAbut(toAbutInput(rec));
members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape);
wings = buildWings(model, members, rec.wings, rec.wingDims);
} catch {
// shown below the tiles
}
// 형식 (경사는 형식이 정한다, 2026-11-04): 매립형 = 하향 4 %, 노출형 = 종단경사 따라.
const grade = slabSlopeLabel(resolveSlab({ form: 'exposed' }), model?.wing?.slopePct);
box.append(
optionTiles(
'형식',
(['buried', 'exposed'] as const).map((f) => ({
label: SLAB_FORM_LABEL[f],
icon: SLAB_ICON.section(f),
on: slab.form === f,
isDefault: f === 'buried',
pick: () => {
if (slab.form === f) return;
try {
apply(setSlabForm(rec, f));
} catch (err) {
say(err);
}
},
})),
`경사 · 매립형 = 하향 4 %, 노출형 = ${grade.replace('종단경사', '종단경사 따라')}`,
),
);
if (!model || !members) {
box.append(el('p', '교대 모델을 만들 수 없어 접속슬래브를 그리지 않습니다.', 'muted small'));
return;
}
const wingSlope = model.wing?.slopePct ?? 0;
const gradePct = slabGradePct(slab, wingSlope);
const slopeText = slabSlopeLabel(slab, wingSlope);
const widthM = (model.wing?.slabWidthMm ?? Math.round(model.length * 1000)) / 1000;
const leftWing = wings.find((w) => w.side === 'left');
const rightWing = wings.find((w) => w.side === 'right');
const chamfer = { left: Math.round((leftWing?.haunchM ?? 0) * 1000), right: Math.round((rightWing?.haunchM ?? 0) * 1000) };
// 단면은 교대축 가운데의 흉벽 상단에서 잰다.
const parapetTopMid = (members.parapetTopStartEl + members.parapetTopEndEl) / 2;
const sh = members.shape;
const figs = el('div', undefined, 'abut-wall-figs abut-slab-figs');
const addFig = (svg: string, caption: string) => {
const fig = el('figure', undefined, 'abut-fig');
fig.innerHTML = svg;
fig.append(el('figcaption', caption, 'muted small'));
figs.append(fig);
};
addFig(
abutSlabPlanSvg({
outline: abutPlanOutline(model, members, wings, slab),
leftSEnd: leftWing?.sEnd ?? (model.bearings[0].s < model.length / 2 ? 0 : model.length),
widthM,
lengthMm: slab.lengthMm,
chamferMm: chamfer,
}),
'평면 · 날개벽 사이 (skew 평행사변형) · 모따기 = 날개벽 헌치 · 축척 같음',
);
addFig(
abutSlabSectionSvg({
form: slab.form,
slopeLabel: slopeText,
section: slabSection(slab, gradePct, sh),
parapetTopEl: parapetTopMid,
bracketHeightM: sh.bracketHeightM,
parapetBackM: sh.parapetBackM,
wallBackM: sh.wallBackM,
lengthMm: slab.lengthMm,
thicknessMm: slab.thicknessMm,
parapetGapMm: slab.parapetGapMm,
haunchLengthMm: slab.haunchLengthMm,
}),
`단면 · 접합 상세 · 교대축 직각 (교대축 가운데) · ${SLAB_FORM_LABEL[slab.form]} · 경사 ${slopeText} · 축척 같음, 중간 생략 · 간격 · 고무판은 키워 그림, 부속재는 표시용`,
);
box.append(figs);
// 흉벽 높이 = 브라켓 상면 ~ 흉벽 상단 (2026-11-04): 벽체·흉벽 카드의 "브라켓 높이"와 같은 값.
{
const id = idOf('slab-parapet');
const lab = el('label', '흉벽 높이 · 브라켓 상면 ~ 흉벽 상단 (mm)');
lab.htmlFor = id;
const now = Math.round(sh.bracketHeightM * 1000);
const input = numberInput(id, String(now));
if (now !== ABUT_SHAPE_DEFAULT.bracketHeightMm) input.classList.add('changed-input');
input.addEventListener('change', () => {
try {
apply(applyShapeEdit(rec, 'bracketHeightMm', Number(input.value)));
} catch (err) {
fail(err, input, String(now));
}
});
box.append(lab, input, el('span', `기본 ${ABUT_SHAPE_DEFAULT.bracketHeightMm} · = 벽체·흉벽 카드 브라켓 높이`, 'muted small'));
}
for (const f of SLAB_EDIT_FIELDS) {
const id = idOf(`slab-${f.key}`);
const lab = el('label', f.label);
lab.htmlFor = id;
const input = numberInput(id, String(slab[f.key]));
if (slab[f.key] !== ABUT_SLAB_DEFAULT[f.key]) input.classList.add('changed-input');
const off = f.exposedOnly && slab.form !== 'exposed';
input.disabled = Boolean(off);
input.addEventListener('change', () => {
try {
apply(applySlabEdit(rec, f.key, Number(input.value)));
} catch (err) {
fail(err, input, String(slab[f.key]));
}
});
box.append(lab, input, el('span', off ? '노출형만' : `기본 ${ABUT_SLAB_DEFAULT[f.key]}${f.note ? ` · ${f.note}` : ''}`, 'muted small'));
}
const auto = (name: string, value: string, note: string) => box.append(el('label', name), el('span', value, 'num'), el('span', note, 'muted small'));
const side = (mm: number, w: WingSolid | undefined) => (w ? (mm > 0 ? `${mm} × ${mm}` : '없음') : '날개벽 없음');
auto('모따기 (자동)', `좌 ${side(chamfer.left, leftWing)} · 우 ${side(chamfer.right, rightWing)}`, '= 그 측 날개벽 헌치');
auto('폭 (자동)', `${widthM.toFixed(3)} m`, '도면 11.670 · QA-6');
const bearing = Math.round((sh.parapetBackM - sh.wallBackM) * 1000) - slab.parapetGapMm;
auto('브라켓 얹힘 길이 (자동)', `${bearing} mm`, '= 브라켓 폭 − 흉벽과 간격');
if (slab.form === 'exposed') auto('끝 두께 (자동)', `${Math.round(sh.bracketHeightM * 1000)} mm`, '= 흉벽 상단 − 브라켓 상면');
box.append(
el(
'p',
'시안 메모 · 매립형은 브라켓 상면에 얹혀 두께가 일정하고, 벽체 배면 뒤에서 경사를 따릅니다. 노출형(2026-11-04 삽도 기준, QA-8 가정)은 상면이 흉벽 상단과 같고 끝은 브라켓 위에 얹히며, 벽체 배면부터 헌치 길이 동안 두께가 끝 두께에서 슬래브 두께로 줄어듭니다. 경사는 형식이 정합니다(매립형 하향 4 %, 노출형 종단경사). 흉벽 높이는 벽체·흉벽 카드의 브라켓 높이와 같은 값입니다. 접합 부속재(고무판, 타르페이퍼, 다웰, 강관)는 표시용이고, 배근은 철근 단계에서 정합니다(노출형 배근 규칙 미정).',
'muted small abut-memo',
),
);
}
/**
* ② 지반·지층 카드 (교대, 2026-11-04: 교각 카드와 같은 입력): 위치 → 위치별 수위 입력 → 단면 삽도 → 자동 값 → 지층 행 →
* 지반 모델 → 검사 → 메모. 표시만 하고 형상 계산에는 쓰지 않는다. 지반고는 ①에서 고친다.
*/
function fillAbutGroundCard(
box: HTMLDivElement,
rec: AbutRecord,
apply: (next: AbutRecord) => void,
fail: (err: unknown, input: HTMLInputElement, back: string) => void,
idOf: (suffix: string) => string,
numberInput: (id: string, value: string) => HTMLInputElement,
): void {
const g: AbutGround = rec.ground ?? {};
const position = g.groundPosition ?? '육상';
const set = (patch: Partial<AbutGround>) => apply(setAbutGround(rec, patch));
box.append(
optionTiles(
'위치',
GROUND_POSITIONS.map((p) => ({ label: p, on: position === p, isDefault: p === '육상', pick: () => position !== p && set({ groundPosition: p }) })),
'표시만 합니다 (형상 계산에 쓰지 않음)',
),
);
// 위치별 수위 입력 (교각과 같음): 육상 지하수위, 하상 설계홍수위 · 세굴, 해상 고조위 · 저조위 · 세굴. 비우면 값 없음.
const level = (key: 'groundWaterEl' | 'floodLevelEl' | 'scourDepthM' | 'highWaterEl' | 'lowWaterEl', label: string, note: string, disabled = false) => {
const id = idOf(`ground-${key}`);
const lab = el('label', label);
lab.htmlFor = id;
const v = g[key];
const input = numberInput(id, v === undefined ? '' : String(v));
input.disabled = disabled;
input.addEventListener('change', () => {
try {
set({ [key]: input.value === '' ? undefined : Number(input.value) });
} catch (err) {
fail(err, input, v === undefined ? '' : String(v));
}
});
box.append(lab, input, el('span', note, 'muted small'));
};
if (position === '하상') {
level('floodLevelEl', '설계홍수위 DHWL EL (m)', '하상');
level('scourDepthM', '세굴 깊이 (m)', '지반고 아래');
} else if (position === '해상') {
level('highWaterEl', '고조위 HWL (m)', `기준면 ${SEA_DATUM}`);
level('lowWaterEl', '저조위 LWL (m)', `기준면 ${SEA_DATUM}`);
level('scourDepthM', '세굴 깊이 (m)', `지반고 칸 = 해저면 EL · 부식 여유 ${CORROSION_ALLOWANCE_MM} mm (확인 필요)`);
} else {
const id = idOf('ground-water-on');
const check = el('input');
check.type = 'checkbox';
check.id = id;
check.checked = g.groundWaterOn === true;
check.addEventListener('change', () => set({ groundWaterOn: check.checked }));
const lab = el('label', '지하수위 고려');
lab.htmlFor = id;
box.append(lab, check, el('span', '', 'muted small'));
level('groundWaterEl', '지하수위 EL (m)', g.groundWaterOn ? '' : '지하수위 고려를 켜면 입력', g.groundWaterOn !== true);
}
let members: AbutMembers | undefined;
try {
const model = buildAbut(toAbutInput(rec));
members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape);
} catch {
members = undefined;
}
if (members && members.footingBottomEl !== undefined) {
const s = members.shape;
const fig = el('figure', undefined, 'ground-fig');
fig.innerHTML = abutGroundSectionSvg({
ground: { ...g, groundEl: rec.groundEl },
footingTopEl: members.footingTopEl,
footingBottomEl: members.footingBottomEl,
seatEl: rec.seatEl,
parapetTopEl: members.parapetTopStartEl,
bracketHeightM: s.bracketHeightM,
wallFrontM: s.wallFrontM,
wallBackM: s.wallBackM,
parapetBackM: s.parapetBackM,
footingFrontM: s.footingFrontM,
footingBackM: s.footingBackM,
pileRowsT: members.piles.map((r) => r.t),
pileBottomEl: members.pileBottomEl,
});
fig.append(el('figcaption', '단면 · 교대축 직각 · 저판 · 벽체 · 흉벽 · 말뚝은 생성본, 지층과 수위는 입력값 · 세로는 EL 축척, 가로는 모식', 'muted small'));
box.append(fig);
}
const rows = g.strataRows ?? [];
const top = supportTopEl(rec.groundEl, rows);
const auto = (name: string, value: string, note: string) => box.append(el('label', name), el('span', value, 'num'), el('span', note, 'muted small'));
auto('지반고 EL (엑셀 입력)', `EL.${rec.groundEl.toFixed(3)}`, '① 기본 정보에서 수정');
auto('연암층 상면 EL (자동)', top === undefined ? '연암 행 없음' : `EL.${top.toFixed(3)}`, '지층 행에서');
auto('지층 수 (자동)', `${rows.length}`, '지층 행에서');
box.append(...strataRowsEditor(rows, rec.groundEl, (next) => set({ strataRows: next.length ? next : undefined })));
// 지반 모델의 지층 (ground_model.json에 이 교대가 있을 때): one click fills the rows.
if (groundModel) {
const modelRows = strataRowsFor(groundModel, rec.name);
if (modelRows) {
const fromModel = el('button', `지반 모델의 지층 ${modelRows.length}개 불러오기`, 'btn small');
fromModel.type = 'button';
fromModel.addEventListener('click', () => set({ strataRows: modelRows.map((r) => ({ ...r })) }));
box.append(fromModel);
} else {
box.append(el('p', `지반 모델에 ${rec.name}의 지층이 없습니다.`, 'muted small'));
}
}
const issues = abutGroundIssues(rec, members);
box.append(el('p', `검사 · ${issues.length ? issues.map((i) => `${i.code} ${i.message}`).join(' · ') : '경고 없음'}`, 'muted small abut-checks'));
box.append(
el(
'p',
'시안 메모 · 교각과 같은 카드입니다. 위치 · 수위 · 지층은 표시와 검사에만 쓰고 형상 계산에는 쓰지 않습니다(3D는 지반면만). 연암 행이 있으면 말뚝 선단 근입(연암 상면 − 1.0 D)을 검사합니다(G04). 지반고가 저판 하면보다 낮으면 흙덮개가 없다는 정보(AI01)를 표시합니다. 다른 구조물에 적용하기는 교대 속성 복사 범위 밖이라 두지 않았습니다.',
'muted small abut-memo',
),
);
}
/** 확대기초 · 말뚝 카드의 자동 값, 열 위치, 열별 본수 표(− / +), 메모 (시안, 2026-11-04). 말뚝 길이는 ①에서 고친다. */
function fillAbutFootingExtras(
box: HTMLDivElement,
rec: AbutRecord,
v: AbutShape,
built: { model: AbutModel; members: AbutMembers },
apply: (next: AbutRecord) => void,
fail: (err: unknown, input: HTMLInputElement, back: string) => void,
idOf: (suffix: string) => string,
): void {
const ends = footingEnds(v);
const rows = pileRowsMm(v);
const L = built.model.length;
const auto = (name: string, value: string, note: string) => box.append(el('label', name), el('span', value, 'num'), el('span', note, 'muted small'));
auto('뒷굽 (자동)', `${ends.heelMm} mm`, '= 폭 − 앞굽 − 벽체 두께');
auto('길이 (자동)', `${L.toFixed(3)} m`, '교좌면 길이 (skew)');
auto('말뚝 길이', `${Math.round(rec.pileLengthMm)} mm`, '① 기본 정보에서 수정');
auto('선단 EL (자동)', built.members.pileBottomEl === undefined ? '—' : `${built.members.pileBottomEl.toFixed(3)} m`, '= 기초 하단 − 말뚝 길이');
abutFckRow(box, 'abutFooting', idOf, '교각 기초와 별도');
// 열 위치 (배면부터): "650 · 2000 · 1300 · 650". The last one (전면 연단) follows from the 폭.
const id = idOf('pile-rows');
const lab = el('label', '열 위치 · 배면부터 (mm)');
lab.htmlFor = id;
const text = `${v.pileRowBackEdgeMm} · ${v.pileRowGap1Mm} · ${v.pileRowGap2Mm} · ${rows.frontEdgeMm}`;
const input = el('input');
input.id = id;
input.type = 'text';
input.value = text;
input.addEventListener('change', () => {
const n = input.value.split(/[·,\s]+/).filter(Boolean).map(Number);
try {
if (n.length < 3 || n.some((x) => !Number.isFinite(x))) throw new Error('열 위치는 "650 · 2000 · 1300 · 650"처럼 숫자 3~4개로 입력하세요.');
if (n.length === 4 && Math.abs(n.reduce((a, b) => a + b, 0) - v.footingWidthMm) > 0.5) throw new Error(`열 위치의 합이 폭 ${v.footingWidthMm}과 같아야 합니다.`);
apply(applyShapeEdits(rec, { pileRowBackEdgeMm: n[0], pileRowGap1Mm: n[1], pileRowGap2Mm: n[2] }));
} catch (err) {
fail(err, input, text);
}
});
box.append(lab, input, el('span', `전면 연단 ${rows.frontEdgeMm} (자동)`, 'muted small'));
auto('본수 규칙', `int((L − ${2 * v.pileEdgeMm})/${v.pileSpacingMm}) + 1`, `= ${Math.floor((L * 1000 - 2 * v.pileEdgeMm) / v.pileSpacingMm) + 1}본`);
// 열별 말뚝 본수 (− / +): 0 = 본수 규칙.
const piles = built.members.piles;
const total = piles.reduce((a, r) => a + r.s.length, 0);
box.append(el('p', `열별 말뚝 본수 · 합계 ${total}본`, 'lbl'));
const table = document.createElement('table');
table.className = 'abut-seg';
const head = table.createTHead().insertRow();
for (const h of ['열', '위치 (교대축 기준)', '본수', '본 간격']) head.append(el('th', h));
const body = table.createTBody();
const order: { key: 'pileCountRow1' | 'pileCountRow2' | 'pileCountRow3'; name: string; row: number }[] = [
{ key: 'pileCountRow1', name: '1열 · 전면', row: 2 },
{ key: 'pileCountRow2', name: '2열', row: 1 },
{ key: 'pileCountRow3', name: '3열 · 배면', row: 0 },
];
for (const o of order) {
const r = piles[o.row];
if (!r) continue;
const n = r.s.length;
const gap = n > 1 ? Math.round(((L - 2 * v.pileEdgeMm / 1000) / (n - 1)) * 1000) : 0;
const tr = body.insertRow();
tr.append(el('td', o.name), el('td', `${r.t >= 0 ? '+' : '−'}${Math.round(Math.abs(r.t) * 1000)}`));
const cell = el('td');
const step = (delta: number) => {
const b = el('button', delta < 0 ? '−' : '+', 'btn small');
b.type = 'button';
b.disabled = delta < 0 && n <= 2;
b.addEventListener('click', () => {
try {
apply(applyShapeEdit(rec, o.key, n + delta));
} catch (err) {
$<HTMLElement>('abut-panel-plan-msg').textContent = (err as Error).message;
}
});
return b;
};
cell.append(step(-1), el('span', ` ${n} `, 'num'), step(1));
if (v[o.key] > 0) cell.append(el('span', ' 수정됨', 'muted small abut-note-warn'));
tr.append(cell, el('td', n > 1 ? `${v.pileEdgeMm} · ${n - 1}@${gap} · ${v.pileEdgeMm}` : `${v.pileEdgeMm}`));
}
box.append(table);
box.append(
el(
'p',
'시안 메모 · 확대기초는 교대 저판입니다(엑셀 "저판"). skew 평면에서는 말뚝 열이 skew만큼 밀리고, 연단 650은 그 열의 끝에서 잽니다. 직사각 평면은 다음 단계입니다. 말뚝 지름(D600 / 609.6)과 전면 열 본수는 확인 필요(QA-5).',
'muted small abut-memo',
),
);
}
/** Shows only the card of the open row in the ② 교대 editor; the basic items stay visible. */
function applyAbutMemberCard(rec: AbutRecord): void {
const member = state.abutMember;
$<HTMLElement>('abut-panel-plan-bearings').hidden = member !== 'bearing';
$<HTMLElement>('abut-panel-plan-shape').hidden = member !== 'shape';
$<HTMLElement>('abut-panel-plan-footing').hidden = member !== 'footing';
$<HTMLElement>('abut-panel-plan-wing').hidden = member !== 'wing';
$<HTMLElement>('abut-panel-plan-slab').hidden = member !== 'slab';
$<HTMLElement>('abut-panel-plan-ground').hidden = member !== 'ground';
// The card title is the picked row; its 요약 follows the name (시안 머리줄).
const slabWidth = abutSummaryRows(rec).find(([label]) => label === '접속슬래브 폭')?.[1] ?? '—';
const row = abutMemberRows(rec, slabWidth).find((r) => r.id === member);
$<HTMLElement>('abut-panel-plan-member').textContent = row?.label ?? '';
$<HTMLElement>('abut-panel-plan-name').textContent = `${rec.name} · ${row?.summary ?? ''}`;
$<HTMLElement>('abut-panel-plan-msg').textContent = ABUT_MEMBER_EDITABLE[member] ? '' : `${rec.name}: 이 부재의 입력은 2단계에서 추가합니다. 현재 값만 보여 줍니다.`;
}
/** Replaces one abutment record, rebuilds the 3D view and redraws (② cards and ③ 교대 배근). */
function applyAbutRecord(next: AbutRecord): void {
state.abutments = state.abutments.map((a) => (a.name === next.name ? next : a));
setupScene();
renderAll();
}
/** Fills one 교대 입력 panel: the basic items, the 받침 values and the 형상 values. Each change is checked and applied. */
function fillAbutSlot(slot: (typeof ABUT_SLOTS)[keyof typeof ABUT_SLOTS], rec: AbutRecord): void {
$<HTMLElement>(slot.name).textContent = rec.name;
// 입력값 (read only, the mockup's "입력값" card) and 상세 (계산값, 8 rows), both as lists of label and value (2026-11-03).
const fillList = (dl: HTMLDListElement, rows: [string, string][]) => {
dl.replaceChildren();
for (const [label, value] of rows) dl.append(el('dt', label), el('dd', value));
};
// ② has no 입력값 list: its values are in ① and on the 삽도 labels (2026-11-03).
if ('input' in slot) fillList($<HTMLDListElement>(slot.input), abutInputRows(rec));
// ② has no 상세 (계산값) block (2026-11-03); ① keeps it.
if ('summary' in slot) fillList($<HTMLDListElement>(slot.summary), abutSummaryRows(rec).filter(([label]) => ABUT_DETAIL_LABELS.includes(label)));
// 편집 opens the editable fields in ①; they stay closed until asked for (2026-11-03). ② has no toggle.
if ('toggle' in slot) {
const toggle = $<HTMLButtonElement>(slot.toggle);
toggle.textContent = state.abutEditOpen ? '닫기' : '편집';
toggle.setAttribute('aria-expanded', String(state.abutEditOpen));
toggle.onclick = () => {
state.abutEditOpen = !state.abutEditOpen;
renderAll();
};
$<HTMLElement>(slot.editor).hidden = !state.abutEditOpen;
}
// ② has no basic items; a detached box keeps the shared code simple.
const basic = 'basic' in slot ? $<HTMLDivElement>(slot.basic) : el('div');
const bearings = $<HTMLDivElement>(slot.bearings);
const shape = $<HTMLDivElement>(slot.shape);
const msg = $<HTMLParagraphElement>(slot.msg);
basic.replaceChildren();
// ② puts the wing forms in their own card; ① keeps them in the basic items.
// ② (plan card) shows no basic items: they are edited in ① only, and ② reads them off the 삽도 labels (2026-11-03).
const planCard = 'wing' in slot;
const wingBox = planCard ? $<HTMLDivElement>(slot.wing) : basic;
wingBox.replaceChildren();
bearings.replaceChildren();
shape.replaceChildren();
msg.textContent = '';
const apply = (next: AbutRecord) => applyAbutRecord(next);
const fail = (err: unknown, input: HTMLInputElement, back: string) => {
msg.textContent = (err as Error).message;
input.value = back;
};
const numberInput = (id: string, value: string) => {
const input = el('input');
input.id = id;
input.type = 'number';
input.step = 'any';
input.value = value;
return input;
};
const idOf = (suffix: string) => `${slot.prefix}-${rec.name}-${suffix}`;
for (const f of planCard ? [] : ABUT_EDIT_FIELDS) {
const id = idOf(f.key);
const lab = el('label', `${f.label}${f.unit ? ` (${f.unit})` : ''}`);
lab.htmlFor = id;
const input = numberInput(id, String(rec[f.key]));
input.addEventListener('change', () => {
try {
apply(applyAbutEdit(rec, f.key, Number(input.value)));
} catch (err) {
fail(err, input, String(rec[f.key]));
}
});
basic.append(lab, input);
}
// ② draws the 날개벽 card (시안); ① keeps the two form selects.
if (planCard) fillAbutWingCard(wingBox, rec, apply, fail, idOf, numberInput);
if ('slab' in slot) {
const slabBox = $<HTMLDivElement>(slot.slab);
slabBox.replaceChildren();
fillAbutSlabCard(slabBox, rec, apply, fail, idOf, numberInput);
}
if ('ground' in slot) {
const groundBox = $<HTMLDivElement>(slot.ground);
groundBox.replaceChildren();
fillAbutGroundCard(groundBox, rec, apply, fail, idOf, numberInput);
}
for (const side of planCard ? [] : (['left', 'right'] as const)) {
const id = idOf(`wing-${side}`);
const lab = el('label', side === 'left' ? '좌 날개벽 (① 쪽)' : '우 날개벽 (n 쪽)');
lab.htmlFor = id;
const sel = el('select');
sel.id = id;
for (const [value, text] of [['general', '일반형'], ['short', '단축형'], ['none', '없음']] as const) sel.appendChild(new Option(text, value));
sel.value = rec.wings?.[side] ?? 'general';
sel.addEventListener('change', () => apply(setWingForm(rec, side, sel.value as WingForm)));
wingBox.append(lab, sel);
}
const restore = el('button', '기본값으로 되돌리기', 'btn small');
restore.type = 'button';
restore.addEventListener('click', () => {
const def = state.abutDefaults.find((a) => a.name === rec.name);
if (def) apply(restoreAbutDefault(def));
});
if (!planCard) basic.appendChild(restore);
// ① keeps the 받침별 z · 교좌면 inputs; ② shows the 교량받침 카드 (tables, rules, checks) instead (2026-11-03).
if (planCard) fillAbutBearingCard(bearings, rec, apply, fail, idOf, numberInput);
for (const b of planCard ? [] : rec.bearings) {
const zId = idOf(`bz-${b.no}`);
const zLab = el('label', `받침 ${b.no} z (m)`);
zLab.htmlFor = zId;
const zIn = numberInput(zId, String(b.z));
zIn.addEventListener('change', () => {
try {
apply(applyBearingEdit(rec, b.no, 'z', Number(zIn.value)));
} catch (err) {
fail(err, zIn, String(b.z));
}
});
const sId = idOf(`bs-${b.no}`);
const sLab = el('label', `받침 ${b.no} 교좌면 (m, 비우면 자동)`);
sLab.htmlFor = sId;
const sIn = numberInput(sId, b.seatEl === undefined ? '' : String(b.seatEl));
sIn.addEventListener('change', () => {
try {
apply(applyBearingEdit(rec, b.no, 'seatEl', sIn.value === '' ? undefined : Number(sIn.value)));
} catch (err) {
fail(err, sIn, b.seatEl === undefined ? '' : String(b.seatEl));
}
});
bearings.append(zLab, zIn, sLab, sIn);
}
// ② 벽체·흉벽 카드 (시안 순서, 2026-11-03): 벽체 형식 → 삽도 2개 → 입력 격자 → 자동 값 → 구간 표 → 확인 필요 → 메모.
// 저판·말뚝 입력은 확대기초 · 말뚝 카드로 나눈다. ①은 모든 형상 입력을 한 곳에 둔다.
const footingBox = 'footing' in slot ? $<HTMLDivElement>(slot.footing) : shape;
footingBox.replaceChildren();
let built: { model: AbutModel; members: AbutMembers } | null = null;
if (planCard) {
try {
const model = buildAbut(toAbutInput(rec));
built = { model, members: buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape) };
} catch {
// A model that does not build shows no 삽도 or 구간 표 (the errors are reported on import).
}
const qa8 = '규칙 미정 (QA-8)';
shape.append(
optionTiles(
'벽체 형식',
[
{ label: '기본형', icon: ICON.wallBasic, on: true, isDefault: true },
{ label: '브라켓형', icon: ICON.wallBracket, on: false, disabled: qa8 },
{ label: '오목형', icon: ICON.wallConcave, on: false, disabled: qa8 },
{ label: '반일체식', icon: ICON.wallSemi, on: false, disabled: qa8 },
],
'브라켓형 · 오목형 · 반일체식은 규칙 미정이라 선택할 수 없습니다',
),
);
if (built) {
const v = { ...ABUT_SHAPE_DEFAULT, ...normalizeShape(rec.shape) };
const s = built.members.shape;
const drawing: AbutWallDrawing = {
lengthM: built.model.length,
segments: built.members.wallSegments,
bearings: built.model.bearings,
footingTopEl: built.members.footingTopEl,
footingBottomEl: built.members.footingBottomEl ?? built.members.footingTopEl - s.footingThicknessM,
parapetTopStartEl: built.members.parapetTopStartEl,
parapetTopEndEl: built.members.parapetTopEndEl,
wallFrontM: s.wallFrontM,
parapetBackM: s.parapetBackM,
wallBackM: s.wallBackM,
footingFrontM: s.footingFrontM,
footingBackM: s.footingBackM,
bracketHeightM: s.bracketHeightM,
shrinkJointCount: v.shrinkJointCount,
bracketWidthMm: v.bracketWidthMm,
parapetThicknessMm: v.parapetThicknessMm,
seatWidthMm: v.wallFrontMm,
};
const figs = el('div', undefined, 'abut-wall-figs');
for (const [svg, caption] of [
[abutWallSectionSvg(drawing), '교대축 직각 단면 · ① 구간 · 축척 같음'],
[abutWallElevationSvg(drawing), '정면 · 배면에서 본 모습 · 왼쪽 IP3(① 쪽) → 오른쪽 IP1 · 수축줄눈 표시'],
]) {
const fig = el('figure', undefined, 'abut-fig');
fig.innerHTML = svg;
fig.append(el('figcaption', caption, 'muted small'));
figs.append(fig);
}
shape.append(figs);
}
}
// ② 확대기초 · 말뚝 카드 (시안, 2026-11-04): 평면 · 말뚝 선택 → 평면 삽도 → 입력 격자 → 자동 값 → 열 위치 → 열별 본수 → 메모.
const shapeAll = { ...ABUT_SHAPE_DEFAULT, ...normalizeShape(rec.shape) };
if (planCard) {
footingBox.append(
optionTiles(
'평면',
[
{ label: 'skew 평행사변형', on: true, isDefault: true },
{ label: '직사각', on: false, disabled: '다음 단계 (길이 = 교좌면 길이 + 폭 × |cot skew|, 100 올림)' },
],
'직사각 평면은 다음 단계입니다',
),
optionTiles(
'말뚝',
[
{ label: `원기둥 D${shapeAll.pileDiameterMm}`, on: true, isDefault: true },
{ label: '강관 · PHC', on: false, disabled: '규칙 미정' },
],
'강관 · PHC는 규칙 미정입니다 (QA-5)',
),
);
if (built) {
const rows = pileRowsMm(shapeAll);
const names = ['3열 · 배면', '2열', '1열 · 전면'];
const fig = el('figure', undefined, 'abut-fig abut-footing-fig');
fig.innerHTML = abutFootingPlanSvg({
lengthM: built.model.length,
skewK: abutSkewK(built.model),
footingFrontM: built.members.shape.footingFrontM,
footingBackM: built.members.shape.footingBackM,
wallFrontM: built.members.shape.wallFrontM,
wallBackM: built.members.shape.wallBackM,
rows: built.members.piles.map((r, i) => ({ t: r.t, s: r.s, name: names[i] ?? `${i + 1}열` })),
pileDiameterM: built.members.shape.pileDiameterM,
rowDimsMm: [shapeAll.pileRowBackEdgeMm, shapeAll.pileRowGap1Mm, shapeAll.pileRowGap2Mm, rows.frontEdgeMm],
footingWidthMm: shapeAll.footingWidthMm,
});
fig.append(el('figcaption', '평면 · 축척 같음 · 실선 = 벽체, 굵은 선 = 확대기초 · 열마다 skew만큼 밀림', 'muted small'));
footingBox.append(fig);
}
}
for (const f of SHAPE_EDIT_FIELDS) {
const current = normalizeShape(rec.shape)[f.key] ?? ABUT_SHAPE_DEFAULT[f.key];
const id = idOf(`shape-${f.key}`);
const lab = el('label', f.label);
lab.htmlFor = id;
const input = numberInput(id, String(current));
if (current !== ABUT_SHAPE_DEFAULT[f.key]) input.classList.add('changed-input');
input.addEventListener('change', () => {
try {
apply(applyShapeEdit(rec, f.key, Number(input.value)));
} catch (err) {
fail(err, input, String(current));
}
});
const note = el('span', `기본 ${ABUT_SHAPE_DEFAULT[f.key]}${f.note ? ` · ${f.note}` : ''}`, f.note ? 'muted small abut-note-warn' : 'muted small');
(planCard && f.group === 'footing' ? footingBox : shape).append(lab, input, note);
}
// 교대 벽체 · 흉벽 · 날개벽 · 접속슬래브 fck (도면 35): ② 벽체·흉벽 카드에서 입력.
if (planCard) abutFckRow(shape, 'abutBody', idOf, '벽체 · 흉벽 · 날개벽 · 접속슬래브');
if (planCard) {
// 자동 값 (시안 입력 격자 마지막 줄): 벽체 두께, 흉벽 상단 IP3 / IP1 끝 (가정, QA-4), 교대축.
const v = { ...ABUT_SHAPE_DEFAULT, ...normalizeShape(rec.shape) };
shape.append(el('label', '벽체 두께 (자동)'), el('span', `${wallThicknessMm(v)} mm`, 'num'), el('span', '= 브라켓 폭 + 흉벽 두께 + 교좌면 폭', 'muted small'));
if (built) {
shape.append(
el('label', '흉벽 상단 IP3 / IP1 끝 (m)'),
el('span', `${built.members.parapetTopEndEl.toFixed(3)} / ${built.members.parapetTopStartEl.toFixed(3)}`, 'num'),
el('span', '가정 (QA-4)', 'muted small abut-note-warn'),
el('label', '교대축 (자동)'),
el('span', `${built.model.length.toFixed(3)} m`, 'num'),
el('span', 'IP1 → IP3', 'muted small'),
);
// 구간 표 (시안 하단): one row per 교좌면 step.
const table = document.createElement('table');
table.className = 'abut-seg';
const thead = table.createTHead().insertRow();
for (const h of ABUT_SEGMENT_HEADER) thead.append(el('th', h));
const tbody = table.createTBody();
for (const row of abutSegmentRows(built.model, built.members)) {
const tr = tbody.insertRow();
for (const cell of row) tr.append(el('td', cell));
}
shape.append(el('p', '교좌면 계단 구간 · IP3(① 쪽)에서 잰 거리', 'lbl'), table);
}
// 확인 필요 (spec_abut, 시안 오른쪽 패널): the open 교대 questions that touch the shape (2026-11-03).
const qa = el('div', undefined, 'abut-qa');
qa.append(el('p', '확인 필요 (spec_abut)', 'lbl'));
for (const line of ABUT_QA_NOTES) qa.append(el('p', line, 'muted small'));
shape.append(qa);
if (built) fillAbutFootingExtras(footingBox, rec, shapeAll, built, apply, fail, idOf);
shape.append(
el(
'p',
'시안 메모 · 벽체는 교좌면 계단 구간마다 높이가 다릅니다. 벽체 높이 H_w(기본 3326)는 받침 ① 위치에서 받침 ① 교좌면 ~ 흉벽 상단 연직 거리이고(사용자 도면 2026-11-04, QA-4 확정), 흉벽 상단은 받침 z 경사를 따릅니다. Level 좌·우는 계산에 쓰지 않습니다. 인접 무시 기준보다 작은 교좌면 단차는 한 구간으로 묶습니다.',
'muted small abut-memo',
),
);
}
}
function abutSummary(rec: AbutRecord): string {
return `${rec.name} (${rec.type}): 교좌면 EL ${rec.seatEl.toFixed(3)} m · 기초 하단 ${rec.foundationBottomEl.toFixed(3)} m · 지반고 ${rec.groundEl.toFixed(3)} m · 받침 ${rec.bearings.length}개 · skew ${(rec.skewDeg).toFixed(2)}° · 종단경사 ${(rec.slopeI * 100).toFixed(2)} %`;
}
/** Rows of the pier list. ① shows the type and warnings; ② shows the count of changes from the defaults. */
function renderPierRows(list: HTMLUListElement, mode: Stage): void {
list.replaceChildren();
const visible = state.piers.filter((p) => state.filter === 'all' || p.input.type === state.filter);
// 교대 rows (2026-11-03, AB): A1 first, A2 last, shown in ① with the piers when the filter is 전체.
const abuts = (mode === 'shape' || mode === 'plan' || mode === 'rebar') && state.filter === 'all' ? structureOrder(state.abutments, visible.map((p) => p.input.name)) : [];
const abutRefs = abuts.filter((r) => r.kind === 'abut');
for (const ref of abutRefs.filter((r) => r.type === 'A1')) {
const rec = state.abutments.find((a) => a.name === ref.name);
if (rec) list.appendChild(abutRow(rec, mode));
}
for (const pier of visible) {
const li = el('li');
// 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';
// One highlighted row at a time: while an abutment is being edited, no pier row is pressed (2026-11-03).
name.setAttribute('aria-pressed', String(pier.input.name === state.active && state.abutActive === null));
name.addEventListener('click', () => {
state.active = pier.input.name;
state.abutActive = null;
state.quantityAll = false;
state.rebarSelection = new Set();
setupScene();
renderAll();
focusPier(pier.input.name);
});
const badge = mode === 'plan'
? el('span', `변경 ${overrideKeys(pier).length}`, 'badge')
: mode === 'rebar' || mode === 'quantity'
? (pier.model ? el('span', pier.input.foundationType === '현장타설말뚝' || pier.model.columns.length > 1 ? '일부 계산' : '계산됨', 'badge') : el('span', '오류', 'badge error'))
: pier.model
? el('span', `${pier.input.type}${pier.issues.some((i) => i.level !== 'I') ? ' · 경고' : ''}`, 'badge')
: el('span', '오류', 'badge error');
li.append(name, badge);
list.appendChild(li);
}
// 종점 교대 (A2) and any other form come after the piers.
for (const ref of abutRefs.filter((r) => r.type !== 'A1')) {
const rec = state.abutments.find((a) => a.name === ref.name);
if (rec) list.appendChild(abutRow(rec, mode));
}
}
function setHeaderStatus(id: string, short: string, detail: string): void {
const status = $(id);
status.textContent = short;
const button = status.parentElement!;
button.title = detail;
button.setAttribute('aria-label', `${button.dataset.label} · ${detail}`);
}
function renderHeader(): void {
const pier = activePier();
const parts = [state.project.name || '프로젝트 미지정'];
if (state.project.bridge && state.project.bridge !== state.project.name) parts.push(state.project.bridge);
parts.push(`교각 ${state.piers.length}기`);
$('project-line').textContent = parts.join(' · ');
$('project-line').title = parts.join(' · ');
setHeaderStatus('step1-status', '', pier?.model ? '생성됨' : '미생성');
renderSaveStatus();
const total = pendingTotal();
const targets = generationTargets();
setHeaderStatus('step2-status', total ? `변경·${total}` : '', total ? `변경 ${total}건 · 재생성 필요` : '변경 없음');
const regen = $<HTMLButtonElement>('regen');
regen.disabled = targets.length === 0;
regen.textContent = targets.length ? `재생성·${targets.length}` : '생성';
regen.title = 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) {
const row = el('div');
row.append(el('dt', k), el('dd', v));
dl.appendChild(row);
}
}
const f3 = (v: number) => v.toFixed(3);
const pt = (p: { e: number; n: number }) => `E ${p.e.toFixed(3)}, N ${p.n.toFixed(3)}`;
function renderPanels(): void {
const pier = activePier();
const m = pier?.model;
// The pier name is in 교각 목록 on wide screens; the title keeps it on narrow ones (승인 2026-10-23).
$('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'), []);
fillKv($('res-column'), []);
fillKv($('res-found'), []);
$('issues').replaceChildren(el('li', '교각을 불러오면 알림이 표시됩니다.'));
$('issue-count').textContent = '';
return;
}
const inp = pier.input;
const front = inp.bearings.filter((b) => b.row === '전열').length;
const back = inp.bearings.filter((b) => b.row === '후열').length;
const ip2 = inp.ips.find((p) => p.name === 'IP4');
fillKv($('inputs'), [
['교각 / 기초 형식', `${inp.type} · ${inp.foundationType}`],
['코핑 상면 EL', `${f3(inp.copingTopEl)} m`],
['기초 하단 EL', `${f3(inp.footingBottomEl)} m`],
['지반고 EL', `${f3(inp.groundEl)} m`],
['코핑 폭 × 길이', `${inp.copingWidthMm} × ${inp.copingLengthMm} mm`],
['말뚝 길이', `${inp.pileLengthMm} mm`],
['IP', ip2 && ip2.angleDeg === 0 ? 'IP1–IP3 직선 (꺾임 0°)' : '꺾임 코핑'],
['받침', `전열 ${front} · 후열 ${back}`],
]);
if (!m) {
fillKv($('res-axis'), [['상태', '생성 불가 (알림 참조)']]);
fillKv($('res-column'), []);
fillKv($('res-found'), []);
} else {
fillKv($('res-axis'), [
['IP 길이 / 축 방위각', `${f3(m.lengthIp)} m / ${m.azimuthDeg.toFixed(2)}°`],
['받침블럭 높이 전열 / 후열', `${Math.round(m.derived.blockHeightFront * 1000)} / ${Math.round(m.derived.blockHeightBack * 1000)} mm`],
['계단 경계 (BP1 기준)', m.segments.slice(1).map((s) => f3(s.s0)).join(' / ') + ' m'],
['횡방향 단차', '없음'],
['코핑 하면 EL 끝단 / Level', `${f3(m.endBottom)} / ${f3(m.levelBottom)} m`],
]);
fillKv($('res-column'), [
['기둥 높이', `${f3(m.derived.columnHeight)} m`],
['기둥 중심', pt(m.pivot)],
]);
fillKv($('res-found'), [
['확대기초 상면 EL', m.footings.length ? `${f3(m.footings[0].top)} m` : '없음'],
['말뚝 배치', m.derived.pileLayout ?? '없음'],
['말뚝 수 / 선단 EL', `${m.derived.pileCount} 본 / ${m.derived.pileTipEl === null ? '없음' : f3(m.derived.pileTipEl) + ' m'}`],
['흙덮개', `${f3(m.derived.soilCover)} m`],
]);
}
const list = $<HTMLUListElement>('issues');
list.replaceChildren();
if (pier.issues.length === 0) list.appendChild(el('li', '알림 없음'));
for (const issue of pier.issues) {
const li = el('li');
li.append(el('span', issue.code, `code ${issue.level}`), document.createTextNode(` ${issue.message}`));
list.appendChild(li);
}
$('issue-count').textContent = `(${pier.issues.length}건)`;
}
function renderDefaults(): void {
const box = $<HTMLUListElement>('defaults');
box.replaceChildren();
const items = [
'[미확정] Q16 받침 EL·E 식: 현재 모델(블럭 높이 = EL 차) 적용',
'[미확정] Q5 말뚝 지름 600 mm (강관) 기본값',
'[미확정] Q4 직접기초 버림콘크리트 적용',
...NOT_IMPLEMENTED_RULES.map((r) => `미구현 규칙: ${r}`),
];
for (const text of items) box.appendChild(el('li', text));
}
/** Pier last brought into view in 교각 목록, so that a manual scroll is not undone by the next render. */
let lastScrolledPier: string | null = null;
function renderAll(): void {
updatePartToggles();
const allToggle = $<HTMLButtonElement>('all-toggle');
allToggle.setAttribute('aria-pressed', String(state.allView));
allToggle.title = `${shownPiers().filter((p) => p.model).length}기를 한 화면에 (① 형상 생성)`;
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' });
}
$('status').textContent = state.piers.length ? '' : '엑셀을 불러오면 교각 형상을 생성합니다.';
renderHeader();
renderPierList();
renderPanels();
renderEditCard();
renderPlan();
renderRebar();
renderReviewTasks();
renderRebarColor();
if (state.stage === 'quantity') renderQuantityScreen();
scheduleBackup();
}
// --- 속성 복사 (시안 ⑥, plan C8) ---------------------------------------------------------
/** Names of the pier targets ticked in the copy panel. */
let copySelection = new Set<string>();
function copyCandidateList(): CopyCandidate[] {
return state.piers.map((p) => ({ name: p.input.name, type: p.input.type, hasModel: p.model !== null }));
}
/** The piers that get the change: only those ticked in the panel (기본 0기). */
function selectedCopyTargets(): Pier[] {
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 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 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.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(c.name);
else copySelection.delete(c.name);
renderCopyPreview();
});
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 box = $('cp-preview');
box.replaceChildren();
let applied = 0;
let skippedTotal = 0;
const selected = selectedCopyTargets();
for (const t of selected) {
if (!src) break;
const plan = planCopy(src.draft, t.draft, items);
const labels = plan.changes.map((c) => `${FIELD_LABELS[c.key]} ${c.from} → ${c.to}`);
const skipped = plan.skipped.map((k) => k.reason);
skippedTotal += skipped.length;
if (plan.changes.length) applied++;
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 = selected.length === 0 ? '대상을 고르세요' : `${selected.map((t) => t.input.name).join(', ')} ${selected.length}기에만 적용`;
apply.disabled = applied === 0;
if (items.length === 0) apply.textContent = '복사 항목 없음';
}
/** The inline 속성 복사 panel: source is the 기준 pier; targets are ticked inside the panel only. */
function renderCopyPanel(): void {
const src = activePier();
$('cp-source').textContent = src ? `${src.input.name} · ${src.input.type} · ${src.input.foundationType}` : '교각을 선택하세요';
renderCopyItems();
renderCopyTargets();
renderCopyPreview();
}
function applyCopyPanel(): void {
const src = activePier();
if (!src) return;
const items = chosenCopyItems();
const regen = $<HTMLInputElement>('cp-regen').checked;
let applied = 0;
let changes = 0;
const targets = new Set(selectedCopyTargets().map((t) => t.input.name));
state.piers = state.piers.map((t) => {
if (t === src || !targets.has(t.input.name)) return t;
const plan = planCopy(src.draft, t.draft, items);
if (plan.changes.length === 0) return t;
applied++;
changes += plan.changes.length;
const next = { ...t, draft: applyCopy(t.draft, plan) };
return regen ? regenerateEntry(next) : next;
});
setupScene();
renderAll();
setStatus(`${applied}기에 속성을 복사했습니다. 변경 ${changes}건${regen ? ' (형상 다시 생성)' : ' · 재생성 필요'}.`);
}
// --- input cards and change tracking (plan B1 · C2) -------------------------------------
function pendingTotal(): number {
return state.piers.reduce((n, p) => n + pendingKeys(p).length, 0);
}
function fieldLabel(key: EditableKey): string {
return FIELD_LABELS[key];
}
function editField(name: string, key: EditableKey, raw: string): void {
const p = state.piers.find((q) => q.input.name === name);
if (!p) return;
const value = Number(raw);
if (!Number.isFinite(value) || value <= 0) {
setError(`${fieldLabel(key)}은(는) 0보다 큰 숫자여야 합니다. 값을 되돌렸습니다.`);
renderAll();
return;
}
p.draft = { ...p.draft, [key]: value };
renderAll();
}
function revertField(name: string, key: EditableKey): void {
const p = state.piers.find((q) => q.input.name === name);
if (!p) return;
p.draft = revertDraft(p, key);
renderAll();
}
function regenerateOne(name: string): void {
const i = state.piers.findIndex((q) => q.input.name === name);
if (i < 0) return;
state.piers[i] = regenerateEntry(state.piers[i]);
setupScene();
renderAll();
const msg = state.piers[i].model ? '형상을 다시 생성했습니다.' : '형상을 생성하지 못했습니다. 알림을 확인하세요.';
setStatus(msg);
}
function renderEditCard(): void {
const pier = activePier();
const btn = $<HTMLButtonElement>('edit-regen');
if (!pier) {
$<HTMLDivElement>('edit-fields').replaceChildren();
$('edit-summary').textContent = '교각을 선택하면 입력을 고칠 수 있습니다.';
btn.textContent = '다시 생성 · 변경 0';
btn.disabled = true;
return;
}
const status = fieldRows($<HTMLDivElement>('edit-fields'), pier, EDITABLE_FIELDS.map((f) => f.key));
$('edit-summary').textContent = status.pending.length
? `미반영 변경 ${status.pending.length}건. 3D 그림은 생성본을 유지합니다.`
: status.overrides.length
? `기본값과 ${status.overrides.length}건 다릅니다. 생성본이 최신입니다.`
: '기본값과 같습니다.';
btn.textContent = `다시 생성 · 변경 ${status.pending.length}`;
btn.disabled = status.pending.length === 0;
}
/** One row per input key: value in the draft, the imported default beside it, and a revert button when overridden. */
function fieldRows(box: HTMLDivElement, pier: Pier, keys: EditableKey[]): { overrides: EditableKey[]; pending: EditableKey[] } {
box.replaceChildren();
const overrides = overrideKeys(pier);
const pending = pendingKeys(pier);
for (const f of EDITABLE_FIELDS.filter((x) => keys.includes(x.key))) {
const changed = overrides.includes(f.key);
const row = el('div', undefined, changed ? 'edit-row changed' : 'edit-row');
const id = `edit-${f.key}`;
const label = el('label', `${f.label} (${f.unit})`);
label.htmlFor = id;
const input = el('input');
input.id = id;
input.type = 'number';
input.step = 'any';
input.value = String(pier.draft[f.key]);
const inactive = pier.input.foundationType === '현장타설말뚝' && ['footingLengthMm', 'footingWidthMm', 'footingHeightMm', 'blindingMarginMm', 'blindingThicknessMm', 'pileOuterMm', 'pileThicknessMm'].includes(f.key);
input.disabled = inactive;
input.addEventListener('change', () => editField(pier.input.name, f.key, input.value));
row.append(label, input, el('span', `기본 ${pier.defaults[f.key]}`, 'muted small'));
if (inactive) row.appendChild(el('span', '현장타설말뚝 형식: 적용 안 함', 'muted small'));
if (changed) {
const revert = el('button', '되돌리기', 'btn small');
revert.type = 'button';
revert.addEventListener('click', () => revertField(pier.input.name, f.key));
row.appendChild(revert);
}
box.appendChild(row);
}
return { overrides, pending };
}
/** Footing length of the generated model, which the 열별 본수 limits depend on. */
function footingLengthOf(p: Pier): number | undefined {
return p.model?.footings[0]?.length;
}
/** 확대기초·말뚝 그림 (2단계, 승인 2026-10-23): plan and side views drawn from the model; θ is shown as a note. */
/**
* The 말뚝 layout (승인 2026-10-23 추천안): the 열별 본수 card on the left, the plan beside it, the side view below.
* The card and the plan share one row; on narrow screens they stack.
*/
function renderFootingDrawings(box: HTMLDivElement, pier: Pier): void {
if (!pier.model) return;
const grid = el('div', undefined, 'footing-grid');
const cardCol = el('div', undefined, 'footing-card-col');
renderPileRows(cardCol, pier);
const plan = el('figure', undefined, 'footing-fig');
plan.innerHTML = footingPlanSvg(pier.model);
plan.appendChild(el('figcaption', '평면 · 1열이 아래, 열은 교축과 직각 방향 줄', 'muted small'));
// The side view (정면) sits to the right of the plan, on the same scale (승인 2026-10-23).
const side = el('figure', undefined, 'footing-fig');
side.innerHTML = footingSideSvg(pier.model);
side.appendChild(el('figcaption', '정면 · 끊음 표시: 기둥과 말뚝 일부 생략 · 점선 = 경사 선택 시 상면 (L2, h2)', 'muted small'));
grid.append(cardCol, plan, side);
box.appendChild(grid);
box.appendChild(el('p', '기초 회전각 θ는 교각축 대비 회전으로, 생성 규칙은 spec에 추가 후 적용합니다 (지금은 0°).', 'muted small'));
}
/** 말뚝 규격 (승인 2026-10-23): 강관 and PHC from their lists by outer diameter and wall; the inner diameter, area and weight are read. */
function renderPileSpec(box: HTMLDivElement, pier: Pier): void {
const kind = pier.draft.pileKind ?? '강관';
const list = kind === '강관' ? STEEL_PILES : PHC_PILES;
const cur = findPileSpec(kind, pier.draft.pileOuterMm ?? 609.6, pier.draft.pileThicknessMm ?? 12);
const wrap = el('div', undefined, 'pile-spec');
const kindSel = el('select');
kindSel.id = 'pile-kind';
for (const k of ['강관', 'PHC'] as const) {
const o = el('option', k === '강관' ? '강관 말뚝' : 'PHC 말뚝');
o.value = k;
kindSel.appendChild(o);
}
kindSel.value = kind;
kindSel.addEventListener('change', () => {
const first = (kindSel.value === '강관' ? STEEL_PILES : PHC_PILES)[0];
setPileSpec(pier.input.name, kindSel.value as '강관' | 'PHC', first.D, first.t);
});
const sizeSel = el('select');
sizeSel.id = 'pile-size';
for (const p of list) {
const o = el('option', `${p.D} × ${p.t}`);
o.value = `${p.D}|${p.t}`;
sizeSel.appendChild(o);
}
if (cur) sizeSel.value = `${cur.D}|${cur.t}`;
sizeSel.addEventListener('change', () => {
const [D, t] = sizeSel.value.split('|').map(Number);
setPileSpec(pier.input.name, kind, D, t);
});
const info = cur
? `내경 ${cur.inner} mm · 단면적 ${cur.area} cm² · 단위중량 ${cur.unitWeight} ${kind === '강관' ? 'kg/m' : 'kN/m'}`
: '목록에 없는 규격입니다.';
wrap.append(el('label', '말뚝 종류 '), kindSel, el('label', ' 외경 × 두께 '), sizeSel, el('p', info, 'muted small'));
box.appendChild(wrap);
}
function setPileSpec(name: string, kind: '강관' | 'PHC', D: number, t: number): void {
const p = state.piers.find((q) => q.input.name === name);
if (!p) return;
p.draft = { ...p.draft, pileKind: kind, pileOuterMm: D, pileThicknessMm: t };
state.piers = state.piers.map((q) => (q === p ? regenerateEntry(q) : q));
setupScene();
renderAll();
}
/** 열별 본수 −/+ per 열 (spec §5.4). Shows the spacing, the total, and a warning below the minimum spacing. */
function renderPileRows(box: HTMLDivElement, pier: Pier): void {
const footingLength = footingLengthOf(pier);
if (pier.input.foundationType !== '말뚝기초' || footingLength === undefined) return;
const g = pileGeometry(footingLength);
let counts: number[];
try {
counts = resolveRowCounts(pier.draft.pileRows, g);
} catch (err) {
box.appendChild(el('p', (err as Error).message, 'warn-text'));
return;
}
const layout = rowLayout(counts, g);
const wrap = el('div', undefined, 'pile-rows');
wrap.appendChild(el('p', `열별 본수 · 합계 ${totalPiles(counts)}본`, 'lbl'));
counts.forEach((count, j) => {
const row = el('div', undefined, layout[j].belowMinimum ? 'pile-row warn' : 'pile-row');
const minus = el('button', '−', 'btn small');
minus.type = 'button';
minus.setAttribute('aria-label', `열 ${j + 1} 본수 줄이기`);
minus.addEventListener('click', () => setPileRow(pier.input.name, j, -1));
const plus = el('button', '+', 'btn small');
plus.type = 'button';
plus.setAttribute('aria-label', `열 ${j + 1} 본수 늘리기`);
plus.addEventListener('click', () => setPileRow(pier.input.name, j, 1));
row.append(el('span', `열 ${j + 1}`), minus, el('span', `${count}본`, 'num'), plus, el('span', `간격 ${Math.round(layout[j].spacing * 1000)} mm`, 'num'));
if (layout[j].belowMinimum) row.append(el('span', `최소 ${Math.round(g.minSpacing * 1000)} mm보다 좁습니다`, 'warn-text'));
wrap.appendChild(row);
});
wrap.appendChild(el('p', layoutText(counts, g), 'num'));
box.appendChild(wrap);
}
function setPileRow(name: string, row: number, delta: number): void {
const p = state.piers.find((q) => q.input.name === name);
const length = p && footingLengthOf(p);
if (!p || length === undefined) return;
const counts = resolveRowCounts(p.draft.pileRows, pileGeometry(length));
p.draft = { ...p.draft, pileRows: adjustRow(counts, row, delta) };
renderAll();
}
/** 형상 선택칩 (plan.md C5): the default is pressed; every other option is shown and disabled with the reason. */
function renderShapeOptions(member: MemberId, pier?: Pier): void {
const box = $<HTMLDivElement>('member-options');
box.replaceChildren();
if (member === 'footing' && pier?.input.foundationType === '현장타설말뚝') {
box.appendChild(el('p', '현장타설말뚝 · 기둥별 1본 · 직경은 기둥과 같음 · 확대기초 적용 안 함', 'muted small'));
return;
}
SHAPE_GROUPS[member].forEach((group, gi) => {
const row = el('div', undefined, 'shape-row');
row.appendChild(el('span', group.title, 'lbl'));
for (const option of group.options) {
const chip = el('button', option.label, 'chip-opt');
chip.type = 'button';
// The ground 위치 is stored in the pier's draft, so its pressed chip is the chosen one (승인 2026-10-26).
const chosen = member === 'ground' && pier ? (pier.draft.groundPosition ?? '육상') : member === 'bearing' && pier ? (pier.draft.bearingLayout ?? '분리') : undefined;
chip.setAttribute('aria-pressed', String(chosen !== undefined ? option.label === chosen : option.isDefault));
chip.disabled = !isSelectable(option);
if (chip.disabled) chip.title = UNDEFINED_REASON;
// 일체 needs every front–back pair within 100 mm (승인 2026-10-26); the reason is shown on the chip.
if (member === 'bearing' && option.label === '일체' && pier?.model) {
const check = integratedCheck(pier.model.blocks);
chip.disabled = !check.allowed;
chip.title = check.allowed ? '' : `편심 100 mm 초과 쌍 ${check.overLimit.length}개 → 분리 우선`;
}
chip.addEventListener('click', () => {
try {
choose(member, gi, option.label);
if (member === 'ground' && pier) editGround(pier.input.name, 'groundPosition', option.label);
if (member === 'bearing' && pier) editBearingLayout(pier.input.name, option.label as '분리' | '일체');
} catch (err) {
setError((err as Error).message);
}
});
row.appendChild(chip);
}
box.appendChild(row);
});
}
// --- ② 형상계획 (plan.md C1) -------------------------------------------------------------
/**
* The 3D viewer block (views, canvas, part toggles) is shared: it sits in ① and moves into the top slot of
* ② or ③ while that screen is shown. Moving the element keeps the WebGL context.
*/
const viewerHome: { parent: Node | null; next: Node | null } = { parent: null, next: null };
function captureViewerHome(): void {
const block = $<HTMLElement>('viewer-block');
viewerHome.parent = block.parentElement;
viewerHome.next = block.nextElementSibling;
}
function placeViewer(stage: Stage): void {
const block = $<HTMLElement>('viewer-block');
const slot = stage === 'plan' ? $<HTMLElement>('slot-plan') : stage === 'rebar' ? $<HTMLElement>('slot-rebar') : null;
if (slot) slot.appendChild(block);
else if (viewerHome.parent) viewerHome.parent.insertBefore(block, viewerHome.next);
renderRebarColor();
resize();
}
/** The 철근표 group that a bar name belongs to. */
function memberOfBar(name: string): RebarRow['member'] {
if (name.startsWith('말뚝-')) return 'pile';
if (/^(기둥|접합)/.test(name)) return 'column';
if (name === 'S1' || name === 'S2') return 'bearing';
if (name.startsWith('B')) return 'footing';
if (name.startsWith('P') || name.includes('커플러')) return 'column';
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;
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 || state.stage !== 'rebar' || !rebar3d) 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(rebar3d.group.children, false).find((h) => h.object.visible);
if (!hit) return;
const name = hit.object.userData.name as string;
state.rebarSelection = new Set([name]);
state.rebarMember = memberOfBar(name);
renderAll();
});
}
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;
const previous = state.stage;
state.stage = stage;
// Switching 전체 보기 on or off in ① needs the scene rebuilt for the new layout.
// Entering or leaving ② or ③ changes which objects are drawn (visibility.ts), so the scene is rebuilt then.
const selectionOnly = (s: Stage) => s === 'plan' || s === 'rebar';
if ((allPiers !== null) !== multiSceneOn() || selectionOnly(stage) !== selectionOnly(previous) || (stage === 'rebar') !== (previous === 'rebar')) setupScene();
if (state.scene) setRebarView(state.scene, stage === 'rebar');
for (const g of abutGroups) setRebarViewOn(g, stage === 'rebar');
$<HTMLElement>('view-shape').hidden = stage !== 'shape';
$<HTMLElement>('view-plan').hidden = stage !== 'plan';
$<HTMLElement>('view-rebar').hidden = stage !== 'rebar';
$<HTMLElement>('view-quantity').hidden = stage !== 'quantity';
if (stage === 'quantity') renderQuantityScreen();
// Move the viewer after its new parent is visible, so that resize() measures the real size.
placeViewer(stage);
if (rebar3d) rebar3d.group.visible = stage === 'rebar';
// The highlight is the 'current' class (style.css); aria-current is set alongside it for assistive tech.
// The 재료표 screen belongs to ③, so ③ stays highlighted while it is open.
const nav: [string, Stage[]][] = [['nav-shape', ['shape']], ['nav-plan', ['plan']], ['nav-rebar', ['rebar', 'quantity']]];
for (const [id, keys] of nav) {
const btn = $<HTMLButtonElement>(id);
btn.classList.toggle('current', keys.includes(stage));
btn.setAttribute('aria-current', String(keys.includes(stage)));
}
renderAll();
}
// --- ③ 철근배근 (plan C5 · ③ screen v1) --------------------------------------------------
const REBAR_MEMBERS: { id: RebarRow['member']; label: string }[] = [
{ id: 'bearing', label: '받침블럭' },
{ id: 'coping', label: '코핑' },
{ id: 'column', label: '기둥' },
{ id: 'footing', label: '확대기초' },
{ id: 'pile', label: '현장타설말뚝' },
];
/** 3D colour basis buttons on ③ and their legend (spec §6.7). */
function renderRebarColor(): void {
const colorLabel: Record<RebarColorMode, string> = { role: '역할', diameter: '직경', member: '부재' };
$('color-state').textContent = `${colorLabel[state.rebarColor]} ▾`;
// The colour control lives in the 3D window and is shown only on ③.
$<HTMLDetailsElement>('color-info').hidden = state.stage !== 'rebar';
for (const b of document.querySelectorAll<HTMLButtonElement>('#rebar-color button')) {
b.setAttribute('aria-pressed', String(b.dataset.color === state.rebarColor));
}
const legend = $<HTMLUListElement>('rebar-legend');
legend.replaceChildren();
const items = rebar3d?.legend(state.rebarColor) ?? [];
if (state.rebarColor === 'role') {
legend.appendChild(el('li', '역할별 색 (명세 §6.7)', 'muted small'));
}
for (const item of items) {
const li = el('li', undefined, 'rebar-legend-item');
const swatch = el('span', undefined, 'swatch');
swatch.style.background = item.color;
li.append(swatch, el('span', item.label));
legend.appendChild(li);
}
}
for (const b of document.querySelectorAll<HTMLButtonElement>('#rebar-color button')) {
b.addEventListener('click', () => {
state.rebarColor = b.dataset.color as RebarColorMode;
rebar3d?.setColorMode(state.rebarColor);
renderRebarColor();
});
}
/** 받침블럭 배근 설정 (승인 2026-10-26, 시안 ③): the six inputs (ranges shown), the 적용 블럭, and the C–C section. */
const BLOCK_REBAR_FIELDS: { key: keyof BlockRebarSettings; label: string }[] = [
{ key: 'coverTop', label: '상면 피복 (철근 중심, mm)' },
{ key: 'coverSide', label: '측면 피복 (mm)' },
{ key: 'firstGap', label: '첫 철근 최소 여유 (mm)' },
{ key: 'spacingS1', label: '간격 S1 (mm)' },
{ key: 'spacingS2', label: '간격 S2 (mm)' },
{ key: 'legLength', label: '다리 길이 (mm)' },
];
function setBlockRebar(name: string, key: keyof BlockRebarSettings, value: number): void {
const p = state.piers.find((q) => q.input.name === name);
if (!p) return;
if (!Number.isFinite(value)) {
setError('받침블럭 철근 값은 숫자로 입력하세요. 값을 되돌렸습니다.');
renderAll();
return;
}
p.draft = { ...p.draft, blockRebar: { ...p.draft.blockRebar, [key]: value } };
regenerateOne(name);
}
function renderBlockRebarSettings(box: HTMLDivElement, pier: Pier): void {
if (!pier.model) return;
const wrap = el('div', undefined, 'bearing-ref');
const current: BlockRebarSettings = { ...DEFAULT_BLOCK_REBAR, ...pier.draft.blockRebar };
wrap.appendChild(el('p', '받침블럭 배근 설정 (기본값 = P1 · 범위는 칸 옆에 표시)', 'lbl'));
// 단면 쌍 (the same selection as ②): the C–C section cuts through one pair.
const pairList = pairEccentricities(pier.model.blocks);
const chipRow = el('div', undefined, 'opts');
pairList.forEach((p, i) => {
const chip = el('button', `${PAIR_CHIP_LABELS[p.frontNo - 1] ?? p.frontNo}·${PAIR_CHIP_LABELS[p.backNo - 1] ?? p.backNo}`, 'btn small');
chip.type = 'button';
chip.setAttribute('aria-pressed', String(i === bearingPairIndex));
chip.addEventListener('click', () => {
bearingPairIndex = i;
renderAll();
});
chipRow.appendChild(chip);
});
wrap.appendChild(chipRow);
const grid = el('div', undefined, 'edit-fields');
for (const f of BLOCK_REBAR_FIELDS) {
const row = el('div', undefined, 'edit-row');
const label = el('label', f.label);
const input = el('input');
input.type = 'number';
input.step = '1';
input.value = String(current[f.key]);
input.addEventListener('change', () => setBlockRebar(pier.input.name, f.key, Number(input.value)));
const [lo, hi] = BLOCK_REBAR_RANGES[f.key];
row.append(label, input, el('span', `범위 ${lo}–${hi} · 기본 ${DEFAULT_BLOCK_REBAR[f.key]}`, 'muted small'));
grid.appendChild(row);
}
wrap.appendChild(grid);
const n = pier.model.integratedBlocks.length;
wrap.appendChild(el('p', `적용 블럭: ${n > 0 ? `일체 ${n}쌍` : `분리 ${pier.model.blocks.length}개`} (전체)`, 'muted small'));
const fig = el('figure', undefined, 'ground-fig');
const pairIdx = Math.min(bearingPairIndex, Math.max(0, pairEccentricities(pier.model.blocks).length - 1));
// 단면 C–C / 평면 (승인 2026-10-26): the two views of the same settings; the 평면 shows the legs as dots and the row numbers.
const viewRow = el('div', undefined, 'opts');
for (const [v, label] of [['section', '단면 C–C'], ['plan', '평면']] as const) {
const b = el('button', label, 'btn small');
b.type = 'button';
b.setAttribute('aria-pressed', String(bearingRebarView === v));
b.addEventListener('click', () => {
bearingRebarView = v;
renderAll();
});
viewRow.appendChild(b);
}
wrap.appendChild(viewRow);
fig.innerHTML = bearingRebarView === 'plan' ? blockRebarPlanSvg(pier.model, pairIdx) : blockRebarSectionSvg(pier.model, pairIdx);
fig.appendChild(el('figcaption', bearingRebarView === 'plan' ? '평면 · 선택한 쌍 · S2 선(교각축 방향), S1 선(교축 방향) · 다리는 점 · 번호는 표의 행 번호' : '단면 C–C · 교축 방향 · 선택한 쌍 · S2 점(상면), S1 선과 다리(⊓) · 치수 mm', 'muted small'));
wrap.appendChild(fig);
box.appendChild(wrap);
}
/** 코핑 배근 (승인 2026-10-26, 추천): 정면, and the 단면·폐합 at a station the user chooses (default mid-length). */
let copingView: 'front' | 'section' = 'front';
/** 코핑 배근 입력 (승인 2026-10-26, 제안 = RAG/코핑_입력범위_제안.md). Order follows the ③ 코핑 배근 panel. */
const COPING_REBAR_FIELDS: { key: keyof CopingRebarSettings; label: string }[] = [
{ key: 'cover', label: '피복 (철근 중심, mm)' },
{ key: 'mainSpacing', label: '주철근 기본간격 (mm)' },
{ key: 'mainEndGap', label: '주철근 끝단 최소 여유 (mm)' },
{ key: 'mainLayerGap', label: '상면 주철근 단 간격 · 세로 (mm, 가로는 d 고정)' },
{ key: 'firstShearGap', label: '첫 전단철근 최소 여유 (피복 다음, mm)' },
{ key: 'shearLegs', label: '전단철근 다리 수' },
{ key: 'spacingS', label: '전단철근 간격 · 경사 (mm)' },
{ key: 'spacingL', label: '전단철근 간격 · Level (mm)' },
{ key: 'sideSpacing', label: '측면 수평 간격 (mm)' },
{ key: 'sideEndGap', label: '측면 위 끝 아래 최소 여유 (mm)' },
{ key: 'c14UpperSpacing', label: 'C14 간격 위 2줄 (mm)' },
{ key: 'c14LowerSpacing', label: 'C14 간격 아래 줄 (mm)' },
];
/** 적용: the values go to the draft and the model is rebuilt only when every check passes (CP01·CP02). */
function applyCopingRebar(name: string, next: CopingRebarSettings, splice: TopSpliceSettings): void {
const p = state.piers.find((q) => q.input.name === name);
if (!p) return;
p.draft = { ...p.draft, copingRebar: next, topSplice: splice };
regenerateOne(name);
}
/** A row of toggle buttons (aria-pressed), as on the 배치 chips of ②. */
function toggleRow<T extends string>(options: [T, string][], current: T, onPick: (v: T) => void): HTMLDivElement {
const row = el('div', undefined, 'opts');
for (const [v, label] of options) {
const b = el('button', label, 'btn small');
b.type = 'button';
b.setAttribute('aria-pressed', String(v === current));
b.addEventListener('click', () => onPick(v));
row.appendChild(b);
}
return row;
}
function renderCopingSettings(box: HTMLDivElement, pier: Pier): void {
if (!pier.model) return;
const wrap = el('div', undefined, 'bearing-ref');
const defaults = copingDefaults(pier.model.columns.length);
const current: CopingRebarSettings = { ...defaults, ...pier.draft.copingRebar };
wrap.appendChild(el('p', `코핑 배근 입력 (기본값 = ${pier.model.columns.length > 1 ? '1106 다주형 전용 삽도' : '코핑철근.md'} · 적용을 눌러야 반영)`, 'lbl'));
if (pier.model.columns.length > 1) wrap.appendChild(el('p', '전용 삽도 규격 우선 · C5/C6은 삽도에 없어 제외했고, 중복 번호와 cycle별 이음 위치는 확인 중입니다. 저장된 간격 입력은 기본값보다 우선합니다.', 'muted small'));
const grid = el('div', undefined, 'edit-fields');
const controls = new Map<keyof CopingRebarSettings, HTMLInputElement | HTMLSelectElement>();
for (const f of COPING_REBAR_FIELDS) {
const row = el('div', undefined, 'edit-row');
const label = el('label', f.label);
let control: HTMLInputElement | HTMLSelectElement;
if (f.key === 'shearLegs') {
// 다리 수는 짝수만 고르도록 목록으로 둔다 (CP02).
const sel = el('select');
for (const n of [4, 6, 8]) {
const opt = el('option', String(n));
opt.value = String(n);
sel.appendChild(opt);
}
sel.value = String(current.shearLegs);
control = sel;
} else {
const input = el('input');
input.type = 'number';
input.step = 'any';
input.value = String(current[f.key]);
control = input;
}
control.id = `coping-${f.key}`;
label.htmlFor = control.id;
controls.set(f.key, control);
const [lo, hi] = COPING_REBAR_RANGES[f.key];
const changed = current[f.key] !== defaults[f.key] ? ' · 기본과 다름' : '';
row.append(label, control, el('span', `범위 ${lo}–${hi} · 기본 ${defaults[f.key]}${changed}`, 'muted small'));
grid.appendChild(row);
}
wrap.appendChild(grid);
// 상면 주철근(C1–C6) 이음 (승인 2026-10-26): 무시 (사양 기본) / 반영, and the 위치 when 반영.
let splice: TopSpliceSettings = { ...DEFAULT_TOP_SPLICE, ...pier.draft.topSplice };
const spliceBox = el('div', undefined, 'bearing-ref');
const drawSplice = (): void => {
spliceBox.replaceChildren(el('p', '상면 주철근(C1–C6) 이음', 'muted small'), toggleRow([['ignore', '이음 무시'], ['apply', '반영']] as const, splice.mode, (v) => {
splice = { ...splice, mode: v };
drawSplice();
}));
if (splice.mode === 'apply') {
spliceBox.appendChild(toggleRow([['center', pier.model!.columns.length > 1 ? '코핑 중앙 (임시)' : '기둥 중심'], ['end', '단부']] as const, splice.at, (v) => {
splice = { ...splice, at: v };
drawSplice();
}));
}
};
drawSplice();
wrap.appendChild(spliceBox);
const msg = el('p', undefined, 'muted small');
const apply = el('button', '적용', 'btn small');
apply.type = 'button';
apply.addEventListener('click', () => {
const next = { ...current };
for (const f of COPING_REBAR_FIELDS) next[f.key] = Number(controls.get(f.key)?.value);
const issues = copingSettingsIssues(next);
if (issues.length > 0) {
msg.textContent = issues.map((i) => i.message).join(' · ');
setError('코핑 배근 입력을 확인하세요. 값은 반영되지 않았습니다.');
return;
}
applyCopingRebar(pier.input.name, next, splice);
});
wrap.append(msg, apply);
box.appendChild(wrap);
}
/** 기둥 배근 입력 (승인 2026-10-26, 시안 ③ 기둥 삽도). The 주철근 하단 갈고리 is fixed. The 띠철근 간격 is three inputs (K3 · K5 · K4). */
const COLUMN_REBAR_FIELDS: { key: keyof ColumnRebarSettings; label: string }[] = [
{ key: 'c', label: 'P1 중심 피복 c (mm)' },
{ key: 'ctcP3', label: '띠철근 간격 P3 (mm)' },
{ key: 'ctcP5', label: '띠철근 간격 P5 (mm)' },
{ key: 'ctcP4', label: '띠철근 간격 P4 (mm)' },
{ key: 'ctcP6', label: '크로스타이 P6 간격 (mm)' },
{ key: 'ctcCoupler', label: '커플러 간격 ctcOfcoupler (mm)' },
{ key: 'dzCp2', label: 'P2 커플러 상승 Δz (mm)' },
{ key: 'nDiv', label: '주철근 원주 등분 수 n_div' },
];
/** 적용: the values go to the draft and the model is rebuilt only when every check passes (CL01·CL02). */
function applyColumnRebar(name: string, next: ColumnRebarSettings): void {
const p = state.piers.find((q) => q.input.name === name);
if (!p) return;
p.draft = { ...p.draft, columnRebar: next };
regenerateOne(name);
}
/** One editor, with independent stored segments and explicit shared-field edits. */
function renderCastSettings(box: HTMLDivElement, pier: Pier): void {
const saved = castRebarDefaults(pier.draft, materialFor(state.material, state.fckByMember, 'column'));
const wrap = el('section', undefined, 'bearing-ref');
wrap.dataset.castPart = 'combined';
wrap.append(el('h3', '기둥·말뚝 배근 · 현장타설'), el('p', '확대기초 미적용 · 전 기둥·말뚝에 적용합니다. 주철근·횡철근·커플러는 적용된 입력으로 생성합니다. 미확정 구간은 알림과 재료표에 표시하며, 말뚝 저부 P6는 검토안으로 수량에서 제외합니다.', 'pnote'));
wrap.appendChild(el('p', '단부 길이 기준: 기둥은 코핑 Level 하단에서 아래로, 말뚝은 β EL−3D에서 위로 적용합니다. 말뚝 단부 길이를 비우면 점토층 하단 EL+D까지 자동 계산합니다. 경계 미입력·역전·범위 이탈 시 해당 단부는 미계산으로 남깁니다.', 'muted small'));
if (pier.model) {
const drawing = el('details', undefined, 'abut-group');
drawing.appendChild(el('summary', '기둥·말뚝 배근 삽도 · 첫 기둥'));
const figure = el('figure');
figure.innerHTML = castRebarSvg(pier.model, castRebarLayout(pier.model, materialFor(state.material, state.fckByMember, 'column')));
drawing.appendChild(figure); wrap.appendChild(drawing);
}
const shape = castShape(pier.draft);
const jointEl = pier.model?.columns[0]?.bottom;
wrap.appendChild(el('p', `직경 ${pier.model?.columnDiameterMm ?? pier.draft.columnDiameterMm ?? 2000} mm · 기둥 하단/말뚝 두부 EL ${jointEl?.toFixed(3) ?? '미생성'} · 말뚝 길이 ${pier.draft.pileLengthMm} mm · 점토층 하단 ${shape.clayBottomEl === undefined ? '미입력' : `EL ${shape.clayBottomEl.toFixed(3)}`}`, 'muted small'));
const plan = el('button', '② 형상계획에서 형상·지질 편집', 'btn small'); plan.type = 'button';
plan.addEventListener('click', () => { $<HTMLButtonElement>('nav-plan').click(); });
wrap.appendChild(plan);
const common = el('div', undefined, 'edit-fields');
const advanced = el('details', undefined, 'abut-group');
advanced.appendChild(el('summary', '상세 설정 · 강도·피복 기준'));
const advancedGrid = el('div', undefined, 'edit-fields'); advanced.appendChild(advancedGrid);
const exceptions = el('details', undefined, 'abut-group'); exceptions.id = 'cip-exceptions';
const numericKeys = ['mainDiameterMm', 'mainCount', 'innerMainDiameterMm', 'innerMainCount', 'coverMm', 'mainGradeMpa', 'concreteStrengthMpa'] as const;
const different = numericKeys.filter(key => saved.column?.[key] !== saved.pile?.[key]);
const coverDiffers = saved.column?.coverReference !== saved.pile?.coverReference;
exceptions.open = different.length > 0 || coverDiffers;
exceptions.appendChild(el('summary', exceptions.open ? '구간별 값 다름 · 원래 값 보존' : '구간별 예외 설정'));
exceptions.appendChild(el('p', '공통값을 바꾸면 해당 항목만 두 구간에 적용합니다. 다른 항목과 구간별 예외는 유지됩니다. 피복은 기준도 함께 확인하세요.', 'pnote'));
const inputs = { column: new Map<keyof CastRebarPart, HTMLInputElement | HTMLSelectElement>(), pile: new Map<keyof CastRebarPart, HTMLInputElement | HTMLSelectElement>() };
const numberRow = (parent: HTMLElement, id: string, labelText: string, value?: number): HTMLInputElement => {
const row = el('div', undefined, 'edit-row');
const input = el('input');
input.id = id; input.type = 'number'; input.step = id.endsWith('Count') ? '1' : 'any';
input.value = value === undefined ? '' : String(value);
input.placeholder = '미입력';
const label = el('label', labelText); label.htmlFor = input.id;
row.append(label, input); parent.appendChild(row); return input;
};
const selectRow = (parent: HTMLElement, id: string, labelText: string, choices: [string, string][], value?: string) => {
const row = el('div', undefined, 'edit-row');
const label = el('label', labelText); label.htmlFor = id;
const select = el('select'); select.id = id;
for (const [v, text] of [['', '미입력'], ...choices]) { const option = el('option', text); option.value = v; select.appendChild(option); }
select.value = value ?? ''; row.append(label, select); parent.appendChild(row); return select;
};
wrap.appendChild(el('h4', '공통 주철근'));
wrap.appendChild(common);
for (const key of numericKeys) {
const field = CAST_PART_FIELDS.find(f => f.key === key)!;
const differs = different.includes(key) || key === 'coverMm' && coverDiffers;
const control = numberRow(['mainGradeMpa', 'concreteStrengthMpa'].includes(key) ? advancedGrid : common, `cip-common-${key}`, field.label, differs ? undefined : saved.column?.[key]);
if (differs) control.placeholder = '구간별 값 다름';
// Segment controls are populated below; only an actual common-field edit synchronises them.
control.addEventListener('input', () => { for (const part of ['column', 'pile'] as const) inputs[part].get(key)!.value = control.value; });
}
const coverChoices: [string, string][] = [['main-center', '외측 주철근 중심'], ['clear', '최외측 철근 표면']];
const cover = selectRow(advancedGrid, 'cip-common-coverReference', '공통 피복 기준', coverChoices, coverDiffers ? undefined : saved.column?.coverReference);
if (coverDiffers) cover.options[0].textContent = '구간별 값 다름';
cover.addEventListener('change', () => { for (const part of ['column', 'pile'] as const) inputs[part].get('coverReference')!.value = cover.value; });
for (const part of ['column', 'pile'] as const) {
const grid = el('div', undefined, 'edit-fields');
exceptions.append(el('h4', part === 'column' ? '기둥 구간 예외' : '말뚝 구간 예외'), grid);
for (const key of numericKeys) {
const field = CAST_PART_FIELDS.find(f => f.key === key)!;
inputs[part].set(key, numberRow(grid, `cip-${part}-${key}`, field.label, saved[part]?.[key]));
}
inputs[part].set('coverReference', selectRow(grid, `cip-${part}-coverReference`, '피복 기준', coverChoices, saved[part]?.coverReference));
}
wrap.append(exceptions, advanced, el('h4', '횡철근 구간표'));
const tableWrap = el('div', undefined, 'pier-table-wrap');
const table = el('table', undefined, 'grid-table compact');
const head = el('thead'); const heading = el('tr');
for (const label of ['구간', '형식', '직경 (mm)', '일반 간격 (mm)', '단부 길이 (mm)', '단부 간격 (mm)']) heading.appendChild(el('th', label));
head.appendChild(heading); table.appendChild(head);
const body = el('tbody');
for (const part of ['column', 'pile'] as const) {
const name = part === 'column' ? '기둥' : '말뚝';
const row = el('tr'); row.appendChild(el('th', name));
const tieCell = el('td');
inputs[part].set('tieKind', selectRow(tieCell, `cip-${part}-tieKind`, `${name} 횡철근 형식`, [['hoop', '띠철근'], ['spiral', '나선철근']], saved[part]?.tieKind));
row.appendChild(tieCell);
for (const key of ['tieDiameterMm', 'tieSpacingMm', 'endZoneLengthMm', 'endZoneSpacingMm'] as const) {
const cell = el('td');
inputs[part].set(key, numberRow(cell, `cip-${part}-${key}`, `${name} ${CAST_PART_FIELDS.find(f => f.key === key)!.label}`, saved[part]?.[key]));
row.appendChild(cell);
}
body.appendChild(row);
inputs[part].set('tieGradeMpa', numberRow(advancedGrid, `cip-${part}-tieGradeMpa`, `${name} 횡철근 강도 fy (MPa)`, saved[part]?.tieGradeMpa));
}
table.appendChild(body); tableWrap.appendChild(table); wrap.appendChild(tableWrap);
wrap.appendChild(el('h4', '연결·하단'));
const connectionGrid = el('div', undefined, 'edit-fields');
const connectionMode = selectRow(connectionGrid, 'cip-connection-mode', '기둥·말뚝 공통 연결 방식 (검토 입력)', [['continuous', '연속'], ['lap', '겹침'], ['coupler', '커플러']], saved.connection?.mode);
for (const f of CAST_PART_FIELDS.filter(f => f.only)) {
const part = f.only!;
inputs[part].set(f.key, numberRow(connectionGrid, `cip-${part}-${f.key}`, f.label, saved[part]?.[f.key]));
}
const connectionInputs = new Map<'lapLengthMm' | 'couplerLengthMm', HTMLInputElement>();
for (const [key, label] of [['lapLengthMm', '접합부 겹침 길이 (mm)'], ['couplerLengthMm', '접합부 커플러 길이 (mm)']] as const) {
connectionInputs.set(key, numberRow(advancedGrid, `cip-connection-${key}`, label, saved.connection?.[key]));
}
const columnValues = { ...DEFAULT_COLUMN_REBAR, ...pier.draft.columnRebar };
const sharedDetails = new Map<'ctcCoupler' | 'dzCp2' | 'ctcP6', HTMLInputElement>();
for (const [key, label] of [['ctcCoupler', '주기 주철근 커플러 간격 (mm)'], ['dzCp2', '내측 커플러 상승 높이 (mm)'], ['ctcP6', '기둥 크로스타이 단 간격 (mm)']] as const) {
sharedDetails.set(key, numberRow(advancedGrid, `cip-detail-${key}`, label, columnValues[key]));
}
wrap.appendChild(connectionGrid);
const msg = el('p', undefined, 'muted small'); msg.setAttribute('role', 'status');
const apply = el('button', '배근 적용 · 미확정 구간 별도 표시', 'btn small'); apply.type = 'button'; apply.id = 'cip-apply';
apply.addEventListener('click', () => {
if ([...wrap.querySelectorAll<HTMLInputElement>('input')].some(input => input.validity.badInput)) { msg.textContent = '숫자 입력을 확인하세요.'; return; }
const next: CastInPlaceRebar = { ...saved, connection: {} };
for (const part of ['column', 'pile'] as const) {
const values: Record<string, unknown> = {};
for (const [key, input] of inputs[part]) if (input.value.trim() !== '') values[key] = key === 'tieKind' || key === 'coverReference' ? input.value : Number(input.value);
next[part] = values as CastRebarPart;
}
if (connectionMode.value) next.connection!.mode = connectionMode.value as NonNullable<CastInPlaceRebar['connection']>['mode'];
for (const [key, input] of connectionInputs) if (input.value.trim() !== '') next.connection![key] = Number(input.value);
const issues = castSettingsIssues(next, pier.draft.pileLengthMm, pier.draft.columnDiameterMm ?? 2000);
if (issues.length) { msg.textContent = issues.join(' · '); return; }
const nextColumn = { ...columnValues };
for (const [key, input] of sharedDetails) {
if (!input.value.trim()) { msg.textContent = '커플러·크로스타이 상세 값을 입력하세요.'; return; }
nextColumn[key] = Number(input.value);
}
const columnIssues = columnSettingsIssues(nextColumn);
if (columnIssues.length) { msg.textContent = columnIssues.map(i => i.message).join(' · '); return; }
pier.draft = { ...pier.draft, castInPlaceRebar: next, columnRebar: nextColumn };
regenerateOne(pier.input.name);
});
wrap.append(msg, apply); box.appendChild(wrap);
}
function renderColumnSettings(box: HTMLDivElement, pier: Pier): void {
if (pier.input.foundationType === '현장타설말뚝') { renderCastSettings(box, pier); return; }
if (!pier.model) return;
const wrap = el('div', undefined, 'bearing-ref');
const current: ColumnRebarSettings = { ...DEFAULT_COLUMN_REBAR, ...pier.draft.columnRebar };
wrap.appendChild(el('p', '기둥 배근 입력 (기본값 = 기둥철근 (1).md · 적용을 눌러야 반영)', 'lbl'));
const grid = el('div', undefined, 'edit-fields');
const inputs = new Map<keyof ColumnRebarSettings, HTMLInputElement>();
for (const f of COLUMN_REBAR_FIELDS) {
const row = el('div', undefined, 'edit-row');
const label = el('label', f.label);
const input = el('input');
input.type = 'number';
input.step = 'any';
input.value = String(current[f.key]);
input.id = `column-${f.key}`;
label.htmlFor = input.id;
inputs.set(f.key, input);
const [lo, hi] = COLUMN_REBAR_RANGES[f.key];
const changed = current[f.key] !== DEFAULT_COLUMN_REBAR[f.key] ? ' · 기본과 다름' : '';
row.append(label, input, el('span', `범위 ${lo}–${hi} · 기본 ${DEFAULT_COLUMN_REBAR[f.key]}${changed}`, 'muted small'));
grid.appendChild(row);
}
wrap.appendChild(grid);
// Read-only results of the current model (계산 값).
const input = columnInputFor(pier.model);
const col = columnRebar(input);
wrap.appendChild(el('p', `P1 외측 r ${col.r1} · P2 내측 r ${col.r2} mm · 주철근 P1 ${col.mainP1}본 · P2 ${col.mainP2}본 · 주철근 커플러 ${col.couplers}개`, 'muted small'));
// 정면 | 단면 A·B·C (승인 2026-10-26): 치수는 입력과 계산 결과, 간섭(B11–P4)은 검토 제외.
let columnView: 'front' | 'section' = 'front';
const figBox = el('div');
const drawColumnFigure = (): void => {
figBox.replaceChildren(toggleRow([['front', '정면'], ['section', '단면 A·B·C']] as const, columnView, (v) => {
columnView = v;
drawColumnFigure();
}));
if (columnView === 'front') {
const fig = el('figure', undefined, 'ground-fig');
fig.innerHTML = columnElevationSvg(input, col);
fig.appendChild(el('figcaption', '정면 · 높이 기준 = 확대기초 상면 (mm) · 간섭(B11–P4)은 검토 제외', 'muted small'));
figBox.appendChild(fig);
return;
}
const captions: Record<ColumnSection, string> = {
A: 'A 일반 · P3 첫 단 · 주철근 P1·P2 · 띠철근 P3 (겹침 표시)',
B: 'B 확대기초 안 · P4 첫 단 · 반원 2개 · 커플러 2개 · P6 4본',
C: 'C 단부 · P5 첫 단 · 커플러 1개 (θ + Δθ/2)',
};
// 단면 A·B·C in one row (승인 2026-10-26): no wrapping, scrolls sideways on a narrow window.
const row = el('div');
row.style.display = 'flex';
row.style.flexWrap = 'nowrap';
row.style.gap = '12px';
row.style.overflowX = 'auto';
for (const kind of ['A', 'B', 'C'] as const) {
const fig = el('figure', undefined, 'ground-fig');
fig.style.margin = '0';
fig.style.flex = '0 0 300px';
fig.innerHTML = columnSectionSvg(kind, input, col);
fig.appendChild(el('figcaption', captions[kind], 'muted small'));
row.appendChild(fig);
}
figBox.appendChild(row);
};
drawColumnFigure();
wrap.appendChild(figBox);
const msg = el('p', undefined, 'muted small');
const apply = el('button', '적용', 'btn small');
apply.type = 'button';
apply.addEventListener('click', () => {
const next = { ...current };
for (const f of COLUMN_REBAR_FIELDS) next[f.key] = Number(inputs.get(f.key)?.value);
const issues = columnSettingsIssues(next);
if (issues.length > 0) {
msg.textContent = issues.map((i) => i.message).join(' · ');
setError('기둥 배근 입력을 확인하세요. 값은 반영되지 않았습니다.');
return;
}
applyColumnRebar(pier.input.name, next);
});
wrap.append(msg, apply);
box.appendChild(wrap);
}
/** 확대기초 배근 입력 (승인 2026-10-26, 시안 ③ 확대기초). Fixed rules (k = 2, Δ₂ = 100, 전단 4s · 엇갈림 2s, cycle 교대) are shown, not edited. */
const FOOTING_REBAR_FIELDS: { key: keyof FootingRebarSettings; label: string }[] = [
{ key: 'cTop', label: '상면 피복 (철근 중심, mm)' },
{ key: 'cSide', label: '측면 피복 (mm)' },
{ key: 'cBottom', label: '하면 피복 (mm)' },
{ key: 'eMin', label: '첫 철근 최소 여유 (mm)' },
{ key: 'sL', label: '격자 실간격 · L 방향 B1–B4 · B9 · B11 (mm)' },
{ key: 'sW', label: '격자 실간격 · W 방향 B5–B8 · B12 (mm)' },
{ key: 'sV', label: 'B10 높이 간격 s_v (mm)' },
{ key: 'eTop', label: 'B5 아래 최소 여유 e_top (mm)' },
{ key: 'n', label: 'B11·B12 연직간격 배수 n (B10 간격의 n배)' },
{ key: 'kB11', label: 'B11 수평간격 배수 k (L 방향, k·sL)' },
{ key: 'kB12', label: 'B12 수평간격 배수 k (W 방향, k·sW)' },
];
/** 적용: the values go to the draft and the model is rebuilt only when every check passes (FT01·FT02). */
function applyFootingRebar(name: string, next: FootingRebarSettings): void {
const p = state.piers.find((q) => q.input.name === name);
if (!p) return;
p.draft = { ...p.draft, footingRebar: next };
regenerateOne(name);
}
function renderFootingSettings(box: HTMLDivElement, pier: Pier): void {
if (pier.input.foundationType === '현장타설말뚝') {
box.appendChild(el('p', '확대기초 적용 안 함 · 현장타설말뚝 전용 입력을 사용하세요.', 'pnote'));
return;
}
if (!pier.model) return;
const wrap = el('div', undefined, 'bearing-ref');
const current: FootingRebarSettings = { ...DEFAULT_FOOTING_REBAR, ...pier.draft.footingRebar };
wrap.appendChild(el('p', '확대기초 배근 입력 (기본값 = 확대기초철근 (1).md · 적용을 눌러야 반영)', 'lbl'));
const grid = el('div', undefined, 'edit-fields');
const inputs = new Map<keyof FootingRebarSettings, HTMLInputElement>();
for (const f of FOOTING_REBAR_FIELDS) {
const row = el('div', undefined, 'edit-row');
const label = el('label', f.label);
const input = el('input');
input.type = 'number';
input.step = 'any';
input.value = String(current[f.key]);
input.id = `footing-${f.key}`;
label.htmlFor = input.id;
inputs.set(f.key, input);
const [lo, hi] = FOOTING_REBAR_RANGES[f.key];
const changed = current[f.key] !== DEFAULT_FOOTING_REBAR[f.key] ? ' · 기본과 다름' : '';
row.append(label, input, el('span', `범위 ${lo}–${hi} · 기본 ${DEFAULT_FOOTING_REBAR[f.key]}${changed}`, 'muted small'));
grid.appendChild(row);
}
wrap.appendChild(grid);
// Fixed rules and derived values (읽기 전용). Derived from the current settings of the first footing.
const fs = current;
const fixed = el('div', undefined, 'muted small');
const foot = pier.model.footings[0];
const H = foot ? (foot.top - foot.bottom) * 1000 : undefined;
if (H === undefined) {
fixed.textContent = '확대기초 없음 · 확대기초 규칙을 표시하지 않습니다';
} else {
const r = footingRuleValues(H, fs);
fixed.textContent = [
`상면·2단 간격 B1·B3 = 2·sL ${r.upper2L} mm · B5·B7 = 2·sW ${r.upper2W} mm (규칙)`,
`하면 1·2단 간격 Δ₂ = ${r.delta2} mm (고정)`,
`B2 디리 ${r.b2Leg} mm · B6 디리 ${r.b6Leg} mm (자동, B1·B5 높이까지)`,
`B9 간격 4·sL ${r.b9Pitch} · 행 엇갈림 2·sL ${r.b9Stagger} mm (규칙)`,
`B11 평면 간격 k·sL ${r.b11Pitch} · B12 평면 간격 k·sW ${r.b12Pitch} mm · 연직 n·s_v ${r.assemblyVertical} mm · 조립 단 ${r.assemblyLevels}단 (자동)`,
'cycle 배치: 교대 배치 (고정)',
].join(' · ');
}
wrap.appendChild(fixed);
const msg = el('p', undefined, 'muted small');
const apply = el('button', '적용', 'btn small');
apply.type = 'button';
apply.addEventListener('click', () => {
const next = { ...current };
for (const f of FOOTING_REBAR_FIELDS) next[f.key] = Number(inputs.get(f.key)?.value);
const issues = footingSettingsIssues(next);
if (issues.length > 0) {
msg.textContent = issues.map((i) => i.message).join(' · ');
setError('확대기초 배근 입력을 확인하세요. 값은 반영되지 않았습니다.');
return;
}
applyFootingRebar(pier.input.name, next);
});
wrap.append(msg, apply);
// 확대기초 단면 탭 (승인 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';
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);
}
function renderCopingDrawings(box: HTMLDivElement, pier: Pier): void {
if (!pier.model || !rebar3d) return;
const L = pier.model.copingL;
const wrap = el('div', undefined, 'bearing-ref');
const row = el('div', undefined, 'opts');
for (const [v, label] of [['front', '정면'], ['section', '단면 · 폐합 구성']] as const) {
const b = el('button', label, 'btn small');
b.type = 'button';
b.setAttribute('aria-pressed', String(copingView === v));
b.addEventListener('click', () => {
copingView = v;
renderAll();
});
row.appendChild(b);
}
wrap.appendChild(row);
const fig = el('figure', undefined, 'ground-fig');
if (copingView === 'front') {
fig.innerHTML = copingFrontSvg(rebar3d.records, L * 1000, pier.model.columns[0]?.s ?? 0);
fig.appendChild(el('figcaption', '정면 · 교축 방향에서 본 코핑 배근 (3D 철근과 같은 데이터)', 'muted small'));
} else {
// 단면 · 폐합 at two fixed stations side by side (승인 2026-10-26): the 단부 (first shear station) and the first column centre.
const sEnd = copingEndStation(rebar3d.records);
const sCol = pier.model.columns[0]?.s ?? L / 2;
const grid = el('div', undefined, 'footing-grid');
for (const [st, label] of [[sEnd, `단부 s = ${sEnd.toFixed(2)} m`], [sCol, `기둥 중심 s = ${sCol.toFixed(2)} m`]] as const) {
const f = el('figure', undefined, 'footing-fig');
f.innerHTML = copingSectionSvg(rebar3d.records, st, pier.model.copingRefTop, pier.model.copingB * 1000);
f.appendChild(el('figcaption', label, 'muted small'));
grid.appendChild(f);
}
wrap.appendChild(grid);
box.appendChild(wrap);
return;
}
wrap.appendChild(fig);
box.appendChild(wrap);
}
/** ③ 교대 부재 (시안 순서, plan_abut AD9). All six 부재 have rules now (AD3–AD8). */
type AbutRebarMemberId = 'blk' | 'wall' | 'para' | 'foot' | 'wing' | 'slab';
const ABUT_REBAR_MEMBERS: { id: AbutRebarMemberId; label: string; marks: string; ready: boolean }[] = [
{ id: 'blk', label: '받침블럭', marks: 'S1·S2', ready: true },
{ id: 'wall', label: '벽체', marks: 'W1–W11', ready: true },
{ id: 'para', label: '흉벽·브라켓', marks: 'P1–P11', ready: true },
{ id: 'foot', label: '확대기초', marks: 'B1–B10', ready: true },
{ id: 'wing', label: '날개벽', marks: 'WR1–WR20', ready: true },
{ id: 'slab', label: '접속슬래브', marks: 'R1–R8', ready: true },
];
/** The 저판 rebar of an abutment (null when the abutment does not build). */
function abutFootingBuilt(rec: AbutRecord): { geom: AbutFootingGeom; result: AbutFootingRebar; skewK: number; backM: number; bottomEl: number | undefined } | null {
try {
const model = buildAbut(toAbutInput(rec));
const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape);
const geom = abutFootingGeom(model.length, members.shape, skewAngle(rec.skewDeg));
const result = abutFootingRebar(geom, rec.rebar?.footing, materialFor(state.material, state.fckByMember, 'abutFooting'), rec.name);
return { geom, result, skewK: abutSkewK(model), backM: members.shape.footingBackM, bottomEl: members.footingBottomEl };
} catch {
return null;
}
}
/** The 벽체 rebar of an abutment, on its 저판 grid (null when the abutment does not build). */
function abutWallBuilt(rec: AbutRecord, footing: AbutFootingRebar): { geom: AbutWallGeom; result: AbutWallRebar } | null {
try {
const model = buildAbut(toAbutInput(rec));
const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape);
const geom = abutWallGeom(model.length, members, skewAngle(rec.skewDeg));
return { geom, result: abutWallRebar(geom, footing, rec.rebar?.wall, materialFor(state.material, state.fckByMember, 'abutBody'), rec.name) };
} catch {
return null;
}
}
/** The 흉벽 · 브라켓 rebar of an abutment, on its 벽체 grid; P10 from the ② 접속슬래브 plan (null when it does not build). */
function abutParapetBuilt(rec: AbutRecord, wall: { geom: AbutWallGeom; result: AbutWallRebar }): { geom: AbutParapetGeom; result: AbutParapetRebar } | null {
try {
const model = buildAbut(toAbutInput(rec));
const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape);
const wings = buildWings(model, members, rec.wings, rec.wingDims);
const slab = resolveSlab(rec.slab);
const tM = (wall.geom.xb / 1000 + members.shape.parapetBackM) / 2;
const span = slabSpanAt(abutPlanOutline(model, members, wings, slab).slab, tM, abutSkewK(model));
const chamfer = (sEnd: number) => (wings.find((w) => Math.abs(w.sEnd - sEnd) < 1e-9)?.haunchM ?? 0) * 1000;
const B = model.wing?.slabWidthMm;
const slabGeom = span && B !== undefined ? { B, a: span.a, b: span.b, nearT: members.shape.parapetBackM * 1000 - slab.parapetGapMm, chamferA: chamfer(0), chamferB: chamfer(model.length) } : null;
const geom = abutParapetGeom(wall.geom, members.shape, model.bearings[0].s, slabGeom);
return { geom, result: abutParapetRebar(geom, wall.result, rec.rebar?.parapet, materialFor(state.material, state.fckByMember, 'abutBody'), rec.name) };
} catch {
return null;
}
}
/** The 날개벽 rebar of an abutment, per side that has a wing (좌우 공통 입력). */
function abutWingsBuilt(rec: AbutRecord, footing: AbutFootingRebar): { geom: AbutWingGeom; result: AbutWingRebar }[] {
try {
const model = buildAbut(toAbutInput(rec));
const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape);
const material = materialFor(state.material, state.fckByMember, 'abutBody');
const zOf = (el: number) => (el - (members.footingBottomEl ?? 0)) * 1000;
const seats = members.wallSegments.map((sg) => ({ from: sg.sFrom * 1000, to: sg.sTo * 1000, z: zOf(sg.seatEl) }));
return buildWings(model, members, rec.wings, rec.wingDims).map((w) => {
const geom = abutWingGeom(model, members, w, footing.z.B68, seats, abutSkewK(model));
// WR9 연직 다리 = 벽체 W9 · W10 정착장 (사용자 결정 2026-11-04).
return { geom, result: abutWingRebar(geom, rec.rebar?.wing, material, rec.name, resolveWallRebar(rec.rebar?.wall).la) };
});
} catch {
return [];
}
}
/** The 받침블럭 rebar of an abutment: one block per bearing (블럭 콘크리트 = 블럭 높이 − 패드 50). */
function abutBlockBuilt(rec: AbutRecord): { result: AbutBlockRebar } | null {
try {
const model = buildAbut(toAbutInput(rec));
const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape);
const blocks = model.bearings.map((b) => ({ no: b.no, s: b.s, t: model.frontSide * b.lateral, rotDeg: b.rotDeg, seatEl: b.seatEl, blockHeightMm: b.blockHeightMm }));
return { result: abutBlockRebar(blocks, members.footingBottomEl ?? 0, rec.rebar?.block, resolveWallRebar(rec.rebar?.wall).c, rec.name) };
} catch {
return null;
}
}
/** The 접속슬래브 rebar of an abutment, on the ② slab plan and section (null when it does not build). */
function abutSlabBuilt(rec: AbutRecord): { geom: AbutSlabGeom; result: AbutSlabRebar } | null {
try {
const model = buildAbut(toAbutInput(rec));
const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape);
const wings = buildWings(model, members, rec.wings, rec.wingDims);
const slab = resolveSlab(rec.slab);
const grade = slabGradePct(slab, model.wing?.slopePct ?? 0);
const chamfer = (sEnd: number) => (wings.find((w) => Math.abs(w.sEnd - sEnd) < 1e-9)?.haunchM ?? 0) * 1000;
const geom = abutSlabGeom(
slab.form,
abutPlanOutline(model, members, wings, slab).slab,
slabSection(slab, grade, members.shape),
{ startEl: members.parapetTopStartEl, endEl: members.parapetTopEndEl, L: model.length, bottomEl: members.footingBottomEl ?? 0 },
[chamfer(0), chamfer(model.length)],
slab.lengthMm,
grade,
);
return { geom, result: abutSlabRebar(geom, rec.rebar?.slab, materialFor(state.material, state.fckByMember, 'abutBody'), rec.name) };
} catch {
return null;
}
}
/** ③ with an abutment selected (2026-11-04): 부재 · 교대 list, the member card, and the RB-AF / RB-AB / RB-AP / RB-AW / RB-AS alerts. */
function renderAbutRebar(rec: AbutRecord): void {
const built = abutFootingBuilt(rec);
const wall = built ? abutWallBuilt(rec, built.result) : null;
const para = wall ? abutParapetBuilt(rec, wall) : null;
const wings = built ? abutWingsBuilt(rec, built.result) : [];
const slabR = abutSlabBuilt(rec);
const blockR = abutBlockBuilt(rec);
const issuesOf = (id: AbutRebarMemberId) =>
(id === 'blk'
? blockR?.result.issues
: id === 'foot'
? built?.result.issues
: id === 'wall'
? wall?.result.issues
: id === 'para'
? para?.result.issues
: id === 'wing'
? wings.flatMap((w) => w.result.issues)
: id === 'slab'
? slabR?.result.issues
: undefined) ?? [];
const warnOf = (id: AbutRebarMemberId) => issuesOf(id).filter((i) => i.level !== 'I').length;
const tabs = $<HTMLUListElement>('rebar-tabs');
tabs.replaceChildren();
for (const m of ABUT_REBAR_MEMBERS) {
const li = el('li');
// 교각 부재 목록처럼 이름과 경고 수만 (2026-11-04). 배근 타입 · 번호와 "다음 단계"는 카드 제목 줄에 있다.
const btn = el('button', m.label, 'member');
btn.type = 'button';
btn.title = `Type-1 · ${m.marks}${m.ready ? '' : ' · 다음 단계'}`;
btn.setAttribute('aria-pressed', String(m.id === state.abutRebarMember));
if (m.ready && warnOf(m.id) > 0) btn.append(el('span', String(warnOf(m.id)), 'badge warn'));
btn.addEventListener('click', () => {
state.abutRebarMember = m.id;
state.rebarSelection = new Set();
renderAll();
});
li.appendChild(btn);
tabs.appendChild(li);
}
const box = $<HTMLDivElement>('rebar-table');
box.replaceChildren();
const issues = $<HTMLUListElement>('rebar-issues');
issues.replaceChildren();
const member = ABUT_REBAR_MEMBERS.find((m) => m.id === state.abutRebarMember)!;
$('rebar-title').textContent = `${member.label} 배근`;
if (!member.ready) {
$('rebar-note').textContent = `${rec.name} · ${member.label} · Type-1 · ${member.marks} · 다음 단계`;
box.append(el('p', `${member.label} 배근은 다음 단계에서 추가합니다 (철근 기획서 확정, plan_abut AD4–AD8).`, 'muted'));
} else if (!built || (member.id === 'blk' && !blockR) || (member.id === 'wall' && !wall) || (member.id === 'para' && !para) || (member.id === 'slab' && !slabR)) {
$('rebar-note').textContent = `${rec.name} · 교대 모델을 만들 수 없어 배근하지 않습니다.`;
} else if (member.id === 'wall' && wall) {
const g = wall.geom;
const low = g.segments.find((sg) => sg.no === 1)!;
const high = g.segments.find((sg) => sg.no === g.segments.length)!;
$('rebar-note').textContent = `${rec.name} · Type-1 · 두께 ${Math.round(g.xf - g.xb)} · 높이 ${Math.round(low.H)} (구간 1) ~ ${Math.round(high.H)} (구간 ${high.no}) · 교좌면 계단 ${g.segments.length}구간 · 피복 ${wall.result.settings.c}`;
fillAbutWallRebarCard(box, rec, wall);
} else if (member.id === 'blk' && blockR) {
const r = blockR.result;
const nBlocks = new Set(r.paths.map((p) => p.block)).size;
$('rebar-note').textContent = `${rec.name} · Type-1 · 블럭 ${nBlocks}개 (분리) · 1200 × 1200 × ${r.concreteH + ABUT_BLOCK_PAD_MM} (패드 ${ABUT_BLOCK_PAD_MM} 포함) · 묻힘 S2 ${Math.round(r.embedment.S2)} · S1 ${Math.round(r.embedment.S1)}`;
fillAbutBlockRebarCard(box, rec, blockR.result);
} else if (member.id === 'slab' && slabR) {
const g = slabR.geom;
$('rebar-note').textContent = `${rec.name} · Type-1 ${g.form === 'buried' ? '매립형' : '노출형'} · 폭 ${Math.round(g.B)} × 길이 ${Math.round(g.Ls)} · 경사 ${g.gradePct.toFixed(1)} %${g.form === 'exposed' ? ' · 매립형 규칙 적용 (사용자 결정 2026-11-05)' : ''}`;
fillAbutSlabRebarCard(box, rec, slabR);
} else if (member.id === 'wing') {
const forms = rec.wings ?? { left: 'general', right: 'general' };
$('rebar-note').textContent = `${rec.name} · Type-1 · 좌 ${WING_FORM_TEXT[forms.left]} · 우 ${WING_FORM_TEXT[forms.right]}`;
fillAbutWingRebarCard(box, rec, wings);
} else if (member.id === 'para' && para) {
const g = para.geom;
$('rebar-note').textContent = `${rec.name} · Type-1 · 흉벽 두께 ${Math.round(-g.parapetBack)} · 높이 ${Math.round(g.Hp)} (대표 단면) · 브라켓 ${Math.round(g.parapetBack - g.wall.xb)} × ${Math.round(g.bracketH)} · 피복 ${para.result.settings.c}`;
fillAbutParapetRebarCard(box, rec, para);
} else {
const g = built.geom;
const toe = Math.round(g.W - g.xf);
$('rebar-note').textContent = `${rec.name} · Type-1 · ${Math.round(g.L)} × ${Math.round(g.W)} × ${Math.round(g.H)} · ${g.plan === 'skew' ? 'skew 평행사변형' : '직사각'} · 앞굽 ${toe} / 벽체 ${Math.round(g.xf - g.xb)} / 뒷굽 ${Math.round(g.xb)}`;
fillAbutFootingRebarCard(box, rec, built);
}
for (const i of issuesOf(member.id)) {
const li = el('li', `${i.code} ${i.message}`);
if (i.level === 'W' || i.level === 'E') li.classList.add('warn');
issues.appendChild(li);
}
if (issues.childElementCount === 0) issues.appendChild(el('li', '알림이 없습니다.'));
setHeaderStatus('step3-status', member.ready ? `경고·${warnOf(member.id)}` : '미완료', member.ready ? `${rec.name} ${member.label} 계산됨 · 경고 ${warnOf(member.id)}` : `${rec.name} ${member.label} 다음 단계`);
rebar3d?.show(state.rebarSelection.size ? state.rebarSelection : null, state.rebarIsolate);
}
/** One ③ 교대 철근표 row's selection (교각 철근표와 같음): a row click selects the bar alone, the checkbox adds or removes it. Returns the 선택 cell. */
function abutSelectRow(tr: HTMLTableRowElement, mark: string, togglePick: (mark: string) => void): HTMLTableCellElement {
const on = state.rebarSelection.has(mark);
if (on) tr.classList.add('hl');
tr.addEventListener('click', () => togglePick(mark));
const check = el('input');
check.type = 'checkbox';
check.checked = on;
check.setAttribute('aria-label', `${mark} 선택`);
// The checkbox changes the selection on its own; its click must not reach the row handler.
check.addEventListener('click', (ev) => ev.stopPropagation());
check.addEventListener('change', () => {
const next = new Set(state.rebarSelection);
if (check.checked) next.add(mark);
else next.delete(mark);
state.rebarSelection = next;
renderAll();
});
const cell = el('td');
cell.append(check);
return cell;
}
/** One 철근표 row of a ③ 교대 벽체 · 흉벽 card. */
interface AbutCardBar {
mark: string;
role: string;
size: string;
spacing: string;
actual: string;
shape: string;
length: number;
lengthMin?: number;
lengthMax: number;
count: number;
position: string;
note: string;
basis: '규칙' | '가정';
modified: boolean;
}
/** What a ③ 교대 벽체 · 흉벽 card shows (시안 순서: 보기 탭 · 삽도 → 메모 · 범례 → 기본간격 · 입력 → 자동 값 → 철근표 → 근거). */
interface AbutCardSpec<S> {
idPrefix: string;
/** A control row above the 보기 탭 (날개벽: 측 선택). */
headExtra?: HTMLElement;
views: readonly (readonly [string, string])[];
view: string;
setView: (id: string) => void;
svg: (pick: AbutRebarPick) => string;
caption: string;
memo?: string;
legendHtml: string;
baseTitle: string;
/** label, value, default, patch. */
base: [string, number, number, (v: number) => Partial<S>][];
inputs: [string, number, number, (v: number) => Partial<S>][];
/** "8 × 4" or "400 · 600" text input of two numbers. */
pair?: { label: string; value: [number, number]; def: [number, number]; sep: string; error: string; patch: (a: number, b: number) => Partial<S> };
autos: [string, string][];
bars: AbutCardBar[];
roleColor: Record<string, string>;
/** 묶음별 간격 수정: the row that edits a group, its key, the 기본간격-linked value, and the label. */
overrides: Partial<Record<string, { key: keyof S; base: number; label: string }>>;
basisNote: string;
set: (patch: Partial<S>) => void;
}
/** ③ 교대 벽체 · 흉벽 카드 공통 (2026-11-04). The selection is the ③ rebarSelection (표 · 삽도 · 3D 공통). */
function abutRebarCard<S>(box: HTMLDivElement, o: AbutCardSpec<S>): void {
const pick: AbutRebarPick = { marks: state.rebarSelection, only: state.rebarIsolate };
const togglePick = (mark: string) => {
const alone = state.rebarSelection.size === 1 && state.rebarSelection.has(mark);
state.rebarSelection = alone ? new Set() : new Set([mark]);
renderAll();
};
if (o.headExtra) box.append(o.headExtra);
// 보기 탭.
const head = el('div', undefined, 'abut-rebar-head');
for (const [id, label] of o.views) {
const b = el('button', label, `abut-optt${o.view === id ? ' on' : ''}`);
b.type = 'button';
b.setAttribute('aria-pressed', String(o.view === id));
b.addEventListener('click', () => {
o.setView(id);
renderAll();
});
head.append(b);
}
box.append(head);
const picked = o.bars.filter((b) => state.rebarSelection.has(b.mark));
box.append(
el(
'p',
picked.length === 0
? '전체 표시 · 표의 행이나 도면의 번호 풍선을 누르면 그 철근만 강조합니다'
: `강조 중 · ${picked.map((b) => b.mark).join(' · ')}${picked.length === 1 ? ` — ${picked[0].position}` : ''}${state.rebarIsolate ? ' · 선택만 보기' : ''} · 철근표의 [전체 해제]로 해제`,
'muted small',
),
);
const fig = el('figure', undefined, 'ground-fig abut-rebar-fig');
fig.innerHTML = o.svg(pick);
fig.querySelectorAll<SVGGElement>('[data-pick]').forEach((g) => {
const mark = g.getAttribute('data-pick')!;
g.addEventListener('click', () => togglePick(mark));
g.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
togglePick(mark);
}
});
});
fig.append(el('figcaption', o.caption, 'muted small'));
box.append(fig);
if (o.memo) box.append(el('p', o.memo, 'muted small'));
const legend = el('p', undefined, 'muted small abut-rebar-legend');
legend.innerHTML = o.legendHtml;
box.append(legend);
// 입력.
const num = (into: HTMLElement, [label, value, def, patch]: [string, number, number, (v: number) => Partial<S>]) => {
const id = `${o.idPrefix}-${label}`;
const lab = el('label', label);
lab.htmlFor = id;
const input = el('input');
input.id = id;
input.type = 'number';
input.step = 'any';
input.value = String(value);
if (value !== def) input.classList.add('changed-input');
input.addEventListener('change', () => o.set(patch(Number(input.value))));
const cell = el('div');
cell.append(lab, input);
into.append(cell);
};
const grid = el('div', undefined, 'abut-rebar-inputs');
grid.append(el('p', o.baseTitle, 'lbl'));
for (const f of o.base) num(grid, f);
box.append(grid);
const grid2 = el('div', undefined, 'abut-rebar-inputs');
for (const f of o.inputs) num(grid2, f);
if (o.pair) {
const p = o.pair;
const id = `${o.idPrefix}-pair`;
const lab = el('label', p.label);
lab.htmlFor = id;
const input = el('input');
input.id = id;
input.type = 'text';
input.value = `${p.value[0]} ${p.sep} ${p.value[1]}`;
if (p.value[0] !== p.def[0] || p.value[1] !== p.def[1]) input.classList.add('changed-input');
input.addEventListener('change', () => {
const n = input.value.split(/[×x*·,/\s]+/).filter(Boolean).map(Number);
if (n.length !== 2 || n.some((v) => !Number.isFinite(v))) {
setError(p.error);
renderAll();
return;
}
o.set(p.patch(n[0], n[1]));
});
const cell = el('div');
cell.append(lab, input);
grid2.append(cell);
}
for (const [label, value] of o.autos) {
const cell = el('div', undefined, 'abut-rebar-auto');
cell.append(el('span', label, 'muted small'), el('span', value, 'num'));
grid2.append(cell);
}
box.append(grid2);
// 철근표.
const bar = el('div', undefined, 'abut-rebar-tablehead');
const assumed = o.bars.filter((b) => b.basis === '가정').map((b) => b.mark);
bar.append(el('span', assumed.length ? `가정값 ${assumed.length}개 (${assumed.join(' · ')}) · 나머지는 규칙 계산` : '가정값 없음 · 모든 철근이 규칙 계산', 'muted small'));
const notes = el('button', state.abutRebarNotes ? '비고 접기' : '비고 모두 펼치기', 'btn small');
notes.type = 'button';
notes.addEventListener('click', () => {
state.abutRebarNotes = !state.abutRebarNotes;
renderAll();
});
bar.append(notes);
box.append(bar);
box.append(rebarSelectBar(o.bars.map((b) => b.mark)));
const table = el('table', undefined, 'grid-table rebar-table');
const hr = table.createTHead().insertRow();
for (const h of ['선택', '구분', '번호', '규격', '간격', '실간격', '형상', '길이 (mm) · 근거', '개수', '위치']) hr.append(el('th', h));
const body = table.createTBody();
for (const b of o.bars) {
const on = state.rebarSelection.has(b.mark);
const tr = body.insertRow();
const checkCell = abutSelectRow(tr, b.mark, togglePick);
const role = el('td');
role.innerHTML = `<span style="display:inline-block;width:22px;border-top:3px solid ${o.roleColor[b.role]};vertical-align:middle"></span> ${b.role}`;
const actual = el('td');
const ov = o.overrides[b.mark];
if (ov) {
const input = el('input');
input.type = 'number';
input.step = 'any';
input.value = b.actual;
input.style.width = '70px';
input.setAttribute('aria-label', ov.label);
input.addEventListener('click', (ev) => ev.stopPropagation());
input.addEventListener('change', () => o.set({ [ov.key]: Number(input.value) === ov.base ? undefined : Number(input.value) } as Partial<S>));
actual.append(input);
if (b.modified) {
actual.append(el('span', ' 수정됨', 'muted small abut-note-warn'));
const back = el('button', '되돌리기', 'btn small');
back.type = 'button';
back.addEventListener('click', (ev) => {
ev.stopPropagation();
o.set({ [ov.key]: undefined } as Partial<S>);
});
actual.append(back);
}
} else {
actual.textContent = b.actual + (b.modified && b.actual !== '—' ? ' · 수정됨' : '');
}
const len = el('td');
// 벽체: 대표 ~ 최대 (구간마다). 흉벽: 대표 (최소 ~ 최대) (상단 경사).
const range = b.lengthMin === undefined ? (b.lengthMax !== b.length ? ` ~ ${b.lengthMax}` : '') : b.lengthMin !== b.lengthMax ? ` (${b.lengthMin} ~ ${b.lengthMax})` : '';
len.append(`${b.length}${range} `, el('span', b.basis, b.basis === '가정' ? 'badge warn' : 'badge'));
tr.append(checkCell, role, el('td', b.mark, 'bar-no'), el('td', b.size), el('td', b.spacing), actual, el('td', b.shape), len, el('td', String(b.count)), el('td', b.position));
if (state.abutRebarNotes || on) {
const nr = body.insertRow();
const cell = el('td', `${b.mark} 비고 · ${b.note}`, 'muted small');
cell.colSpan = 10;
nr.append(cell);
}
}
const scroll = el('div', undefined, 'table-scroll');
scroll.append(table);
box.append(scroll);
box.append(el('p', o.basisNote, 'muted small'));
}
/** ③ 교대 벽체 카드 (시안, 교대벽체철근.md §9). */
function fillAbutWallRebarCard(box: HTMLDivElement, rec: AbutRecord, built: { geom: AbutWallGeom; result: AbutWallRebar }): void {
const { geom, result: r } = built;
const s = r.settings;
const D = ABUT_WALL_REBAR_DEFAULT;
const C = ABUT_WALL_COLORS;
const SZ = ABUT_WALL_SIZES;
const fck = materialFor(state.material, state.fckByMember, 'abutBody').fck;
const w7 = r.bars.find((b) => b.mark === 'W7');
const lv = r.levels.find((l) => l.no === 1);
abutRebarCard<AbutWallRebarSettings>(box, {
idPrefix: `awr-${rec.name}`,
views: [['sec', '교대축 직각 단면'], ['elev', '교대축 단면 (계단)']],
view: state.abutWallView,
setView: (id) => (state.abutWallView = id as 'sec' | 'elev'),
svg: (pick) => (state.abutWallView === 'sec' ? abutWallRebarSectionSvg(geom, r, pick) : abutWallRebarElevationSvg(geom, r, pick)),
caption:
state.abutWallView === 'sec'
? '교대축 직각 단면 · 구간 1 (IP3 끝) · 왼쪽 배면 → 오른쪽 전면 · 선 = 연직 · W2 · W7, 점 = W5 · W6 · W9 · W10'
: '교대축 단면 · 전면 철근 · 왼쪽 IP3 = 구간 1 → 오른쪽 IP1 · W5는 계단에서 끊김, W9 (ㄷ) · W10 (ㄴ)은 낮은 쪽 연직 갈고리 · 높은 쪽 직선 겹침',
memo: `벽체 높이 구간 ${[...geom.segments].sort((a, b) => a.no - b.no).map((sg) => `${sg.no} ${Math.round(sg.H)}`).join(' · ')} · 수평 W5는 높이가 들어가는 구간까지만 (위 계단 면 − ${lenText(s.c)}에서 끊음) · W9 (ㄷ) · W10 (ㄴ): 낮은 이웃 · 끝면 쪽 연직 갈고리, 높은 이웃 쪽 직선 겹침, 정착장 ${lenText(s.la)}`,
legendHtml: `<span style="color:${C.vertical}">━ 연직 (W1 · W3 · W4 · W11)</span> · <span style="color:${C.horizontal}">━ 수평 · 교좌면 (W2 · W5 · W6 · W8)</span> · <span style="color:${C.step}">━ 계단 상면 (W9 실선 · W10 파선)</span> · <span style="color:${C.tie}">┅ 간격재 W7</span>`,
baseTitle: '기본간격 · 벽체 (부재별 입력)',
base: [
['교대축 (mm)', s.sL, D.sL, (v) => ({ sL: v })],
['교대축 직각 (mm)', s.sW, D.sW, (v) => ({ sW: v })],
['연직 (mm)', s.sV, D.sV, (v) => ({ sV: v })],
],
// 사용자 결정 2026-11-04: W7 배수 편집, e_min 표시.
inputs: [
['피복 (mm)', s.c, D.c, (v) => ({ c: v })],
['첫 철근 최소 여유 (mm)', s.eMin, D.eMin, (v) => ({ eMin: v })],
// 사용자 도면 2026-11-04: W9 · W10 갈고리 = 겹침 길이.
['W9 · W10 정착장 (mm)', s.la, D.la, (v) => ({ la: v })],
],
pair: { label: '간격재 W7 배수 (교대축 × 연직)', value: [s.k7L, s.k7V], def: [D.k7L, D.k7V], sep: '×', error: 'W7 배수는 "8 × 4"처럼 두 정수로 입력하세요.', patch: (a, b) => ({ k7L: a, k7V: b }) },
autos: [
['연직철근 격자 (저판 B1 · B3 피함)', `저판 격자 − ${lenText(r.vertical.shift)}`],
['W7 간격', w7 ? `@${w7.spacing.replace(' × ', ' × @')}` : '—'],
['교대축 직각 철근 방향', '① skew 고정'],
[`l_d (fck ${fck})`, `H13 ${r.ld.H13} · H16 ${r.ld.H16} · H19 ${r.ld.H19}`],
],
bars: r.bars,
roleColor: { 연직: C.vertical, 수평: C.horizontal, 간격재: C.tie, '계단 상면': C.step },
// 묶음별 간격 수정 (사용자 결정 2026-11-04): W3 · W4 (교대축), W5 · W6 · W8 (연직), W9 · W10 (폭). W1 · W2는 연직 격자를 따름.
overrides: {
W3: { key: 'sVert', base: s.sL, label: 'W1–W4 · W2 교대축 실간격' },
W5: { key: 'sHoriz', base: s.sV, label: 'W5 · W6 · W8 연직 간격' },
W9: { key: 'sTop', base: 2 * s.sW, label: 'W9 · W10 폭 간격' },
},
basisNote: `근거 · 교대벽체철근.md · spec_abut.md §6.3 · 규격 W1 · W3 · W4 · W5 ${SZ.W1} / W2 · W6 · W9 · W10 · W11 ${SZ.W2} / W7 · W8 ${SZ.W7} · 교대축 ${r.vertical.text}${lv ? ` · 삽도 재현: 구간 1 "${lv.text.split(' · ')[0]}"` : ''}`,
set: (patch) => {
try {
applyAbutRecord(applyWallRebar(rec, patch));
} catch (err) {
setError((err as Error).message);
renderAll();
}
},
});
}
/** ③ 교대 받침블럭 카드 (시안, 받침블럭철근.md §8). 입력 두 칸: 교대축 (S2) · 교대축 직각 (S1) — 사용자 결정 2026-11-05. */
function fillAbutBlockRebarCard(box: HTMLDivElement, rec: AbutRecord, r: AbutBlockRebar): void {
const s = r.settings;
const D = ABUT_BLOCK_REBAR_DEFAULT;
const C = ABUT_BLOCK_COLORS;
const view = state.abutBlockView;
const seat = (m: 'S1' | 'S2') => `${Math.round(r.embedment[m])}`;
abutRebarCard<AbutBlockSettings>(box, {
idPrefix: `abk-${rec.name}`,
views: [['plan', '평면'], ['sec', '단면']],
view,
setView: (id) => (state.abutBlockView = id as 'plan' | 'sec'),
svg: (pick) => (view === 'plan' ? abutBlockRebarPlanSvg(r, pick) : abutBlockRebarSectionSvg(r, pick)),
caption: view === 'plan' ? '평면 · 블럭 1200 × 1200 (받침 1개) · S2 실선 (위, 교대축 간격) · S1 파선 (아래, 교대축 직각 간격)' : '단면 · 교좌면 기준 · 몰탈패드 50 · S2 위 줄 · S1 아래 줄 · 다리는 교좌면 아래로 묻힘',
memo: `블럭 ${r.paths.length ? new Set(r.paths.map((p) => p.block)).size : 0}개 (받침마다 1개, 분리). S1 다리 묻힘 ${seat('S1')} — 벽체 W2 층(교좌면 − 100)을 지나면 경고만 (Q-S4, 확인 후 결정).`,
legendHtml: `<span style="color:${C.s1}">━ S1 (주철근, 아래)</span> · <span style="color:${C.s2}">━ S2 (보조, 위)</span>`,
baseTitle: '기본간격 · 받침블럭 (부재별 입력, 두 칸)',
base: [
['교대축 (mm)', s.spacingS2, D.spacingS2, (v) => ({ spacingS2: v })],
['교대축 직각 (mm)', s.spacingS1, D.spacingS1, (v) => ({ spacingS1: v })],
],
inputs: [
['상면 피복 (mm)', s.coverTop, D.coverTop, (v) => ({ coverTop: v })],
['측면 피복 (mm)', s.coverSide, D.coverSide, (v) => ({ coverSide: v })],
['첫 철근 최소 여유 (mm)', s.firstGap, D.firstGap, (v) => ({ firstGap: v })],
['S1 · S2 다리 길이 (mm)', s.legLength, D.legLength, (v) => ({ legLength: v })],
],
autos: [
['블럭 콘크리트 높이 (블럭 − 패드 50)', `${r.concreteH} (≥ ${s.coverTop} + 13 → 블럭 안 배근)`],
['교좌면 아래 묻힘 S2 · S1', `${seat('S2')} · ${seat('S1')}`],
['적용 블럭', `${r.paths.length ? new Set(r.paths.map((p) => p.block)).size : 0}개 · 배치 분리`],
],
bars: r.bars.map((b) => ({ mark: b.mark, role: b.role, size: b.size, spacing: b.spacing, actual: b.actual, shape: b.shape, length: b.length, count: b.count, position: b.position, note: b.note, basis: b.basis, modified: b.modified, lengthMin: undefined, lengthMax: b.length })),
roleColor: { 주철근: C.s1, 보조: C.s2 },
overrides: {
S1: { key: 'spacingS1', base: s.spacingS1, label: 'S1 교대축 직각 간격' },
S2: { key: 'spacingS2', base: s.spacingS2, label: 'S2 교대축 간격' },
},
basisNote: `근거 · 받침블럭철근.md §6.6 · 규격 S1 · S2 H13 · 교각 받침블럭 규칙 재사용 (깊이만 H13) · 묻힘 S2 ${seat('S2')} / S1 ${seat('S1')}`,
set: (patch) => {
try {
applyAbutRecord(applyBlockRebar(rec, patch));
} catch (err) {
setError((err as Error).message);
renderAll();
}
},
});
}
/** ③ 교대 접속슬래브 카드 (시안, 교대접속슬래브철근.md §9). */
function fillAbutSlabRebarCard(box: HTMLDivElement, rec: AbutRecord, built: { geom: AbutSlabGeom; result: AbutSlabRebar }): void {
const { geom: g, result: r } = built;
const s = r.settings;
const D = ABUT_SLAB_REBAR_DEFAULT;
const C = ABUT_SLAB_COLORS;
const SZ = ABUT_SLAB_SIZES;
const fck = materialFor(state.material, state.fckByMember, 'abutBody').fck;
const sA = s.sMain ?? s.sA;
const sL = s.sDist ?? s.sL;
const view = state.abutSlabView;
abutRebarCard<AbutSlabRebarSettings>(box, {
idPrefix: `asr-${rec.name}`,
views: [['plan', '평면'], ['sec', '단면']],
view,
setView: (id) => (state.abutSlabView = id as 'plan' | 'sec'),
svg: (pick) => (view === 'plan' ? abutSlabRebarPlanSvg(g, r, pick) : abutSlabRebarSectionSvg(g, r, pick)),
caption: view === 'plan' ? '평면 · 왼쪽 절반 하면 (R3 · R4 · R8) · 오른쪽 절반 상면 (R1 · R2) · 위 = 흉벽 쪽 끝 · 모따기 모서리 R7' : '단면 (길이 방향, 폭 가운데) · 왼쪽 흉벽 쪽 끝 · 상면 R1 → R2, 하면 R3 → R4, R8은 R1 · R3을 감쌈',
memo: g.form === 'exposed' ? '노출형도 매립형 규칙을 적용합니다 (사용자 결정 2026-11-05): 상면 철근은 상면, 하면 철근은 하면을 따라갑니다. P10 다웰과의 간섭은 보지 않습니다.' : 'P10 다웰과의 간섭은 보지 않습니다 (사용자 결정 2026-11-05).',
legendHtml: `<span style="color:${C.main}">━ 길이 방향 주철근 (R1 · R3)</span> · <span style="color:${C.dist}">━ 교대축 방향 (R2 · R4)</span> · <span style="color:${C.corner}">━ 모서리 R7</span> · <span style="color:${C.shear}">● 전단 R8</span>`,
baseTitle: '기본간격 · 접속슬래브 (부재별 입력)',
base: [
['교대축 (mm)', s.sA, D.sA, (v) => ({ sA: v })],
['교대축 직각 (mm)', s.sL, D.sL, (v) => ({ sL: v })],
],
// 사용자 결정 2026-11-05: R8 배수 편집.
inputs: [
['첫 철근 최소 여유 (mm)', s.eMin, D.eMin, (v) => ({ eMin: v })],
['R8 배수', s.k8, D.k8, (v) => ({ k8: v })],
],
pair: { label: '피복 상·하면 / 측면 (mm)', value: [s.cv, s.c], def: [D.cv, D.c], sep: '/', error: '피복은 "80 / 100"처럼 두 숫자로 입력하세요.', patch: (a, b) => ({ cv: a, c: b }) },
autos: [
['상면 2s · 하면 s · R8 k·s', `${lenText(2 * sA)} · ${lenText(sA)} · ${lenText(s.k8 * sA)}`],
['하면 격자', '상면 구간을 절반으로'],
['모따기 자름 판정', `끝점 ~ 모따기선 < c · 짧아진 철근 ${r.trimmed}개`],
[`R7 l_d (fck ${fck})`, `H13 ${r.ld}`],
],
bars: r.bars,
roleColor: { 주철근: C.main, 배력철근: C.dist, 모서리: C.corner, 전단: C.shear },
// 묶음별 간격 수정 (사용자 결정 2026-11-05): R1 · R3 (교대축), R2 · R4 (길이). 하면 행에서 하면 간격으로 입력 (상면은 2배).
overrides: {
R3: { key: 'sMain', base: s.sA, label: 'R1 · R3 교대축 간격 (하면)' },
R4: { key: 'sDist', base: s.sL, label: 'R2 · R4 길이 간격 (하면)' },
},
basisNote: `근거 · 교대접속슬래브철근.md · spec_abut.md §4.8 · §6.7 · 규격 R1 · R3 ${SZ.R1} / R2 · R4 · R7 · R8 ${SZ.R2} · 교대축 ${r.across.text} · 길이 ${r.along.text} · R5 · R6 결번`,
set: (patch) => {
try {
applyAbutRecord(applySlabRebar(rec, patch));
} catch (err) {
setError((err as Error).message);
renderAll();
}
},
});
}
const WING_FORM_TEXT: Record<string, string> = { general: '일반형', short: '단축형', none: '없음' };
/** ③ 교대 날개벽 카드 (시안, 교대날개벽철근.md §10): 측 선택 + 공통 카드. 입력은 좌우 공통 (사용자 결정 2026-11-04). */
function fillAbutWingRebarCard(box: HTMLDivElement, rec: AbutRecord, wings: { geom: AbutWingGeom; result: AbutWingRebar }[]): void {
// 측 선택 (시안): 날개벽이 있는 측만. 고른 측이 없으면 있는 측으로.
const sideRow = el('div', undefined, 'abut-rebar-head');
sideRow.append(el('span', '측 선택', 'muted small'));
const forms = rec.wings ?? { left: 'general', right: 'general' };
for (const side of ['left', 'right'] as const) {
const has = wings.some((w) => w.geom.side === side);
const label = `${side === 'left' ? '좌 (① 쪽)' : '우 (⑤ 쪽)'} · ${WING_FORM_TEXT[forms[side]]}`;
const b = el('button', label, `abut-optt${state.abutWingSide === side ? ' on' : ''}`);
b.type = 'button';
b.disabled = !has;
b.setAttribute('aria-pressed', String(state.abutWingSide === side));
b.addEventListener('click', () => {
state.abutWingSide = side;
renderAll();
});
sideRow.append(b);
}
const cur = wings.find((w) => w.geom.side === state.abutWingSide) ?? wings[0];
if (!cur) {
box.append(sideRow, el('p', '날개벽이 없습니다 (② 날개벽 형식이 양쪽 모두 "없음"). 벽체 끝면은 W8 · W11, 흉벽 끝면은 P8 · P9 · P11로 마감합니다.', 'muted'));
return;
}
const { geom: g, result: r } = cur;
const s = r.settings;
const D = ABUT_WING_REBAR_DEFAULT;
const C = ABUT_WING_COLORS;
const SZ = ABUT_WING_SIZES;
const fck = materialFor(state.material, state.fckByMember, 'abutBody').fck;
const view = state.abutWingView;
const sideText = g.side === 'left' ? '좌' : '우';
abutRebarCard<AbutWingRebarSettings>(box, {
idPrefix: `awg-${rec.name}`,
headExtra: sideRow,
// 사용자 결정 2026-11-04: 시안 두 탭에 배면도를 더해 세 탭.
views: [['front', '전면도'], ['back', '배면도'], ['corner', '헌치 · 모서리 평면']],
view,
setView: (id) => (state.abutWingView = id as 'front' | 'back' | 'corner'),
svg: (pick) => (view === 'corner' ? abutWingRebarCornerSvg(g, r, pick) : abutWingFaceSvg(g, r, view, pick)),
caption:
view === 'corner'
? `${sideText} 날개벽 · 헌치 · 모서리 평면 · WR7-1 · 7-2 · 7-3 · WR10 각 띠의 한 단, WR9 점`
: `${sideText} 날개벽 · ${view === 'front' ? '전면도 (바깥)' : '배면도 (흙 쪽)'} · 왼쪽 교대 → 오른쪽 끝 · 수평 띠 등단면 실선 / 변화단면1 파선 / 변화단면2 점선`,
memo: `${sideText} 날개벽 ${WING_FORM_TEXT[g.form]} · 길이 ${Math.round(g.L)} · 경사 ${(g.slope * 100).toFixed(2)} % · 끝 높이 ${Math.round(g.endH)} · 헌치 ${Math.round(g.haunch)}. 맨 위 2단은 상단 경사와 나란히, 교대 철근과의 간섭은 보지 않음 (사용자 결정 2026-11-04). 좌우는 같은 입력, skew로 배면 길이가 측마다 다릅니다.`,
legendHtml: `<span style="color:${C.vertical}">━ 연직 (WR1 · 2 · 11 · 12)</span> · <span style="color:${C.horizontal}">━ 수평 등단면 (WR3 · 13) · ┅ 변화단면1 (WR4 · 14) · ┈ 변화단면2 (WR5 · 15)</span> · <span style="color:${C.haunch}">━ 헌치 WR7 · WR9 · ┅ 모서리 WR10</span> · <span style="color:${C.other}">━ WR6 · ┈ WR20</span>`,
baseTitle: '기본간격 · 날개벽 (부재별 입력, 좌우 공통)',
base: [
['수평 (mm)', s.sH, D.sH, (v) => ({ sH: v })],
['연직 (mm)', s.sV, D.sV, (v) => ({ sV: v })],
],
// 사용자 결정 2026-11-04: c · e_min · WR20 배수도 보임, WR9 개수는 없앰 (@2·s_h 자동).
inputs: [
['피복 (mm)', s.c, D.c, (v) => ({ c: v })],
['첫 철근 최소 여유 (mm)', s.eMin, D.eMin, (v) => ({ eMin: v })],
['등단면 올림 허용차 δ (mm)', s.delta, D.delta, (v) => ({ delta: v })],
['WR20 배수', s.k20, D.k20, (v) => ({ k20: v })],
],
autos: [
['형식 (②에서 정함)', WING_FORM_TEXT[g.form]],
['배면 s · 전면 · 헌치 2s', `${lenText(s.sVert ?? s.sH)} · ${lenText(2 * (s.sVert ?? s.sH))}`],
['연직 기준선 (뒷굽 끝 − c)', `전면 ${lenText(r.vertical.front.base)} · 배면 ${lenText(r.vertical.back.base)}`],
['수평 단 (교대 쪽 · 끝)', `전면 ${r.levels.front.start} · ${r.levels.front.end} / 배면 ${r.levels.back.start} · ${r.levels.back.end}`],
['WR9 (저판 상면 헌치 보강, 자동)', `${r.bars.find((b) => b.mark === 'WR9')?.count ?? 0}개 @${lenText(2 * (s.sVert ?? s.sH))} · 연직 다리 = 벽체 정착장 ${lenText(resolveWallRebar(rec.rebar?.wall).la)}`],
[`l_d (fck ${fck})`, `H13 ${r.ld.H13} · H16 ${r.ld.H16} · H25 ${r.ld.H25}`],
],
bars: r.bars,
roleColor: { 연직: C.vertical, 수평: C.horizontal, 헌치: C.haunch, 모서리: C.haunch, 간격재: C.other, '경사 하단': C.other },
// 묶음별 간격 수정 (사용자 결정 2026-11-04): 연직 WR1 · 2 · 11 · 12, 수평 WR3–5 · 13–15 (배면 값, 전면은 2배).
overrides: {
WR1: { key: 'sVert', base: s.sH, label: 'WR1 · 2 · 11 · 12 수평 간격 (배면)' },
WR3: { key: 'sHoriz', base: s.sV, label: 'WR3–5 · 13–15 연직 간격 (배면)' },
},
basisNote: `근거 · 교대날개벽철근.md · spec_abut.md §4.7 · §6.5 · 규격 WR1 · 2 · 6 · 9 · 11 · 12 ${SZ.WR1} / WR3–5 · 7 · 10 · 13–15 ${SZ.WR3} / WR20 ${SZ.WR20} · 연직 배치 전면 ${r.vertical.front.text} · 배면 ${r.vertical.back.text} · WR8 · WR16–19 결번`,
set: (patch) => {
try {
applyAbutRecord(applyWingRebar(rec, patch));
} catch (err) {
setError((err as Error).message);
renderAll();
}
},
});
}
/** ③ 교대 흉벽 · 브라켓 카드 (시안, 교대흉벽철근.md §10). */
function fillAbutParapetRebarCard(box: HTMLDivElement, rec: AbutRecord, built: { geom: AbutParapetGeom; result: AbutParapetRebar }): void {
const { geom, result: r } = built;
const s = r.settings;
const D = ABUT_PARAPET_REBAR_DEFAULT;
const C = ABUT_PARAPET_COLORS;
const SZ = ABUT_PARAPET_SIZES;
const fck = materialFor(state.material, state.fckByMember, 'abutBody').fck;
const p6 = r.bars.find((b) => b.mark === 'P6');
const off = (v: number) => (v >= 0 ? `+ ${lenText(v)}` : `− ${lenText(-v)}`);
const view = state.abutParapetView;
abutRebarCard<AbutParapetRebarSettings>(box, {
idPrefix: `apr-${rec.name}`,
// 사용자 결정 2026-11-04: 시안 두 탭에 교대축 단면을 더해 세 탭.
views: [['sec', '단면'], ['elev', '교대축 단면'], ['dw', 'P10 다웰 배치']],
view,
setView: (id) => (state.abutParapetView = id as 'sec' | 'elev' | 'dw'),
svg: (pick) => (view === 'sec' ? abutParapetSectionSvg(geom, r, pick) : view === 'elev' ? abutParapetElevationSvg(geom, r, pick) : abutParapetDowelSvg(geom, r, pick)),
caption:
view === 'sec'
? `교대축 직각 단면 · 받침 ① 위치 (벽체 높이 H_w ${lenText(geom.Hp)}) · 왼쪽 브라켓 배면 → 오른쪽 흉벽 전면`
: view === 'elev'
? '교대축 단면 · 전면 · 왼쪽 IP3 → 오른쪽 IP1 · 수평 P4는 흉벽 상단 경사와 나란함, 끝면 P9 (위) · P8 (아래) · P11'
: `P10 다웰 배치 · 평면 (접속슬래브 폭 ${geom.slab ? Math.round(geom.slab.B) : '—'}) · 다웰이 P3 수평 다리와 겹치면 가장 가까운 자리로 옮김 (Q-P5)`,
memo: `흉벽 상단은 받침 ① 교좌면 + 벽체 높이 H_w ${lenText(geom.Hp)}에서 받침 z 경사를 따라 기울어 교좌면 위 높이가 위치마다 다릅니다 (IP1 ${Math.round(geom.top[0] - (geom.wall.HF + geom.wall.segments[0].H))} · IP3 ${Math.round(geom.top[1] - (geom.wall.HF + geom.wall.segments[geom.wall.segments.length - 1].H))}). 철근표 대표 길이 · 단 수는 받침 ① 위치 단면, 괄호는 범위입니다.`,
legendHtml: `<span style="color:${C.vertical}">━ 연직 (P1–P3 · P11)</span> · <span style="color:${C.horizontal}">━ 수평 (P4 · P5)</span> · <span style="color:${C.tie}">┅ 간격재 (P6 · P7)</span> · <span style="color:${C.side}">┅ 측면 (P8 · P9) · 다웰 P10</span>`,
baseTitle: '기본간격 · 흉벽 (부재별 입력) · 교대축 직각은 두지 않음',
base: [
['교대축 (mm)', s.sL, D.sL, (v) => ({ sL: v })],
['연직 (mm)', s.sV, D.sV, (v) => ({ sV: v })],
],
inputs: [
['피복 (mm)', s.c, D.c, (v) => ({ c: v })],
['간격재 P6 · P7 배수 · 교대축', s.k67L, D.k67L, (v) => ({ k67L: v })],
['간격재 P6 · P7 배수 · 연직', s.k67V, D.k67V, (v) => ({ k67V: v })],
],
pair: { label: 'P10 간격 · 길이 (기본간격과 별도)', value: [s.s10, s.L10], def: [D.s10, D.L10], sep: '·', error: 'P10 간격 · 길이는 "400 · 600"처럼 두 숫자로 입력하세요.', patch: (a, b) => ({ s10: a, L10: b }) },
autos: [
['상단 · 하단 경계', `브라켓 상면 (흉벽 상단 − ${lenText(geom.bracketH)})`],
['연직 격자 (저판 기준)', `P1 ${off(r.offsets.P1)} · P2 ${off(r.offsets.P2)} · P3 ${off(r.offsets.P3)}`],
['P6 · P7 간격', p6 ? `@${p6.spacing.replace(' × ', ' × @')}` : '—'],
[`l_d (fck ${fck})`, `H13 ${r.ld.H13} · H16 ${r.ld.H16}`],
['P10', `${r.dowel.n}개 · 양끝 여유 ${lenText(r.settings.c + r.dowel.e)}${r.dowel.moved ? ` · ${r.dowel.moved}개 P3 비켜 옮김` : ''}`],
],
bars: r.bars,
roleColor: { 연직: C.vertical, 수평: C.horizontal, 간격재: C.tie, 측면: C.side, 다웰: C.side },
// 묶음별 간격 수정 (사용자 결정 2026-11-04): P1–P3 (교대축), P4 · P5 · P8 · P9 (연직). P10은 별도 입력, P11은 흉벽 선.
overrides: {
P2: { key: 'sVert', base: s.sL, label: 'P1–P3 교대축 실간격' },
P4: { key: 'sHoriz', base: s.sV, label: 'P4 · P5 · P8 · P9 연직 간격' },
},
basisNote: `근거 · 교대흉벽철근.md · spec_abut.md §6.4 · 규격 P1–P5 · P11 ${SZ.P1} / P6–P9 ${SZ.P6} / P10 ${SZ.P10} · 흉벽 연직철근 격자 = 저판 격자 ${off(r.offsets.P1)} (P1) / ${off(r.offsets.P2)} (P2) / ${off(r.offsets.P3)} (P3)`,
set: (patch) => {
try {
applyAbutRecord(applyParapetRebar(rec, patch));
} catch (err) {
setError((err as Error).message);
renderAll();
}
},
});
}
/** ③ 교대 확대기초 카드 (시안, 교대확대기초철근.md §10): 보기 탭 · 삽도 → 범례 → 기본간격 · 배근 방향 · 입력 → 자동 값 → 철근표 → 근거. */
function fillAbutFootingRebarCard(box: HTMLDivElement, rec: AbutRecord, built: { geom: AbutFootingGeom; result: AbutFootingRebar; skewK: number }): void {
const { geom, result: r, skewK } = built;
const s = r.settings;
const pick: AbutRebarPick = { marks: state.rebarSelection, only: state.rebarIsolate };
// The B10 풍선 and 철근표 rows stand for B10-1 and B10-2 together.
const marksOf = (mark: string) => (mark === 'B10' ? r.bars.filter((b) => b.mark.startsWith('B10')).map((b) => b.mark) : [mark]);
const set = (patch: Partial<AbutFootingRebarSettings>) => {
try {
applyAbutRecord(applyAbutFootingRebar(rec, patch));
} catch (err) {
setError((err as Error).message);
renderAll();
}
};
// A 번호 풍선 or row click selects that bar alone; clicking it again clears it (교각 철근표와 같음).
const togglePick = (mark: string) => {
const marks = marksOf(mark);
const alone = state.rebarSelection.size === marks.length && marks.every((m) => state.rebarSelection.has(m));
state.rebarSelection = alone ? new Set() : new Set(marks);
renderAll();
};
// 보기 탭 + 전체 보기 + 강조 상태.
const head = el('div', undefined, 'abut-rebar-head');
const views: ['w' | 'l' | 'plan', string][] = [['w', '교대축 직각 단면 (폭 W)'], ['l', '교대축 단면 (길이 L)'], ['plan', '상면 평면']];
for (const [id, label] of views) {
const b = el('button', label, `abut-optt${state.abutRebarView === id ? ' on' : ''}`);
b.type = 'button';
b.setAttribute('aria-pressed', String(state.abutRebarView === id));
b.addEventListener('click', () => {
state.abutRebarView = id;
renderAll();
});
head.append(b);
}
box.append(head);
const picked = r.bars.filter((b) => state.rebarSelection.has(b.mark));
box.append(
el(
'p',
picked.length === 0
? '전체 표시 · 표의 행이나 도면의 번호 풍선을 누르면 그 철근만 강조합니다'
: `강조 중 · ${picked.map((b) => b.mark).join(' · ')}${picked.length === 1 ? ` — ${picked[0].position}` : ''}${state.rebarIsolate ? ' · 선택만 보기' : ''} · 철근표의 [전체 해제]로 해제`,
'muted small',
),
);
const fig = el('figure', undefined, 'ground-fig abut-rebar-fig');
fig.innerHTML =
state.abutRebarView === 'w' ? abutFootingSectionWSvg(geom, r, pick) : state.abutRebarView === 'l' ? abutFootingSectionLSvg(geom, r, pick) : abutFootingRebarPlanSvg(geom, r, skewK, pick);
fig.querySelectorAll<SVGGElement>('[data-pick]').forEach((g) => {
const mark = g.getAttribute('data-pick')!;
g.addEventListener('click', () => togglePick(mark));
g.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
togglePick(mark);
}
});
});
const caption =
state.abutRebarView === 'w'
? '교대축 직각 단면 · 폭 W (왼쪽 배면 · 뒷굽 → 오른쪽 전면 · 앞굽) · 선 = B1–B4, 점 = B5–B8 · B10'
: state.abutRebarView === 'l'
? `교대축 단면 · 길이 L (가로 축척 0.4배) · B1·B3 / B2·B4 같은 높이에 번갈아 (실간격 ${s.sMain ?? s.sL}) · B1은 B2 옆 +${r.sideShift.B1.toFixed(2)}`
: `상면 평면 · 배근 방향 ${r.dir === 'skew' ? '① skew 방향 (날개벽 선과 나란히)' : '② 교대축 직각 (가운데 격자 + 끝 skew 철근 B1-S)'} · 위 = 전면, 왼쪽 IP3 → 오른쪽 IP1`;
fig.append(el('figcaption', caption, 'muted small'));
box.append(fig);
const C = ABUT_FOOTING_COLORS;
const legend = el('p', undefined, 'muted small abut-rebar-legend');
legend.innerHTML = `<span style="color:${C.main}">━ 주철근 폭 방향 (B1–B4${r.skewEnd ? ' · B1-S·B2-S' : ''})</span> · <span style="color:${C.dist}">━ 배력 교대축 방향 (B5–B8)</span> · <span style="color:${C.shear}">━ 전단 B9</span> · <span style="color:${C.side}">┅ 측면 B10</span> · 색은 역할 기준 (교대는 폭 방향 B1–B4가 주철근)`;
box.append(legend);
// 입력: 기본간격 · 배근 방향 · 피복 · 여유 · B9 배수.
const num = (into: HTMLElement, label: string, value: number, apply: (v: number) => Partial<AbutFootingRebarSettings>, def: number) => {
const id = `abr-${rec.name}-${label}`;
const lab = el('label', label);
lab.htmlFor = id;
const input = el('input');
input.id = id;
input.type = 'number';
input.step = 'any';
input.value = String(value);
if (value !== def) input.classList.add('changed-input');
input.addEventListener('change', () => set(apply(Number(input.value))));
const cell = el('div');
cell.append(lab, input);
into.append(cell);
};
const grid = el('div', undefined, 'abut-rebar-inputs');
const D = ABUT_FOOTING_REBAR_DEFAULT;
grid.append(el('p', '기본간격 · 확대기초 (부재별 입력)', 'lbl'));
num(grid, '교대축 (mm)', s.sL, (v) => ({ sL: v }), D.sL);
num(grid, '교대축 직각 (mm)', s.sW, (v) => ({ sW: v }), D.sW);
num(grid, '연직 (mm)', s.sV, (v) => ({ sV: v }), D.sV);
box.append(grid);
box.append(
optionTiles(
'B1–B4 배근 방향',
[
{ label: '① skew 방향', on: r.dir === 'skew', isDefault: true, disabled: geom.plan === 'rect' ? '직사각 평면에서는 선택 불가' : undefined, pick: () => r.dir !== 'skew' && set({ dir: 'skew' }) },
{ label: '② 교대축 직각', on: r.dir === 'perp', pick: () => r.dir !== 'perp' && set({ dir: 'perp' }) },
],
'직사각 평면이면 ② 고정 (RB-AF04)',
),
);
const grid2 = el('div', undefined, 'abut-rebar-inputs');
num(grid2, '상면·측면 피복 (mm)', s.cTop, (v) => ({ cTop: v, cSide: v }), D.cTop);
num(grid2, '하면 피복 (mm)', s.cBottom, (v) => ({ cBottom: v }), D.cBottom);
num(grid2, '첫 철근 최소 여유 (mm)', s.eMin, (v) => ({ eMin: v }), D.eMin);
{
const id = `abr-${rec.name}-k9`;
const lab = el('label', 'B9 배수 (교대축 × 폭)');
lab.htmlFor = id;
const input = el('input');
input.id = id;
input.type = 'text';
input.value = `${s.k9L} × ${s.k9W}`;
if (s.k9L !== D.k9L || s.k9W !== D.k9W) input.classList.add('changed-input');
input.addEventListener('change', () => {
const n = input.value.split(/[×x*,\s]+/).filter(Boolean).map(Number);
if (n.length !== 2) {
setError('B9 배수는 "2 × 2"처럼 두 정수로 입력하세요.');
renderAll();
return;
}
set({ k9L: n[0], k9W: n[1] });
});
const cell = el('div');
cell.append(lab, input);
grid2.append(cell);
}
// 자동 값.
const fck = materialFor(state.material, state.fckByMember, 'abutFooting').fck;
const fy = (size: string) => fyOf(state.material, size);
const auto = (label: string, value: string) => {
const cell = el('div', undefined, 'abut-rebar-auto');
cell.append(el('span', label, 'muted small'), el('span', value, 'num'));
grid2.append(cell);
};
auto('cycle 간격', `B1–B4 ${2 * (s.sMain ?? s.sL)} · B5–B8 ${2 * (s.sDist ?? s.sW)}`);
auto('B10 위 여유 (계산)', `${Math.round(r.side.topGap)} (≥ ${s.eTop})`);
auto(`이음 l_s = 1.3 l_d (fck ${fck})`, `H16 ${lapLength(15.9, fy('H16'), fck)} · H19 ${lapLength(19.1, fy('H19'), fck)}`);
auto('B1 옆붙임 · B5 옆붙임', `+${r.sideShift.B1.toFixed(2)} · +${r.sideShift.B5.toFixed(1)}`);
box.append(grid2);
// 철근표.
const bar = el('div', undefined, 'abut-rebar-tablehead');
bar.append(el('span', '가정값 없음 · 모든 철근이 규칙 계산', 'muted small'));
const notes = el('button', state.abutRebarNotes ? '비고 접기' : '비고 모두 펼치기', 'btn small');
notes.type = 'button';
notes.addEventListener('click', () => {
state.abutRebarNotes = !state.abutRebarNotes;
renderAll();
});
bar.append(notes);
box.append(bar);
box.append(rebarSelectBar(r.bars.map((b) => b.mark)));
const table = el('table', undefined, 'grid-table rebar-table');
const hr = table.createTHead().insertRow();
for (const h of ['선택', '구분', '번호', '규격', '간격', '실간격', '형상', '길이 (mm)', '개수', '위치']) hr.append(el('th', h));
const body = table.createTBody();
const roleColor: Record<string, string> = { 주철근: C.main, 보조: C.dist, '전단·간격재': C.shear, 측면: C.side };
// 철근별 간격 수정 (RB-AF10): the first row of each linked group takes the 실간격; B9 goes by the 배수 above.
const overrideOf: Record<string, { key: 'sMain' | 'sDist' | 'sSide'; base: number } | undefined> = {
B1: { key: 'sMain', base: s.sL },
B5: { key: 'sDist', base: s.sW },
'B10-1': { key: 'sSide', base: s.sV },
};
for (const b of r.bars) {
const on = state.rebarSelection.has(b.mark);
const tr = body.insertRow();
const checkCell = abutSelectRow(tr, b.mark, togglePick);
const role = el('td');
role.innerHTML = `<span style="display:inline-block;width:22px;border-top:3px solid ${roleColor[b.role]};vertical-align:middle"></span> ${b.role}`;
const markCell = el('td', b.mark, 'bar-no');
const actual = el('td');
const ov = overrideOf[b.mark];
if (ov) {
const input = el('input');
input.type = 'number';
input.step = 'any';
input.value = b.actual;
input.style.width = '70px';
input.setAttribute('aria-label', `${b.mark} 묶음 실간격`);
input.addEventListener('click', (ev) => ev.stopPropagation());
input.addEventListener('change', () => set({ [ov.key]: Number(input.value) === ov.base ? undefined : Number(input.value) }));
actual.append(input);
if (b.modified) {
actual.append(el('span', ' 수정됨', 'muted small abut-note-warn'));
const back = el('button', '되돌리기', 'btn small');
back.type = 'button';
back.addEventListener('click', (ev) => {
ev.stopPropagation();
set({ [ov.key]: undefined });
});
actual.append(back);
}
} else {
actual.textContent = b.actual + (b.modified && b.mark !== 'B9' ? ' · 수정됨' : '');
}
tr.append(checkCell, role, markCell, el('td', b.size), el('td', b.spacing), actual, el('td', b.shape), el('td', String(b.length)), el('td', String(b.count)), el('td', b.position));
if (state.abutRebarNotes || on) {
const nr = body.insertRow();
const cell = el('td', `${b.mark} 비고 · ${b.note}`, 'muted small');
cell.colSpan = 10;
nr.append(cell);
}
}
const scroll = el('div', undefined, 'table-scroll');
scroll.append(table);
box.append(scroll);
const sizes = ABUT_FOOTING_SIZES;
box.append(
el(
'p',
`근거 · 교대확대기초철근.md · spec_abut.md §6.2 · 규격 B1·B3 ${sizes.top} / B2·B4 ${sizes.bottom} / B5–B8 ${sizes.dist} / B9 ${sizes.shear} / B10 ${sizes.side} · 하면 ${r.dist.bottomText} · 상면 ${r.dist.topText} · 측면 ${r.side.text} · 교대축 ${r.main.text}`,
'muted small',
),
);
}
/** Registered review items are scoped independently from computed warnings. */
function renderReviewTasks(): void {
const rec = state.abutments.find((a) => a.name === state.abutActive);
const pier = activePier();
const list = $('review-tasks');
list.replaceChildren();
if (!rec && !pier?.model) {
$('review-summary').textContent = '구조물과 부재를 선택하면 검토 항목을 표시합니다.';
return;
}
const member = rec ? state.abutRebarMember : state.rebarMember;
const label = rec ? ABUT_REBAR_MEMBERS.find((m) => m.id === member)?.label : pier?.input.foundationType === '현장타설말뚝' && (member === 'column' || member === 'pile') ? '기둥·말뚝' : REBAR_MEMBERS.find((m) => m.id === member)?.label;
const tasks: { code: string; title: string; detail: string; marks: string[]; decided?: boolean }[] = [];
if (rec && member === 'blk') tasks.push({ code: 'Q-S4', title: 'S1–W2 배치 확인', detail: '경고 유지 상태입니다. 높이 층 통과 경고와 실제 철근 간섭을 구분하고 배치·정착을 확인하세요.', marks: ['S1'] });
if (rec && member === 'wing') tasks.push(
{ code: 'E19', title: '갈고리 방향·정착 확인', detail: 'WR12 안쪽 갈고리와 WR7·WR10의 실제 다리 길이를 상세도와 대조하세요. 콘크리트 안에 있다는 것만으로 정착 충족을 판단하지 않습니다.', marks: ['WR12', 'WR7-1', 'WR7-2', 'WR7-3', 'WR10'] },
{ code: 'V-A2', title: 'WR3·WR13 정착 확인', detail: 'RB-AW02가 표시된 구간의 정착 길이를 상세도와 대조하세요.', marks: ['WR3', 'WR13'] },
);
if (!rec && member === 'footing') tasks.push(
{ code: 'Q-B5', title: '다리 길이 확인', detail: 'B1·B3·B5·B7의 제안값과 적용 도면을 대조하세요.', marks: ['B1', 'B3', 'B5', 'B7'] },
{ code: 'Q-B6', title: '이음 위치·길이 확인', detail: 'B2·B6 이음의 계산값과 적용 도면을 대조하세요.', marks: ['B2', 'B6'] },
);
if (!rec && pier?.input.foundationType === '현장타설말뚝' && (member === 'column' || member === 'pile')) {
tasks.push({ code: 'CP-F1', title: '현장타설 미확정 상세 확인', detail: '주철근·횡철근·커플러는 입력에 따라 생성합니다. 단부 경계, 기둥 크로스타이 상세, 말뚝 저부 P6 등 미완료 항목은 계산 알림을 확인하세요.', marks: [] });
} else if (!rec && member === 'column') tasks.push({ code: 'Q-C9', title: 'P4–B11 간섭 처리', detail: '기존 결정: 이 간섭은 무시합니다. 도면·기록을 검토할 때 결정 내용을 함께 확인하세요.', marks: ['P4'], decided: true });
const pending = tasks.filter((task) => !task.decided).length;
$('review-summary').textContent = `${rec?.name ?? pier!.input.name} · ${label} · 등록된 검토 미결 ${pending}건${tasks.some((task) => task.decided) ? ' · 기존 결정 1건' : ''} (2026-11-05 기준). 계산 알림과 별도입니다.`;
if (!tasks.length) list.append(el('li', '이 부재에 등록된 미결 항목은 없습니다. 위의 계산 알림도 함께 확인하세요.'));
for (const task of tasks) {
const item = el('li');
item.append(el('strong', `${task.code} · ${task.title} · ${task.decided ? '결정됨' : '확인 필요'}`), el('p', task.detail, 'small'));
const button = el('button', '해당 철근 보기', 'btn small');
button.type = 'button';
if (!task.marks.length) { button.disabled = true; button.textContent = '배근 상세 확정 후 표시'; }
button.addEventListener('click', () => {
state.rebarSelection = new Set(task.marks);
state.rebarIsolate = false;
renderAll();
const row = [...document.querySelectorAll<HTMLTableRowElement>('#rebar-table tbody tr')].find((tr) => task.marks.includes(tr.querySelector('.bar-no')?.textContent ?? tr.cells[2]?.textContent ?? ''));
if (row) {
row.tabIndex = -1;
row.focus({ preventScroll: true });
row.scrollIntoView({ block: 'center', inline: 'nearest' });
}
});
item.append(button);
list.append(item);
}
}
function renderRebar(): void {
const pier = activePier();
// 교각 목록 with the 배근 status (시안 ③). The checks are the same as in ① and ②.
renderPierRows($<HTMLUListElement>('rebar-pier-items'), 'rebar');
// ③ 교대 (2026-11-04): a selected abutment shows its own member list and card.
const abutRec = state.abutments.find((a) => a.name === state.abutActive);
if (abutRec) {
renderAbutRebar(abutRec);
renderMaterial();
return;
}
$('rebar-title').textContent = '철근표';
const tabs = $<HTMLUListElement>('rebar-tabs');
tabs.replaceChildren();
const cast = pier?.input.foundationType === '현장타설말뚝';
if ((cast && (state.rebarMember === 'footing' || state.rebarMember === 'pile')) || (!cast && state.rebarMember === 'pile')) state.rebarMember = 'column';
const members = REBAR_MEMBERS.filter(m => cast ? m.id !== 'footing' && m.id !== 'pile' : m.id !== 'pile');
for (const m of members) {
const li = el('li');
const btn = el('button', m.label, 'member');
btn.type = 'button';
if (m.id === 'column' && cast) btn.textContent = '기둥·말뚝';
btn.setAttribute('aria-pressed', String(m.id === state.rebarMember));
btn.addEventListener('click', () => {
state.rebarMember = m.id;
state.rebarSelection = new Set();
renderAll();
});
li.appendChild(btn);
tabs.appendChild(li);
}
const box = $<HTMLDivElement>('rebar-table');
box.replaceChildren();
const issues = $<HTMLUListElement>('rebar-issues');
issues.replaceChildren();
if (!pier?.model) {
$('rebar-note').textContent = '교각을 생성하면 철근표가 나옵니다.';
return;
}
const { rows, warnings, incomplete } = rebarSummary(pier.model, state.material, state.fckByMember);
$('rebar-note').textContent = `${pier.input.name} · 형상 기준 계산 · 값은 기본값 기준 문서 규칙`;
if (state.rebarMember === 'bearing') renderBlockRebarSettings(box, pier);
if (state.rebarMember === 'coping' && rebar3d) renderCopingDrawings(box, pier);
if (state.rebarMember === 'coping') renderCopingSettings(box, pier);
if (state.rebarMember === 'column') renderColumnSettings(box, pier);
if (state.rebarMember === 'footing') renderFootingSettings(box, pier);
const shown = rows.filter((r) => r.member === state.rebarMember || cast && state.rebarMember === 'column' && r.member === 'pile');
box.appendChild(rebarSelectBar(shown.map((r) => r.name)));
const table = el('table', undefined, 'grid-table rebar-table');
const head = el('thead');
const hr = el('tr');
for (const h of ['선택', '구분', '번호', '규격', '강도', '간격', '실간격', '위치', '방향', '형상', '길이 (mm)', '개수', '근거', '비고']) hr.appendChild(el('th', h));
head.appendChild(hr);
table.appendChild(head);
const body = el('tbody');
for (const r of shown) {
const tr = el('tr');
const nameCell = el('td', r.name, 'bar-no');
// Clicking a row selects that bar alone; clicking it again clears it (시안: 번호를 누르면 그 철근만 강조).
tr.addEventListener('click', () => {
state.rebarSelection = state.rebarSelection.size === 1 && state.rebarSelection.has(r.name) ? new Set() : new Set([r.name]);
renderAll();
});
if (state.rebarSelection.has(r.name)) tr.classList.add('hl');
const check = el('input');
check.type = 'checkbox';
check.checked = state.rebarSelection.has(r.name);
check.setAttribute('aria-label', `${r.name} 선택`);
// The checkbox changes the selection on its own; its click must not reach the row handler.
check.addEventListener('click', (ev) => ev.stopPropagation());
check.addEventListener('change', () => {
const next = new Set(state.rebarSelection);
if (check.checked) next.add(r.name);
else next.delete(r.name);
state.rebarSelection = next;
renderAll();
});
const pick = el('td');
pick.appendChild(check);
tr.append(
pick,
el('td', ROLE[r.name] ?? (r.name.includes('커플러') ? '연결' : /P[12]/.test(r.name) ? '주철근' : r.name.includes('크로스타이') ? '크로스타이' : r.diameterMm !== undefined ? '횡철근' : '—')),
nameCell,
el('td', r.size),
el('td', r.name.includes('커플러') ? '—' : r.gradeMpa !== undefined ? `SD${r.gradeMpa}` : /^[DHS]\d/.test(r.size) ? `SD${fyOf(state.material, r.size)}` : '—'),
el('td', r.spacing),
el('td', r.actual),
el('td', r.position),
el('td', r.direction),
el('td', r.shape),
el('td', r.length === null ? (r.pieces ? '여러 길이' : '—') : String(Math.round(r.length * 10) / 10), undefined),
el('td', String(r.count)),
basisCell(r.basis),
el('td', r.note),
);
body.appendChild(tr);
}
table.appendChild(body);
box.appendChild(table);
// Alerts: [보기] goes to the member, [입력] goes to the 공통 설정 input (시안 ③ 알림).
for (const w of warnings) {
const li = el('li', `${w.code} ${w.message} `);
const btn = el('button', w.target === 'common' ? '입력' : '보기', 'btn small');
btn.type = 'button';
btn.addEventListener('click', () => {
if (w.target === 'common') {
$<HTMLInputElement>('mat-fck').focus();
$<HTMLInputElement>('mat-fck').scrollIntoView({ block: 'center' });
} else {
// [보기]: go to the member and highlight the bar the alert is about.
state.rebarMember = w.target;
state.rebarSelection = new Set(w.bar ? [w.bar] : []);
renderAll();
}
});
li.appendChild(btn);
issues.appendChild(li);
}
if (issues.childElementCount === 0) issues.appendChild(el('li', '알림이 없습니다.'));
// Warning count on each member tab (시안: 부재 탭 배지).
const counts = (member: RebarRow['member']) => warnings.filter((w) => w.target === member).length;
for (const [i, m] of members.entries()) {
const badge = tabs.children[i]?.querySelector('button');
const n = counts(m.id) + (cast && m.id === 'column' ? counts('pile') : 0);
if (badge && n > 0) badge.appendChild(el('span', String(n), 'badge warn'));
}
setHeaderStatus('step3-status', `${incomplete.length ? '일부·' : '경고·'}${warnings.length}`, `${incomplete.length ? '일부 계산 · ' + incomplete.join(' · ') : '계산됨'} · 경고 ${warnings.length}`);
renderMaterial();
// 전단철근 구간 표 (시안 ③ 코핑 탭): segments of C9 / C10 / C11 along the coping, in metres.
if (state.rebarMember === 'coping') {
const seg = shearSegments(pier.model.copingL * 1000, pier.model.columns.map((c) => c.s * 1000), pier.model.copingGeom);
const st = el('table', undefined, 'grid-table rebar-table');
const sh = el('thead');
const shr = el('tr');
for (const h of ['구간', '시작 (m)', '끝 (m)', '간격 (mm)', '개수', '번호']) shr.appendChild(el('th', h));
sh.appendChild(shr);
st.appendChild(sh);
const sb = el('tbody');
for (const g of seg) {
const tr = el('tr');
tr.append(
el('td', g.segment),
el('td', (g.from / 1000).toFixed(3)),
el('td', (g.to / 1000).toFixed(3)),
el('td', String(g.spacing)),
el('td', String(g.count)),
el('td', g.bar),
);
sb.appendChild(tr);
}
st.appendChild(sb);
box.appendChild(el('p', '전단철근 구간 (끝면 기준, 철근 위치의 처음과 끝)', 'lbl'));
box.appendChild(st);
}
// Table and 3D model point at each other: the focused bar is highlighted in both.
renderQuantityLink(box, rows, incomplete);
rebar3d?.show(state.rebarSelection.size ? state.rebarSelection : null, state.rebarIsolate);
const focused = box.querySelector('tr.hl');
if (focused && state.rebarSelection.size) focused.scrollIntoView({ block: 'center' });
}
/** ③ keeps a summary line and a link to the 철근재료표 screen (opened from ③; header ①②③, 2026-11-06). */
function renderQuantityLink(box: HTMLElement, rows: RebarRow[], incomplete: readonly string[] = []): void {
const q = buildQuantity(rows, state.material, incomplete);
const line = el('p', `철근재료표 · ${q.missing.length ? '계산된 부재 소계 · 전체 수량 미완료' : '합계'} ${q.kg.toFixed(1)} kg · 할증 포함 ${q.kgWithWaste.toFixed(1)} kg`, 'pnote');
const link = el('button', '철근재료표 보기', 'btn small');
link.type = 'button';
link.addEventListener('click', () => setStage('quantity'));
box.append(line, link);
}
/** ④ 재료표: 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 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. */
function lengthCell(l: QuantityLine): HTMLTableCellElement {
if (l.length !== null) return el('td', String(Math.round(l.length * 10) / 10));
const cell = el('td', `여러 길이 (${l.pieces.length}종)`);
cell.title = l.pieces.map((p) => `${Math.round(p.length)} × ${p.count}`).join(' · ');
return cell;
}
function quantityTable(q: Quantity): HTMLTableElement {
const table = el('table', undefined, 'grid-table rebar-table');
const head = el('thead');
const hr = el('tr');
for (const h of ['강종', '번호', '형상', '규격', '개수', '길이 (mm)', '총길이 (m)', '단위중량 (kg/m)', '총중량 (kg)']) hr.appendChild(el('th', h));
head.appendChild(hr);
table.appendChild(head);
const body = el('tbody');
for (const g of q.groups) {
for (const d of g.diameters) {
for (const l of d.lines) {
body.appendChild(el('tr')).append(
el('td', `SD${g.grade}`), el('td', l.name, 'bar-no'), el('td', l.shape), el('td', l.size), el('td', String(l.count)),
lengthCell(l), el('td', l.totalM.toFixed(1)), el('td', l.kgPerM.toFixed(3)), el('td', l.kg.toFixed(1)),
);
}
body.appendChild(el('tr', undefined, 'subtotal')).append(el('td', ''), el('td', `${d.size} 소계`), el('td', ''), el('td', d.size), el('td', ''), el('td', ''), el('td', d.totalM.toFixed(1)), el('td', ''), el('td', d.kg.toFixed(1)));
}
body.appendChild(el('tr', undefined, 'subtotal')).append(el('td', `SD${g.grade} 소계`), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', g.kg.toFixed(1)));
}
table.appendChild(body);
return table;
}
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();
$('quantity-note').textContent = targets.length
? `${state.quantityAll ? `전체 ${targets.length}기` : targets[0].input.name} · 할증 ${state.material.waste}% (기본 0%, 가정). 삿갓 길이는 h − 100으로 가정(Q-K5). 교각 목록에서 고른 교각만 보며, 전체를 누르면 모든 교각을 보여 줍니다.`
: '계산된 교각이 없습니다. 교각을 생성하세요.';
const entries = targets.flatMap((pier) =>
pier.model ? [{ pier, q: pierQuantity(pier) }] : [],
);
// 전체 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(', ')}`, 'pnote'));
body.appendChild(quantityTable(q));
body.appendChild(el('p', `${q.missing.length ? '계산된 부재 소계 (전체 수량 미완료)' : '합계'} ${q.kg.toFixed(1)} kg · 할증 포함 ${q.kgWithWaste.toFixed(1)} kg (${q.ton.toFixed(3)} ton) · 커플러 ${q.couplers.map((c) => `${c.name} ${c.count}개`).join(', ') || '없음'}`, 'pnote'));
}
}
/** 교량 전체 집계: the 강종·직경 totals over every pier on ④ (전체). */
function bridgeTotalBlock(total: QuantityTotal, pierCount: number): HTMLElement {
const wrap = el('section', undefined, 'quantity-total');
wrap.appendChild(el('h3', `교량 전체 ${total.missingCount ? '부분 집계 · 전체 수량 미완료' : '집계'} · ${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). */
function pierQuantity(pier: Pier): Quantity {
if (!pier.model) throw new Error('형상이 생성되지 않았습니다.');
const result = rebarSummary(pier.model, state.material, state.fckByMember);
return buildQuantity(result.rows, state.material, result.incomplete);
}
function exportQuantityWorkbook(): void {
const targets = quantityTargets();
if (!targets.length) return;
const wb = XLSX.utils.book_new();
const summary: (string | number)[][] = [['교각', '합계 (kg)', '할증 포함 (kg)', 'ton', '계산 상태']];
for (const pier of targets) {
if (!pier.model) continue;
const q = pierQuantity(pier);
const sheet = pier.input.name.replace(/[[\]:*?/\\]/g, '_').slice(0, 31);
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(quantityRows(q)), sheet);
summary.push([pier.input.name, Math.round(q.kg * 10) / 10, Math.round(q.kgWithWaste * 10) / 10, Math.round(q.ton * 1000) / 1000, q.missing.length ? `전체 수량 미완료: ${q.missing.join(' · ')}` : '계산 완료']);
}
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(summary), '합계');
XLSX.writeFile(wb, `${state.project.bridge || '교량'}_철근재료표.xlsx`);
}
$<HTMLButtonElement>('quantity-all').addEventListener('click', () => {
state.quantityAll = true;
renderAll();
});
$<HTMLButtonElement>('quantity-export').addEventListener('click', exportQuantityWorkbook);
$<HTMLButtonElement>('quantity-print').addEventListener('click', () => window.print());
/** Above the 철근표: selection count, select-all / clear for the shown member, and 선택만 보기 (승인 2026-10-23). */
function rebarSelectBar(names: string[]): HTMLDivElement {
const bar = el('div', undefined, 'rebar-select');
const picked = names.filter((n) => state.rebarSelection.has(n)).length;
bar.appendChild(el('span', `선택 ${picked} / ${names.length}`, 'muted small'));
const all = el('button', picked === names.length && names.length > 0 ? '전체 해제' : '전체 선택', 'btn small');
all.type = 'button';
all.addEventListener('click', () => {
state.rebarSelection = picked === names.length ? new Set() : new Set(names);
renderAll();
});
const iso = el('button', '선택만 보기', 'btn small');
iso.type = 'button';
iso.setAttribute('aria-pressed', String(state.rebarIsolate));
iso.addEventListener('click', () => {
state.rebarIsolate = !state.rebarIsolate;
renderAll();
});
bar.append(all, iso);
return bar;
}
/** 구분 (role) of each bar, as in the 시안 ③ table. */
const ROLE: Record<string, string> = {
S1: '받침블럭', S2: '받침블럭',
C1: '주철근', C2: '주철근', C3: '주철근', C4: '주철근', C5: '주철근', C6: '주철근', C7: '주철근', C8: '주철근',
C9: '전단·띠', C10: '전단·띠', C11: '전단·띠', C12: '측면 수평', C13: '측면 수평', C14: '직각 수평', C15: '계단부', C16: '계단부',
P1: '주철근', P2: '주철근', '주철근 커플러': '조립', '띠철근 커플러': '조립', 커플러: '조립', P3: '띠철근', P5: '띠철근', P4: '띠철근', P6: '크로스타이',
B1: '주철근', B2: '주철근', B3: '주철근', B4: '주철근', B5: '주철근', B6: '주철근', B7: '주철근', B8: '주철근',
'B10-1': '측면 수평', 'B10-2': '측면 수평', B11: '조립 수평', B12: '조립 수평', B9: '전단·띠',
};
/** 근거 badge (시안 ③): 입력 / 기본값 / 계산 / 가정. */
function basisCell(b: RebarRow['basis']): HTMLElement {
const label = { input: '입력', default: '기본값', calc: '계산', assumed: '가정' }[b];
return el('td', label, `basis basis-${b}`);
}
/** ③ 공통 설정 inputs. Changes apply on 'change' and regenerate the rebar screen. */
function renderMaterial(): void {
const m = state.material;
const assumed = isAssumedMaterial(m);
$<HTMLInputElement>('mat-fck').value = String(m.fck);
for (const k of ['d', 'h', 's'] as const) {
$<HTMLSelectElement>(`mat-grade-${k}`).value = String(m.grade[k.toUpperCase() as GradeKey]);
}
for (const k of ['small16', 'main25', 'main35', 'main51'] as const) {
$<HTMLInputElement>(`bend-${k}`).value = String(m.bend[k]);
}
$<HTMLInputElement>('mat-waste').value = String(m.waste);
$('mat-note').textContent = assumed ? 'fck 가정값 (I02): 입력을 바꾸면 정착·이음 길이가 달라집니다.' : '입력값을 사용합니다.';
}
/** Sets or clears one member's fck. An empty value clears it, so the member uses the 공통 fck again. */
function setMemberFck(member: FckMember, raw: string): void {
const next = { ...state.fckByMember };
if (raw.trim() === '') {
// 되돌리기: 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) {
setError('부재별 fck는 0보다 큰 숫자여야 합니다.');
renderPlan();
return;
}
next[member] = value;
}
state.fckByMember = next;
setupScene();
renderAll();
}
/** 교대 fck row on a ② card (label · input · note, the card's 3-column grid). 교각 fck와 따로 둔다 (사용자 결정 2026-11-04). Empty = default. */
function abutFckRow(box: HTMLElement, member: 'abutFooting' | 'abutBody', idOf: (suffix: string) => string, note: string): void {
const id = idOf(`fck-${member}`);
const lab = el('label', 'fck (MPa)');
lab.htmlFor = id;
const input = el('input');
input.id = id;
input.type = 'number';
input.step = 'any';
input.value = String(state.fckByMember[member] ?? state.material.fck);
if (fckChanged(state.fckByMember, member)) input.classList.add('changed-input');
input.addEventListener('change', () => setMemberFck(member, input.value));
box.append(lab, input, el('span', `기본 ${IMPORTED_FCK[member]} · ${note} · 배근에만 반영`, 'muted small'));
}
/** 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];
// 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;
const input = el('input');
input.id = id;
input.type = 'number';
input.step = 'any';
input.value = String(own ?? state.material.fck);
input.addEventListener('change', () => setMemberFck(member, input.value));
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, ''));
row.appendChild(revert);
}
box.appendChild(row);
}
/** 재료표 할증 (%) on 공통 설정. Zero or more. */
function setWaste(raw: string): void {
const value = Number(raw);
if (!Number.isFinite(value) || value < 0) {
setError('재료표 할증은 0 이상의 숫자여야 합니다.');
renderMaterial();
return;
}
state.material = { ...state.material, waste: value };
renderAll();
}
/** Bend factor (× diameter) input on 공통 설정. Must be a positive number. */
function setBend(key: keyof BendFactors, raw: string): void {
const value = Number(raw);
if (!Number.isFinite(value) || value <= 0) {
setError('굽힘 내면반경 배수는 0보다 큰 숫자여야 합니다.');
renderMaterial();
return;
}
state.material = { ...state.material, bend: { ...state.material.bend, [key]: value } };
setupScene();
renderAll();
}
function setMaterial(key: 'fck' | GradeKey, raw: string): void {
const value = Number(raw);
const valid = key === 'fck' ? Number.isFinite(value) && value > 0 : (GRADE_CHOICES as readonly number[]).includes(value);
if (!valid) {
setError(key === 'fck' ? '공통 설정 fck는 0보다 큰 숫자여야 합니다.' : '철근 강도는 SD300 · SD400 · SD500 중 하나여야 합니다.');
renderMaterial();
return;
}
state.material = key === 'fck' ? { ...state.material, fck: value } : { ...state.material, grade: { ...state.material.grade, [key]: value } };
// The 3D bars depend on the material too (their lengths), so the scene is rebuilt with it.
setupScene();
renderAll();
}
/**
* 받침 배치 (승인 2026-10-26, read-only): C4·C5 from the model (coping edge ↔ bearing centre, 교각축 방향) and the
* eccentricity of each front–back pair (일체 allowed up to 100 mm, otherwise 분리 first).
*/
/** 상세 보기 pair shown in the plan (screen state only, 승인 2026-10-26). */
let bearingPairIndex = 0;
/** ③ 받침블럭 view: the C–C section or the plan (승인 2026-10-26). */
let bearingRebarView: 'section' | 'plan' = 'section';
function renderBearingLayout(wrap: HTMLDivElement, pier: Pier): void {
if (!pier.model) return;
// 상세 보기 (승인 2026-10-26): pair chips (①·⑥ …), then the 삽도 of the pair (plan, section, solplate). Values are the model's (2026-11-03).
const pairList = pairEccentricities(pier.model.blocks);
if (bearingPairIndex >= pairList.length) bearingPairIndex = 0;
wrap.appendChild(el('p', '상세 보기 (전열 · 후열 쌍)', 'lbl'));
const chips = el('div', undefined, 'opts');
pairList.forEach((p, i) => {
const chip = el('button', `${PAIR_CHIP_LABELS[p.frontNo - 1] ?? p.frontNo}·${PAIR_CHIP_LABELS[p.backNo - 1] ?? p.backNo}`, 'btn small');
chip.type = 'button';
chip.setAttribute('aria-pressed', String(i === bearingPairIndex));
chip.addEventListener('click', () => {
bearingPairIndex = i;
renderAll();
});
chips.appendChild(chip);
});
wrap.appendChild(chips);
const current = pairList[bearingPairIndex];
if (current) {
// 삽도 set: the plan on the left and the section on its right (the solplate table carries its values).
const sketch = el('div', undefined, 'bearing-sketch');
const fig = el('figure', undefined, 'ground-fig plan-fig');
fig.innerHTML = bearingPlanSvg(pier.model, bearingPairIndex);
fig.appendChild(el('figcaption', '교량받침 삽도 · 평면 (방위각 회전 생략) · C1–C3 교축, C4·C5 교각축, C6 블록 폭', 'muted small'));
sketch.appendChild(fig);
const section = el('figure', undefined, 'ground-fig');
section.innerHTML = bearingSectionSvg(pier.model, bearingPairIndex);
section.appendChild(el('figcaption', '교량받침 삽도 · 단면 · E = h1 + h2 + h3 + h4 · S1 = 받침중심 ↔ 코핑 연단, S2 = 받침 사이', 'muted small'));
sketch.appendChild(section);
wrap.appendChild(sketch);
}
const pairs = pairEccentricities(pier.model.blocks);
const forced = pairs.filter((p) => !p.allowed).length;
wrap.appendChild(el('p', `쌍 편심 (전열 k · 후열 k, 교각축 방향, 한계 ${PAIR_ECCENTRICITY_LIMIT_MM} mm): 최대 ${Math.max(...pairs.map((p) => p.eccentricityMm)).toFixed(0)} mm · ${forced === 0 ? '모든 쌍 일체 가능' : `${forced}쌍은 편심 초과 → 분리 우선`}`, 'muted small'));
}
/** Text of one tracked value in the 변경 list. */
function valueText(key: EditableKey, v: unknown): string {
if (v === undefined || v === null) return '없음';
if (key === 'strataRows') {
const rows = v as StrataRow[];
return rows.length ? rows.map((r) => `${r.name} ${r.bottomEl}`).join(' / ') : '없음';
}
if (typeof v === 'boolean') return v ? '예' : '아니오';
if (typeof v === 'object') return JSON.stringify(v);
return String(v);
}
/** Sets 받침 배치 (분리 / 일체) in the draft (applied by 다시 생성). */
function editBearingLayout(name: string, value: '분리' | '일체'): void {
const p = state.piers.find((q) => q.input.name === name);
if (!p) return;
p.draft = { ...p.draft, bearingLayout: value };
renderAll();
}
/** Sets one 지반·지층 input in the draft (applied to the model by 다시 생성). */
function editGround(name: string, key: 'groundPosition' | 'groundWaterOn' | 'groundWaterEl' | 'floodLevelEl' | 'scourDepthM' | 'highWaterEl' | 'lowWaterEl', value: unknown): void {
const p = state.piers.find((q) => q.input.name === name);
if (!p) return;
const numeric = key !== 'groundPosition' && key !== 'groundWaterOn';
if (numeric && value !== undefined && !Number.isFinite(value)) {
setError(`${FIELD_LABELS[key]}은(는) 숫자로 입력하세요. 값을 되돌렸습니다.`);
renderAll();
return;
}
p.draft = { ...p.draft, [key]: value };
renderAll();
}
function setStrataRows(name: string, rows: StrataRow[]): void {
const p = state.piers.find((q) => q.input.name === name);
if (!p) return;
p.draft = { ...p.draft, strataRows: rows };
renderAll();
}
/**
* 지층 행 표 (승인 2026-10-26; 2026-11-04 교각 · 교대 공용): one row per layer from the top down (지층 · 하단 EL · N값 · 삭제) and
* the 행 추가 button. `set` receives the new rows.
*/
function strataRowsEditor(rows: StrataRow[], groundEl: number, set: (rows: StrataRow[]) => void): HTMLElement[] {
const out: HTMLElement[] = [];
out.push(el('p', `지층 행 (위에서 아래 · 하단 EL m · 지반고 ${groundEl.toFixed(3)} 아래)`, 'lbl'));
const table = el('table', undefined, 'grid-table rebar-table');
const head = el('thead');
const hr = el('tr');
for (const h of ['지층', '하단 EL (m)', 'N값 (타수 · 타수/관입량 mm)', '']) hr.appendChild(el('th', h));
head.appendChild(hr);
table.appendChild(head);
const body = el('tbody');
rows.forEach((row, i) => {
const tr = el('tr');
const select = el('select');
for (const s of STRATA_NAMES) {
const o = el('option', s);
o.value = s;
select.appendChild(o);
}
if (!(STRATA_NAMES as readonly string[]).includes(row.name)) {
const o = el('option', row.name);
o.value = row.name;
select.appendChild(o);
}
select.value = row.name;
select.addEventListener('change', () => set(rows.map((r, j) => (j === i ? { ...r, name: select.value } : r))));
const bottomInput = el('input');
bottomInput.type = 'number';
bottomInput.step = '0.001';
bottomInput.value = String(row.bottomEl);
bottomInput.addEventListener('change', () => set(rows.map((r, j) => (j === i ? { ...r, bottomEl: Number(bottomInput.value) } : r))));
// N값 as text: a blow count or "타수/관입량 mm" (승인 2026-10-26). Empty means not known yet.
const nInput = el('input');
nInput.type = 'text';
nInput.placeholder = '예: 23 · 50/5';
nInput.value = row.nValue ?? '';
nInput.addEventListener('change', () => {
const v = nInput.value.trim();
set(rows.map((r, j) => (j === i ? { ...r, nValue: v === '' ? undefined : v } : r)));
});
if (parseNValue(row.nValue).kind === 'invalid') nInput.classList.add('warn-text');
const del = el('button', '삭제', 'btn small');
del.type = 'button';
del.addEventListener('click', () => set(rows.filter((_, j) => j !== i)));
tr.append(...[select, bottomInput, nInput, del].map((x) => {
const td = el('td');
td.appendChild(x);
return td;
}));
body.appendChild(tr);
});
table.appendChild(body);
const scroll = el('div', undefined, 'table-scroll');
scroll.appendChild(table);
out.push(scroll);
const add = el('button', '지층 행 추가', 'btn small');
add.type = 'button';
add.addEventListener('click', () => {
const last = rows[rows.length - 1];
const next = last ? (STRATA_NAMES as readonly string[])[Math.min((STRATA_NAMES as readonly string[]).indexOf(last.name) + 1, STRATA_NAMES.length - 1)] : STRATA_NAMES[0];
set([...rows, { name: next, bottomEl: Number(((last?.bottomEl ?? groundEl) - 2).toFixed(3)) }]);
});
out.push(add);
return out;
}
/**
* ② 지반·지층 card (시안 2026-10-26; 승인 2026-10-26: 지반고는 계산에 쓰고, 지층은 행으로 입력하며, 지하수위는 직접 입력).
* The ground model import and the 지반고 apply stay at the bottom, as before.
*/
function renderGroundModel(box: HTMLDivElement, pier: Pier): void {
const name = pier.input.name;
const d = pier.draft;
const wrap = el('div', undefined, 'ground-model');
// 위치 is the 형상 옵션 of this member (the chips above the card); only the default is selectable until Q6.
const position = d.groundPosition ?? '육상';
if (position === '하상') {
// 하상 (승인 2026-10-26): the 설계홍수위 DHWL and the 세굴 깊이 replace the groundwater; the groundwater value is kept.
wrap.appendChild(el('p', '설계홍수위 DHWL · 세굴', 'lbl'));
const scourRow = el('div', undefined, 'ground-water');
scourRow.style.display = 'flex';
scourRow.style.flexWrap = 'wrap';
scourRow.style.alignItems = 'center';
scourRow.style.gap = '8px';
for (const [key, label, value] of [['floodLevelEl', '설계홍수위 EL (m)', d.floodLevelEl], ['scourDepthM', '세굴 깊이 (m)', d.scourDepthM]] as const) {
const input = el('input');
input.type = 'number';
input.step = 'any';
input.placeholder = label;
input.value = value === undefined ? '' : String(value);
input.addEventListener('change', () => editGround(name, key, input.value === '' ? undefined : Number(input.value)));
scourRow.append(el('span', label, 'muted small'), input);
}
wrap.appendChild(scourRow);
} else if (position === '해상') {
// 해상 (승인 2026-10-26, 기준면 MSL): 고조위·저조위와 세굴. 파랑과 선박 하중은 이번 범위에서 제외.
wrap.appendChild(el('p', `해상 · 기준면 ${SEA_DATUM} · 지반고 칸은 해저면 EL`, 'lbl'));
const seaRow = el('div', undefined, 'ground-water');
seaRow.style.display = 'flex';
seaRow.style.flexWrap = 'wrap';
seaRow.style.alignItems = 'center';
seaRow.style.gap = '8px';
for (const [key, label, value] of [['highWaterEl', '고조위 HWL (m)', d.highWaterEl], ['lowWaterEl', '저조위 LWL (m)', d.lowWaterEl], ['scourDepthM', '세굴 깊이 (m)', d.scourDepthM]] as const) {
const input = el('input');
input.type = 'number';
input.step = 'any';
input.placeholder = label;
input.value = value === undefined ? '' : String(value);
input.addEventListener('change', () => editGround(name, key, input.value === '' ? undefined : Number(input.value)));
seaRow.append(el('span', label, 'muted small'), input);
}
wrap.appendChild(seaRow);
wrap.appendChild(el('p', `부식 여유 기준 ${CORROSION_ALLOWANCE_MM} mm (확인 필요) · 파랑과 선박 하중은 이번 범위에서 제외`, 'muted small'));
} else {
wrap.appendChild(el('p', '지하수위', 'lbl'));
const water = el('div', undefined, 'ground-water');
water.style.display = 'flex';
water.style.flexWrap = 'wrap';
water.style.alignItems = 'center';
water.style.gap = '8px';
const check = el('input');
check.type = 'checkbox';
check.checked = d.groundWaterOn === true;
check.id = 'ground-water-on';
check.addEventListener('change', () => editGround(name, 'groundWaterOn', check.checked));
const checkLabel = el('label', ' 지하수위 고려');
checkLabel.prepend(check);
const level = el('input');
level.type = 'number';
level.step = 'any';
level.id = 'ground-water-el';
level.placeholder = '지하수위 EL (m)';
level.value = d.groundWaterEl === undefined ? '' : String(d.groundWaterEl);
level.disabled = !check.checked;
level.addEventListener('change', () => editGround(name, 'groundWaterEl', level.value === '' ? undefined : Number(level.value)));
water.append(checkLabel, level, el('span', 'm', 'muted small'));
wrap.appendChild(water);
}
// 지층 행 (승인 2026-10-26): one row per layer, from the top down; the EL is the layer's bottom EL.
wrap.append(...strataRowsEditor(d.strataRows ?? [], d.groundEl, (rows) => setStrataRows(name, rows)));
// 지반 모델의 지층 기둥 (Python, 시추 자료가 있을 때): one click fills the rows (승인 2026-10-26).
if (groundModel) {
const modelRows = strataRowsFor(groundModel, name);
if (modelRows) {
const fromModel = el('button', `지반 모델의 지층 ${modelRows.length}개 불러오기`, 'btn small');
fromModel.type = 'button';
fromModel.addEventListener('click', () => setStrataRows(name, modelRows.map((r) => ({ ...r }))));
wrap.appendChild(fromModel);
} else {
wrap.appendChild(el('p', '지반 모델에 이 교각의 지층이 없습니다 (시추 자료가 있으면 만들어집니다).', 'muted small'));
}
}
// 단면 (삽도): the footings and piles are the generated model; the layers and water level are the inputs.
if (pier.model) {
const fig = el('figure', undefined, 'ground-fig');
fig.innerHTML = groundSectionSvg(d, pier.model);
fig.appendChild(el('figcaption', '단면 · 기초와 말뚝은 생성본, 지층과 지하수위는 입력값', 'muted small'));
wrap.appendChild(fig);
}
// ② 지반 모델 (승인 2026-10-23): the pier-centre ground EL from ground_model.json. Applied only when the user presses the button.
wrap.appendChild(el('p', '지반 모델 (지반 모델 결과 · ground_model.json)', 'lbl'));
const importInput = el('input');
importInput.type = 'file';
importInput.accept = '.json';
importInput.id = 'ground-import';
importInput.addEventListener('change', async () => {
const file = importInput.files?.[0];
if (!file) return;
try {
const imported = parseGroundModel(await file.text());
await Promise.all((imported.mapLayers ?? []).map(decodeGroundMapImage));
groundModel = imported;
groundMapMode = groundModel.mapLayers?.length ? 'satellite' : 'none';
setGroundMapMode(groundMapMode);
state.visible.ground = true;
const mapStatus = groundModel.mapLayers?.length ? '2D 지도·위성사진을 지반면 위에 굴곡대로 표시합니다.' : '포함된 지도 이미지가 없습니다.';
setStatus(`${file.name}에서 교각 ${groundModel.piers.length}개의 지반 모델 값을 불러왔습니다. 3D 지반 표시를 켰습니다. ${mapStatus}`);
setupScene();
renderAll();
} catch (err) {
setError(`지반 모델을 읽지 못했습니다: ${(err as Error).message}`);
} finally {
importInput.value = '';
}
});
const importLabel = el('label', '지반 모델 파일 불러오기 (json)');
importLabel.appendChild(importInput);
wrap.appendChild(importLabel);
if (!groundModel) {
wrap.appendChild(el('p', '지반 모델을 불러오면 이 교각의 지반고를 엑셀 값과 비교하고 적용할 수 있습니다.', 'muted small'));
} else {
const entry = groundModel.piers.find((q) => q.id === pier.input.name);
const center = groundElFor(groundModel, pier.input.name);
if (!entry) {
wrap.appendChild(el('p', `지반 모델에 ${pier.input.name} 교각이 없습니다.`, 'warn-text'));
} else if (center === undefined) {
wrap.appendChild(el('p', '지형 자료 범위 밖이라 지반 모델 값이 없습니다. 입력된 지반고를 그대로 씁니다.', 'warn-text'));
} else {
const diff = entry.excelEl === null ? '—' : `${center - entry.excelEl >= 0 ? '+' : ''}${(center - entry.excelEl).toFixed(3)} m`;
wrap.appendChild(el('p', `지형 계산 ${center.toFixed(3)} m · 엑셀 ${entry.excelEl?.toFixed(3) ?? '—'} m · 차이 ${diff}`));
const apply = el('button', `지반고 ${center.toFixed(3)} m 적용`, 'btn small');
apply.type = 'button';
apply.disabled = pier.draft.groundEl === center;
apply.addEventListener('click', () => editField(pier.input.name, 'groundEl', String(center)));
wrap.appendChild(apply);
}
wrap.appendChild(el('p', '불러온 지반 모델의 지반고는 3D에 바로 표시됩니다. [적용]은 설계 입력값을 변경하며, 변경한 입력으로 형상을 보려면 다시 생성하세요.', 'muted small'));
}
box.appendChild(wrap);
}
function renderBearingRef(box: HTMLDivElement, pier: Pier): void {
const wrap = el('div', undefined, 'bearing-ref');
// 받침 종류·용량 (승인 2026-10-26: 글자 입력, 기본 탄성받침). Saved in the project; no rule yet, so it does not change the model.
wrap.appendChild(el('p', '받침 종류·용량 (글자 입력 · 기본 탄성받침)', 'lbl'));
const typeInput = el('input');
typeInput.type = 'text';
typeInput.id = 'bearing-type';
typeInput.value = pier.draft.bearingType ?? '탄성받침';
typeInput.addEventListener('change', () => {
const v = typeInput.value.trim();
const p = state.piers.find((q) => q.input.name === pier.input.name);
if (!p) return;
p.draft = { ...p.draft, bearingType: v === '' ? undefined : v };
renderAll();
});
wrap.appendChild(typeInput);
renderBearingLayout(wrap, pier);
renderSolplateRule(wrap, pier);
renderElevationTable(wrap, pier);
renderSolplateTable(wrap, pier);
box.appendChild(wrap);
}
/** 교량받침 ELEVATION TABLE (모델값, mm · EL m, 2026-11-03): one row per bearing, from the model only. */
function renderElevationTable(wrap: HTMLDivElement, pier: Pier): void {
if (!pier.model) return;
wrap.appendChild(el('p', '교량받침 ELEVATION TABLE (모델값 · mm · EL m)', 'lbl'));
const table = el('table', undefined, 'grid-table rebar-table');
const head = el('thead');
const hr = el('tr');
for (const h of ['No', '열', 'EL.X', '교좌면 EL', 'h1 블록', 'h2 몰탈', 'h3 받침', 'h4 솔플', 'E', 'C1', 'C2', 'C3', 'C4', 'C5', 'C6', 'S1 (본수·길이)', 'S2 (본수·길이)']) hr.appendChild(el('th', h));
head.appendChild(hr);
table.appendChild(head);
const body = el('tbody');
const dash = (v: number | null) => (v === null ? '—' : String(v));
const bars = (b: BarSummary | null) => (b === null ? '—' : `${b.count} · ${b.length}`);
for (const r of elevationRows(pier.model)) {
const tr = el('tr');
const cells = [String(r.no), r.row, r.elx.toFixed(3), r.seatEl.toFixed(3), String(r.h1), String(r.h2), String(r.h3), String(r.h4), String(r.E), String(r.C1), dash(r.C2), dash(r.C3), String(r.C4), String(r.C5), String(r.C6), bars(r.S1), bars(r.S2)];
for (const c of cells) tr.appendChild(el('td', c));
body.appendChild(tr);
}
table.appendChild(body);
wrap.appendChild(el('div', undefined, 'table-scroll')).appendChild(table);
}
/** 솔플레이트 TABLE (모델값, mm, 2026-11-03): S, L, W, T, T1, T2 and the check T1 − T2 ≈ L·|S|/100. */
function renderSolplateTable(wrap: HTMLDivElement, pier: Pier): void {
if (!pier.model) return;
wrap.appendChild(el('p', '솔플레이트 TABLE (모델값 · mm)', 'lbl'));
const table = el('table', undefined, 'grid-table rebar-table');
const head = el('thead');
const hr = el('tr');
for (const h of ['No', 'S (%)', 'L (교직)', 'W (교축)', 'T (중심)', 'T1 (−측)', 'T2 (+측)', '검산 T1 − T2 ≈ L·|S|/100']) hr.appendChild(el('th', h));
head.appendChild(hr);
table.appendChild(head);
const body = el('tbody');
for (const r of solplateRows(pier.model)) {
const tr = el('tr');
const cells = [String(r.no), r.S.toFixed(2), String(r.L), String(r.W), String(r.T), r.T1.toFixed(1), r.T2.toFixed(1), `${r.checkLhs.toFixed(1)} ≈ ${r.checkRhs.toFixed(1)} ${r.ok ? '일치' : '불일치'}`];
for (const c of cells) tr.appendChild(el('td', c));
body.appendChild(tr);
}
table.appendChild(body);
wrap.appendChild(el('div', undefined, 'table-scroll')).appendChild(table);
}
/** Sets one pier's solplate rule (C안, 2026-11-03) and rebuilds that pier. */
function setSolplateRule(name: string, rule: SolplateRule): void {
const p = state.piers.find((q) => q.input.name === name);
if (!p) return;
p.draft = { ...p.draft, solplate: rule };
state.piers = state.piers.map((q) => (q === p ? regenerateEntry(q) : q));
setupScene();
renderAll();
}
/** 솔플레이트 규칙 (C안, 2026-11-03): per pier. The plan L × W and the slope S are inputs; 받침높이 H is fixed (211 mm, not on the UI). T and h3 follow. */
function renderSolplateRule(wrap: HTMLDivElement, pier: Pier): void {
const rule = pier.draft.solplate ?? SOLPLATE_RULE_DEFAULT;
wrap.appendChild(el('p', `솔플레이트 규칙 (이 교각 · 기본 1000 × 1100 · −0.5 %) · 받침높이 H ${rule.heightMm} mm 고정 (솔플레이트 포함)`, 'lbl'));
const fields: [keyof SolplateRule, string][] = [
['lengthMm', '솔플레이트 교직 L (mm)'],
['widthMm', '솔플레이트 교축 W (mm)'],
['slopePct', '종단 기울기 S (%)'],
];
const grid = el('div', undefined, 'edit-row');
for (const [key, label] of fields) {
const id = `sol-${key}`;
const lab = el('label', label);
lab.htmlFor = id;
const input = el('input');
input.id = id;
input.type = 'number';
input.step = 'any';
input.value = String(rule[key]);
if (rule[key] !== SOLPLATE_RULE_DEFAULT[key]) input.classList.add('changed-input');
input.addEventListener('change', () => setSolplateRule(pier.input.name, { ...rule, [key]: Number(input.value) }));
grid.append(lab, input, el('span', `기본 ${SOLPLATE_RULE_DEFAULT[key]}`, 'muted small'));
}
wrap.appendChild(grid);
const issues = solplateRuleIssues(rule);
if (issues.length > 0) {
wrap.appendChild(el('p', issues.join(' '), 'warn-text'));
return;
}
const plan = solplatePlan(rule);
wrap.appendChild(el('p', `중심 두께 T = ${plan.T} mm (T2 ≥ 22 mm) · 받침 본체 h3 = H − T = ${plan.h3} mm · T1 = ${plan.T1.toFixed(1)} mm · T2 = ${plan.T2.toFixed(1)} mm`, 'muted small'));
}
/** ② 코핑 상세 (2단계, 읽기 전용): d_IP2 and the 계단 구간 표 from the model (승인 2026-10-23). */
function renderCopingInfo(box: HTMLDivElement, pier: Pier): void {
const m = pier.model;
if (!m) return;
const wrap = el('div', undefined, 'coping-info');
wrap.appendChild(el('p', `d_IP2 (자동) ${m.dIp2.toFixed(3)} m`, 'num'));
wrap.appendChild(el('p', '계단 구간 (BP1 기준 m · EL m · mm)', 'lbl'));
const table = el('table', undefined, 'grid-table rebar-table');
const head = el('thead');
const hr = el('tr');
for (const h of ['구간', '시작', '끝', '전열 상면 EL', '후열 상면 EL', '횡방향 단차 (mm)', '받침블럭 높이 전열 / 후열 (mm)']) hr.appendChild(el('th', h));
head.appendChild(hr);
table.appendChild(head);
const body = el('tbody');
const front = m.blocks.filter((b) => b.row === '전열');
const back = m.blocks.filter((b) => b.row === '후열');
const unequalRows = front.length !== back.length;
const rearSteps = m.backSegments ?? m.segments;
const topAt = (steps: typeof m.segments, s: number): number => (steps.find(seg => s >= seg.s0 && s < seg.s1) ?? steps[steps.length-1]).top;
const boundaries = [...new Set([0,m.copingL,...m.segments.slice(1).map(seg=>seg.s0),...rearSteps.slice(1).map(seg=>seg.s0)])].sort((a,b)=>a-b);
const rows = unequalRows ? boundaries.slice(1).map((s1,k)=>({s0:boundaries[k],s1,top:topAt(m.segments,(boundaries[k]+s1)/2)})) : m.segments;
rows.forEach((seg, k) => {
const f = unequalRows ? front.find(b=>b.g>=seg.s0 && b.g<seg.s1) : front[k];
const b = unequalRows ? back.find(b=>b.g>=seg.s0 && b.g<seg.s1) : back[k];
const backTop = unequalRows ? topAt(rearSteps,(seg.s0+seg.s1)/2) : b ? b.bottom : seg.top;
const step = Math.round((seg.top - backTop) * 1000);
// Block height includes the 50 mm pad (spec Q16): from the coping top to the pad top.
const heights = f && b ? `${Math.round((f.padTop - f.bottom) * 1000)} / ${Math.round((b.padTop - b.bottom) * 1000)}` : '—';
body.appendChild(el('tr')).append(
el('td', String(k + 1)), el('td', seg.s0.toFixed(3)), el('td', seg.s1.toFixed(3)),
el('td', seg.top.toFixed(3)), el('td', backTop.toFixed(3)), el('td', String(step)), el('td', heights),
);
});
table.appendChild(body);
wrap.appendChild(table);
wrap.appendChild(el('p', '기본 외 형상 규칙(교좌면 모따기·라운드, 2·3주형 헌치)은 spec 2단계 확정 후 추가합니다.', 'muted small'));
box.appendChild(wrap);
}
function renderCastShape(fields: HTMLDivElement, pier: Pier): void {
const shape = castShape(pier.draft);
const keys: { key: Exclude<keyof CastInPlaceShapeSettings, 'casingOn'>; label: string }[] = [
{ key: 'betaLengthMm', label: '1/β 길이 (mm)' },
{ key: 'weatheredRockEl', label: '풍화암 EL (m)' },
{ key: 'bedrockEl', label: '기반암 EL (m)' },
{ key: 'clayBottomEl', label: '점토층 하단 EL (m, 별도 입력)' },
{ key: 'casingTopEl', label: '희생강관 상단 EL (m)' },
{ key: 'casingBottomEl', label: '희생강관 하단 EL (m)' },
];
const change = (key: keyof CastInPlaceShapeSettings, value: number | boolean | undefined) => {
const next = { ...pier.draft.castInPlaceShape, [key]: value };
const issues = castShapeIssues(next);
if (issues.length) { setError(issues.join(' · ')); return; }
pier.draft = { ...pier.draft, castInPlaceShape: next };
renderAll();
};
const label = el('label', undefined, 'edit-row');
const enabled = el('input'); enabled.type = 'checkbox'; enabled.id = 'cip-casing-on'; enabled.checked = shape.casingOn;
enabled.style.width = 'auto'; enabled.style.minHeight = 'auto';
enabled.addEventListener('change', () => change('casingOn', enabled.checked));
label.append(enabled, document.createTextNode('희생강관 적용')); fields.appendChild(label);
for (const f of keys) {
const row = el('div', undefined, 'edit-row');
const lab = el('label', f.label); lab.htmlFor = `cip-shape-${f.key}`;
const input = el('input'); input.id = lab.htmlFor; input.type = 'number'; input.step = 'any';
input.value = shape[f.key] === undefined ? '' : String(shape[f.key]);
input.placeholder = f.key === 'clayBottomEl' ? '직접 입력' : '자동 기본값';
input.disabled = f.key.startsWith('casing') && !shape.casingOn;
input.addEventListener('change', () => change(f.key, input.value.trim() ? Number(input.value) : undefined));
const automatic = pier.draft.castInPlaceShape?.[f.key] === undefined;
row.append(lab, input, el('span', f.key === 'clayBottomEl' && automatic ? '풍화암과 별도 · 입력 필요' : automatic ? '자동 기본값 · 비우면 자동 연동' : '직접 입력 · 비우면 자동값으로 복원', 'muted small'));
fields.appendChild(row);
}
fields.appendChild(el('p', `말뚝 두부 = 지반고 EL ${shape.pileTopEl.toFixed(3)} · 말뚝 하단 EL ${shape.pileBottomEl.toFixed(3)} (지반고 − 말뚝길이). 풍화암 기본 = 선단+5 m, 기반암 기본 = 선단+1 m. 강관 기본 = 지반고+3 m ~ 기반암.`, 'muted small'));
const zone = castLowerZone(pier.draft);
fields.appendChild(el('p', zone ? `DXF 하단 단부구역 기준점: EL ${zone.bottomEl.toFixed(3)} ~ ${zone.topEl.toFixed(3)} (1/β−3D, 점토층 하단+D).${zone.valid ? '' : ' 구간 순서가 역전됩니다. 입력 EL을 확인하세요.'}` : '말뚝 하단 단부구역: 점토층 하단 EL 입력 필요. 풍화암 값으로 대체하지 않습니다.', 'muted small'));
if (shape.casingOn) fields.appendChild(el('p', '희생강관은 현재 위치·범위 입력입니다. 강관 두께·재료 수량은 포함하지 않습니다.', 'muted small'));
}
function renderPlan(): void {
const pier = activePier();
const cast = pier?.input.foundationType === '현장타설말뚝';
const members = $<HTMLUListElement>('members');
members.replaceChildren();
for (const m of MEMBERS) {
const li = el('li');
if (m.id === 'footing' && cast) {
const inactive = el('button', '확대기초 · 적용 안 함', 'member');
inactive.disabled = true; inactive.title = '현장타설말뚝 형식에는 확대기초가 없습니다.';
li.appendChild(inactive);
}
const btn = el('button', m.id === 'footing' && cast ? '현장타설말뚝' : m.label, 'member');
btn.type = 'button';
btn.setAttribute('aria-pressed', String(m.id === state.member));
const changed = pier ? overrideKeys(pier).filter((k) => m.keys.includes(k)).length : 0;
btn.append(el('span', changed ? `변경 ${changed}` : '변경 0', 'badge'));
btn.addEventListener('click', () => {
state.member = m.id;
renderAll();
});
li.appendChild(btn);
members.appendChild(li);
}
const member = MEMBERS.find((m) => m.id === state.member)!;
$('member-title').textContent = member.id === 'footing' && cast ? '현장타설말뚝' : member.label;
// Default and current selection are shown separately (spec §5.1). Only defaults are selectable today.
// 선택 shows the pier's own 위치 for the ground; the other members still choose only their defaults.
const selected = member.id === 'ground' && pier ? (pier.draft.groundPosition ?? '육상') : member.id === 'bearing' && pier ? (pier.draft.bearingLayout ?? '분리') : defaultSummary(member.id);
$('member-summary').textContent = `기본: ${defaultSummary(member.id)} · 선택: ${selected}`;
renderShapeOptions(member.id, pier);
$('member-note').textContent = member.note;
if (member.id === 'bearing' && pier?.model) {
// Row skew of the bearing blocks (계산값): the block sides follow the bearing row (승인 2026-10-23).
const front = pier.model.blocks.find((b) => b.row === '전열');
const back = pier.model.blocks.find((b) => b.row === '후열');
$('member-note').textContent += ` 받침 열 기울기(교축 직각 기준, 계산값): 전열 ${front?.rowSkewDeg.toFixed(2) ?? '—'}° · 후열 ${back?.rowSkewDeg.toFixed(2) ?? '—'}°.`;
}
const regen = $<HTMLButtonElement>('member-regen');
const fields = $<HTMLDivElement>('member-fields');
if (!pier) {
fields.replaceChildren();
regen.textContent = '다시 생성 · 변경 0';
regen.disabled = true;
} else {
fieldRows(fields, pier, member.id === 'footing' && cast ? ['groundEl', 'pileLengthMm'] : member.keys);
if (member.id === 'footing' && cast) {
$('member-summary').textContent = '현장타설말뚝 · 확대기초 적용 안 함';
$('member-note').textContent = '말뚝 길이를 입력합니다. 두부는 기둥 하단에 연결하고 직경은 기둥 직경을 사용합니다.';
const p = pier.model?.piles[0];
fields.appendChild(el('p', `두부 EL ${p?.top.toFixed(3) ?? '미생성'} · 선단 EL ${p?.bottom.toFixed(3) ?? '미생성'} · 직경 ${pier.model?.columnDiameterMm ?? pier.draft.columnDiameterMm ?? 2000} mm`, 'muted small'));
renderCastShape(fields, pier);
}
if (member.id === 'footing' && !cast) {
renderPileSpec(fields, pier);
renderFootingDrawings(fields, pier);
}
if (member.id === 'coping') renderCopingInfo(fields, pier);
if (member.id === 'bearing') renderBearingRef(fields, pier);
if (member.id === 'ground') renderGroundModel(fields, pier);
if (!(member.id === 'footing' && cast) && FCK_MEMBERS.includes(member.id as FckMember)) renderMemberFck(fields, member.id as FckMember);
regen.textContent = `다시 생성 · 변경 ${pendingKeys(pier).length}`;
regen.disabled = pendingKeys(pier).length === 0;
}
const changes = $<HTMLUListElement>('changes');
changes.replaceChildren();
const pending = pier ? pendingKeys(pier) : [];
for (const key of pier ? overrideKeys(pier) : []) {
const li = el('li', `${FIELD_LABELS[key]}: 기본 ${valueText(key, pier!.defaults[key])} → 현재 ${valueText(key, pier!.draft[key])}${pending.includes(key) ? ' (미반영)' : ''}`);
const revert = el('button', '되돌리기', 'btn small');
revert.type = 'button';
revert.addEventListener('click', () => revertField(pier!.input.name, key));
li.appendChild(revert);
changes.appendChild(li);
}
if (changes.childElementCount === 0) changes.appendChild(el('li', '기본값과 같습니다.'));
const issues = $<HTMLUListElement>('plan-issues');
issues.replaceChildren();
for (const i of pier?.issues ?? []) issues.appendChild(el('li', `${i.code} ${i.message}`));
if (issues.childElementCount === 0) issues.appendChild(el('li', '알림이 없습니다.'));
}
// --- project file (plan B3·B4·B5) --------------------------------------------------------
/** Everything that a save writes except the history, so that a save itself does not look like a change. */
function snapshot(): string {
return JSON.stringify({
project: state.project,
source: state.source,
piers: state.piers.map((p) => ({ input: p.input, defaults: p.defaults, draft: p.draft })),
material: state.material,
fckByMember: state.fckByMember,
groundModel,
abutments: state.abutments,
abutDefaults: state.abutDefaults,
});
}
function isDirty(): boolean {
return state.piers.length > 0 && snapshot() !== state.savedSnapshot;
}
function currentProjectText(): string {
return serializeProject({
schemaVersion: SCHEMA_VERSION,
project: { ...state.project },
source: state.source,
piers: state.piers.map((p) => ({ id: p.input.name, input: p.input, defaults: p.defaults, draft: p.draft })),
history: state.history,
common: state.material,
fckByMember: state.fckByMember,
groundModel: groundModel ?? undefined,
abutments: state.abutments,
abutDefaults: state.abutDefaults,
});
}
function renderSaveStatus(): void {
const saveBtn = $<HTMLButtonElement>('save-project');
saveBtn.disabled = state.piers.length === 0;
const status = $('save-status');
if (state.piers.length === 0) {
status.textContent = '저장 전';
} else if (state.savedSnapshot === null || isDirty()) {
status.textContent = '미저장';
} else {
status.textContent = '저장됨';
}
}
/** 저장 button and Ctrl+S: the first save opens the project dialog (시안 ⑤); later saves write the same file. */
function exportBearingCoordinatesTxt(): void {
const generated = state.piers.filter((p) => p.model !== null);
if (!generated.length) {
setError('생성된 교각이 없습니다. 엑셀을 불러오고 교각 형상을 생성하세요.');
return;
}
const f = (value: number): string => value.toFixed(6);
const lines: string[] = [
'교량 받침 좌표 정보',
`좌표계: 엑셀 원본 좌표 (X=북, Y=동), 방위각도 엑셀 원본 기준`,
`생성 교각 수: ${generated.length}`,
'',
];
for (const pier of generated) {
const input = pier.input;
lines.push(`[교각 ${input.name}]`);
lines.push('받침번호\t열\tX(m)\tY(m)\tEL(m)\t방위각(deg)');
const bearings = [...input.bearings].sort((a, b) => a.no - b.no);
for (const bearing of bearings) {
lines.push([
String(bearing.no),
bearing.row,
f(bearing.y),
f(bearing.x),
f(bearing.z),
f(swapXYAzimuth(bearing.azimuthDeg)),
].join('\t'));
}
lines.push('');
}
const bridge = (state.project.bridge || generated[0].input.name).replace(/[\\/:*?"<>|]/g, '_');
const filename = `${bridge}_교각받침좌표.txt`;
const blob = new Blob(['\uFEFF' + lines.join('\r\n')], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
setStatus(`${filename} 파일을 저장했습니다.`);
}
function saveProject(): void {
if (state.piers.length === 0) return;
if (!state.savedOnce) {
openSaveDialog();
return;
}
writeProjectFile();
}
function projectFileName(): string {
return `${state.project.bridge || state.project.name || '교각설계'}_교각설계.pzr.json`;
}
function openSaveDialog(): void {
$<HTMLInputElement>('sv-name').value = state.project.name;
$<HTMLInputElement>('sv-bridge').value = state.project.bridge;
$<HTMLInputElement>('sv-client').value = state.project.client;
$<HTMLInputElement>('sv-author').value = state.project.author;
$<HTMLTextAreaElement>('sv-memo').value = state.project.memo;
$('sv-file').textContent = projectFileName();
$<HTMLDialogElement>('save-dialog').showModal();
$<HTMLInputElement>('sv-name').focus();
}
function confirmSaveDialog(): void {
const name = $<HTMLInputElement>('sv-name').value.trim();
if (!name) {
$<HTMLInputElement>('sv-name').focus();
setError('프로젝트명을 입력하세요.');
return;
}
state.project = {
name,
bridge: $<HTMLInputElement>('sv-bridge').value.trim(),
client: $<HTMLInputElement>('sv-client').value.trim(),
author: $<HTMLInputElement>('sv-author').value.trim(),
crs: CRS,
memo: $<HTMLTextAreaElement>('sv-memo').value.trim(),
};
$<HTMLDialogElement>('save-dialog').close();
writeProjectFile();
}
function writeProjectFile(): void {
state.savedOnce = true;
state.history = [...state.history, { version: String(state.history.length + 1), note: '저장', at: new Date().toISOString() }];
const text = currentProjectText();
const name = projectFileName();
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
const link = document.createElement('a');
link.href = url;
link.download = name;
link.click();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
state.savedSnapshot = snapshot();
clearBackup().catch(() => undefined);
renderHeader();
setStatus(`${name} 파일을 저장했습니다.`);
}
/** Opens a project from its text. A backup stays unsaved until the user saves it. */
function openProjectText(text: string, saved: boolean): void {
const parsed = parseProject(text);
state.project = { ...parsed.project };
state.source = parsed.source;
state.history = parsed.history;
state.material = materialFrom(parsed.common);
state.fckByMember = { ...parsed.fckByMember };
state.abutments = parsed.abutments ?? [];
state.abutDefaults = parsed.abutDefaults ?? JSON.parse(JSON.stringify(state.abutments)) as AbutRecord[];
groundModel = parsed.groundModel ?? null;
groundMapMode = groundModel?.mapLayers?.length ? 'satellite' : 'none';
setGroundMapMode(groundMapMode);
state.piers = parsed.piers.map((p) => ({
...buildEntry(p.input, false),
defaults: p.defaults ?? p.input,
draft: p.draft ?? p.input,
}));
state.active = state.piers[0]?.input.name ?? null;
state.filter = 'all';
state.pending = null;
state.savedSnapshot = saved ? snapshot() : null;
setupScene();
renderAll();
}
async function openProjectFile(file: File): Promise<void> {
try {
openProjectText(await file.text(), true);
state.savedOnce = true;
clearBackup().catch(() => undefined);
setStatus(`${file.name}을 열었습니다. ${state.piers.length}기를 원본 엑셀 없이 다시 생성했습니다.`);
} catch (err) {
setError(`프로젝트 파일을 열지 못했습니다: ${(err as Error).message}`);
}
}
function scheduleBackup(): void {
window.clearTimeout(backupTimer);
backupTimer = window.setTimeout(() => {
if (!isDirty()) return;
writeBackup(currentProjectText()).catch(() => setError('자동 백업을 저장하지 못했습니다. 브라우저 저장소 설정을 확인하세요.'));
}, 800);
}
async function checkBackup(): Promise<void> {
try {
const backup = await readBackup();
if (!backup) return;
pendingBackup = backup;
$('backup-text').textContent = `저장되지 않은 작업이 있습니다 (${new Date(backup.at).toLocaleString('ko-KR')}).`;
$<HTMLDivElement>('backup-banner').hidden = false;
} catch {
// Storage unavailable (private window, blocked): no recovery is offered, and the app works without it.
}
}
// --- import dialog (시안 ④) ------------------------------------------------------------
function selectable(p: Pending, e: Pier): boolean {
return !!e.model && sheetOk(p.sheets, e.input.sheet);
}
function selectableAbut(p: Pending, rec: AbutRecord): boolean {
if (p.abutIssues.some((i) => i.level === 'E' && (i.pier === rec.name || (i.pier === undefined && i.sheet === rec.sheet)))) return false;
try {
buildAbut(toAbutInput(rec));
return true;
} catch {
return false;
}
}
function renderImportPreviews(p: Pending): void {
renderPreview(p.entries.map((e) => ({ input: e.input, checked: e.checked && selectable(p, e) })));
$('preview-title').textContent = `인식된 구조물 · 교각 ${p.entries.length}기 · 교대 ${p.abutments.length}기`;
const table = $<HTMLTableElement>('pv-abuts');
table.replaceChildren();
if (!p.abutments.length) {
table.createTBody().insertRow().append(el('td', '인식된 교대가 없습니다.'));
return;
}
const head = table.createTHead().insertRow();
for (const label of ['교대', '형식', '원본 시트', '교대축 (m)', '교좌면 EL (m)', '기초 하단 EL (m)', '받침', '불러오기']) head.append(el('th', label));
const body = table.createTBody();
for (const rec of p.abutments) {
const tr = body.insertRow();
const valid = selectableAbut(p, rec);
const length = Math.hypot(rec.ip3.e - rec.ip1.e, rec.ip3.n - rec.ip1.n);
for (const value of [rec.name, rec.type, rec.sheet, f3(length), f3(rec.seatEl), f3(rec.foundationBottomEl), String(rec.bearings.length), valid ? (p.selectedAbutments.has(rec.name) ? '예' : '아니오') : '입력 확인 필요']) tr.append(el('td', value));
}
}
function renderDialog(): void {
const p = state.pending;
const sheetsBox = $<HTMLUListElement>('sheets');
sheetsBox.replaceChildren();
$('file-name').textContent = p ? `선택된 파일: ${p.fileName}` : '';
if (!p) {
sheetsBox.appendChild(el('li', '파일을 선택하면 시트별 인식 결과가 표시됩니다.'));
renderPreview([]);
$('pv-abuts').replaceChildren();
updateApply();
return;
}
for (const sheet of p.sheets) {
const li = el('li');
const head = el('div', undefined);
const label =
sheet.piers === 0 ? (sheet.ok ? '보조 표 시트 (IP·받침)' : '오류') : sheet.ok ? '선택 가능' : '선택 불가 (오류)';
head.append(
el('span', `${sheet.name} · ${label}`, sheet.ok ? 'ok' : 'bad'),
document.createTextNode(` · 교각 ${sheet.tables.pier}표 · IP ${sheet.tables.ip}표 · 받침 ${sheet.tables.bearing}표`),
);
li.appendChild(head);
for (const issue of sheet.issues) {
const line = el('div', `${issue.code} ${issue.pier ? `[${issue.pier}] ` : ''}${issue.message}`, 'small');
line.style.color = issue.level === 'E' ? 'var(--error)' : 'var(--muted)';
li.appendChild(line);
}
for (const entry of p.entries.filter((e) => e.input.sheet === sheet.name)) {
const row = el('div', undefined, 'sheet-pier');
const check = el('input');
check.type = 'checkbox';
check.checked = entry.checked;
check.dataset.importKind = 'pier';
check.disabled = !selectable(p, entry);
check.setAttribute('aria-label', `${entry.input.name} 불러오기`);
check.addEventListener('change', () => {
entry.checked = check.checked;
updateApply();
renderImportPreviews(p);
});
const name = el('span', `${entry.input.name} · ${entry.input.type}`, 'pier-name');
row.append(check, name);
li.appendChild(row);
}
sheetsBox.appendChild(li);
}
if (p.abutments.length || p.abutIssues.length) {
const group = el('li');
group.append(el('strong', `교대 인식 결과 · ${p.abutments.length}기`));
for (const rec of p.abutments) {
const valid = selectableAbut(p, rec);
const row = el('div', undefined, 'sheet-pier');
const check = el('input');
check.type = 'checkbox';
check.id = `import-abut-${rec.name}`;
check.dataset.importKind = 'abut';
check.checked = valid && p.selectedAbutments.has(rec.name);
check.disabled = !valid;
check.addEventListener('change', () => {
if (check.checked) p.selectedAbutments.add(rec.name);
else p.selectedAbutments.delete(rec.name);
renderImportPreviews(p);
updateApply();
});
const label = el('label', `${rec.name} · 교대 ${rec.type} · 시트 ${rec.sheet} · ${valid ? '선택 가능' : '입력 확인 필요'}`);
label.htmlFor = check.id;
row.append(check, label);
group.append(row);
}
for (const item of groupAbutImportIssues(p.abutIssues)) {
for (const issue of item.primary) group.append(el('p', issue.message, issue.level === 'E' ? 'bad small' : 'small'));
if (item.related.length) {
const details = el('details');
details.append(el('summary', `${item.name || '교대'} · 관련 IP·받침 ${item.related.length}개 행 보기`, 'small'));
for (const issue of item.related) details.append(el('p', issue.message, 'small'));
group.append(details);
}
}
sheetsBox.prepend(group);
}
renderImportPreviews(p);
updateApply();
}
/** 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');
const bearingBody = $<HTMLTableElement>('pv-bearings').tBodies[0];
const ipBody = $<HTMLTableElement>('pv-ips').tBodies[0];
pierTable.replaceChildren();
bearingBody.replaceChildren();
ipBody.replaceChildren();
if (items.length === 0) {
title.textContent = '미리볼 교각이 없습니다.';
return;
}
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)],
['지반고 EL', f3(input.groundEl)],
['기초 형식', input.foundationType],
['말뚝길이 (mm)', String(input.pileLengthMm)],
['두부보 폭 × 길이 (mm)', `${input.copingWidthMm} × ${input.copingLengthMm}`],
['기둥갯수', String(input.columnCount)],
['받침 · IP', `${input.bearings.length} · ${input.ips.length}`],
];
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);
}
}
}
function updateApply(): void {
const p = state.pending;
const count = p ? p.entries.filter((e) => e.checked && selectable(p, e)).length : 0;
const total = p ? p.entries.filter((e) => selectable(p, e)).length : 0;
const abuts = p ? p.abutments.filter((rec) => selectableAbut(p, rec)) : [];
const selectedAbuts = p ? abuts.filter((rec) => p.selectedAbutments.has(rec.name)).length : 0;
const scope = `교각 ${count}기 + 교대 ${selectedAbuts}기`;
$('dlg-selected').textContent = p
? `선택 ${scope} · 선택 가능: 교각 ${total}기, 교대 ${abuts.length}기${count === 0 && selectedAbuts > 0 ? ' · 교각도 1기 이상 선택하세요.' : ''}`
: '파일을 선택하면 불러올 교각·교대를 확인할 수 있습니다.';
const named = $<HTMLInputElement>('project-name').value.trim().length > 0;
const apply = $<HTMLButtonElement>('dlg-apply');
apply.textContent = count || selectedAbuts ? `${scope} 가져오기` : '불러올 구조물을 선택하세요';
apply.disabled = count === 0 || !named;
}
function loadBytes(bytes: Uint8Array, fileName: string): void {
const wb = XLSX.read(bytes, { type: 'array' });
const { piers, sheets } = parseWorkbook(wb);
const abut = parseAbutments(wb, piers.map((pier) => pier.name));
const entries = piers.map((input) => buildEntry(input, false));
// A sheet is selectable only if every pier in it builds. Show the reason next to the sheet.
for (const sheet of sheets) {
const own = entries.filter((e) => e.input.sheet === sheet.name);
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, abutments: abut.abutments, abutIssues: abut.issues, selectedAbutments: new Set() };
for (const e of entries) e.checked = selectable(pending, e);
for (const rec of pending.abutments) if (selectableAbut(pending, rec)) pending.selectedAbutments.add(rec.name);
state.pending = pending;
renderDialog();
}
let workbookReadVersion = 0;
async function readWorkbook(fileName: string, bytes: () => Promise<Uint8Array>): Promise<boolean> {
const version = ++workbookReadVersion;
state.pending = null;
renderDialog();
$('file-name').textContent = `파일을 읽고 있습니다: ${fileName}`;
try {
const data = await bytes();
if (version !== workbookReadVersion) return false;
loadBytes(data, fileName);
return true;
} catch (err) {
if (version === workbookReadVersion) {
$('file-name').textContent = `${fileName}을 읽지 못했습니다. 파일을 다시 선택하세요.`;
setError(`엑셀을 읽지 못했습니다: ${(err as Error).message}`);
}
return false;
}
}
function applyImport(): void {
const p = state.pending;
const name = $<HTMLInputElement>('project-name').value.trim();
if (!p || !name || !p.entries.some((e) => e.checked && selectable(p, e))) return;
state.project = { ...state.project, name, bridge: $<HTMLInputElement>('bridge-name').value.trim() };
state.piers = p.entries.filter((e) => e.checked && selectable(p, e));
state.abutments = p.abutments.filter((rec) => p.selectedAbutments.has(rec.name) && selectableAbut(p, rec));
state.abutDefaults = JSON.parse(JSON.stringify(state.abutments)) as AbutRecord[];
state.active = state.piers[0]?.input.name ?? null;
state.filter = 'all';
state.pending = null;
state.source = { fileName: p.fileName, sheets: p.sheets.map((sheet) => sheet.name) };
state.history = [];
state.savedSnapshot = null;
state.savedOnce = false;
$<HTMLDialogElement>('import-dialog').close();
document.querySelector<HTMLElement>('.landing-overlay')?.classList.remove('modal-open');
const appRoot = document.querySelector<HTMLElement>('.app');
if (appRoot) {
appRoot.hidden = false;
appRoot.setAttribute('aria-hidden', 'false');
}
const homeButton = document.querySelector<HTMLButtonElement>('#home-from-app');
if (homeButton) homeButton.hidden = false;
document.querySelector<HTMLElement>('.landing-overlay')?.setAttribute('hidden', 'true');
setupScene();
renderAll();
const abutNote = state.abutments.length > 0 ? ` 교대 ${state.abutments.length}기를 읽었습니다${p.abutIssues.some((i) => i.level === 'E') ? ' (입력 오류 있음)' : ''}.` : '';
setStatus(`${state.piers.length}기 형상을 생성했습니다.${abutNote} 정보·경고는 오른쪽 알림에서 확인하세요.`);
}
function openImport(): void {
const landing = document.querySelector<HTMLDivElement>('.landing-overlay');
if (landing && !landing.hidden) return;
const dlg = $<HTMLDialogElement>('import-dialog');
if (!dlg.open) dlg.showModal();
$<HTMLInputElement>('project-name').focus();
}
async function fetchSample(): Promise<Uint8Array> {
const res = await fetch(new URL('samples/운곡교P1입력.xlsx', document.baseURI));
if (!res.ok) throw new Error(`샘플 응답 ${res.status}`);
return new Uint8Array(await res.arrayBuffer());
}
declare global {
interface Window {
loadBytesToApp?: (bytes: Uint8Array, fileName: string) => void;
openSampleProject?: () => Promise<void>;
openDrawingFlow?: () => void;
}
}
window.loadBytesToApp = loadBytes;
window.openSampleProject = async () => {
try {
const bytes = await fetchSample();
const app = document.querySelector<HTMLElement>('.app');
const landing = document.querySelector<HTMLDivElement>('.landing-overlay');
const homeButton = document.querySelector<HTMLButtonElement>('#home-from-app');
if (app) {
app.hidden = false;
app.setAttribute('aria-hidden', 'false');
}
if (landing) landing.hidden = true;
if (homeButton) homeButton.hidden = false;
window.loadBytesToApp?.(bytes, '샘플 P1 (운곡교P1입력.xlsx)');
} catch (error) {
setError(error instanceof Error ? error.message : '샘플 교량 모델을 불러오지 못했습니다.');
}
};
window.openDrawingFlow = () => {
const app = document.querySelector<HTMLElement>('.app');
const landing = document.querySelector<HTMLDivElement>('.landing-overlay');
if (app) {
app.hidden = false;
app.setAttribute('aria-hidden', 'false');
}
if (landing) landing.hidden = true;
setStatus('도면 입력은 현재 개발 중입니다. 기존 도면 분석 모듈과 연결되기 전까지는 파일 선택 흐름만 유지합니다.');
};
/** 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;
state.piers = state.piers.map((p) => {
if (!names.has(p.input.name)) return p;
const next = regenerateEntry(p);
if (!next.model) errors++;
return next;
});
setupScene();
renderAll();
setStatus(`${names.size}기 형상을 생성했습니다.${errors ? ` 오류 ${errors}기는 생성되지 않았습니다.` : ''}`);
}
// --- events -----------------------------------------------------------------------------
$<HTMLButtonElement>('open-import').addEventListener('click', openImport);
$<HTMLButtonElement>('dlg-close').addEventListener('click', () => {
$<HTMLDialogElement>('import-dialog').close();
document.querySelector<HTMLElement>('.landing-overlay')?.classList.remove('modal-open');
});
$<HTMLButtonElement>('dlg-cancel').addEventListener('click', () => {
$<HTMLDialogElement>('import-dialog').close();
document.querySelector<HTMLElement>('.landing-overlay')?.classList.remove('modal-open');
});
$<HTMLButtonElement>('dlg-apply').addEventListener('click', applyImport);
$<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'));
$<HTMLButtonElement>('quantity-back').addEventListener('click', () => setStage('rebar'));
$<HTMLInputElement>('mat-fck').addEventListener('change', (ev) => setMaterial('fck', (ev.target as HTMLInputElement).value));
$<HTMLInputElement>('mat-waste').addEventListener('change', (ev) => setWaste((ev.target as HTMLInputElement).value));
for (const k of ['small16', 'main25', 'main35', 'main51'] as const) {
$<HTMLInputElement>(`bend-${k}`).addEventListener('change', (ev) => setBend(k, (ev.target as HTMLInputElement).value));
}
for (const k of ['D', 'H', 'S'] as const) {
$<HTMLSelectElement>(`mat-grade-${k.toLowerCase()}`).addEventListener('change', (ev) => setMaterial(k, (ev.target as HTMLSelectElement).value));
}
$<HTMLButtonElement>('member-regen').addEventListener('click', () => {
const pier = activePier();
if (pier) regenerateOne(pier.input.name);
});
$<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();
});
$<HTMLInputElement>('cp-regen').addEventListener('change', renderCopyPreview);
$<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());
$<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());
$<HTMLButtonElement>('download-template').addEventListener('click', () => {
XLSX.writeFile(buildTemplateWorkbook(), '교각입력템플릿.xlsx');
});
$<HTMLInputElement>('file').addEventListener('change', async (ev) => {
const input = ev.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
input.value = '';
await readWorkbook(file.name, async () => new Uint8Array(await file.arrayBuffer()));
});
/** The project's standing input and names (RAG/운곡교P1입력.xlsx → 프로젝트 운곡교P1, 교량 운곡교). */
const SAMPLE_PROJECT = { name: '운곡교P1', bridge: '운곡교' } as const;
function fillSampleNames(): void {
$<HTMLInputElement>('project-name').value = SAMPLE_PROJECT.name;
$<HTMLInputElement>('bridge-name').value = SAMPLE_PROJECT.bridge;
// Setting .value does not fire 'input', so the apply button must be refreshed here.
updateApply();
}
$<HTMLButtonElement>('pick-sample').addEventListener('click', async () => {
if (await readWorkbook('샘플 P1 (운곡교P1입력.xlsx)', fetchSample)) fillSampleNames();
});
const drop = $<HTMLDivElement>('drop');
drop.addEventListener('dragover', (ev) => {
ev.preventDefault();
drop.classList.add('over');
});
drop.addEventListener('dragleave', () => drop.classList.remove('over'));
drop.addEventListener('drop', async (ev) => {
ev.preventDefault();
drop.classList.remove('over');
const file = ev.dataTransfer?.files?.[0];
if (!file) return;
await readWorkbook(file.name, async () => new Uint8Array(await file.arrayBuffer()));
});
$<HTMLSelectElement>('type-filter').addEventListener('change', (ev) => {
state.filter = (ev.target as HTMLSelectElement).value as 'all' | PierType;
renderPierList();
});
$<HTMLButtonElement>('save-project').addEventListener('click', saveProject);
$<HTMLButtonElement>('open-project').addEventListener('click', () => $<HTMLInputElement>('project-file').click());
const fileMenu = $<HTMLDetailsElement>('file-menu');
fileMenu.addEventListener('click', ev => {
if ((ev.target as HTMLElement).closest('button')) fileMenu.open = false;
});
document.addEventListener('click', ev => {
if (!fileMenu.contains(ev.target as Node)) fileMenu.open = false;
});
fileMenu.addEventListener('keydown', ev => {
if (ev.key === 'Escape' && fileMenu.open) {
fileMenu.open = false;
fileMenu.querySelector('summary')!.focus();
ev.preventDefault();
}
});
$<HTMLInputElement>('project-file').addEventListener('change', (ev) => {
const file = (ev.target as HTMLInputElement).files?.[0];
if (file) void openProjectFile(file);
(ev.target as HTMLInputElement).value = '';
});
window.addEventListener('keydown', (ev) => {
if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === 's') {
ev.preventDefault();
saveProject();
}
});
window.addEventListener('beforeunload', (ev) => {
if (isDirty()) ev.preventDefault();
});
$<HTMLButtonElement>('restore-backup').addEventListener('click', () => {
if (!pendingBackup) return;
try {
openProjectText(pendingBackup.text, false);
setStatus('자동 백업을 복구했습니다. 저장하지 않은 변경이 있습니다.');
} catch (err) {
setError(`백업을 복구하지 못했습니다: ${(err as Error).message}`);
}
pendingBackup = null;
$<HTMLDivElement>('backup-banner').hidden = true;
});
$<HTMLButtonElement>('discard-backup').addEventListener('click', () => {
pendingBackup = null;
$<HTMLDivElement>('backup-banner').hidden = true;
clearBackup().catch(() => undefined);
});
// --- start ------------------------------------------------------------------------------
captureViewerHome();
wireRebarPicking();
wireAllPiersPicking();
buildPartToggles();
buildViewButtons();
document.querySelectorAll<HTMLInputElement>('input[name="ground-map-mode"]').forEach((input) => {
input.addEventListener('change', () => setGroundMapMode(input.value as 'none' | 'ground' | 'satellite'));
});
setGroundMapMode('none');
renderDefaults();
renderDialog();
renderAll();
void checkBackup();
// Deep link for the bundled sample: ?sample=1 loads P1 directly and skips the dialog.
if (new URLSearchParams(window.location.search).get('sample') === '1') {
fetchSample()
.then((bytes) => {
loadBytes(bytes, '샘플 P1');
fillSampleNames();
applyImport();
})
.catch((err: Error) => setError(`샘플을 불러오지 못했습니다: ${err.message}`));
}
// --- 3D band: collapsible view menu, origin toggle, and remembered panel states (승인 2026-10-23) ---
/** Open or close the view menu or the origin note, and keep the buttons' aria-expanded in step. */
function setMenuOpen(which: 'views' | 'origin', open: boolean): void {
const panel = $<HTMLElement>(which);
panel.hidden = !open;
$(`${which}-toggle`).setAttribute('aria-expanded', String(open));
}
/** Counts the bar parts shown on the 3D view in the 부재 표시 summary. */
function updatePartsCount(): void {
const boxes = Array.from(document.querySelectorAll<HTMLInputElement>('#parts input[type="checkbox"]:not(:disabled)'));
const on = boxes.filter((b) => b.checked).length;
$('parts-count').textContent = `${on}/${boxes.length} ▾`;
}
/** Panel open states remembered per viewer; the storage can be missing or blocked, so every access is guarded. */
const PANEL_KEY = 'pierzainer.panel.';
function restorePanel(id: string): void {
const panel = $<HTMLDetailsElement>(id);
try {
panel.open = localStorage.getItem(PANEL_KEY + id) === '1';
} catch {
panel.open = false;
}
panel.addEventListener('toggle', () => {
try {
localStorage.setItem(PANEL_KEY + id, panel.open ? '1' : '0');
} catch {
/* storage unavailable: the panel still works */
}
});
}
restorePanel('parts-info');
restorePanel('color-info');
/** Viewer sizing is a local display preference, separate from project/design data. */
function initReviewWorkspace(): void {
const workspace = $('review-workspace');
const slider = $<HTMLInputElement>('review-model-height');
const toggle = $<HTMLButtonElement>('review-model-toggle');
let collapsed = false;
try {
const saved = JSON.parse(localStorage.getItem('pierzainer.review-layout') ?? 'null');
if (saved && Number.isFinite(saved.height) && saved.height >= 180 && saved.height <= 560) slider.value = String(saved.height);
collapsed = saved?.collapsed === true;
} catch { /* A blocked store must not prevent resizing. */ }
const apply = () => {
workspace.style.setProperty('--review-model-height', `${slider.value}px`);
workspace.classList.toggle('model-collapsed', collapsed);
$('review-model-height-value').textContent = `${slider.value} px`;
toggle.textContent = collapsed ? '모델 펼치기' : '모델 접기';
toggle.setAttribute('aria-expanded', String(!collapsed));
slider.disabled = collapsed;
try { localStorage.setItem('pierzainer.review-layout', JSON.stringify({ height: Number(slider.value), collapsed })); } catch { /* Display preference only. */ }
};
slider.addEventListener('input', apply);
toggle.addEventListener('click', () => { collapsed = !collapsed; apply(); });
const jump = (selector: string) => {
const node = document.querySelector<HTMLElement>(selector);
if (!node) return;
if (!node.matches('input, select, button, a[href]')) node.tabIndex = -1;
node.focus({ preventScroll: true });
node.scrollIntoView({ block: 'start', inline: 'nearest' });
};
$('review-inputs').addEventListener('click', () => jump('#rebar-table .abut-rebar-inputs input, #rebar-table input[type="number"], #rebar-table select'));
$('review-table').addEventListener('click', () => jump('#rebar-table table.rebar-table'));
apply();
new ResizeObserver(() => {
if (workspace.clientHeight === 0 || !window.matchMedia('(min-width: 1101px)').matches) return;
const available = workspace.clientHeight - workspace.querySelector<HTMLElement>('.workspace-tools')!.offsetHeight - 264;
const max = Math.max(180, Math.min(560, Math.floor(available / 20) * 20));
const next = Math.min(Number(slider.value), max);
if (slider.max !== String(max) || slider.value !== String(next)) {
slider.max = String(max);
slider.value = String(next);
apply();
}
}).observe(workspace);
}
initReviewWorkspace();
$<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) => {
const target = ev.target as Node;
if (!$<HTMLDivElement>('views-menu').contains(target)) setMenuOpen('views', false);
if (!$<HTMLButtonElement>('origin-toggle').contains(target) && !$<HTMLSpanElement>('origin').contains(target)) setMenuOpen('origin', false);
});
document.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape') {
setMenuOpen('views', false);
setMenuOpen('origin', false);
}
});