diff --git a/index.html b/index.html index 3f8b71a..32ca4fc 100644 --- a/index.html +++ b/index.html @@ -3,55 +3,173 @@ - 교각 3D 형상 생성 (1단계) + Pierzainer — 형상 생성 -
-

교각 3D 형상 생성 ① 형상 생성 (1단계)

-
- - +
+
+
+ 교각 설계 + 프로젝트 미지정 +
+ +
+ + +
+
+ +

엑셀을 불러오면 교각 형상을 생성합니다.

+ +
+ + +
+
+

교각 3D 뷰

+
+ +
+
+
+ 부재 표시 +
+
+
+ + +
+ +
+

입력값

+
+
+
+ + +
+

엑셀 불러오기

+
    +
  1. 1 파일 선택
  2. +
  3. 2 인식 결과 · 미리보기
  4. +
  5. 3 형상 생성
  6. +
+ +
+ +
+
+
+

프로젝트

+ + +
+ +
+

파일

+
+

다른 .xlsx 파일을 끌어놓거나

+
+ + +
+ +
+

+
+ +
+

시트별 인식 결과

+
    +
    + +
    +

    작성 규칙

    +
      +
    • 교각 1기 = 시트 1장을 기본으로 하며, 한 시트에 여러 기를 넣어도 됩니다.
    • +
    • 표는 머리글 문자열로 찾으므로 위치는 자유입니다.
    • +
    • 수식 셀은 계산된 값을 읽습니다.
    • +
    • 방위각은 75°56'55.9" 형식으로 적습니다.
    • +
    +

    엑셀 열 이름 → 화면 용어

    + + + + + + + +
    두부보코핑
    대좌블럭받침블럭
    저판확대기초
    받침교량받침
    +
    +
    + +
    +
    +

    미리보기

    +

    +

    교각 정보

    +
    +

    받침

    + + + +
    번호x (m)y (m)z (m)방위각위치
    +

    IP

    + + + +
    점x (m)y (m)꺾임각
    +
    +
    -
    -
    - - -
    -
    -
    - -
    -

    엑셀 시트 인식 결과

    - -

    계산 결과

    -
    -

    알림

    - -

    기본값 적용 · 미구현

    - -
    -
    + + diff --git a/src/main.ts b/src/main.ts index d0470ce..cb15382 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,36 +5,69 @@ import * as XLSX from 'xlsx'; import { parseWorkbook, type SheetReport } from './core/parseWorkbook'; import { buildPier, type PierModel } from './core/geometry'; import { NOT_IMPLEMENTED_RULES, validateInput, validatePier } from './core/validate'; -import type { Issue, PierInput } from './core/types'; -import { buildPierScene, PART_LABELS, viewPosition, type PartName, type PierScene, type ViewKind } from './view/scene'; +import type { Issue, PierInput, PierType } from './core/types'; +import { buildPierScene, viewPosition, type PartName, type PierScene, type ViewKind } from './view/scene'; -interface Loaded { +/** One pier as shown in the list. `checked` is the multi-select state (시안 ①). */ +interface Pier { input: PierInput; model: PierModel | null; issues: Issue[]; + checked: boolean; } +/** Part toggles in the order of 시안 ① "부재 표시". Each toggle controls one or more scene parts. */ +const PART_GROUPS: { label: string; parts: PartName[] }[] = [ + { label: '받침·받침블럭', parts: ['blocks', 'pads'] }, + { label: '코핑', parts: ['coping'] }, + { label: '기둥', parts: ['columns'] }, + { label: '확대기초', parts: ['footings'] }, + { label: '말뚝', parts: ['piles'] }, + { label: '버림', parts: ['blinding'] }, + { label: '지반면', parts: ['ground'] }, +]; + +const VIEWS: { kind: ViewKind; label: string }[] = [ + { kind: 'iso', label: '아이소' }, + { kind: 'front', label: '정면' }, + { kind: 'side', label: '측면' }, + { kind: 'top', label: '평면' }, +]; + +const TYPES: PierType[] = ['T형', '2주형', '3주형']; + +interface Pending { + fileName: string; + entries: Pier[]; + sheets: SheetReport[]; + preview: string | null; +} + +const state = { + piers: [] as Pier[], + active: null as string | null, + view: 'iso' as ViewKind, + visible: Object.fromEntries(PART_GROUPS.flatMap((g) => g.parts).map((p) => [p, true])) as Record, + scene: null as PierScene | null, + project: { name: '', bridge: '' }, + filter: 'all' as 'all' | PierType, + pending: null as Pending | null, +}; + const $ = (id: string): T => document.getElementById(id) as T; -const VIEW_LABELS: Record = { iso: '아이소', front: '정면', side: '측면', top: '평면' }; +function el(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; +} -const state: { - piers: Loaded[]; - parseIssues: Issue[]; - sheets: SheetReport[]; - current: { loaded: Loaded; scene: PierScene } | null; - view: ViewKind; - visible: Record; -} = { - piers: [], - parseIssues: [], - sheets: [], - current: null, - view: 'iso', - visible: Object.fromEntries(Object.keys(PART_LABELS).map((k) => [k, true])) as Record, -}; +function setStatus(msg: string): void { + $('status').textContent = msg; +} -// --- 3D renderer ----------------------------------------------------------- +// --- 3D renderer ------------------------------------------------------------- const container = $('canvas'); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); @@ -52,6 +85,7 @@ 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(); @@ -66,37 +100,57 @@ function animate(): void { } animate(); -// --- UI builders (textContent only: workbook text is never parsed as HTML) ------- -function el(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; +// --- pier model ---------------------------------------------------------------- +function buildEntry(input: PierInput, checked: boolean): Pier { + const issues: Issue[] = []; + let model: PierModel | null = null; + if (validateInput(input, issues)) { + try { + model = buildPier(input); + validatePier(input, model, issues); + } catch (err) { + issues.push({ code: '미실시', level: 'E', pier: input.name, message: (err as Error).message }); + } + } + return { input, model, issues, checked }; } +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 ------------------------------------------------------------- function buildPartToggles(): void { const box = $('parts'); - for (const key of Object.keys(PART_LABELS) as PartName[]) { + 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.checked = state.visible[key]; + input.checked = group.parts.every((p) => state.visible[p]); input.addEventListener('change', () => { - state.visible[key] = input.checked; - if (state.current) state.current.scene.parts[key].visible = input.checked; + for (const p of group.parts) { + state.visible[p] = input.checked; + if (state.scene) state.scene.parts[p].visible = input.checked; + } }); - label.append(input, document.createTextNode(PART_LABELS[key])); - box.appendChild(label); + label.append(input, document.createTextNode(group.label)); + row.appendChild(label); } + box.appendChild(row); } function buildViewButtons(): void { const box = $('views'); - for (const kind of Object.keys(VIEW_LABELS) as ViewKind[]) { - const btn = el('button', VIEW_LABELS[kind]); + for (const v of VIEWS) { + const btn = el('button', v.label, 'btn'); btn.type = 'button'; - btn.setAttribute('aria-pressed', String(kind === state.view)); - btn.addEventListener('click', () => setView(kind)); + btn.setAttribute('aria-pressed', String(v.kind === state.view)); + btn.addEventListener('click', () => setView(v.kind)); box.appendChild(btn); } } @@ -104,188 +158,421 @@ function buildViewButtons(): void { function setView(kind: ViewKind): void { state.view = kind; document.querySelectorAll('#views button').forEach((b, i) => { - b.setAttribute('aria-pressed', String(Object.keys(VIEW_LABELS)[i] === kind)); + b.setAttribute('aria-pressed', String(VIEWS[i].kind === kind)); }); - if (!state.current) return; - const { position, target } = viewPosition(state.current.scene, kind, state.current.loaded.model!); + if (!state.scene) return; + const pier = activePier(); + if (!pier?.model) return; + const { position, target } = viewPosition(state.scene, kind, pier.model); camera.position.copy(position); controls.target.copy(target); controls.update(); } -// --- Loading ---------------------------------------------------------------------- -function loadWorkbookBytes(bytes: Uint8Array): void { - const { piers, issues: parseIssues, sheets } = parseWorkbook(XLSX.read(bytes, { type: 'array' })); - const loaded: Loaded[] = piers.map((input) => { - const issues: Issue[] = []; - let model: PierModel | null = null; - if (validateInput(input, issues)) { - try { - model = buildPier(input); - validatePier(input, model, issues); - } catch (err) { - issues.push({ code: '미실시', level: 'E', pier: input.name, message: (err as Error).message }); - } +function setupScene(): void { + if (state.scene) world.remove(state.scene.root); + state.scene = null; + const pier = activePier(); + if (pier?.model) { + const scene = buildPierScene(pier.model); + for (const key of Object.keys(state.visible) as PartName[]) { + scene.parts[key].visible = state.visible[key]; } - return { input, model, issues }; - }); - state.piers = loaded; - state.parseIssues = parseIssues; - state.sheets = sheets; - renderSheets(); - - // Only piers from sheets without E-level issues can be selected (spec §2.3). - const selectable = (l: Loaded) => !!l.model && sheets.find((s) => s.name === l.input.sheet)?.ok === true; - const select = $('pier'); - select.replaceChildren(); - for (const item of loaded.filter(selectable)) { - select.appendChild(new Option(item.input.name, item.input.name)); + world.add(scene.root); + state.scene = scene; + setView(state.view); } - select.disabled = select.options.length === 0; - const built = loaded.filter(selectable).length; - $('status').textContent = `시트 ${sheets.length}장 · 교각 ${piers.length}기 인식 · 선택 가능 ${built}기`; - const first = loaded.find(selectable); - if (first) showPier(first.input.name); - else showPier(null); } -function showPier(name: string | null): void { - const select = $('pier'); - if (state.current) world.remove(state.current.scene.root); - state.current = null; - if (!name) { - renderResults(null); - return; +// --- main list (시안 ① 교각 목록) ---------------------------------------------------- +function renderPierList(): void { + const select = $('type-filter'); + if (select.options.length === 0) { + select.appendChild(new Option('전체', 'all')); + for (const t of TYPES) select.appendChild(new Option(t, t)); } - const loaded = state.piers.find((l) => l.input.name === name && l.model); - if (!loaded || !loaded.model) return; - const scene = buildPierScene(loaded.model); - for (const key of Object.keys(PART_LABELS) as PartName[]) { - scene.parts[key].visible = state.visible[key]; + 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})`; } - world.add(scene.root); - state.current = { loaded, scene }; - select.value = name; - setView(state.view); - renderResults(loaded); + + const list = $('pier-items'); + list.replaceChildren(); + const visible = state.piers.filter((p) => state.filter === 'all' || p.input.type === state.filter); + $('pier-empty').hidden = state.piers.length > 0; + for (const pier of visible) { + const li = el('li'); + const check = el('input'); + check.type = 'checkbox'; + check.checked = pier.checked; + check.disabled = !pier.model; + check.setAttribute('aria-label', `${pier.input.name} 선택`); + check.addEventListener('change', () => { + pier.checked = check.checked; + renderAll(); + }); + const name = el('button', pier.input.name, 'pier-name'); + name.type = 'button'; + name.setAttribute('aria-pressed', String(pier.input.name === state.active)); + name.addEventListener('click', () => { + state.active = pier.input.name; + setupScene(); + renderAll(); + }); + const badge = pier.model + ? el('span', `${pier.input.type}${pier.issues.some((i) => i.level !== 'I') ? ' · 경고' : ''}`, 'badge') + : el('span', '오류', 'badge error'); + li.append(check, name, badge); + list.appendChild(li); + } + + const allChecked = visible.length > 0 && visible.every((p) => p.checked); + $('select-all').textContent = allChecked ? '선택 해제' : '전체 선택'; } -// --- Results ------------------------------------------------------------------------ -function row(table: HTMLTableSectionElement, label: string, value: string): void { - const tr = el('tr'); - tr.append(el('td', label), el('td', value)); - table.appendChild(tr); +function renderHeader(): void { + const pier = activePier(); + const parts = [state.project.name || '프로젝트 미지정']; + if (state.project.bridge) parts.push(state.project.bridge); + parts.push(`교각 ${state.piers.length}기`); + $('project-line').textContent = parts.join(' · '); + $('step1-status').textContent = pier?.model ? '생성됨' : '미생성'; + $('regen').disabled = !state.piers.some((p) => p.checked); } -const fmt = (v: number, d = 3) => v.toFixed(d); +// --- results panel (시안 ① 입력값 · 계산 결과) ---------------------------------------- +type Pair = [string, string]; + +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 renderResults(loaded: Loaded | null): void { - const body = $('summary').tBodies[0] ?? $('summary').createTBody(); - body.replaceChildren(); - const issuesBox = $('issues'); - issuesBox.replaceChildren(); +function renderPanels(): void { + const pier = activePier(); + const m = pier?.model; + $('viewer-title').textContent = pier ? `${pier.input.name} · ${pier.input.type} 교각 형상` : '교각 3D 뷰'; + $('origin').textContent = m ? `원점 = 교각 중심 (${pt(m.pivot)})` : ''; - const m = loaded?.model; - if (m && loaded) { - row(body, '교각', loaded.input.name); - row(body, '교각축 길이 L_IP (m)', fmt(m.lengthIp)); - row(body, '교각축 방위각 (°)', fmt(m.azimuthDeg, 2)); - row(body, 'BP1 (국토좌표 m)', pt(m.bp1)); - row(body, 'BP2 (국토좌표 m)', pt(m.bp2)); - row(body, 'd_IP2 (m)', fmt(m.dIp2)); - row(body, '기둥 중심 (국토좌표 m)', pt(m.pivot)); - row(body, '코핑 상면 EL 기준 (m)', fmt(loaded.input.copingTopEl)); - row(body, '전열 구간 상면 EL (m)', m.segments.map((s) => fmt(s.top)).join(' / ')); - row(body, '계단 경계 (BP1 기준, m)', m.segments.slice(1).map((s) => fmt(s.s0)).join(' / ')); - row(body, '횡방향 단차', '없음'); - row(body, '코핑 끝단 하면 EL (m)', fmt(m.endBottom)); - row(body, '코핑 Level 하면 EL (m)', fmt(m.levelBottom)); - row(body, '기둥 높이 (m)', fmt(m.derived.columnHeight)); - row(body, '확대기초 상면 EL (m)', m.footings.length ? fmt(m.footings[0].top) : '없음'); - row(body, '흙덮개 (m)', fmt(m.derived.soilCover)); - row(body, '말뚝 수 (본)', String(m.derived.pileCount)); - row(body, '말뚝 배치', m.derived.pileLayout ?? '없음'); - row(body, '말뚝 선단 EL (m)', m.derived.pileTipEl === null ? '없음' : fmt(m.derived.pileTipEl)); - row(body, '받침블럭 콘크리트 전열 / 후열 (mm)', `${Math.round(m.derived.blockHeightFront * 1000)} / ${Math.round(m.derived.blockHeightBack * 1000)}`); + 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 { - row(body, '상태', '교각을 불러오면 결과가 표시됩니다.'); + 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 allIssues = [...state.parseIssues, ...state.piers.flatMap((l) => l.issues)]; - if (allIssues.length === 0) { - issuesBox.appendChild(el('li', '알림 없음')); - } - for (const issue of allIssues) { + const list = $('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'), document.createTextNode(` ${issue.pier ? `[${issue.pier}] ` : ''}${issue.message}`)); - issuesBox.appendChild(li); + li.append(el('span', issue.code, `code ${issue.level}`), document.createTextNode(` ${issue.message}`)); + list.appendChild(li); } + $('issue-count').textContent = `(${pier.issues.length}건)`; +} - const defaults = $('defaults'); - defaults.replaceChildren(); +function renderDefaults(): void { + const box = $('defaults'); + box.replaceChildren(); const items = [ '[미확정] Q16 받침 EL·E 식: 현재 모델(블럭 높이 = EL 차) 적용', '[미확정] Q5 말뚝 지름 600 mm (강관) 기본값', '[미확정] Q4 직접기초 버림콘크리트 적용', - '횡방향 단차: 없을 때만 생성 (있으면 미실시 알림)', ...NOT_IMPLEMENTED_RULES.map((r) => `미구현 규칙: ${r}`), ]; - for (const text of items) defaults.appendChild(el('li', text)); + for (const text of items) box.appendChild(el('li', text)); } -function renderSheets(): void { - const box = $('sheets'); - box.replaceChildren(); - if (state.sheets.length === 0) { - box.appendChild(el('li', '엑셀을 불러오면 시트별 인식 결과가 표시됩니다.')); +function renderAll(): void { + renderHeader(); + renderPierList(); + renderPanels(); +} + +// --- import dialog (시안 ④) ------------------------------------------------------------ +function selectable(p: Pending, e: Pier): boolean { + return !!e.model && sheetOk(p.sheets, e.input.sheet); +} + +function renderDialog(): void { + const p = state.pending; + const sheetsBox = $('sheets'); + sheetsBox.replaceChildren(); + $('file-name').textContent = p ? `선택된 파일: ${p.fileName}` : ''; + if (!p) { + sheetsBox.appendChild(el('li', '파일을 선택하면 시트별 인식 결과가 표시됩니다.')); + renderPreview(null); + updateApply(); return; } - for (const s of state.sheets) { + + for (const sheet of p.sheets) { const li = el('li'); - const status = s.ok ? '선택 가능' : '선택 불가 (오류)'; - const head = el('span', `${s.name} · ${status}`, s.ok ? 'sheet-ok' : 'code'); - const detail = document.createTextNode(` · 교각 ${s.tables.pier}표 · IP ${s.tables.ip}표 · 받침 ${s.tables.bearing}표`); - li.append(head, detail); - box.appendChild(li); - for (const issue of s.issues) { - const sub = el('li', `${issue.code} ${issue.pier ? `[${issue.pier}] ` : ''}${issue.message}`, issue.level === 'E' ? 'code' : undefined); - sub.style.marginLeft = '16px'; - box.appendChild(sub); + const head = el('div', undefined); + head.append( + el('span', `${sheet.name} · ${sheet.ok ? '선택 가능' : '선택 불가 (오류)'}`, 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.disabled = !selectable(p, entry); + check.setAttribute('aria-label', `${entry.input.name} 불러오기`); + check.addEventListener('change', () => { + entry.checked = check.checked; + updateApply(); + }); + const name = el('button', `${entry.input.name} · ${entry.input.type}`, 'pier-name'); + name.type = 'button'; + name.setAttribute('aria-pressed', String(p.preview === entry.input.name)); + name.addEventListener('click', () => { + p.preview = entry.input.name; + renderDialog(); + }); + row.append(check, name); + li.appendChild(row); + } + sheetsBox.appendChild(li); + } + const preview = p.entries.find((e) => e.input.name === p.preview) ?? p.entries.find((e) => selectable(p, e)) ?? null; + renderPreview(preview ? preview.input : null); + updateApply(); +} + +function renderPreview(input: PierInput | null): void { + const title = $('preview-title'); + const pierTable = $('pv-pier').tBodies[0]; + const bearingBody = $('pv-bearings').tBodies[0]; + const ipBody = $('pv-ips').tBodies[0]; + pierTable.replaceChildren(); + bearingBody.replaceChildren(); + ipBody.replaceChildren(); + if (!input) { + title.textContent = '미리볼 교각이 없습니다.'; + return; + } + title.textContent = `${input.name} · 시트 ${input.sheet}`; + const pairs: Pair[] = [ + ['교각명', input.name], + ['교각형식', 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)], + ]; + for (const [k, v] of pairs) { + const tr = el('tr'); + tr.append(el('th', k), el('td', v)); + pierTable.appendChild(tr); + } + for (const b of input.bearings) { + const tr = el('tr'); + const azimuth = Number.isNaN(b.azimuthDeg) ? '오류' : `${b.azimuthDeg.toFixed(4)}°`; + for (const v of [String(b.no), b.x.toFixed(3), b.y.toFixed(3), f3(b.z), azimuth, b.row]) tr.appendChild(el('td', v)); + bearingBody.appendChild(tr); + } + for (const ip of input.ips) { + const tr = el('tr'); + for (const v of [ip.name, ip.x.toFixed(3), ip.y.toFixed(3), `${ip.angleDeg}°`]) tr.appendChild(el('td', v)); + ipBody.appendChild(tr); } } -// --- Events ----------------------------------------------------------------------- -$('sample').addEventListener('click', async () => { - $('status').textContent = '샘플 불러오는 중…'; - try { - const res = await fetch(new URL('samples/P1.xlsx', document.baseURI)); - loadWorkbookBytes(new Uint8Array(await res.arrayBuffer())); - } catch (err) { - $('status').textContent = `샘플을 불러오지 못했습니다: ${(err as Error).message}`; - } -}); +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; + $('dlg-selected').textContent = `선택 ${count} / ${total}기`; + const named = $('project-name').value.trim().length > 0; + const apply = $('dlg-apply'); + apply.textContent = `선택한 교각 ${count}기 형상 생성`; + apply.disabled = count === 0 || !named; +} +function loadBytes(bytes: Uint8Array, fileName: string): void { + const { piers, sheets } = parseWorkbook(XLSX.read(bytes, { type: 'array' })); + const entries = piers.map((input) => buildEntry(input, false)); + const pending: Pending = { fileName, entries, sheets, preview: null }; + for (const e of entries) e.checked = selectable(pending, e); + pending.preview = entries.find((e) => selectable(pending, e))?.input.name ?? entries[0]?.input.name ?? null; + state.pending = pending; + renderDialog(); +} + +function applyImport(): void { + const p = state.pending; + const name = $('project-name').value.trim(); + if (!p || !name) return; + state.project = { name, bridge: $('bridge-name').value.trim() }; + state.piers = p.entries.filter((e) => e.checked && selectable(p, e)); + state.active = state.piers[0]?.input.name ?? null; + state.filter = 'all'; + state.pending = null; + $('import-dialog').close(); + setupScene(); + renderAll(); + setStatus(`${state.piers.length}기 형상을 생성했습니다. 정보·경고는 오른쪽 알림에서 확인하세요.`); +} + +function openImport(): void { + const dlg = $('import-dialog'); + if (!dlg.open) dlg.showModal(); + $('project-name').focus(); +} + +async function fetchSample(): Promise { + 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()); +} + +function regenerateChecked(): void { + let errors = 0; + let count = 0; + state.piers = state.piers.map((p) => { + if (!p.checked) return p; + count++; + const next = buildEntry(p.input, true); + if (!next.model) errors++; + return next; + }); + setupScene(); + renderAll(); + setStatus(`${count}기 형상을 생성했습니다.${errors ? ` 오류 ${errors}기는 생성되지 않았습니다.` : ''}`); +} + +// --- events ----------------------------------------------------------------------------- +$('open-import').addEventListener('click', openImport); +$('dlg-close').addEventListener('click', () => $('import-dialog').close()); +$('dlg-cancel').addEventListener('click', () => $('import-dialog').close()); +$('dlg-apply').addEventListener('click', applyImport); +$('regen').addEventListener('click', regenerateChecked); +$('project-name').addEventListener('input', updateApply); + +$('pick-file').addEventListener('click', () => $('file').click()); $('file').addEventListener('change', async (ev) => { const file = (ev.target as HTMLInputElement).files?.[0]; if (!file) return; - $('status').textContent = `${file.name} 읽는 중…`; try { - loadWorkbookBytes(new Uint8Array(await file.arrayBuffer())); + loadBytes(new Uint8Array(await file.arrayBuffer()), file.name); } catch (err) { - $('status').textContent = `엑셀을 읽지 못했습니다: ${(err as Error).message}`; + setStatus(`엑셀을 읽지 못했습니다: ${(err as Error).message}`); + } +}); +$('pick-sample').addEventListener('click', async () => { + try { + loadBytes(await fetchSample(), '샘플 P1 (Pier ui_xxx.xlsx)'); + } catch (err) { + setStatus(`샘플을 불러오지 못했습니다: ${(err as Error).message}`); } }); -$('pier').addEventListener('change', (ev) => showPier((ev.target as HTMLSelectElement).value)); +const drop = $('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; + try { + loadBytes(new Uint8Array(await file.arrayBuffer()), file.name); + } catch (err) { + setStatus(`엑셀을 읽지 못했습니다: ${(err as Error).message}`); + } +}); +$('type-filter').addEventListener('change', (ev) => { + state.filter = (ev.target as HTMLSelectElement).value as 'all' | PierType; + renderPierList(); +}); +$('select-all').addEventListener('click', () => { + const visible = state.piers.filter((p) => (state.filter === 'all' || p.input.type === state.filter) && p.model); + const all = visible.every((p) => p.checked); + for (const p of visible) p.checked = !all; + renderAll(); +}); + +// --- start ------------------------------------------------------------------------------ buildPartToggles(); buildViewButtons(); -renderSheets(); -renderResults(null); +renderDefaults(); +renderDialog(); +renderAll(); -// Deep link for the bundled sample: open with ?sample=1 to load P1 without clicking. +// Deep link for the bundled sample: ?sample=1 loads P1 directly and skips the dialog. if (new URLSearchParams(window.location.search).get('sample') === '1') { - $('sample').click(); + fetchSample() + .then((bytes) => { + loadBytes(bytes, '샘플 P1'); + $('project-name').value = '운곡교 P1 샘플'; + $('bridge-name').value = '운곡교'; + applyImport(); + }) + .catch((err: Error) => setStatus(`샘플을 불러오지 못했습니다: ${err.message}`)); } diff --git a/src/style.css b/src/style.css index c194302..fde2c94 100644 --- a/src/style.css +++ b/src/style.css @@ -1,25 +1,16 @@ +/* Tokens from 시안 (RAG/Pierzainer UI 시안 (1).html): background, primary action, dimension colour. */ :root { - --bg: #f5f7fa; + --bg: #faf9f5; --panel: #ffffff; --text: #1d2733; --muted: #5b6878; - --line: #d5dce4; + --line: #d9d6cc; --primary: #1f5fa8; - --warn: #9a5b00; + --dim: #4a5868; + --warn: #8a5300; + --error: #9b2c2c; --focus: 2px solid #1f5fa8; - color-scheme: light dark; -} - -@media (prefers-color-scheme: dark) { - :root:not([data-theme='light']) { - --bg: #111820; - --panel: #1a232d; - --text: #e6ebf0; - --muted: #9aa7b4; - --line: #2d3a47; - --primary: #5b93d6; - --warn: #e0a24a; - } + color-scheme: light; } * { @@ -33,172 +24,341 @@ body { font: 14px/1.5 system-ui, 'Noto Sans KR', sans-serif; } -button, -select, -input, -label { - font: inherit; +h2 { + font-size: 16px; + margin: 0 0 10px; +} + +h3 { + font-size: 14px; + margin: 0 0 6px; +} + +h4 { + font-size: 13px; + margin: 12px 0 6px; + color: var(--muted); +} + +.muted { + color: var(--muted); +} + +.small { + font-size: 12px; } button, select, -.file { +input { + font: inherit; min-height: 44px; } button:focus-visible, select:focus-visible, -input:focus-visible { +input:focus-visible, +summary:focus-visible { outline: var(--focus); outline-offset: 2px; } +.btn { + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel); + color: var(--text); + padding: 0 14px; + cursor: pointer; +} + +.btn.small { + min-height: 44px; + padding: 0 10px; +} + +.btn.primary { + background: var(--primary); + border-color: var(--primary); + color: #fff; +} + +.btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.btn[aria-pressed='true'] { + border-color: var(--primary); + color: var(--primary); + font-weight: 600; +} + +/* --- header ------------------------------------------------------------- */ + +.app { + min-height: 100vh; + display: flex; + flex-direction: column; +} + .top { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; - padding: 12px 16px; - border-bottom: 1px solid var(--line); + padding: 10px 16px; background: var(--panel); + border-bottom: 1px solid var(--line); } -.top h1 { - font-size: 18px; - margin: 0; +.brand { + display: flex; + gap: 10px; + align-items: baseline; } -.stage { - font-size: 13px; +.brand strong { + font-size: 16px; +} + +.steps { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.step { + padding: 8px 12px; + border: 1px solid var(--line); + border-radius: 6px; color: var(--muted); +} + +.step.current { + border-color: var(--primary); + color: var(--text); + font-weight: 600; +} + +.step em { + font-style: normal; font-weight: 400; - margin-left: 8px; + color: var(--muted); + margin-left: 4px; } .actions { display: flex; gap: 8px; - align-items: center; flex-wrap: wrap; } -.primary { - background: var(--primary); - color: #fff; - border: 0; - border-radius: 6px; - padding: 0 14px; - cursor: pointer; +.status-line { + margin: 0; + padding: 8px 16px; + color: var(--muted); + font-size: 13px; } -.file { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 0 10px; - border: 1px solid var(--line); - border-radius: 6px; - cursor: pointer; -} +/* --- layout ------------------------------------------------------------- */ -.file input { - font-size: 12px; - max-width: 220px; -} - -.layout { +.grid { display: grid; - grid-template-columns: 240px 1fr 320px; + grid-template-columns: 260px minmax(0, 1fr) 320px; gap: 12px; - padding: 12px 16px; - min-height: calc(100vh - 80px); + padding: 0 16px 12px; + flex: 1; } -.side, -.results, -.viewer { +.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 12px; + min-width: 0; } -.side section, -.results section { - margin-bottom: 16px; +.inputs { + margin: 0 16px 16px; } -h2 { - font-size: 15px; - margin: 0 0 8px; +/* --- pier list ---------------------------------------------------------- */ + +.pier-tools { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 8px; + flex-wrap: wrap; } -.muted { - color: var(--muted); +.pier-tools label { + display: flex; + gap: 6px; + align-items: center; } select { - width: 100%; - padding: 6px; + padding: 4px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); color: var(--text); } -.parts label, -.views button { - display: flex; +.pier-items { + list-style: none; + padding: 0; + margin: 0; + max-height: 70vh; + overflow: auto; +} + +.pier-items li { + display: grid; + grid-template-columns: 28px 1fr auto; align-items: center; - gap: 8px; - min-height: 44px; + gap: 6px; + border-bottom: 1px solid var(--line); + padding: 2px 0; +} + +.pier-items input[type='checkbox'] { + width: 28px; + height: 28px; + margin: 8px; +} + +.pier-name { + border: 0; + background: none; + text-align: left; + color: var(--text); + cursor: pointer; + padding: 0 4px; +} + +.pier-name[aria-pressed='true'] { + color: var(--primary); + font-weight: 700; +} + +.badge { + font-size: 12px; + color: var(--muted); +} + +.badge.error { + color: var(--error); + font-weight: 600; +} + +/* --- viewer ------------------------------------------------------------- */ + +.viewer-head { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + margin-bottom: 8px; +} + +.viewer-head h2 { + margin: 0; } .views { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px; + display: flex; + gap: 6px; + flex-wrap: wrap; } -.views button { - border: 1px solid var(--line); - border-radius: 6px; - background: var(--panel); - color: var(--text); - justify-content: center; - cursor: pointer; +.views .btn { + min-width: 64px; } -.views button[aria-pressed='true'] { - border-color: var(--primary); - color: var(--primary); - font-weight: 600; +#origin { + margin-left: auto; + font-size: 12px; } .canvas { width: 100%; - height: calc(100vh - 140px); + height: 62vh; min-height: 420px; border-radius: 6px; overflow: hidden; background: #e9eef4; } -table { - width: 100%; - border-collapse: collapse; +.parts { + border: 1px solid var(--line); + border-radius: 6px; + margin: 10px 0 0; + padding: 6px 10px; } -td { - padding: 4px 6px; - border-bottom: 1px solid var(--line); - vertical-align: top; -} - -td:first-child { +.parts legend { color: var(--muted); - width: 48%; + font-size: 12px; + padding: 0 4px; +} + +.parts-row { + display: flex; + gap: 4px; + flex-wrap: wrap; +} + +.parts-row label { + display: flex; + align-items: center; + gap: 6px; + min-height: 44px; + padding: 0 8px; +} + +.parts-row input { + width: 22px; + height: 22px; +} + +/* --- results ------------------------------------------------------------ */ + +.results section { + border-bottom: 1px solid var(--line); + padding-bottom: 8px; + margin-bottom: 8px; +} + +.kv { + display: grid; + grid-template-columns: 1fr; + gap: 2px 8px; + margin: 0; +} + +.kv.wide { + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); +} + +.kv div { + display: flex; + justify-content: space-between; + gap: 8px; + border-bottom: 1px dotted var(--line); + padding: 2px 0; +} + +.kv dt { + color: var(--muted); +} + +.kv dd { + margin: 0; + text-align: right; } .issues { @@ -208,21 +368,218 @@ td:first-child { .issues li { margin-bottom: 6px; - color: var(--text); } .issues .code { - font-weight: 600; + font-weight: 700; color: var(--warn); } -.issues .sheet-ok { - font-weight: 600; - color: var(--primary); +.issues .code.E { + color: var(--error); } -@media (max-width: 900px) { - .layout { +details { + margin-top: 8px; +} + +summary { + cursor: pointer; + min-height: 44px; + display: flex; + align-items: center; +} + +/* --- import dialog (④) --------------------------------------------------- */ + +#import-dialog { + width: min(1140px, 96vw); + max-height: 92vh; + padding: 0; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--panel); + color: var(--text); +} + +#import-dialog::backdrop { + background: rgba(20, 28, 38, 0.35); +} + +.dlg-head { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--line); +} + +.dlg-head h2 { + margin: 0; +} + +.dlg-steps { + display: flex; + gap: 8px; + list-style: none; + padding: 0; + margin: 0; + flex: 1; + color: var(--muted); + font-size: 13px; +} + +.dlg-steps li { + padding: 4px 10px; + border-radius: 999px; + background: #f0eee6; +} + +.dlg-body { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 16px; + padding: 16px; + overflow: auto; + max-height: 70vh; +} + +.dlg-col section { + margin-bottom: 16px; +} + +.field { + display: grid; + gap: 4px; + margin-bottom: 8px; + font-size: 13px; +} + +.field input { + border: 1px solid var(--line); + border-radius: 6px; + padding: 4px 8px; + background: var(--panel); + color: var(--text); +} + +.drop { + border: 2px dashed var(--line); + border-radius: 8px; + padding: 16px; + text-align: center; +} + +.drop.over { + border-color: var(--primary); +} + +.drop p { + margin: 0 0 8px; +} + +.drop-actions { + display: flex; + gap: 8px; + justify-content: center; + flex-wrap: wrap; +} + +.sheet-list { + list-style: none; + padding: 0; + margin: 0; +} + +.sheet-list > li { + border: 1px solid var(--line); + border-radius: 6px; + padding: 8px; + margin-bottom: 8px; +} + +.sheet-list .ok { + color: var(--primary); + font-weight: 600; +} + +.sheet-list .bad { + color: var(--error); + font-weight: 600; +} + +.sheet-pier { + display: flex; + align-items: center; + gap: 8px; + min-height: 44px; +} + +.sheet-pier input { + width: 24px; + height: 24px; +} + +.rules { + margin: 0; + padding-left: 18px; + font-size: 13px; +} + +table { + border-collapse: collapse; + width: 100%; + font-size: 13px; +} + +.terms th { + text-align: left; + width: 40%; + color: var(--muted); + font-weight: 400; + padding: 4px 6px; + border-bottom: 1px solid var(--line); +} + +.terms td { + padding: 4px 6px; + border-bottom: 1px solid var(--line); +} + +.grid-table th, +.grid-table td { + border-bottom: 1px solid var(--line); + padding: 4px 6px; + text-align: right; + white-space: nowrap; +} + +.grid-table th { + color: var(--muted); + font-weight: 400; +} + +.dlg-foot { + display: flex; + gap: 8px; + align-items: center; + justify-content: flex-end; + padding: 12px 16px; + border-top: 1px solid var(--line); + flex-wrap: wrap; +} + +#dlg-selected { + margin-right: auto; +} + +@media (max-width: 1100px) { + .grid { + grid-template-columns: 1fr; + } + + .dlg-body { grid-template-columns: 1fr; }