현장타설말뚝철근.md §5–§6 (사용자 결정 2026-11-06: 11-06 겹침 결정이 11-05 커플러 회신을 대체). - 희생강관 기본 적용, 상단 지반고 + 2500 (그림 값, Q-PL4 가정). 암반 근입부 지름 입력 추가(기본 D − 150, 강관 없으면 D) - 말뚝 철근망 반지름을 근입부 기준으로 (D 3000: r 1300 / 1268.2) - 두 철근 원이 다르면 자동 겹침이음: 기둥 주철근이 지반고 아래 l_s(1.3 l_d, S32 2387)까지 내려감, 지반고 커플러 없음. 원이 같으면(근입부 없음) 지반고 커플러. 저장된 방식은 그대로 둠 - 겹침 구간의 말뚝 띠철근은 바깥 기둥 주철근을 감싸는 반지름 - 겹침 모드에서도 주기 커플러(기둥 지반고 + @3000, 말뚝 지반고 − @3000, 내측 + 150) - RB-PL07: 두 원 간격 > min(l_s/5, 150)이면 경고 - 이전 '겹침부 미계산' 표시 제거. 기존 커플러 테스트는 희생강관 미적용 입력으로 유지 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6158 lines
311 KiB
TypeScript
6158 lines
311 KiB
TypeScript
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)' },
|
||
{ key: 'socketDiameterMm', label: '암반 근입부 지름 (mm)' },
|
||
];
|
||
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;
|
||
if (f.key === 'socketDiameterMm') input.step = '50';
|
||
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. 강관 기본 = 적용, 지반고+2.5 m(가정, Q-PL4) ~ 기반암. 근입부 지름 기본 = 강관 적용 시 D − 150 (말뚝 철근망은 이 지름 기준).`, '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);
|
||
}
|
||
});
|