Files
CMPlanning/viewer/index.html
T
Claude 133db4e3f7 feat(viewer): colour lining by fortification pattern and shorten buttons
- Lining segments take a colour per 지보패턴 (order along chainage); the pattern
  checkbox shows the same colour. Colours come from dv tokens; patterns 7 and
  beyond use a 50 % tint of the token.
- Button visual height 36px (was 44px) on the user's instruction; this overrides
  the Prism 44px default (spec 1.4 rank 1). 24px WCAG AA minimum is still met;
  the 44px AAA enhancement target is not.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5Cun3RHQKna9qxE6ybZ4A
2026-10-21 23:50:58 +00:00

671 lines
35 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>대산2터널 본선 3D 모델</title>
<style>
/* ── Prism CSS 변수 정본 (webapp/module.webapp.md §3 탑재, 수정 금지) ── */
:root {
/* ── color (light) ─────────────────────────────── */
--prism-bg-canvas: #F4F3F0; /* sand G4 — 순백 전면 금지 */
--prism-bg-card: #FFFFFF; /* white G0 */
--prism-text-body: #474235; /* brown G72 — on sand 9.02 AAA */
--prism-text-heading: #1F524B; /* green G69 — on sand 8.00 AAA */
--prism-text-subtle: #474235; /* 저강조는 색이 아니라 크기·웨이트로 */
--prism-text-accent: #D05C26; /* orange G47 — 대형 전용(24px+/19px+Bold) */
--prism-border-default: #474235;
--prism-border-strong: #1F524B;
--prism-status-do: #005A4E; --prism-status-do-bg: #DCEAE6;
--prism-status-dont: #8F2A11; --prism-status-dont-bg: #F6DFDA;
--prism-status-caution: #6B4A00; --prism-status-caution-bg: #F5E7CC;
--prism-status-tip: #0A4E86; --prism-status-tip-bg: #DDE8F3;
--prism-dv-1: #C08A2E; --prism-dv-2: #6E8F3C; --prism-dv-3: #3C6E9F;
--prism-dv-4: #8C3B14; --prism-dv-5: #1B4A43; --prism-dv-6: #5E4370;
/* ── space (base 8px = 0.5rem) ──────────────────── */
--prism-space-025: 0.125rem; /* 2px */
--prism-space-050: 0.25rem; /* 4px */
--prism-space-100: 0.5rem; /* 8px */
--prism-space-150: 0.75rem; /* 12px */
--prism-space-200: 1rem; /* 16px — card padding */
--prism-space-300: 1.5rem; /* 24px — block gap */
--prism-space-400: 2rem; /* 32px — zone gap */
--prism-space-600: 3rem; /* 48px */
--prism-space-800: 4rem; /* 64px */
/* ── type (root 16px 미고정 — html font-size 선언 금지) ── */
--prism-type-caption: 0.8125rem; --prism-lh-caption: 1.55;
--prism-type-body-sm: 0.875rem; --prism-lh-body: 1.70;
--prism-type-body: 1rem;
--prism-type-body-lg: 1.125rem;
--prism-type-title-sm: 1.25rem; --prism-lh-title: 1.50;
--prism-type-title: 1.625rem;
--prism-type-title-lg: 2rem;
--prism-type-display: 2.875rem; --prism-lh-display: 1.25;
--prism-type-display-lg: 4.125rem;
/* ── radius · stroke · z · motion ───────────────── */
--prism-radius-sm: 4px; --prism-radius-md: 8px; --prism-radius-lg: 12px;
--prism-radius-pill: 9999px;
--prism-stroke-hairline: 1px; --prism-stroke-regular: 2px; --prism-stroke-bold: 3px;
--prism-z-raised: 100; --prism-z-sticky: 200; --prism-z-dropdown: 300;
--prism-z-scrim: 400; --prism-z-modal: 500; --prism-z-popover: 600;
--prism-z-toast: 700; --prism-z-tooltip: 800;
--prism-scrim: rgba(0, 0, 0, 0.32);
--prism-motion-fast: 100ms; --prism-motion-moderate: 200ms; --prism-motion-slow: 300ms;
--prism-ease-standard: cubic-bezier(0.2, 0, 0, 1);
--prism-ease-enter: cubic-bezier(0, 0, 0, 1);
--prism-ease-exit: cubic-bezier(0.3, 0, 1, 1);
--prism-focus-ring: 3px; --prism-focus-offset: 2px;
--prism-measure-max: 38em;
}
/* ── dark theme (data-theme + OS 연동) ─────────────── */
:root[data-theme="dark"] {
--prism-bg-canvas: #111111; /* obsidian G95 */
--prism-bg-card: #111111; /* 카드는 보더로 분리 */
--prism-text-body: #F4F3F0; /* sand on obsidian 17.02 AAA */
--prism-text-heading: #F4F3F0; /* ⚠ green 텍스트 금지 (2.13:1) — 면·도형만 */
--prism-text-subtle: #D3D3D3; /* subtle 12.61 AAA */
--prism-text-accent: #D05C26; /* 4.71 AA — 대형 권장 */
--prism-border-default: #D3D3D3;
--prism-border-strong: #F4F3F0;
/* 상태색 다크: container↔on 반전 (tokens v0.1.1, 실측 동일) */
--prism-status-do: #DCEAE6; --prism-status-do-bg: #005A4E;
--prism-status-dont: #F6DFDA; --prism-status-dont-bg: #8F2A11;
--prism-status-caution: #F5E7CC; --prism-status-caution-bg: #6B4A00;
--prism-status-tip: #DDE8F3; --prism-status-tip-bg: #0A4E86;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { /* 위 dark 값셋 동일 적용 */
--prism-bg-canvas: #111111; --prism-bg-card: #111111;
--prism-text-body: #F4F3F0; --prism-text-heading: #F4F3F0;
--prism-text-subtle: #D3D3D3; --prism-text-accent: #D05C26;
--prism-border-default: #D3D3D3; --prism-border-strong: #F4F3F0;
--prism-status-do: #DCEAE6; --prism-status-do-bg: #005A4E;
--prism-status-dont: #F6DFDA; --prism-status-dont-bg: #8F2A11;
--prism-status-caution: #F5E7CC; --prism-status-caution-bg: #6B4A00;
--prism-status-tip: #DDE8F3; --prism-status-tip-bg: #0A4E86;
}
}
/* ── 베이스 (CSS-1·CSS-2: root font-size 미지정, 타입·간격 rem) ── */
*, *::before, *::after { box-sizing: border-box; }
[hidden] { display: none !important; }
html, body { height: 100%; margin: 0; }
body {
display: flex; flex-direction: column; overflow: hidden;
background: var(--prism-bg-canvas); color: var(--prism-text-body);
font-family: Pretendard, -apple-system, BlinkMacSystemFont, "Noto Sans KR", "Apple SD Gothic Neo", "Malgun Gothic", sans-serif;
font-size: var(--prism-type-body); line-height: var(--prism-lh-body);
}
h1, h2, p, dl, dd, dt { margin: 0; }
h1 { font-size: var(--prism-type-title-sm); line-height: var(--prism-lh-title); font-weight: 700; color: var(--prism-text-heading); }
h2 { font-size: var(--prism-type-body-sm); line-height: var(--prism-lh-title); font-weight: 700; color: var(--prism-text-heading); margin-block: var(--prism-space-150) var(--prism-space-050); }
.rail h2:first-child { margin-block-start: 0; }
/* 포커스: 3px 링 + 오프셋 2px, :focus-visible 전용 (FC-1·FC-2) */
:focus-visible { outline: var(--prism-focus-ring) solid var(--prism-text-heading); outline-offset: var(--prism-focus-offset); }
.skip-link {
position: absolute; inset-inline-start: var(--prism-space-200); inset-block-start: var(--prism-space-100);
transform: translateY(-200%); z-index: var(--prism-z-toast);
padding: var(--prism-space-100) var(--prism-space-200); border-radius: var(--prism-radius-md);
background: var(--prism-bg-card); color: var(--prism-text-heading); border: var(--prism-stroke-hairline) solid var(--prism-border-strong);
}
.skip-link:focus { transform: none; }
/* 헤더 — W1 셸 메타 슬롯 */
.hdr {
display: flex; flex-wrap: wrap; align-items: center; gap: var(--prism-space-100) var(--prism-space-400);
padding: var(--prism-space-150) var(--prism-space-300);
background: var(--prism-bg-card); border-bottom: var(--prism-stroke-hairline) solid var(--prism-border-default);
}
.meta {
display: flex; flex-wrap: wrap; gap: var(--prism-space-050) var(--prism-space-300);
margin-inline-start: auto; font-size: var(--prism-type-caption); line-height: var(--prism-lh-caption);
}
.meta div { display: flex; gap: var(--prism-space-050); }
.meta dt { color: var(--prism-text-subtle); }
.meta dd { color: var(--prism-text-body); font-weight: 500; font-variant-numeric: tabular-nums; }
/* 테마 3택 (TC-2) */
.seg { display: inline-flex; overflow: hidden; border-radius: var(--prism-radius-md); border: var(--prism-stroke-hairline) solid var(--prism-border-default); }
.seg button { border: 0; border-radius: 0; background: transparent; }
.seg button + button { border-inline-start: var(--prism-stroke-hairline) solid var(--prism-border-default); }
.seg button[aria-pressed="true"] { background: var(--prism-text-heading); color: var(--prism-bg-card); }
/* 셸: 좌측 내비 레일 + 뷰포트 (W1) */
.shell { flex: 1; min-height: 0; display: grid; grid-template-columns: 17.5rem minmax(0, 1fr); }
.rail {
overflow: auto; padding: var(--prism-space-150);
background: var(--prism-bg-card); border-inline-end: var(--prism-stroke-hairline) solid var(--prism-border-default);
}
.stage { position: relative; min-width: 0; min-height: 0; }
#viewport { position: absolute; inset: 0; }
#viewport canvas { display: block; }
/* 버튼 — primary 화면당 1개 (BT-1), 히트 영역 44px (HT-1) */
button {
font: inherit; font-size: var(--prism-type-body-sm); font-weight: 500; line-height: 1.2;
min-height: 2.25rem; padding: var(--prism-space-050) var(--prism-space-100); white-space: nowrap;
border: var(--prism-stroke-hairline) solid var(--prism-border-default); border-radius: var(--prism-radius-md);
background: var(--prism-bg-card); color: var(--prism-text-body); cursor: pointer;
}
button.primary { background: var(--prism-text-heading); border-color: var(--prism-text-heading); color: var(--prism-bg-card); }
button.quiet { background: transparent; border-color: transparent; color: var(--prism-text-heading); }
.btn-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--prism-space-100); }
.btn-grid .wide { grid-column: 1 / -1; }
/* 체크박스 행 — 히트 영역 44px */
.opt {
display: flex; align-items: center; gap: var(--prism-space-100);
min-height: 2.75rem; cursor: pointer; color: var(--prism-text-body); font-size: var(--prism-type-body-sm);
}
.opt input[type="checkbox"] { width: 1.25rem; height: 1.25rem; margin: 0; flex: none; accent-color: var(--prism-text-heading); }
.pats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); column-gap: var(--prism-space-050); }
.pats .opt { gap: var(--prism-space-050); white-space: nowrap; }
.sw {
width: var(--prism-space-200); height: var(--prism-space-200); flex: none;
border-radius: var(--prism-radius-sm); border: var(--prism-stroke-hairline) solid var(--prism-border-default);
}
.sw.ghost { opacity: 0.5; }
/* 구간 슬라이더 — 두 핸들, 히트 영역 44px */
.dual { position: relative; height: 2.75rem; margin-block: var(--prism-space-050); }
.dual .track {
position: absolute; inset-inline: var(--prism-space-150); top: 50%; height: var(--prism-stroke-regular);
transform: translateY(-50%); border-radius: var(--prism-radius-sm); background: var(--prism-border-default);
}
.dual input[type="range"] {
position: absolute; inset: 0; width: 100%; height: 100%; margin: 0;
background: transparent; pointer-events: none; appearance: none; -webkit-appearance: none;
}
.dual input[type="range"]::-webkit-slider-runnable-track { background: transparent; height: 100%; }
.dual input[type="range"]::-moz-range-track { background: transparent; }
.dual input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none; pointer-events: auto; cursor: pointer;
width: 1.25rem; height: 1.25rem; margin-block-start: var(--prism-space-150); border-radius: 50%;
background: var(--prism-text-heading); border: var(--prism-stroke-regular) solid var(--prism-bg-card);
}
.dual input[type="range"]::-moz-range-thumb {
pointer-events: auto; cursor: pointer; width: 1.25rem; height: 1.25rem; border-radius: 50%;
background: var(--prism-text-heading); border: var(--prism-stroke-regular) solid var(--prism-bg-card);
}
.sta { font-size: var(--prism-type-body-sm); font-weight: 700; font-variant-numeric: tabular-nums; margin-block: var(--prism-space-050) var(--prism-space-100); }
/* 개폐 (component.disclosure) — 부품 선택은 기본 접힘 */
.disc { margin-block-start: var(--prism-space-150); border-top: var(--prism-stroke-hairline) solid var(--prism-border-default); padding-block-start: var(--prism-space-050); }
.disc > summary {
display: flex; align-items: center; justify-content: space-between; gap: var(--prism-space-100);
min-height: 2.75rem; cursor: pointer; list-style: none; font-size: var(--prism-type-body-sm); font-weight: 700; color: var(--prism-text-heading);
}
.disc > summary::-webkit-details-marker { display: none; }
.disc > summary > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.disc > summary::after { content: "+"; flex: none; font-weight: 500; color: var(--prism-text-subtle); }
.disc[open] > summary::after { content: "−"; }
.disc > summary:focus-visible { outline: var(--prism-focus-ring) solid var(--prism-text-heading); outline-offset: var(--prism-focus-offset); }
.disc > *:not(summary) { margin-block-start: var(--prism-space-100); }
/* 선택 목록 */
select {
font: inherit; font-size: var(--prism-type-body-sm); width: 100%; min-height: 2.75rem;
padding: var(--prism-space-050) var(--prism-space-100); border-radius: var(--prism-radius-md);
border: var(--prism-stroke-hairline) solid var(--prism-border-default);
background: var(--prism-bg-card); color: var(--prism-text-body);
}
.label { display: block; margin-block-end: var(--prism-space-050); color: var(--prism-text-subtle); font-size: var(--prism-type-caption); line-height: var(--prism-lh-caption); }
.note { color: var(--prism-text-subtle); font-size: var(--prism-type-caption); line-height: var(--prism-lh-caption); max-inline-size: var(--prism-measure-max); margin-block-start: var(--prism-space-100); }
.empty { color: var(--prism-text-subtle); font-size: var(--prism-type-caption); margin-block-start: var(--prism-space-100); }
/* 명세표 (specsheet) — 항목 말미 고정 위치, 열 구성 동일 (SS-1·SS-2) */
.spec {
display: grid; grid-template-columns: auto minmax(0, 1fr); gap: var(--prism-space-050) var(--prism-space-150);
font-size: var(--prism-type-caption); line-height: var(--prism-lh-caption); font-variant-numeric: tabular-nums;
}
.spec dt { color: var(--prism-text-subtle); }
.spec dd { overflow-wrap: anywhere; color: var(--prism-text-body); }
/* 툴팁 — overlay 단계 (z.tooltip 800, 보더 우선) */
#tip {
position: fixed; display: none; pointer-events: none; z-index: var(--prism-z-tooltip);
max-inline-size: 20rem; padding: var(--prism-space-150) var(--prism-space-200);
background: var(--prism-bg-card); color: var(--prism-text-body);
border: var(--prism-stroke-hairline) solid var(--prism-border-strong); border-radius: var(--prism-radius-md);
box-shadow: 0 var(--prism-space-050) var(--prism-space-150) var(--prism-scrim);
}
#tip .tip-title { font-weight: 700; color: var(--prism-text-heading); margin-block-end: var(--prism-space-100); }
/* 상호작용 — hover는 보강 수단, hover 기기에서만 (HV-3·HV-4) */
@media (hover: hover) {
button:not(.primary):hover { border-color: var(--prism-border-strong); }
button.primary:hover { background: color-mix(in srgb, var(--prism-text-heading) 88%, var(--prism-text-body)); border-color: transparent; }
button.quiet:hover { background: var(--prism-bg-canvas); }
.opt:hover { color: var(--prism-text-heading); }
}
/* 좁은 화면: 레일을 본문 아래로 (BP md 904 미만) */
@media (max-width: 903px) {
body { height: auto; min-height: 100%; overflow: auto; }
.hdr { padding: var(--prism-space-150) var(--prism-space-200); }
.meta { margin-inline-start: 0; }
.shell { display: flex; flex-direction: column; flex: none; }
.stage { order: -1; flex: none; height: 60vh; min-height: 20rem; }
.rail { overflow: visible; border-inline-end: 0; border-block-start: var(--prism-stroke-hairline) solid var(--prism-border-default); }
}
/* 모션: 축소 요청 시 전환 제거 (MT-3·RM-1) */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition: none !important; animation: none !important; scroll-behavior: auto !important; }
}
</style>
<script type="importmap">
{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"}}
</script>
</head>
<body>
<a class="skip-link" href="#main">본문 바로가기</a>
<header class="hdr">
<h1>대산2터널 본선 3D 모델</h1>
<dl class="meta">
<div><dt>측점</dt><dd>4+745 ~ 5+480</dd></div>
<div><dt>부품</dt><dd id="metaCount">—</dd></div>
<div><dt>단위</dt><dd>m</dd></div>
<div><dt>상태</dt><dd id="status" role="status">불러오는 중</dd></div>
</dl>
<div class="seg" role="group" aria-label="화면 테마">
<button type="button" data-theme-choice="system" aria-pressed="true">시스템</button>
<button type="button" data-theme-choice="light" aria-pressed="false">라이트</button>
<button type="button" data-theme-choice="dark" aria-pressed="false">다크</button>
</div>
</header>
<div class="shell">
<nav class="rail" aria-label="모델 제어">
<div class="btn-grid" role="group" aria-label="시점">
<button type="button" class="primary" id="btnStart">종단 보기</button>
<button type="button" id="btnAll">전체 맞추기</button>
</div>
<h2>구간</h2>
<div class="dual">
<div class="track"></div>
<input type="range" id="s0" min="4745" max="5480" step="5" value="4745" aria-label="시작 측점">
<input type="range" id="s1" min="4745" max="5480" step="5" value="5480" aria-label="끝 측점">
</div>
<p class="sta" id="staText" aria-live="polite">4+745 ~ 5+480</p>
<div class="btn-grid">
<button type="button" id="btnWin">구간 맞추기</button>
<button type="button" id="btnWinReset" class="quiet">구간 전체</button>
</div>
<h2>지보패턴</h2>
<div class="pats" id="pats"></div>
<h2>구성요소</h2>
<div id="comps"></div>
<details class="disc" id="partDisc">
<summary><span id="partSummary">부품 선택</span></summary>
<select id="pickSel" aria-label="부품 목록에서 고르기">
<option value="">선택 없음</option>
</select>
<dl class="spec" id="detailSpec" hidden></dl>
<p class="empty" id="detailEmpty">부품을 클릭하거나 목록에서 고르면 속성이 표시됩니다.</p>
<div class="btn-grid" style="margin-block-start:var(--prism-space-100)">
<button type="button" id="btnFitSel">선택 맞추기</button>
<button type="button" id="btnDesel" class="quiet">선택 해제</button>
<button type="button" id="btnHideSel">선택 숨기기</button>
<button type="button" id="btnIsoSel">선택만 보기</button>
<button type="button" id="btnShowAll" class="quiet wide">모두 보기</button>
</div>
<label class="opt" style="margin-block-start:var(--prism-space-050)"><input type="checkbox" id="wire"> 와이어프레임으로 보기</label>
</details>
</nav>
<main id="main" class="stage">
<div id="viewport" role="img" aria-label="대산2터널 본선 3D 모델, 측점 4+745부터 5+480까지. 드래그로 회전하고 클릭하면 부품이 선택됩니다. 키보드는 부품 선택 목록을 사용합니다."></div>
<div id="tip" aria-hidden="true"></div>
</main>
</div>
<script type="module">
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const MODEL = '../model/tunnel_solids.glb';
const META = '../model/tunnel_solids_meta.json';
const S_MIN = 4745, S_MAX = 5480;
const THEME_KEY = 'prism-viewer-theme';
// 부품 그룹 → 디자인 토큰 (색은 모두 토큰에서 읽는다, 리터럴 없음)
const PART = {
'라이닝': { token: '--prism-dv-1', order: 0 },
'굴착면': { token: '--prism-text-subtle', order: 1, ghost: true },
'노면(계획고)': { token: '--prism-dv-4', order: 2 },
'기층·인버트(가정)': { token: '--prism-dv-2', order: 3 },
'배수(측구)': { token: '--prism-dv-3', order: 4 },
'공동구(가정)': { token: '--prism-dv-6', order: 5 },
'시설한계(참조선)': { token: '--prism-text-accent', order: 6 }
};
const TOGGLES = [
['굴착면', '굴착 여유대'], ['노면(계획고)', '노면 (계획고)'], ['기층·인버트(가정)', '기층·인버트 (가정)'],
['배수(측구)', '배수 (측구)'], ['공동구(가정)', '공동구 (가정)'], ['시설한계(참조선)', '시설한계 50 mm 참조선']
];
const NOTE = {
'노면(계획고)': '노면 포장 280 mm · 측점별 편경사 적용 (도면 계획고 기준)',
'기층·인버트(가정)': '가정 형상 · 도면에 표현되지 않아 가정하여 생성',
'배수(측구)': '측구 300×836 mm 및 관 r150 (단면 도면 기준)',
'공동구(가정)': '가정 형상 · H/I 박스를 공동구로 가정',
'시설한계(참조선)': '시설한계 50 mm 기준선 · 참조용 선 (솔리드 아님)',
'굴착면': '굴착 여유대 R 7.402 m 기준 · 노면 위',
'라이닝': '라이닝 R 6.982 / 7.282 m (지보패턴별 구간)'
};
const $ = (id) => document.getElementById(id);
const fmtSta = (m) => Math.floor(m / 1000) + '+' + String(Math.round(m % 1000)).padStart(3, '0');
const tok = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
const statusEl = $('status'), tipEl = $('tip');
// ── 테마 (TC-1~TC-4): 시스템 기본, 사용자 재정의 영속 ──
let scene = null, renderer = null;
let currentChoice = 'system';
try { currentChoice = localStorage.getItem(THEME_KEY) || 'system'; } catch (e) { /* 저장소 불가 시 시스템 값 */ }
function setThemeAttr(choice) {
const root = document.documentElement;
if (choice === 'light' || choice === 'dark') root.setAttribute('data-theme', choice);
else root.removeAttribute('data-theme');
document.querySelectorAll('[data-theme-choice]').forEach((b) =>
b.setAttribute('aria-pressed', String(b.dataset.themeChoice === choice)));
}
function applyTheme(choice, persist = true) {
currentChoice = choice;
setThemeAttr(choice);
if (persist) { try { localStorage.setItem(THEME_KEY, choice); } catch (e) { /* 무시 */ } }
if (scene) refreshColors();
}
setThemeAttr(currentChoice);
document.querySelectorAll('[data-theme-choice]').forEach((b) =>
b.addEventListener('click', () => applyTheme(b.dataset.themeChoice)));
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { if (scene) refreshColors(); });
// ── 3D 장면 ──
const vp = $('viewport');
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(vp.clientWidth, vp.clientHeight);
renderer.domElement.setAttribute('aria-hidden', 'true');
vp.appendChild(renderer.domElement);
scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(30, vp.clientWidth / Math.max(vp.clientHeight, 1), 0.5, 200000);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
scene.add(new THREE.HemisphereLight(new THREE.Color(1, 1, 1), new THREE.Color(0.333, 0.376, 0.439), 1.0)); // 조명(장면 광원, UI 색 아님)
const sun = new THREE.DirectionalLight(0xffffff, 0.9); sun.position.set(1, 2, 1); scene.add(sun);
const fill = new THREE.DirectionalLight(0xffffff, 0.6); fill.position.set(-1, -0.5, -1.2); scene.add(fill);
new ResizeObserver(() => {
const w = vp.clientWidth, h = Math.max(vp.clientHeight, 1);
renderer.setSize(w, h); camera.aspect = w / h; camera.updateProjectionMatrix();
}).observe(vp);
const meshes = [];
let META_BY_ID = {};
const patternOn = {}, groupOn = {};
let winLo = S_MIN, winHi = S_MAX;
let selected = null, isolate = false;
const hiddenSel = new Set();
let world = null;
// 지보패턴별 색: 패턴 순번(측점 순)마다 dv 토큰을 쓰고, 7번째부터는 배경과 50% 혼합한 명도 차로 구분
const patIdx = {};
function patCss(p) { const k = patIdx[p] ?? 0; return 'color-mix(in srgb, var(--prism-dv-' + (k % 6 + 1) + ') ' + (k >= 6 ? 50 : 100) + '%, var(--prism-bg-canvas))'; }
function patColor(p) {
const k = patIdx[p] ?? 0;
const c = new THREE.Color(tok('--prism-dv-' + (k % 6 + 1)));
if (k >= 6) c.lerp(new THREE.Color(tok('--prism-bg-canvas')), 0.5);
return c;
}
function refreshColors() {
scene.background = new THREE.Color(tok('--prism-bg-canvas'));
for (const m of meshes) {
const u = m.userData, p = PART[u.group];
if (u.group === '라이닝' && u.pattern) m.material.color.copy(patColor(u.pattern));
else m.material.color.set(tok(p ? p.token : '--prism-text-subtle'));
}
if (selected) selected.material.emissive.set(tok('--prism-text-heading'));
}
function visibleOf(m) {
const u = m.userData;
if (hiddenSel.has(m)) return false;
if (isolate && selected && m !== selected) return false;
if (u.from != null && (u.to < winLo || u.from > winHi)) return false;
if (u.group === '시설한계(참조선)' && (winLo > S_MIN || winHi < S_MAX)) return false; // 전체 길이 참조선: 전체 구간에서만
if (u.pattern) { if (!patternOn[u.pattern]) return false; }
if (u.group in groupOn && !groupOn[u.group]) return false;
return true;
}
function applyVisibility() { for (const m of meshes) m.visible = visibleOf(m); }
function frame(center, dist, dir) {
const d = (dir || new THREE.Vector3(0.6, 0.45, 0.9)).clone().normalize();
controls.target.copy(center);
camera.position.copy(center).addScaledVector(d, dist);
camera.near = Math.max(dist / 3000, 0.05); camera.far = dist * 40;
camera.updateProjectionMatrix();
controls.update();
}
function boxOf(list) {
const b = new THREE.Box3(); let any = false;
for (const m of list) { b.union(new THREE.Box3().setFromObject(m)); any = true; }
return any ? b : null;
}
function frameBox(b, pad) {
const c = b.getCenter(new THREE.Vector3()), s = b.getSize(new THREE.Vector3());
frame(c, Math.max(s.x, s.y, s.z) * (pad || 0.9) + 20);
}
function frameAll() { applyVisibility(); const b = boxOf(meshes.filter((m) => m.visible)); if (b) frameBox(b, 0.75); }
function frameWindow() { applyVisibility(); const b = boxOf(meshes.filter((m) => m.visible)); if (b) frameBox(b, 0.9); }
// 종단 시점: 측점이 좌→우로 나열, 높이는 위
function startView() {
const first = meshes.find((m) => m.userData.group === '라이닝' && m.userData.from === S_MIN);
const last = meshes.find((m) => m.userData.group === '라이닝' && m.userData.to === S_MAX);
if (!first || !last) return frameAll();
const c0 = new THREE.Box3().setFromObject(first).getCenter(new THREE.Vector3());
const c1 = new THREE.Box3().setFromObject(last).getCenter(new THREE.Vector3());
const u = new THREE.Vector3(c1.x - c0.x, 0, c1.z - c0.z);
const L = u.length(); u.normalize();
const mid = c0.clone().add(c1).multiplyScalar(0.5);
const w = new THREE.Vector3(u.z, 0, -u.x); // 카메라 시선 방향 (측점 u가 화면 우측)
controls.target.copy(mid);
camera.up.set(0, 1, 0);
camera.position.copy(mid).addScaledVector(w, -L * 2.1).add(new THREE.Vector3(0, L * 0.02, 0));
camera.near = 1; camera.far = L * 40; camera.updateProjectionMatrix();
controls.update();
}
// 부품 선택 개폐 상태 저장 (DC-3)
const DISC_KEY = 'prism-viewer-part-open';
try { $('partDisc').open = localStorage.getItem(DISC_KEY) === '1'; } catch (e) { /* 무시 */ }
$('partDisc').addEventListener('toggle', () => { try { localStorage.setItem(DISC_KEY, $('partDisc').open ? '1' : '0'); } catch (e) { /* 무시 */ } });
// ── 부품 상세 (명세표) ──
function specRows(u) {
return [
['구분', u.group || '—'],
['지보패턴', u.pattern || '—'],
['측점', u.from != null ? fmtSta(u.from) + ' ~ ' + fmtSta(u.to) + ' (' + Math.round(u.to - u.from) + ' m)' : '—'],
['부피', u.volume != null ? Number(u.volume).toFixed(2) + ' m³' : '—'],
['비고', NOTE[u.group] || '—'],
['부품 ID', u.id || '—']
];
}
function fillSpec(dl, rows) {
dl.replaceChildren();
for (const [k, v] of rows) {
const dt = document.createElement('dt'); dt.textContent = k;
const dd = document.createElement('dd'); dd.textContent = v;
dl.append(dt, dd);
}
}
function renderDetail(u) {
const dl = $('detailSpec'), empty = $('detailEmpty');
if (!u) { dl.hidden = true; empty.hidden = false; dl.replaceChildren(); return; }
fillSpec(dl, specRows(u)); dl.hidden = false; empty.hidden = true;
}
function setSelected(m) {
if (selected) selected.material.emissive.setRGB(0, 0, 0);
selected = m;
if (m) m.material.emissive.set(tok('--prism-text-heading'));
$('pickSel').value = m ? String(m.userData.idx) : '';
$('partSummary').textContent = m ? '부품 선택 · ' + m.userData.id : '부품 선택 · 선택 없음';
applyVisibility();
renderDetail(m ? m.userData : null);
}
function showTip(u, x, y) {
tipEl.replaceChildren();
const t = document.createElement('p'); t.className = 'tip-title';
t.textContent = u.group + (u.pattern ? ' · 지보패턴 ' + u.pattern : '');
const dl = document.createElement('dl'); dl.className = 'spec';
fillSpec(dl, specRows(u).filter((r) => r[0] !== '구분' && r[0] !== '지보패턴'));
tipEl.append(t, dl);
tipEl.style.display = 'block';
const gap = 14;
let left = x + gap, top = y + gap;
if (left + tipEl.offsetWidth > innerWidth) left = x - tipEl.offsetWidth - gap;
if (top + tipEl.offsetHeight > innerHeight) top = y - tipEl.offsetHeight - gap;
tipEl.style.left = Math.max(8, left) + 'px';
tipEl.style.top = Math.max(8, top) + 'px';
}
function hideTip() { tipEl.style.display = 'none'; }
Promise.all([
fetch(META).then((r) => r.json()),
new Promise((res, rej) => new GLTFLoader().load(MODEL, res, undefined, rej))
]).then(([meta, gltf]) => {
for (const c of meta.components) META_BY_ID[c.id] = c;
world = new THREE.Group();
world.rotation.x = -Math.PI / 2; // 모델은 Z-up, 화면은 Y-up
world.add(gltf.scene);
scene.add(world);
gltf.scene.traverse((o) => {
if (!o.isMesh) return;
const c = META_BY_ID[o.name] || {};
o.userData = { id: o.name, group: c.group || '', pattern: c.pattern || null,
from: c.from_sta ?? null, to: c.to_sta ?? null, volume: c.volume_m3 ?? null,
idx: meshes.length };
o.geometry = o.geometry.toNonIndexed();
o.geometry.computeVertexNormals();
const p = PART[o.userData.group];
const mat = new THREE.MeshStandardMaterial({ roughness: 0.8, metalness: 0.0, side: THREE.DoubleSide, flatShading: true });
if (p && p.ghost) { mat.transparent = true; mat.opacity = 0.28; mat.depthWrite = false; }
o.material = mat;
meshes.push(o);
});
world.updateMatrixWorld(true);
refreshColors();
// 지보패턴 체크박스
const firstFrom = {};
for (const c of meta.components) if (c.pattern && c.from_sta != null) firstFrom[c.pattern] = Math.min(firstFrom[c.pattern] ?? Infinity, c.from_sta);
const pats = Object.keys(firstFrom).sort((a, b) => firstFrom[a] - firstFrom[b]);
pats.forEach((p, i) => { patIdx[p] = i; });
refreshColors();
for (const p of pats) {
patternOn[p] = true;
const lab = document.createElement('label'); lab.className = 'opt';
const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = true;
cb.style.accentColor = patCss(p);
cb.addEventListener('change', () => { patternOn[p] = cb.checked; applyVisibility(); });
const span = document.createElement('span'); span.textContent = p;
lab.append(cb, span); $('pats').appendChild(lab);
}
// 구성요소 체크박스 (색 견본 = 부품 토큰)
for (const [key, text] of TOGGLES) {
groupOn[key] = true;
const lab = document.createElement('label'); lab.className = 'opt';
const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = true;
cb.addEventListener('change', () => { groupOn[key] = cb.checked; applyVisibility(); });
const sw = document.createElement('span'); sw.className = 'sw' + (PART[key].ghost ? ' ghost' : '');
sw.setAttribute('aria-hidden', 'true'); sw.style.background = 'var(' + PART[key].token + ')';
const span = document.createElement('span'); span.textContent = text;
lab.append(cb, sw, span); $('comps').appendChild(lab);
}
// 키보드용 부품 목록 (그룹 → 측점 순)
const order = [...meshes].sort((a, b) =>
(PART[a.userData.group]?.order ?? 9) - (PART[b.userData.group]?.order ?? 9) ||
(a.userData.from ?? 0) - (b.userData.from ?? 0) || a.userData.id.localeCompare(b.userData.id));
for (const m of order) {
const u = m.userData;
const o = document.createElement('option');
o.value = String(u.idx);
o.textContent = u.group + (u.pattern ? ' · ' + u.pattern : '') +
(u.from != null ? ' · ' + fmtSta(u.from) + '~' + fmtSta(u.to) : '') + ' · ' + u.id;
$('pickSel').appendChild(o);
}
startView();
$('metaCount').textContent = String(meshes.length);
statusEl.textContent = '로드 완료';
}).catch((e) => { statusEl.textContent = '로드 실패: HTTP 서버에서 여세요 (' + e.message + ')'; });
// ── 구간 슬라이더 (두 핸들) ──
function syncWin(from) {
let a = Number($('s0').value), b = Number($('s1').value);
if (a > b) { if (from === 0) { b = a; $('s1').value = b; } else { a = b; $('s0').value = a; } }
winLo = a; winHi = b;
$('staText').textContent = fmtSta(a) + ' ~ ' + fmtSta(b);
$('s0').setAttribute('aria-valuetext', fmtSta(a));
$('s1').setAttribute('aria-valuetext', fmtSta(b));
applyVisibility();
}
$('s0').addEventListener('input', () => syncWin(0));
$('s1').addEventListener('input', () => syncWin(1));
$('btnWin').addEventListener('click', () => frameWindow());
$('btnWinReset').addEventListener('click', () => { $('s0').value = S_MIN; $('s1').value = S_MAX; syncWin(0); frameAll(); });
$('btnStart').addEventListener('click', () => startView());
$('btnAll').addEventListener('click', () => frameAll());
// ── 선택 도구 ──
$('btnFitSel').addEventListener('click', () => { if (selected) frameBox(new THREE.Box3().setFromObject(selected), 0.9); });
$('btnDesel').addEventListener('click', () => { setSelected(null); hideTip(); });
$('btnHideSel').addEventListener('click', () => { if (selected) { hiddenSel.add(selected); setSelected(null); hideTip(); } });
$('btnIsoSel').addEventListener('click', () => { if (selected) { isolate = true; applyVisibility(); } });
$('btnShowAll').addEventListener('click', () => { hiddenSel.clear(); isolate = false; applyVisibility(); });
$('wire').addEventListener('change', () => { for (const m of meshes) m.material.wireframe = $('wire').checked; });
$('pickSel').addEventListener('change', () => {
const v = $('pickSel').value;
setSelected(v === '' ? null : meshes[Number(v)]);
});
addEventListener('keydown', (e) => { if (e.key === 'Escape') { setSelected(null); hideTip(); } });
// ── 포인터 선택 ──
const ray = new THREE.Raycaster(); const ptr = new THREE.Vector2();
let downAt = null;
renderer.domElement.addEventListener('pointerdown', (e) => { downAt = [e.clientX, e.clientY]; });
renderer.domElement.addEventListener('pointerup', (e) => {
if (!downAt || Math.hypot(e.clientX - downAt[0], e.clientY - downAt[1]) > 4) return;
const r = renderer.domElement.getBoundingClientRect();
ptr.set(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1);
ray.setFromCamera(ptr, camera);
const hit = ray.intersectObjects(meshes.filter((m) => m.visible), false)[0];
if (!hit) { setSelected(null); hideTip(); return; }
setSelected(hit.object);
showTip(hit.object.userData, e.clientX, e.clientY);
});
renderer.setAnimationLoop(() => { controls.update(); renderer.render(scene, camera); });
</script>
</body>
</html>