feat: 프로젝트 공통 지반모델·교대 지반 통합·재료표/수량 진입·옥성교 랜딩 히어로 (11-06~11-09 작업분)
공용 파일(main.ts·index.html·style.css)에서 서로 얽혀 있어 한 커밋으로 묶음. - 지반: ① 형상 프로젝트 지반모델 관리창(생성·JSON 불러오기·교각별 비교·선택 적용), 프로젝트 JSON 내장 groundModel 추출, 교대 8행 통합 적용·공통지형 중복격자 제외 - 재료표·수량: 상단 ⑤ 메뉴·배근 바로가기, 결과 교각/탭 기억, 수량 워크북·정합 보정 - 랜딩: 옥성교 6교각 히어로 이미지·투영 강조점·좌→우 광선·지형 라운드/도로 연결 - 검증 스크립트(verify-project-ground/ground-abutments/okseong-user-files/quantity-*/landing-hero 등) 정본: DOC/04_실행/실행_*_2026-11-09_v0.1.00.md Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b2bebe47eb
commit
fc520ec03d
47 files changed
+2848
-149
No files matched your search
@@ -72,8 +72,21 @@ function numOrNull(v: unknown): number | null {
|
||||
export function checkGroundModel(raw: unknown): GroundModelFile {
|
||||
const obj = raw as Partial<GroundModelFile> | null;
|
||||
if (!obj || typeof obj !== 'object') throw new Error('지반 모델 파일 형식이 아닙니다.');
|
||||
if (!('schema' in obj) && 'schemaVersion' in obj) {
|
||||
const project = obj as { schemaVersion?: unknown; groundModel?: unknown };
|
||||
if (project.schemaVersion !== 1) throw new Error(`프로젝트 형식 번호 ${String(project.schemaVersion)}에서 지반모델을 가져올 수 없습니다.`);
|
||||
if (project.groundModel == null) throw new Error('이 프로젝트 파일에는 지반모델이 없습니다. 프로젝트 전체는 [불러오기 → 프로젝트 열기]에서 열 수 있습니다. 지반 결과는 ground_model.json을 선택하세요.');
|
||||
return checkStoredGroundModel(project.groundModel);
|
||||
}
|
||||
const knownSchema = schemaOrLegacy(obj);
|
||||
if (knownSchema === undefined) {
|
||||
const data = obj as Record<string, unknown>;
|
||||
if ('drawing' in data && 'output' in data && typeof data.piers === 'string') {
|
||||
throw new Error('지반 생성 설정 파일입니다. 계산 결과인 ground_model.json 또는 ground_model_3d_with_maps.json을 선택하세요.');
|
||||
}
|
||||
if (!('schema' in data)) {
|
||||
throw new Error('계산된 지반모델을 찾을 수 없습니다. ground_model.json 또는 ground_model_3d_with_maps.json을 선택하세요. 설정·진단·결과 요약 JSON은 적용할 수 없습니다.');
|
||||
}
|
||||
throw new Error(`지반 모델 형식 번호 ${String((obj as { schema?: unknown }).schema)}을(를) 읽을 수 없습니다 (지원: ${READABLE_SCHEMAS.join(', ')}).`);
|
||||
}
|
||||
if (!READABLE_SCHEMAS.includes(knownSchema as number)) {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { GroundModelFile, TerrainGrid } from './ground-model';
|
||||
import type { AbutRecord } from './abut/parse';
|
||||
import type { PierInput } from './types';
|
||||
|
||||
export function applyProjectGround(draft: PierInput, model: GroundModelFile, options: { ground: boolean; layers: boolean }): PierInput {
|
||||
const entry = model.piers.find(p => p.id === draft.name);
|
||||
if (!entry) return draft;
|
||||
return { ...draft,
|
||||
...(options.ground && entry.centerEl !== null && Number.isFinite(entry.centerEl) ? { groundEl: entry.centerEl } : {}),
|
||||
...(options.layers && entry.layers?.length ? { strataRows: entry.layers.map(row => ({ ...row })) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function groundInputChanged(input: PierInput, draft: PierInput): boolean {
|
||||
return input.groundEl !== draft.groundEl || JSON.stringify(input.strataRows ?? []) !== JSON.stringify(draft.strataRows ?? []);
|
||||
}
|
||||
|
||||
/** No extrapolation or filling null nodes; coordinates are survey north/east. */
|
||||
export function sampleGroundGrid(grid: TerrainGrid, point: { e: number; n: number }): number | null {
|
||||
const x = (point.n - grid.origin[0]) / grid.step, y = (point.e - grid.origin[1]) / grid.step;
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || y < 0 || x > grid.nx - 1 || y > grid.ny - 1) return null;
|
||||
const i = Math.min(Math.floor(x), grid.nx - 2), j = Math.min(Math.floor(y), grid.ny - 2);
|
||||
const h = [grid.z[j * grid.nx + i], grid.z[j * grid.nx + i + 1], grid.z[(j + 1) * grid.nx + i], grid.z[(j + 1) * grid.nx + i + 1]];
|
||||
if (h.some(v => v == null || !Number.isFinite(v))) return null;
|
||||
const u = x - i, v = y - j;
|
||||
return h[0]! * (1-u) * (1-v) + h[1]! * u * (1-v) + h[2]! * (1-u) * v + h[3]! * u * v;
|
||||
}
|
||||
|
||||
export function abutGroundFromModel(model: GroundModelFile | null, abut: Pick<AbutRecord, 'name' | 'ip2'>) {
|
||||
const explicit = model?.piers.find(p => p.id === abut.name);
|
||||
if (explicit) return { ...explicit, source: 'JSON 개별값' };
|
||||
const centerEl = model?.terrainGrid ? sampleGroundGrid(model.terrainGrid, abut.ip2) : null;
|
||||
return { id: abut.name, centerEl, layers: undefined, source: centerEl === null ? 'IP2 격자 자료 없음' : 'IP2 격자 보간' };
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
import { quantityRows, type Quantity } from './rebar/quantity';
|
||||
|
||||
export interface QuantityRecord {
|
||||
name: string;
|
||||
quantity: Quantity | null;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
/** Export checks use the same records as the visible result, including ungenerated piers. */
|
||||
export function quantityExportIssues(records: QuantityRecord[]): string[] {
|
||||
return records.flatMap(r => r.dirty ? [`${r.name}: 형상 재생성 필요`] : !r.quantity ? [`${r.name}: 형상 없음`] : []);
|
||||
}
|
||||
|
||||
export function quantityPieceRows(records: QuantityRecord[]): (string | number)[][] {
|
||||
const rows: (string | number)[][] = [['교각', '부재', '강종', '번호', '규격', '조각', '길이 (mm)', '가공 본수', '총길이 (m)', '총중량 (kg)']];
|
||||
for (const r of records) for (const g of r.quantity?.groups ?? []) for (const d of g.diameters) for (const line of d.lines) {
|
||||
line.pieces.forEach((p, i) => {
|
||||
const totalM = p.length * p.count / 1000;
|
||||
rows.push([r.name, line.member, `SD${g.grade}`, line.name, line.size, i + 1, p.length, p.count, totalM, totalM * line.kgPerM]);
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Generated values remain numbers; only the display format rounds them. */
|
||||
export function createQuantityWorkbook(records: QuantityRecord[], meta: { project: string; bridge: string; generatedAt: string; abutmentCount: number }): XLSX.WorkBook {
|
||||
const issues = quantityExportIssues(records);
|
||||
if (!records.length || issues.length) throw new Error(issues.join('\n') || '출력할 교각이 없습니다.');
|
||||
const wb = XLSX.utils.book_new(), used = new Set(['합계', '가공 상세', '커플러', '검토 항목', '생성 정보']);
|
||||
const append = (name: string, rows: (string | number)[][]) => XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(rows), name);
|
||||
const sheetName = (name: string) => {
|
||||
const base = name.replace(/[[\]:*?/\\\x00-\x1f]/g, '_').replace(/^'+|'+$/g, '') || '교각';
|
||||
let candidate = base.slice(0, 31), n = 1;
|
||||
while (used.has(candidate.toLowerCase())) { const suffix = `_${n++}`; candidate = base.slice(0, 31 - suffix.length) + suffix; }
|
||||
used.add(candidate.toLowerCase()); return candidate;
|
||||
};
|
||||
const summary: (string | number)[][] = [['교각', '합계 (kg)', '할증 포함 (kg)', 'ton', '계산 상태']];
|
||||
const couplers: (string | number)[][] = [['교각', '번호', '개수']];
|
||||
const review: (string | number)[][] = [['교각', '미계산·검토 항목']];
|
||||
let kg = 0, kgWithWaste = 0;
|
||||
for (const r of records) {
|
||||
const q = r.quantity!;
|
||||
const status = q.missing.length ? '일부 계산 · 검토용' : '계산 완료';
|
||||
append(sheetName(r.name), [['교각', r.name], ['계산 상태', status], ['할증 (%)', q.wastePercent], [], ...quantityRows(q)]);
|
||||
summary.push([r.name, q.kg, q.kgWithWaste, q.ton, status]);
|
||||
kg += q.kg; kgWithWaste += q.kgWithWaste;
|
||||
q.couplers.forEach(c => couplers.push([r.name, c.name, c.count]));
|
||||
q.missing.forEach(m => review.push([r.name, m]));
|
||||
}
|
||||
const partial = records.some(r => r.quantity!.missing.length);
|
||||
summary.push([partial ? '계산된 부재 소계 · 전체 수량 미완료' : '합계', kg, kgWithWaste, kgWithWaste / 1000, partial ? '검토용' : '계산 완료']);
|
||||
append('합계', summary); append('가공 상세', quantityPieceRows(records)); append('커플러', couplers); append('검토 항목', review);
|
||||
append('생성 정보', [['프로젝트', meta.project], ['교량', meta.bridge], ['생성 시각', meta.generatedAt], ['대상', records.map(r => r.name).join(', ')], ['출력 상태', partial ? '검토용 · 전체 수량 미완료' : '계산 완료'], ['범위', `교각 ${records.length}기 · 교대 ${meta.abutmentCount}기 제외`], ['수량 기준', '할증 전 실제 길이 · 가공 상세 길이×본수로 검산'], ['원표 수량', '철근표 행 수량이며 가공 조각 본수와 다를 수 있음']]);
|
||||
return wb;
|
||||
}
|
||||
@@ -110,11 +110,11 @@ export function buildQuantity(rows: RebarRow[], material: Material, incomplete:
|
||||
|
||||
/** The 철근재료표 as rows (for the Excel export): group and diameter headers, lines, subtotals, totals. */
|
||||
export function quantityRows(q: Quantity): (string | number)[][] {
|
||||
const out: (string | number)[][] = [['강종', '번호', '형상', '규격', '개수', '길이 (mm)', '총길이 (m)', '단위중량 (kg/m)', '총중량 (kg)']];
|
||||
const out: (string | number)[][] = [['강종', '번호', '형상', '규격', '원표 수량', '길이 (mm)', '총길이 (m)', '단위중량 (kg/m)', '총중량 (kg)', '가공 본수', '부재']];
|
||||
for (const g of q.groups) {
|
||||
for (const d of g.diameters) {
|
||||
for (const l of d.lines) {
|
||||
out.push([`SD${g.grade}`, l.name, l.shape, l.size, l.count, l.length === null ? '여러 길이' : round(l.length), round(l.totalM), round(l.kgPerM, 3), round(l.kg)]);
|
||||
out.push([`SD${g.grade}`, l.name, l.shape, l.size, l.count, l.length === null ? '여러 길이' : round(l.length), round(l.totalM), round(l.kgPerM, 3), round(l.kg), l.pieceCount, l.member]);
|
||||
}
|
||||
out.push(['', `${d.size} 소계`, '', d.size, '', '', round(d.totalM), '', round(d.kg)]);
|
||||
}
|
||||
|
||||
+134
-70
@@ -1,3 +1,7 @@
|
||||
import { createProjectGroundManager } from './view/project-ground';
|
||||
import { hasTerrainSurface } from './view/terrain';
|
||||
import { abutGroundFromModel, applyProjectGround, groundInputChanged } from './core/project-ground';
|
||||
import { registerGroundImporter } from '../pierzainer_ground/ground-link';
|
||||
import './style.css';
|
||||
import { createDrawingWorkspace } from '../도면작성/workspace';
|
||||
import { createTakeoffWorkspace } from '../수량산출/workspace';
|
||||
@@ -86,13 +90,14 @@ import { applyCopy, COPY_ITEMS, planCopy, type CopyItem } from './core/copy';
|
||||
import { copyCandidates, copyItemsFor, copyItemsWithOverrides, hasCopyItems, selectedTargets, type CopyCandidate } from './core/copy-targets';
|
||||
import { quantityTargetNames } from './core/quantity-scope';
|
||||
import { aggregateQuantities, type QuantityTotal } from './core/quantity-total';
|
||||
import { createQuantityWorkbook, quantityExportIssues, quantityPieceRows, type QuantityRecord } from './core/quantity-workbook';
|
||||
import { defaultLevelWidthMm, pileGeometry } from './core/geometry';
|
||||
import { 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 { buildQuantity, 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';
|
||||
@@ -130,6 +135,8 @@ import { type BarSummary, elevationRows, solplateRows } from './core/bearing-tab
|
||||
|
||||
/** 지반 모델 (ground_model.json) for the project. Its values reach a pier's 지반고 only when the user applies them. */
|
||||
let groundModel: GroundModelFile | null = null;
|
||||
let groundProjectVersion = 0;
|
||||
let groundImportSerial = 0;
|
||||
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';
|
||||
@@ -147,8 +154,9 @@ interface Pier extends PierValues {
|
||||
}
|
||||
|
||||
/** Part toggles in the order of 시안 ① "부재 표시". Each toggle controls one or more scene parts. */
|
||||
/** ① 형상 생성, ② 형상계획, ③ 철근배근, ④ 도면작성 (origin). 'quantity' is the 재료표 · 수량산출 screen opened from ③ (시안: no header step, 2026-11-06). */
|
||||
/** ① 형상 생성, ② 형상계획, ③ 철근배근, ④ 도면작성, ⑤ 재료표·수량산출. */
|
||||
type Stage = 'shape' | 'plan' | 'rebar' | 'drawing' | 'quantity';
|
||||
let quantitySelected: string | null = null;
|
||||
|
||||
/** 부재 표시 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[] }[] = [
|
||||
@@ -284,18 +292,67 @@ const $ = <T extends HTMLElement>(id: string): T => document.getElementById(id)
|
||||
const drawingWorkspace = createDrawingWorkspace($('view-drawing'), () => ({
|
||||
project: state.project, selected: state.active,
|
||||
piers: state.piers.map(p => ({ input: p.input, model: p.model, dirty: pendingKeys(p).length > 0 })),
|
||||
}), selectPier);
|
||||
}), selectPier, () => openQuantity());
|
||||
|
||||
const quantityRebarTon = new Map<string,number|null>();
|
||||
const quantityResults = new Map<string, Quantity>();
|
||||
const quantityWorkspace = createTakeoffWorkspace($('view-quantity'), () => ({
|
||||
project: state.project, selected: state.active,
|
||||
piers: state.piers.map(p => ({input:p.input,model:p.model,dirty:pendingKeys(p).length>0,rebarTon:quantityRebarTon.get(p.input.name)})),
|
||||
piers: state.piers.map(p => {
|
||||
const q = quantityResults.get(p.input.name);
|
||||
return {input:p.input,model:p.model,dirty:pendingKeys(p).length>0,rebarTon:q&&!q.missing.length?q.kg/1000:null,rebarIssues:q?.missing};
|
||||
}),
|
||||
}), selectPier, (name,data) => {
|
||||
const pier=state.piers.find(p=>p.input.name===name);if(!pier)return;
|
||||
pier.input={...pier.input,quantityTakeoff:data};pier.draft={...pier.draft,quantityTakeoff:data};
|
||||
renderSaveStatus();scheduleBackup();
|
||||
});
|
||||
|
||||
async function importProjectGround(text: string, name: string): Promise<void> {
|
||||
const version = groundProjectVersion, serial = ++groundImportSerial;
|
||||
const imported = parseGroundModel(text);
|
||||
await Promise.all((imported.mapLayers ?? []).map(decodeGroundMapImage));
|
||||
if (version !== groundProjectVersion || serial !== groundImportSerial) throw new Error('프로젝트 또는 불러올 모델이 변경되어 이전 결과를 적용하지 않았습니다.');
|
||||
groundModel = imported;
|
||||
groundMapMode = imported.mapLayers?.length ? 'satellite' : 'none';
|
||||
setGroundMapMode(groundMapMode);
|
||||
state.visible.ground = true;
|
||||
setupScene(); renderAll();
|
||||
setStatus(`${name} 지반모델을 프로젝트 공통으로 불러왔습니다. 설계 입력은 비교 후 적용하세요.`);
|
||||
}
|
||||
registerGroundImporter(importProjectGround);
|
||||
const projectGround = createProjectGroundManager({
|
||||
snapshot: () => ({ model: groundModel, piers: state.piers, abutments: state.abutments }),
|
||||
importModel: async file => {
|
||||
const version = groundProjectVersion, serial = ++groundImportSerial;
|
||||
const text = await file.text();
|
||||
if (version !== groundProjectVersion || serial !== groundImportSerial) throw new Error('프로젝트 또는 불러올 모델이 변경되어 이전 파일을 불러오지 않았습니다.');
|
||||
await importProjectGround(text, file.name);
|
||||
},
|
||||
apply: (names, fields) => {
|
||||
if (!groundModel) return;
|
||||
for (const p of state.piers) if (names.has(`pier:${p.input.name}`)) p.draft = applyProjectGround(p.draft, groundModel, fields);
|
||||
state.abutments = state.abutments.map(a => {
|
||||
if (!names.has(`abut:${a.name}`)) return a;
|
||||
const entry = abutGroundFromModel(groundModel, a);
|
||||
let next = fields.ground && entry.centerEl !== null ? applyAbutEdit(a, 'groundEl', entry.centerEl) : a;
|
||||
if (fields.layers && entry.layers?.length) next = setAbutGround(next, { strataRows: entry.layers.map(row => ({ ...row })) });
|
||||
return next;
|
||||
});
|
||||
setupScene();
|
||||
renderAll();
|
||||
},
|
||||
regenerate: () => {
|
||||
state.piers = state.piers.map(p => groundInputChanged(p.input, p.draft) ? regenerateEntry(p) : p);
|
||||
setupScene(); renderAll();
|
||||
setStatus('지반 입력이 변경된 교각을 다시 생성했습니다. 생성 상태와 알림을 확인하세요.');
|
||||
},
|
||||
});
|
||||
function resetProjectGround(): void {
|
||||
groundProjectVersion++; groundImportSerial++;
|
||||
projectGround.reset();
|
||||
window.dispatchEvent(new Event('pierzainer:project-changed'));
|
||||
}
|
||||
|
||||
function el<K extends keyof HTMLElementTagNameMap>(tag: K, text?: string, cls?: string): HTMLElementTagNameMap[K] {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
@@ -687,7 +744,8 @@ function setupScene(): void {
|
||||
inputAzimuthDeg: Object.fromEntries(rec.bearings.filter((x) => x.azimuthDeg !== undefined).map((x) => [String(x.no), x.azimuthDeg!])),
|
||||
});
|
||||
const group = buildAbutGroup(model, members, buildWings(model, members, rec.wings, rec.wingDims), pivot, rec.groundEl, groundModel?.terrainGrid, rec.slab, soles,
|
||||
jointPositions(model.length, model.bearings.map((b) => b.s), rec.shape?.shrinkJointCount));
|
||||
jointPositions(model.length, model.bearings.map((b) => b.s), rec.shape?.shrinkJointCount),
|
||||
!!(groundModel?.terrainGrid && hasTerrainSurface(groundModel.terrainGrid) && shownPiers().some(p => p.model) && piersShown(state.stage, state.abutActive)));
|
||||
group.visible = abutShown(state.stage, state.abutActive, rec.name);
|
||||
// The 부재 표시 toggles hold for a new abutment group too.
|
||||
for (const [name, on] of Object.entries(state.abutVisible)) {
|
||||
@@ -2214,6 +2272,9 @@ let lastScrolledPier: string | null = null;
|
||||
|
||||
function renderAll(): void {
|
||||
updatePartToggles();
|
||||
projectGround.refresh();
|
||||
$<HTMLButtonElement>('rebar-materials').disabled = !state.piers.length;
|
||||
$('rebar-materials').title = '교각 재료표·수량산출을 엽니다. 교대는 집계 대상에서 제외됩니다.';
|
||||
const allToggle = $<HTMLButtonElement>('all-toggle');
|
||||
allToggle.setAttribute('aria-pressed', String(state.allView));
|
||||
allToggle.title = `${shownPiers().filter((p) => p.model).length}기를 한 화면에 (① 형상 생성)`;
|
||||
@@ -2701,6 +2762,11 @@ 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;
|
||||
if (previous === 'quantity') quantitySelected = state.active;
|
||||
if (stage === 'quantity') {
|
||||
if (quantitySelected && state.piers.some(p => p.input.name === quantitySelected)) state.active = quantitySelected;
|
||||
state.abutActive = null;
|
||||
}
|
||||
state.stage = stage;
|
||||
// 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.
|
||||
@@ -2719,8 +2785,7 @@ function setStage(stage: Stage): void {
|
||||
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']], ['nav-drawing', ['drawing']]];
|
||||
const nav: [string, Stage[]][] = [['nav-shape', ['shape']], ['nav-plan', ['plan']], ['nav-rebar', ['rebar']], ['nav-drawing', ['drawing']], ['nav-quantity', ['quantity']]];
|
||||
for (const [id, keys] of nav) {
|
||||
const btn = $<HTMLButtonElement>(id);
|
||||
btn.classList.toggle('current', keys.includes(stage));
|
||||
@@ -4568,15 +4633,19 @@ function renderQuantityLink(box: HTMLElement, rows: RebarRow[], incomplete: read
|
||||
const line = el('p', `철근재료표 · ${q.missing.length ? '계산된 부재 소계 · 전체 수량 미완료' : '합계'} ${q.kg.toFixed(1)} kg · 할증 포함 ${q.kgWithWaste.toFixed(1)} kg`, 'pnote');
|
||||
const link = el('button', '철근재료표 보기', 'btn small');
|
||||
link.type = 'button';
|
||||
link.addEventListener('click', () => setStage('quantity'));
|
||||
link.addEventListener('click', () => openQuantity());
|
||||
box.append(line, link);
|
||||
}
|
||||
|
||||
function openQuantity(): void {
|
||||
setStage('quantity');
|
||||
}
|
||||
|
||||
/** ④ 재료표: 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));
|
||||
return state.piers.filter((p) => state.quantityAll || names.has(p.input.name));
|
||||
}
|
||||
|
||||
/** Length cell of a 재료표 line: the length, or 여러 길이 with the pieces on hover. */
|
||||
@@ -4591,7 +4660,7 @@ 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));
|
||||
for (const h of ['강종', '번호', '형상', '규격', '원표 수량', '길이 (mm)', '총길이 (m)', '단위중량 (kg/m)', '총중량 (kg)', '가공 본수']) hr.appendChild(el('th', h));
|
||||
head.appendChild(hr);
|
||||
table.appendChild(head);
|
||||
const body = el('tbody');
|
||||
@@ -4600,12 +4669,12 @@ function quantityTable(q: Quantity): HTMLTableElement {
|
||||
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)),
|
||||
lengthCell(l), el('td', l.totalM.toFixed(1)), el('td', l.kgPerM.toFixed(3)), el('td', l.kg.toFixed(1)), el('td', String(l.pieceCount)),
|
||||
);
|
||||
}
|
||||
body.appendChild(el('tr', undefined, 'subtotal')).append(el('td', ''), el('td', `${d.size} 소계`), el('td', ''), el('td', d.size), el('td', ''), el('td', ''), el('td', d.totalM.toFixed(1)), el('td', ''), el('td', d.kg.toFixed(1)));
|
||||
body.appendChild(el('tr', undefined, 'subtotal')).append(el('td', ''), el('td', `${d.size} 소계`), el('td', ''), el('td', d.size), el('td', ''), el('td', ''), el('td', d.totalM.toFixed(1)), el('td', ''), el('td', d.kg.toFixed(1)), el('td', String(d.lines.reduce((n,l)=>n+l.pieceCount,0))));
|
||||
}
|
||||
body.appendChild(el('tr', undefined, 'subtotal')).append(el('td', `SD${g.grade} 소계`), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', g.kg.toFixed(1)));
|
||||
body.appendChild(el('tr', undefined, 'subtotal')).append(el('td', `SD${g.grade} 소계`), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', ''), el('td', g.kg.toFixed(1)), el('td', ''));
|
||||
}
|
||||
table.appendChild(body);
|
||||
return table;
|
||||
@@ -4617,17 +4686,27 @@ function renderQuantityScreen(): void {
|
||||
renderPierRows($<HTMLUListElement>('quantity-pier-items'), 'quantity');
|
||||
const allButton = $<HTMLButtonElement>('quantity-all');
|
||||
const generated = state.piers.filter((p) => p.model).length;
|
||||
allButton.textContent = `전체 합계 (${generated}기)`;
|
||||
allButton.textContent = `교각 전체 (${state.piers.length}기 · 생성 ${generated}기)`;
|
||||
allButton.setAttribute('aria-pressed', String(state.quantityAll));
|
||||
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) }] : [],
|
||||
);
|
||||
quantityRebarTon.clear();
|
||||
for(const {pier,q} of entries)quantityRebarTon.set(pier.input.name,q.missing.length?null:q.kg/1000);
|
||||
quantityResults.clear();
|
||||
for (const pier of state.piers) if (pier.model) quantityResults.set(pier.input.name, pierQuantity(pier));
|
||||
const records = quantityRecords(targets), issues = quantityExportIssues(records);
|
||||
const entries = targets.flatMap(pier => {
|
||||
const q = quantityResults.get(pier.input.name);
|
||||
return q ? [{pier,q}] : [];
|
||||
});
|
||||
const partial = entries.some(e=>e.q.missing.length);
|
||||
const output = $<HTMLButtonElement>('quantity-export');
|
||||
output.disabled = !targets.length || issues.length > 0;
|
||||
output.textContent = partial ? '검토용 Excel' : 'Excel 출력';
|
||||
output.title = issues.join(' · ') || (partial ? '미계산 항목을 제외한 검토용 소계입니다.' : '현재 계산 결과를 출력합니다.');
|
||||
$<HTMLButtonElement>('quantity-print').disabled = output.disabled;
|
||||
if (issues.length) body.appendChild(el('p', `출력 불가 · ${issues.join(' · ')}`, 'pnote'));
|
||||
body.appendChild(el('p', `대상: 교각 ${targets.length}기 · 교대 ${state.abutments.length}기 제외. 원표 수량은 철근표 행 수량이며 가공 본수와 다를 수 있습니다.`, 'pnote'));
|
||||
quantityWorkspace.refresh();
|
||||
// 전체 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));
|
||||
@@ -4635,6 +4714,14 @@ function renderQuantityScreen(): void {
|
||||
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));
|
||||
const details = el('details'); details.appendChild(el('summary', '길이별 가공 상세'));
|
||||
const table = el('table', undefined, 'grid-table rebar-table');
|
||||
const rows = quantityPieceRows([{name:pier.input.name,quantity:q,dirty:false}]);
|
||||
for (const [i,row] of rows.entries()) {
|
||||
const tr = el('tr'); for (const value of row) tr.appendChild(el(i===0?'th':'td', typeof value==='number'?String(Math.round(value*1000)/1000):value));
|
||||
table.appendChild(tr);
|
||||
}
|
||||
details.appendChild(table); body.appendChild(details);
|
||||
body.appendChild(el('p', `${q.missing.length ? '계산된 부재 소계 (전체 수량 미완료)' : '합계'} ${q.kg.toFixed(1)} kg · 할증 포함 ${q.kgWithWaste.toFixed(1)} kg (${q.ton.toFixed(3)} ton) · 커플러 ${q.couplers.map((c) => `${c.name} ${c.count}개`).join(', ') || '없음'}`, 'pnote'));
|
||||
}
|
||||
}
|
||||
@@ -4671,20 +4758,18 @@ function pierQuantity(pier: Pier): Quantity {
|
||||
return buildQuantity(result.rows, state.material, result.incomplete);
|
||||
}
|
||||
|
||||
function quantityRecords(targets = quantityTargets()): QuantityRecord[] {
|
||||
return targets.map(pier => ({name:pier.input.name,quantity:quantityResults.get(pier.input.name)??null,dirty:pendingKeys(pier).length>0}));
|
||||
}
|
||||
|
||||
function exportQuantityWorkbook(): void {
|
||||
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`);
|
||||
renderQuantityScreen();
|
||||
try {
|
||||
const records = quantityRecords();
|
||||
const wb = createQuantityWorkbook(records, {project:state.project.name,bridge:state.project.bridge,generatedAt:new Date().toISOString(),abutmentCount:state.abutments.length});
|
||||
const prefix = records.some(r=>r.quantity?.missing.length) ? '검토용_' : '';
|
||||
XLSX.writeFile(wb, `${prefix}${state.project.bridge || '교량'}_철근재료표.xlsx`);
|
||||
} catch (error) { setError((error as Error).message); }
|
||||
}
|
||||
|
||||
$<HTMLButtonElement>('quantity-all').addEventListener('click', () => {
|
||||
@@ -4692,7 +4777,11 @@ $<HTMLButtonElement>('quantity-all').addEventListener('click', () => {
|
||||
renderAll();
|
||||
});
|
||||
$<HTMLButtonElement>('quantity-export').addEventListener('click', exportQuantityWorkbook);
|
||||
$<HTMLButtonElement>('quantity-print').addEventListener('click', () => window.print());
|
||||
$<HTMLButtonElement>('quantity-print').addEventListener('click', () => {
|
||||
const issues = quantityExportIssues(quantityRecords());
|
||||
if (issues.length) { setError(issues.join(' · ')); return; }
|
||||
window.print();
|
||||
});
|
||||
|
||||
/** Above the 철근표: selection count, select-all / clear for the shown member, and 선택만 보기 (승인 2026-10-23). */
|
||||
function rebarSelectBar(names: string[]): HTMLDivElement {
|
||||
@@ -5006,7 +5095,7 @@ function strataRowsEditor(rows: StrataRow[], groundEl: number, set: (rows: Strat
|
||||
|
||||
/**
|
||||
* ② 지반·지층 card (시안 2026-10-26; 승인 2026-10-26: 지반고는 계산에 쓰고, 지층은 행으로 입력하며, 지하수위는 직접 입력).
|
||||
* The ground model import and the 지반고 apply stay at the bottom, as before.
|
||||
* Project model generation/import is managed from ①; this card retains local inputs.
|
||||
*/
|
||||
function renderGroundModel(box: HTMLDivElement, pier: Pier): void {
|
||||
const name = pier.input.name;
|
||||
@@ -5102,41 +5191,7 @@ function renderGroundModel(box: HTMLDivElement, pier: Pier): void {
|
||||
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 groundLaunch = el('button', '지반 모델 파일 불러오기 · 지반 생성 프로그램');
|
||||
groundLaunch.type = 'button';groundLaunch.className = 'btn';
|
||||
groundLaunch.addEventListener('click', () => window.dispatchEvent(new Event('pierzainer:open-ground')));
|
||||
wrap.appendChild(groundLaunch);
|
||||
const linkStatus = el('p', '현재 Excel을 사용하여 종평면도에서 지반을 생성합니다.', 'muted small');linkStatus.id='ground-link-status';wrap.appendChild(linkStatus);
|
||||
const importLabel = el('label', '기존 지반 JSON 직접 불러오기');
|
||||
importLabel.appendChild(importInput);
|
||||
wrap.appendChild(importLabel);
|
||||
wrap.appendChild(el('p', '프로젝트 공통 지반모델은 ① 형상의 [프로젝트 지반모델]에서 생성·불러오기·선택 적용합니다.', 'muted small'));
|
||||
|
||||
if (!groundModel) {
|
||||
wrap.appendChild(el('p', '지반 모델을 불러오면 이 교각의 지반고를 엑셀 값과 비교하고 적용할 수 있습니다.', 'muted small'));
|
||||
@@ -5717,8 +5772,10 @@ function writeProjectFile(): void {
|
||||
/** 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);
|
||||
resetProjectGround();
|
||||
state.project = { ...parsed.project };
|
||||
state.source = parsed.source;
|
||||
quantitySelected = null;
|
||||
state.history = parsed.history;
|
||||
state.material = materialFrom(parsed.common);
|
||||
state.fckByMember = { ...parsed.fckByMember };
|
||||
@@ -6015,7 +6072,12 @@ function applyImport(): void {
|
||||
const anyPier = !!p && p.entries.some((e) => e.checked && selectable(p, e));
|
||||
const anyAbut = !!p && p.abutments.some((rec) => p.selectedAbutments.has(rec.name) && selectableAbut(p, rec));
|
||||
if (!p || !name || (!anyPier && !anyAbut)) return;
|
||||
resetProjectGround();
|
||||
groundModel = null;
|
||||
groundMapMode = 'none';
|
||||
setGroundMapMode(groundMapMode);
|
||||
state.project = { ...state.project, name, bridge: $<HTMLInputElement>('bridge-name').value.trim() };
|
||||
quantitySelected = null;
|
||||
state.piers = p.entries.filter((e) => e.checked && selectable(p, e));
|
||||
state.abutments = p.abutments.filter((rec) => p.selectedAbutments.has(rec.name) && selectableAbut(p, rec));
|
||||
state.abutDefaults = JSON.parse(JSON.stringify(state.abutments)) as AbutRecord[];
|
||||
@@ -6111,7 +6173,9 @@ $<HTMLButtonElement>('nav-shape').addEventListener('click', () => setStage('shap
|
||||
$<HTMLButtonElement>('nav-plan').addEventListener('click', () => setStage('plan'));
|
||||
$<HTMLButtonElement>('nav-rebar').addEventListener('click', () => setStage('rebar'));
|
||||
$<HTMLButtonElement>('quantity-back').addEventListener('click', () => setStage('rebar'));
|
||||
$<HTMLButtonElement>('rebar-materials').addEventListener('click', openQuantity);
|
||||
$<HTMLButtonElement>('nav-drawing').addEventListener('click', () => setStage('drawing'));
|
||||
$<HTMLButtonElement>('nav-quantity').addEventListener('click', openQuantity);
|
||||
$<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) {
|
||||
|
||||
@@ -13,6 +13,15 @@
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
#project-ground-dialog { width: min(1050px, 94vw); max-height: 88vh; overflow: auto; padding: 20px; border: 1px solid var(--line); border-radius: 8px; color: var(--text); background: var(--panel); }
|
||||
#project-ground-dialog::backdrop { background: #0006; }
|
||||
.project-ground-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
|
||||
#project-ground-dialog fieldset { display: flex; flex-wrap: wrap; gap: 16px; }
|
||||
#project-ground-dialog label { display: flex; align-items: center; gap: 6px; }
|
||||
.project-ground-table { overflow: auto; max-height: 42vh; }
|
||||
.project-ground-table table { min-width: 720px; }
|
||||
#project-ground-dialog > p { font-size: 12px; }
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -248,6 +248,8 @@ export function buildAbutGroup(
|
||||
soles?: BearingSole[],
|
||||
/** 수축줄눈 위치 s (m, core/abut/joints.ts). Absent = the default count between the bearings. */
|
||||
jointS?: number[],
|
||||
/** Another object already draws the shared terrain in this scene. */
|
||||
sharedTerrain = false,
|
||||
): THREE.Group {
|
||||
const root = new THREE.Group();
|
||||
root.name = model.name;
|
||||
@@ -486,13 +488,16 @@ export function buildAbutGroup(
|
||||
// piers); otherwise a flat pad at 지반고 covers the footing and the wings.
|
||||
const footprint = [model.ip1, input3(model), ...wings.flatMap((w) => w.corners)];
|
||||
const grid = terrain && pivot && covers(terrain, footprint, ABUT_GROUND_MARGIN_M) ? terrain : undefined;
|
||||
if (grid && pivot) {
|
||||
const mesh = new THREE.Mesh(terrainGeometry(grid, pivot), new THREE.MeshStandardMaterial({ color: 0x6b8f4e, roughness: 0.9, transparent: true, opacity: 0.35, depthWrite: false, side: THREE.DoubleSide }));
|
||||
if (grid && pivot && !sharedTerrain) {
|
||||
const geometry = terrainGeometry(grid, pivot);
|
||||
// Terrain is already in the common scene frame; cancel the root placement once.
|
||||
geometry.applyMatrix4(abutPlacement(model, pivot).invert());
|
||||
const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial({ color: 0x6b8f4e, roughness: 0.9, transparent: true, opacity: 0.35, depthWrite: false, side: THREE.DoubleSide }));
|
||||
const group = new THREE.Group();
|
||||
group.name = 'ground';
|
||||
group.add(mesh);
|
||||
root.add(group);
|
||||
} else if (groundEl !== undefined) {
|
||||
} else if (!sharedTerrain && groundEl !== undefined) {
|
||||
const ts = [members.shape.footingBackM, members.shape.footingFrontM, ...wings.flatMap((w) => w.corners.map((c) => toAxis(model, c).t))];
|
||||
const tMin = Math.min(...ts) - ABUT_GROUND_MARGIN_M;
|
||||
const tMax = Math.max(...ts) + ABUT_GROUND_MARGIN_M;
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { GroundModelFile } from '../core/ground-model';
|
||||
import type { PierInput } from '../core/types';
|
||||
import { abutGroundFromModel, groundInputChanged } from '../core/project-ground';
|
||||
import type { AbutRecord } from '../core/abut/parse';
|
||||
|
||||
type Snapshot = { model: GroundModelFile | null; piers: { input: PierInput; draft: PierInput }[]; abutments: AbutRecord[] };
|
||||
const node = <K extends keyof HTMLElementTagNameMap>(tag: K, text?: string) => {
|
||||
const e = document.createElement(tag); if (text !== undefined) e.textContent = text; return e;
|
||||
};
|
||||
export function createProjectGroundManager(options: {
|
||||
snapshot: () => Snapshot;
|
||||
importModel: (file: File) => Promise<void>;
|
||||
apply: (names: Set<string>, fields: { ground: boolean; layers: boolean }) => void;
|
||||
regenerate: () => void;
|
||||
}) {
|
||||
const dialog = node('dialog'); dialog.id = 'project-ground-dialog';
|
||||
dialog.setAttribute('aria-labelledby', 'project-ground-title');
|
||||
const header = node('div'); header.className = 'viewer-head';
|
||||
const title = node('h2', '프로젝트 지반모델'); title.id = 'project-ground-title';
|
||||
const close = node('button', '닫기'); close.type = 'button'; close.className = 'btn-o'; close.onclick = () => dialog.close();
|
||||
header.append(title, close);
|
||||
const controls = node('div'); controls.className = 'project-ground-actions';
|
||||
const generate = node('button', '지반모델 생성'); generate.type = 'button'; generate.className = 'btn-o';
|
||||
generate.onclick = () => window.dispatchEvent(new Event('pierzainer:open-ground'));
|
||||
const picker = node('input'); picker.type = 'file'; picker.accept = '.json'; picker.id = 'ground-import'; picker.hidden = true;
|
||||
const load = node('button', '기존 JSON 불러오기'); load.type = 'button'; load.className = 'btn-o'; load.onclick = () => picker.click();
|
||||
const message = node('p'); message.setAttribute('role', 'status'); message.className = 'pnote';
|
||||
picker.onchange = async () => {
|
||||
const file = picker.files?.[0]; if (!file) return;
|
||||
try { await options.importModel(file); message.textContent = `${file.name}에서 지반모델을 불러왔습니다. 기존 입력은 유지됩니다. 적용할 구조물을 선택하세요.`; }
|
||||
catch (error) { message.textContent = `${file.name}: ${(error as Error).message}`; }
|
||||
finally { picker.value = ''; }
|
||||
};
|
||||
controls.append(generate, load, picker);
|
||||
const info = node('p'); info.id = 'project-ground-info'; info.className = 'pnote';
|
||||
const fields = node('fieldset'); const legend = node('legend', '선택 구조물에 적용할 값'); fields.append(legend);
|
||||
const ground = node('input'); ground.type = 'checkbox'; ground.checked = true; ground.id = 'project-ground-el';
|
||||
const layers = node('input'); layers.type = 'checkbox'; layers.id = 'project-ground-layers';
|
||||
const gl = node('label'); gl.append(ground, ' 지반고'); const ll = node('label'); ll.append(layers, ' 지층 (기존 지층·N값 대체)'); fields.append(gl, ll);
|
||||
const scroll = node('div'); scroll.className = 'project-ground-table';
|
||||
const table = node('table'); table.className = 'grid-table'; scroll.append(table);
|
||||
const actions = node('div'); actions.className = 'project-ground-actions';
|
||||
const all = node('button', '적용 가능한 구조물 선택'); all.type = 'button'; all.className = 'btn-o'; all.id = 'project-ground-select-all';
|
||||
const apply = node('button', '선택 구조물에 적용'); apply.type = 'button'; apply.id = 'project-ground-apply'; apply.className = 'btn';
|
||||
const regenerate = node('button'); regenerate.type = 'button'; regenerate.id = 'project-ground-regenerate'; regenerate.className = 'btn-o'; regenerate.onclick = options.regenerate;
|
||||
const selected = new Set<string>();
|
||||
let lastModel: GroundModelFile | null = null;
|
||||
const rows = () => {
|
||||
const snap = options.snapshot();
|
||||
return [
|
||||
...snap.abutments.map(a => ({ key: `abut:${a.name}`, name: a.name, kind: '교대', current: a.groundEl, layers: a.ground?.strataRows, pending: false, entry: snap.model ? abutGroundFromModel(snap.model, a) : undefined })),
|
||||
...snap.piers.map(p => ({ key: `pier:${p.input.name}`, name: p.input.name, kind: '교각', current: p.draft.groundEl, layers: p.draft.strataRows, pending: groundInputChanged(p.input, p.draft), entry: snap.model?.piers.find(e => e.id === p.input.name) ? { ...snap.model.piers.find(e => e.id === p.input.name)!, source: 'JSON 개별값' } : undefined })),
|
||||
];
|
||||
};
|
||||
const eligible = (key: string) => {
|
||||
const entry = rows().find(p => p.key === key)?.entry;
|
||||
return !!entry && ((ground.checked && entry.centerEl !== null) || (layers.checked && !!entry.layers?.length));
|
||||
};
|
||||
const update = () => { apply.disabled = ![...selected].some(eligible); };
|
||||
all.onclick = () => { for (const p of rows()) if (eligible(p.key)) selected.add(p.key); refresh(); };
|
||||
// Keep the message outside refresh so applying a model retains the result notice.
|
||||
apply.onclick = () => { options.apply(new Set([...selected].filter(eligible)), { ground: ground.checked, layers: layers.checked }); message.textContent = '선택한 값을 적용했습니다. 교대는 즉시 갱신되며, 변경 교각은 다시 생성하세요.'; };
|
||||
ground.onchange = layers.onchange = () => refresh();
|
||||
function refresh() {
|
||||
const { model, piers } = options.snapshot();
|
||||
if (model !== lastModel) { selected.clear(); lastModel = model; }
|
||||
const items = rows();
|
||||
document.getElementById('project-ground-status')!.textContent = model ? `불러옴 · 구조물 ${items.filter(p => p.entry?.centerEl != null).length}/${items.length}기 연결` : '모델 없음';
|
||||
if (!dialog.open) return;
|
||||
info.textContent = model ? `생성일 ${model.generated || '정보 없음'} · 모델 ${model.piers.length}기 · 지형 ${model.terrainGrid ? '있음' : '없음'} · 지도 ${model.mapLayers?.length ?? 0}개. 프로젝트에 함께 저장됩니다.` : '지반모델 JSON 또는 지반모델이 포함된 프로젝트 JSON을 불러온 뒤 적용할 교각을 선택하세요. 프로젝트 JSON에서는 지반모델만 가져옵니다. 생성 설정 JSON은 사용할 수 없습니다.';
|
||||
table.replaceChildren();
|
||||
const head = node('thead'), hr = node('tr'); for (const label of ['선택', '구조물', '현재 입력 EL (m)', '모델 EL (m)', '차이 (m)', '모델 지층', '상태', '종류', '값의 출처']) hr.append(node('th', label)); head.append(hr); table.append(head);
|
||||
const body = node('tbody');
|
||||
for (const p of items) {
|
||||
const entry = p.entry, value = entry?.centerEl;
|
||||
const row = node('tr'), cell = node('td'), check = node('input'); check.type = 'checkbox'; check.dataset.groundPier = p.name; check.dataset.groundKind = p.kind; check.setAttribute('aria-label', `${p.name} ${p.kind} 적용 선택`); check.disabled = !eligible(p.key); check.checked = selected.has(p.key) && !check.disabled;
|
||||
check.onchange = () => { if (check.checked) selected.add(p.key); else selected.delete(p.key); update(); }; cell.append(check); row.append(cell);
|
||||
let status = !model ? '모델 없음' : !entry ? '모델에 없음' : value === null ? '지형 범위 밖/자료 없음' : p.pending ? '형상 재생성 필요' : value === p.current ? '지반고 일치' : '지반고 차이';
|
||||
if (entry?.layers?.length && JSON.stringify(entry.layers) !== JSON.stringify(p.layers ?? [])) status += ' · 지층 차이';
|
||||
for (const text of [p.name, p.current.toFixed(3), value == null ? '—' : value.toFixed(3), value == null ? '—' : (value - p.current).toFixed(3), `${entry?.layers?.length ?? 0}개`, status, p.kind, entry?.source ?? '—']) row.append(node('td', text)); body.append(row);
|
||||
}
|
||||
table.append(body);
|
||||
const count = piers.filter(p => groundInputChanged(p.input, p.draft)).length;
|
||||
regenerate.textContent = `변경 교각 다시 생성 (${count}기)`; regenerate.disabled = count === 0;
|
||||
all.disabled = !items.some(p => eligible(p.key)); update();
|
||||
}
|
||||
actions.append(all, apply, regenerate);
|
||||
dialog.append(header, controls, info, fields, scroll, actions, message, node('p', '교대는 JSON 개별값이 없으면 IP2 위치의 유효 격자 4점으로 지반고를 보간합니다. 범위 밖·자료 없음은 적용하지 않으며 지층은 개별 자료만 사용합니다. 교대는 적용 즉시 갱신됩니다. 교각은 다시 생성할 때 다른 수정 입력도 함께 반영됩니다.'));
|
||||
document.body.append(dialog);
|
||||
document.getElementById('project-ground-open')!.onclick = () => { dialog.showModal(); refresh(); };
|
||||
return { refresh, reset: () => { selected.clear(); ground.checked = true; layers.checked = false; message.textContent = ''; dialog.close(); } };
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
import * as THREE from 'three';
|
||||
import type { TerrainGrid } from '../core/ground-model';
|
||||
|
||||
export function hasTerrainSurface(grid: TerrainGrid): boolean {
|
||||
for (let j = 0; j < grid.ny - 1; j++) for (let i = 0; i < grid.nx - 1; i++) {
|
||||
const k = j * grid.nx + i;
|
||||
if ([grid.z[k], grid.z[k + 1], grid.z[k + grid.nx], grid.z[k + grid.nx + 1]].every(h => h != null)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terrain grid → mesh in scene coordinates. The scene origin is the pier centre (pivot), with y up:
|
||||
* JSON 격자는 X 열(nx), Y 행(ny)이다. 내부 X/Y 축 교환을 적용해 x = rawY − pivot.e, z = −(rawX − pivot.n)로 놓는다.
|
||||
|
||||
Reference in new issue
Block a user