feat: viewer part picking with highlight and tooltip, station window slider, zoom fit, longitudinal start view (Task 8)

- Z-up model rotated for the Y-up viewer; start view is the longitudinal (종단) view, chainage left to right
- 구간 보기: two-handle station slider (start/end) with 구간 맞춤 / 구간 전체
- click a part: yellow highlight + tooltip (group, pattern, station range, length, volume, part ID)
- 선택 맞춤 (zoom fit), 선택 해제 (Esc), 선택 숨기기, 선택만 보기, 모두 보기, 와이어프레임
- full-length reference lines shown only for the full range

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5Cun3RHQKna9qxE6ybZ4A
This commit is contained in:
조선두andClaude Haiku 5.5 committed 2026-10-21 22:34:11 +00:00
1 parent 455980b052
commit b1bb768f09
1 file changed
+194 -113
+194 -113
View File
@@ -5,17 +5,28 @@
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>대산2터널 본선 3D 모델</title>
<style>
:root{--bg:#f6f6f4;--fg:#1d1d1b;--panel:rgba(255,255,255,.93);--line:#d7d7d2;--accent:#2f6f6a}
@media (prefers-color-scheme: dark){:root:not([data-theme="light"]){--bg:#15171a;--fg:#e9e9e6;--panel:rgba(31,34,38,.93);--line:#3a3f46;--accent:#6fb7b0}}
:root[data-theme="dark"]{--bg:#15171a;--fg:#e9e9e6;--panel:rgba(31,34,38,.93);--line:#3a3f46;--accent:#6fb7b0}
:root{--bg:#f6f6f4;--fg:#1d1d1b;--panel:rgba(255,255,255,.95);--line:#d7d7d2;--accent:#2f6f6a;--muted:#6b6b66}
@media (prefers-color-scheme: dark){:root:not([data-theme="light"]){--bg:#15171a;--fg:#e9e9e6;--panel:rgba(31,34,38,.95);--line:#3a3f46;--accent:#6fb7b0;--muted:#9aa0a6}}
:root[data-theme="dark"]{--bg:#15171a;--fg:#e9e9e6;--panel:rgba(31,34,38,.95);--line:#3a3f46;--accent:#6fb7b0;--muted:#9aa0a6}
html,body{margin:0;height:100%;background:var(--bg);color:var(--fg);font-family:system-ui,"Noto Sans KR",sans-serif;overflow:hidden}
#app{position:fixed;inset:0}
#panel{position:absolute;left:12px;top:12px;width:290px;max-width:calc(100vw - 24px);max-height:calc(100vh - 24px);overflow:auto;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:12px 14px;font-size:13px;box-sizing:border-box}
#panel h1{font-size:15px;margin:0 0 6px;color:var(--accent)}
#panel h2{font-size:12px;margin:12px 0 4px;color:var(--accent)}
#panel{position:absolute;left:12px;top:12px;width:300px;max-width:calc(100vw - 24px);max-height:calc(100vh - 24px);overflow:auto;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:12px 14px;font-size:13px;box-sizing:border-box}
#panel h1{font-size:15px;margin:0 0 4px;color:var(--accent)}
#panel h2{font-size:12px;margin:12px 0 5px;color:var(--accent)}
#panel label{display:flex;gap:6px;align-items:center;margin:3px 0;cursor:pointer}
button{font:inherit;font-size:12px;padding:4px 8px;border:1px solid var(--line);border-radius:6px;background:transparent;color:var(--fg);cursor:pointer}
button:hover{border-color:var(--accent)}
.row{display:flex;gap:6px;flex-wrap:wrap;margin:4px 0}
#pats{display:grid;grid-template-columns:1fr 1fr;gap:0 10px}
#info{white-space:pre-wrap;font-size:12px;line-height:1.55;margin-top:10px;border-top:1px solid var(--line);padding-top:8px;min-height:3.2em}
.dual{position:relative;height:26px;margin:4px 0}
.dual input[type=range]{position:absolute;left:0;top:4px;width:100%;margin:0;pointer-events:none;-webkit-appearance:none;appearance:none;background:transparent;height:18px}
.dual input[type=range]::-webkit-slider-thumb{pointer-events:auto;-webkit-appearance:none;width:14px;height:14px;border-radius:50%;background:var(--accent);cursor:pointer}
.dual input[type=range]::-moz-range-thumb{pointer-events:auto;width:14px;height:14px;border-radius:50%;background:var(--accent);cursor:pointer;border:0}
.dual .track{position:absolute;left:7px;right:7px;top:11px;height:4px;background:var(--line);border-radius:2px}
#staText{font-weight:600;font-size:13px;margin-top:2px}
#info{font-size:12px;color:var(--muted);margin-top:10px;border-top:1px solid var(--line);padding-top:8px;min-height:2.6em}
#tip{position:fixed;display:none;max-width:320px;background:var(--panel);border:1px solid var(--accent);border-radius:8px;padding:8px 10px;font-size:12px;line-height:1.55;white-space:pre-wrap;pointer-events:none;z-index:5;box-shadow:0 2px 10px rgba(0,0,0,.2)}
#tip b{color:var(--accent)}
#status{position:absolute;right:12px;bottom:12px;font-size:12px;background:var(--panel);padding:4px 8px;border-radius:6px;border:1px solid var(--line)}
</style>
<script type="importmap">
@@ -26,21 +37,39 @@ html,body{margin:0;height:100%;background:var(--bg);color:var(--fg);font-family:
<div id="app"></div>
<div id="panel">
<h1>대산2터널 본선 3D 모델</h1>
<div style="color:var(--fg);opacity:.75">측점 4+745 ~ 5+480 · 단위 m</div>
<div style="display:flex;gap:6px;margin:6px 0 2px"><button id="viewAll" type="button">전체 보기</button><button id="viewStart" type="button">입구 (4+745) 보기</button></div>
<h2>구간 보기 (측점)</h2>
<input type="range" id="sta" min="4745" max="5480" step="5" value="4745" style="width:100%;margin:4px 0">
<div style="display:flex;justify-content:space-between;align-items:center;gap:6px">
<button id="prev" type="button">&#9664; 5 m</button><span id="staLabel" style="font-weight:600">4+745</span><button id="next" type="button">5 m &#9654;</button>
<div style="color:var(--muted)">측점 4+745 ~ 5+480 · 단위 m</div>
<div class="row"><button id="btnStart" type="button">종단 시점</button><button id="btnAll" type="button">전체 맞춤</button></div>
<h2>구간 보기 (시작 · 끝 측점)</h2>
<div class="dual">
<div class="track"></div>
<input type="range" id="s0" min="4745" max="5480" step="5" value="4745">
<input type="range" id="s1" min="4745" max="5480" step="5" value="5480">
</div>
<div id="staText">4+745 ~ 5+480</div>
<div class="row"><button id="btnWin" type="button">구간 맞춤</button><button id="btnWinReset" type="button">구간 전체</button></div>
<h2>지보패턴 (라이닝·굴착 여유대)</h2>
<div id="pats"></div>
<h2>굴착 여유대</h2>
<label><input type="checkbox" id="showExc" checked> 굴착 여유대 표시</label>
<h2>구성요소</h2>
<div id="comps"></div>
<div id="info">구성요소를 클릭하면 정보가 표시됩니다.</div>
<h2>선택 부품</h2>
<div class="row">
<button id="btnFitSel" type="button">선택 맞춤 (zoom fit)</button>
<button id="btnDesel" type="button">선택 해제 (Esc)</button>
</div>
<div class="row">
<button id="btnHideSel" type="button">선택 숨기기</button>
<button id="btnIsoSel" type="button">선택만 보기</button>
<button id="btnShowAll" type="button">모두 보기</button>
</div>
<label><input type="checkbox" id="wire"> 와이어프레임</label>
<div id="info">부품을 클릭하면 강조되고 속성이 표시됩니다.</div>
</div>
<div id="tip"></div>
<div id="status">불러오는 중…</div>
<script type="module">
import * as THREE from 'three';
@@ -49,138 +78,183 @@ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const MODEL = '../model/tunnel_solids.glb';
const META = '../model/tunnel_solids_meta.json';
const status = document.getElementById('status');
const info = document.getElementById('info');
const patsEl = document.getElementById('pats');
const compsEl = document.getElementById('comps');
const showExc = document.getElementById('showExc');
const S_MIN = 4745, S_MAX = 5480;
const $ = (id) => document.getElementById(id);
const statusEl = $('status'), infoEl = $('info'), tipEl = $('tip');
const dark = matchMedia('(prefers-color-scheme: dark)').matches;
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
document.getElementById('app').appendChild(renderer.domElement);
$('app').appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(dark ? 0x15171a : 0xf6f6f4);
const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 20000);
const camera = new THREE.PerspectiveCamera(30, innerWidth / innerHeight, 0.5, 200000);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
scene.add(new THREE.HemisphereLight(0xffffff, 0x556070, 1.0));
const sun = new THREE.DirectionalLight(0xffffff, 0.9);
sun.position.set(1, 2, 1);
scene.add(sun);
const fill = new THREE.DirectionalLight(0xffffff, 0.7);
fill.position.set(-1, -0.5, -1.2);
scene.add(fill);
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);
const fmtSta = (m) => Math.floor(m / 1000) + '+' + String(Math.round(m % 1000)).padStart(3, '0');
const GROUP_NOTE = {
'기층·인버트(가정)': '기층·인버트: 도면에 표현되지 않아 가정하여 생성한 형상',
'공동구(가정)': '공동구 H/I 박스: 공동구로 가정하여 생성한 형상',
'배수(측구)': '측구 및 배수관 (단면 도면 기준)',
'노면(계획고)': '노면 (계획고 기준, 측점별 편경사 적용)',
'시설한계(참조선)': '시설한계 50 mm 기준선 (참조용 선, 솔리드 아님)',
'굴착면': '굴착 여유대 (R 7.402 m 기준)',
'라이닝': '라이닝 (지보패턴별)'
const NOTE = {
'노면(계획고)': '노면 포장 280 mm · 측점별 편경사 적용 (도면 계획고 기준)',
'기층·인버트(가정)': '가정 형상 · 도면에 표현되지 않아 가정하여 생성',
'배수(측구)': '측구 300×836 mm 및 관 r150 (단면 도면 기준)',
'공동구(가정)': '가정 형상 · H/I 박스를 공동구로 가정',
'시설한계(참조선)': '시설한계 50 mm 기준선 · 참조용 선 (솔리드 아님)',
'굴착면': '굴착 여유대 R 7.402 m 기준 · 노면 위',
'라이닝': '라이닝 R 6.982 / 7.282 m (지보패턴별 구간)'
};
const meshes = [];
let META_BY_ID = {};
let patternOn = {};
let groupOn = {};
const patternOn = {}, groupOn = {};
let winLo = S_MIN, winHi = S_MAX;
let selected = null, isolate = false;
const hiddenSel = new Set();
let world = null;
function applyVisibility() {
for (const m of meshes) {
const d = m.userData;
let v = true;
if (d.pattern) v = !!patternOn[d.pattern];
if (d.group === '굴착면') v = v && showExc.checked;
if (d.group in groupOn) v = v && groupOn[d.group];
m.visible = v;
}
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; // full-length reference lines: full range only
if (u.pattern) { if (!patternOn[u.pattern]) return false; }
if (u.group === '굴착면' && !$('showExc').checked) 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); }
// longitudinal (종단) start view: chainage runs left -> right, height up
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);
// camera looks along lateral w; chainage (u) must point to screen-right: right = w × up = u
const w = new THREE.Vector3(u.z, 0, -u.x);
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();
}
function setSelected(m) {
if (selected && selected.material) selected.material.emissive.setHex(0x000000);
selected = m;
if (m) m.material.emissive.setHex(0xb8a000); // highlight: strong yellow emissive
applyVisibility();
if (!m) { infoEl.textContent = '부품을 클릭하면 강조되고 속성이 표시됩니다.'; tipEl.style.display = 'none'; return; }
infoEl.textContent = '선택: ' + m.userData.group + (m.userData.pattern ? ' · 지보패턴 ' + m.userData.pattern : '');
}
function tipText(u) {
const lines = ['<b>' + u.group + '</b>' + (u.pattern ? ' · 지보패턴 ' + u.pattern : '')];
if (u.from != null) lines.push('측점 ' + fmtSta(u.from) + ' ~ ' + fmtSta(u.to) + ' (' + Math.round(u.to - u.from) + ' m)');
if (u.volume != null) lines.push('부피 ' + Number(u.volume).toFixed(2) + ' m³');
if (NOTE[u.group]) lines.push(NOTE[u.group]);
lines.push('부품 ID ' + u.id);
return lines.join('\n');
}
showExc.addEventListener('change', applyVisibility);
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;
const root = gltf.scene;
scene.add(root);
root.traverse((o) => {
world = new THREE.Group();
world.rotation.x = -Math.PI / 2; // model is Z-up (height on z); view is 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 ? c.group : '', pattern: c && c.pattern ? c.pattern : null,
from: c ? c.from_sta : null, to: c ? c.to_sta : null, label: c ? c.label : o.name };
o.geometry = o.geometry.toNonIndexed(); // flat shading: one normal per triangle, no smoothing
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 };
o.geometry = o.geometry.toNonIndexed();
o.geometry.computeVertexNormals();
const rgb = c && c.color ? c.color : [200, 200, 200];
const rgb = c.color || [200, 200, 200];
o.material = new THREE.MeshStandardMaterial({ color: new THREE.Color(rgb[0] / 255, rgb[1] / 255, rgb[2] / 255),
roughness: 0.8, metalness: 0.0, side: THREE.DoubleSide, flatShading: true });
meshes.push(o);
});
const box = new THREE.Box3().setFromObject(root);
const c = box.getCenter(new THREE.Vector3());
const s = box.getSize(new THREE.Vector3());
const d = Math.max(s.x, s.y, s.z);
const viewFrom = (center, dist) => {
const dir = new THREE.Vector3(0.6, 0.45, 0.9).normalize();
controls.target.copy(center);
camera.position.copy(center).addScaledVector(dir, dist);
camera.near = Math.max(dist / 2000, 0.05); camera.far = dist * 20; camera.updateProjectionMatrix();
};
const overview = () => viewFrom(c, d * 0.75);
const focusStation = (sta) => {
sta = Math.max(4745, Math.min(5480, sta));
const bb = new THREE.Box3(); let any = false;
for (const m of meshes) {
const u = m.userData;
if (!m.visible || u.from == null) continue;
if (u.to < sta - 15 || u.from > sta + 15) continue;
bb.union(new THREE.Box3().setFromObject(m)); any = true;
}
if (!any) return;
const cc = bb.getCenter(new THREE.Vector3()), ss = bb.getSize(new THREE.Vector3());
viewFrom(cc, Math.max(ss.x, ss.y, ss.z) * 0.9 + 25);
staEl.value = String(sta); staLabel.textContent = fmtSta(sta);
};
const staEl = document.getElementById('sta'), staLabel = document.getElementById('staLabel');
staEl.addEventListener('input', () => focusStation(Number(staEl.value)));
document.getElementById('prev').addEventListener('click', () => focusStation(Number(staEl.value) - 5));
document.getElementById('next').addEventListener('click', () => focusStation(Number(staEl.value) + 5));
document.getElementById('viewAll').addEventListener('click', overview);
document.getElementById('viewStart').addEventListener('click', () => focusStation(4745));
overview();
world.updateMatrixWorld(true);
const pats = [...new Set(meta.components.filter((x) => x.pattern).map((x) => x.pattern))];
for (const p of pats) {
patternOn[p] = true;
const lab = document.createElement('label');
const cb = document.createElement('input');
const lab = document.createElement('label'); const cb = document.createElement('input');
cb.type = 'checkbox'; cb.checked = true;
cb.addEventListener('change', () => { patternOn[p] = cb.checked; applyVisibility(); });
lab.append(cb, document.createTextNode(p));
patsEl.appendChild(lab);
lab.append(cb, document.createTextNode(p)); $('pats').appendChild(lab);
}
const groups = [['노면(계획고)', '노면(계획고)'], ['기층·인버트(가정)', '기층·인버트(가정)'], ['배수(측구)', '배수(측구)'], ['공동구(가정)', '공동구(가정)'], ['시설한계(참조선)', '시설한계 50 mm 참조선']];
const groups = [['노면(계획고)', '노면(계획고)'], ['기층·인버트(가정)', '기층·인버트 (가정)'], ['배수(측구)', '배수(측구)'],
['공동구(가정)', '공동구 (가정)'], ['시설한계(참조선)', '시설한계 50 mm 참조선']];
for (const [key, text] of groups) {
groupOn[key] = key !== '시설한계(참조선)' ? true : true;
const lab = document.createElement('label');
const cb = document.createElement('input');
groupOn[key] = true;
const lab = document.createElement('label'); const cb = document.createElement('input');
cb.type = 'checkbox'; cb.checked = true;
cb.addEventListener('change', () => { groupOn[key] = cb.checked; applyVisibility(); });
lab.append(cb, document.createTextNode(text));
compsEl.appendChild(lab);
lab.append(cb, document.createTextNode(text)); $('comps').appendChild(lab);
}
applyVisibility();
const nLin = meta.components.filter((x) => x.group === '라이닝').length;
status.textContent = '로드 완료 · 지보패턴 구간 ' + nLin + '개';
}).catch((e) => {
status.textContent = '로드 실패: HTTP 서버에서 여세요 (' + e.message + ')';
});
startView();
statusEl.textContent = '로드 완료 · 부품 ' + meshes.length + '개';
}).catch((e) => { statusEl.textContent = '로드 실패: HTTP 서버에서 여세요 (' + e.message + ')'; });
const ray = new THREE.Raycaster();
const ptr = new THREE.Vector2();
// ---- station window (two handles) ----
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);
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());
$('showExc').addEventListener('change', applyVisibility);
// ---- selection tools ----
$('btnFitSel').addEventListener('click', () => { if (selected) frameBox(new THREE.Box3().setFromObject(selected), 0.9); });
$('btnDesel').addEventListener('click', () => setSelected(null));
$('btnHideSel').addEventListener('click', () => { if (selected) { hiddenSel.add(selected); setSelected(null); } });
$('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; });
addEventListener('keydown', (e) => { if (e.key === 'Escape') setSelected(null); });
// ---- pick ----
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) => {
@@ -189,16 +263,23 @@ renderer.domElement.addEventListener('pointerup', (e) => {
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) return;
const u = hit.object.userData;
const head = u.group + (u.pattern ? ' · 지보패턴 ' + u.pattern : '');
const sta = u.from != null ? '\n측점 ' + fmtSta(u.from) + ' ~ ' + fmtSta(u.to) : '';
const note = GROUP_NOTE[u.group] ? '\n' + GROUP_NOTE[u.group] : '';
info.textContent = head + sta + note;
if (!hit) { setSelected(null); return; }
setSelected(hit.object);
tipEl.innerHTML = tipText(hit.object.userData).replace(/\n/g, '<br>');
tipEl.style.display = 'block';
tipEl.style.left = Math.min(e.clientX + 14, innerWidth - 340) + 'px';
tipEl.style.top = Math.min(e.clientY + 14, innerHeight - 160) + 'px';
});
// keep the model centred in the area right of the control panel (wide screens only)
function shiftForPanel() {
const off = innerWidth > 760 ? -170 : 0;
camera.setViewOffset(innerWidth, innerHeight, off, 0, innerWidth, innerHeight);
camera.updateProjectionMatrix();
}
shiftForPanel();
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
camera.aspect = innerWidth / innerHeight; shiftForPanel();
renderer.setSize(innerWidth, innerHeight);
});
renderer.setAnimationLoop(() => { controls.update(); renderer.render(scene, camera); });