Mobile polish: panel toggle everywhere, file-size guard, free two-finger rotate

- The MENU toggle now works at any viewport width, not just small screens —
  desktop/tablet default to the panel open, phones default to closed, and
  crossing the 768px breakpoint resets to that side's default. The canvas
  already spans the full window behind the panel, so hiding it needs no
  resize. The empty-state "open a file" prompt re-centers when the panel
  is hidden.

- Android's WebView renders the page in a separate process with its own
  memory ceiling, independent of the app's Java heap (android:largeHeap
  wouldn't help). A 1.35GB .pcd blew through it and crashed the renderer,
  taking the whole app down with no catchable JS exception — confirmed via
  a device tombstone: "Render process crash wasn't handled by all
  associated webviews, triggering application crash." openFile() now
  blocks anything over 300MB on Android (empirical: 233MB survived, 1.35GB
  didn't) with a clear in-app message instead of a silent kill. Desktop is
  unaffected — it has far more headroom.

- Two-finger touch now free-rotates (any drag direction, not locked to one
  axis) while pinch still zooms (OrbitControls' DOLLY_ROTATE instead of the
  default DOLLY_PAN). Trades away two-finger panning, which double-click
  to recenter mostly substitutes for.

Verified on a physical Galaxy Z Fold.
This commit is contained in:
Dongubak
2026-08-23 15:32:30 +09:00
parent 2f570681eb
commit 0acddf3f5e
+38 -16
View File
@@ -73,9 +73,9 @@ try {
display:flex; align-items:center; justify-content:space-between; padding-left:16px; padding-right:16px; gap:16px; display:flex; align-items:center; justify-content:space-between; padding-left:16px; padding-right:16px; gap:16px;
} }
#rail .mark{ display:flex; align-items:baseline; gap:11px; min-width:0; } #rail .mark{ display:flex; align-items:baseline; gap:11px; min-width:0; }
/* drawer toggle — only shown under the small-screen media query below */ /* panel show/hide — usable at any viewport width, not just small screens */
#panelToggle{ #panelToggle{
display:none; flex:none; background:transparent; color:var(--txt); border:1px solid var(--line-strong); flex:none; background:transparent; color:var(--txt); border:1px solid var(--line-strong);
padding:5px 10px; font:inherit; font-size:9.5px; letter-spacing:0.12em; cursor:pointer; padding:5px 10px; font:inherit; font-size:9.5px; letter-spacing:0.12em; cursor:pointer;
} }
#panelToggle:hover{ background:var(--hover-strong); } #panelToggle:hover{ background:var(--hover-strong); }
@@ -110,7 +110,9 @@ try {
position:fixed; top:var(--rail-h); left:0; bottom:0; z-index:10; width:284px; position:fixed; top:var(--rail-h); left:0; bottom:0; z-index:10; width:284px;
background:var(--panel); border-right:1px solid var(--line); background:var(--panel); border-right:1px solid var(--line);
display:flex; flex-direction:column; overflow-y:auto; display:flex; flex-direction:column; overflow-y:auto;
transform:translateX(0); transition:transform .18s ease;
} }
#panel.panel-hidden{ transform:translateX(-100%); }
.sect{ padding:16px 18px; border-bottom:1px solid var(--line); display:flex; flex-direction:column; gap:12px; } .sect{ padding:16px 18px; border-bottom:1px solid var(--line); display:flex; flex-direction:column; gap:12px; }
.sect:last-child{ border-bottom:none; } .sect:last-child{ border-bottom:none; }
.lab{ font-size:9.5px; letter-spacing:0.18em; color:var(--faint); } .lab{ font-size:9.5px; letter-spacing:0.18em; color:var(--faint); }
@@ -240,7 +242,9 @@ try {
#loading.empty .load-live{ display:none; } #loading.empty .load-live{ display:none; }
#loading.empty .prompt{ display:flex; pointer-events:auto; } #loading.empty .prompt{ display:flex; pointer-events:auto; }
/* the "no cloud yet" state must not block the dashboard — you can go live without a file */ /* the "no cloud yet" state must not block the dashboard — you can go live without a file */
#loading.empty{ top:var(--rail-h); left:284px; background:transparent; pointer-events:none; } #loading.empty{ top:var(--rail-h); left:284px; background:transparent; pointer-events:none;
transition:left .18s ease; }
body:has(#panel.panel-hidden) #loading.empty{ left:0; }
#loading .glyph{ font-size:10px; color:var(--faint); letter-spacing:0.5em; text-indent:0.5em; } #loading .glyph{ font-size:10px; color:var(--faint); letter-spacing:0.5em; text-indent:0.5em; }
/* ── drag & drop overlay ── */ /* ── drag & drop overlay ── */
@@ -252,16 +256,11 @@ try {
#drop .frame .s{ display:block; margin-top:9px; font-size:10px; color:var(--faint); letter-spacing:0.24em; } #drop .frame .s{ display:block; margin-top:9px; font-size:10px; color:var(--faint); letter-spacing:0.24em; }
/* ── small screens (phones; tablets stay on the fixed-sidebar layout above) — /* ── small screens (phones; tablets stay on the fixed-sidebar layout above) —
the instrument column becomes a drawer instead of eating a third of the the instrument column defaults to hidden (see boot code) and dims the
width, since it's a fixed 284px regardless of viewport size ── */ viewport behind it while open, since it now overlays a much bigger
fraction of a narrow screen than it does on desktop ── */
@media (max-width:768px){ @media (max-width:768px){
#panelToggle{ display:block; } #panel{ width:min(284px, 84vw); }
#panel{
width:min(284px, 84vw);
transform:translateX(-100%);
transition:transform .18s ease;
}
#panel.open{ transform:translateX(0); }
#panelBackdrop{ #panelBackdrop{
display:block; position:fixed; top:var(--rail-h); left:0; right:0; bottom:0; z-index:9; display:block; position:fixed; top:var(--rail-h); left:0; right:0; bottom:0; z-index:9;
background:var(--overlay); opacity:0; pointer-events:none; transition:opacity .18s ease; background:var(--overlay); opacity:0; pointer-events:none; transition:opacity .18s ease;
@@ -553,6 +552,10 @@ const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; controls.enableDamping = true;
controls.dampingFactor = 0.08; controls.dampingFactor = 0.08;
controls.zoomToCursor = true; // zoom toward the cursor, not the orbit target — matches CAD/CloudCompare controls.zoomToCursor = true; // zoom toward the cursor, not the orbit target — matches CAD/CloudCompare
// two-finger touch: pinch still zooms, but the drag also free-rotates (any direction the
// fingers move, not locked to one axis) instead of panning — trades away touch-panning,
// which double-click-to-recenter (below) mostly covers instead.
controls.touches.TWO = THREE.TOUCH.DOLLY_ROTATE;
// ─── on-demand rendering (perf: idle frames cost nothing) ──────────────── // ─── on-demand rendering (perf: idle frames cost nothing) ────────────────
let needsRender = true; let needsRender = true;
@@ -617,16 +620,20 @@ function applyTheme(name) {
} }
themeButtons.forEach(b => b.onclick = () => applyTheme(b.dataset.theme)); themeButtons.forEach(b => b.onclick = () => applyTheme(b.dataset.theme));
// ─── small-screen drawer: the instrument column overlays instead of docking ── // ─── panel show/hide — a docked column on wide screens, an overlay drawer on
// narrow ones (see the max-width:768px rules above); same toggle either way.
// Canvas already spans the full window behind it, so hiding it needs no resize. ──
const panelEl = $('panel'), panelToggleEl = $('panelToggle'), panelBackdropEl = $('panelBackdrop'); const panelEl = $('panel'), panelToggleEl = $('panelToggle'), panelBackdropEl = $('panelBackdrop');
const panelNarrowMQ = matchMedia('(max-width:768px)');
function setPanelOpen(open) { function setPanelOpen(open) {
panelEl.classList.toggle('open', open); panelEl.classList.toggle('panel-hidden', !open);
panelBackdropEl.classList.toggle('show', open); panelBackdropEl.classList.toggle('show', open);
panelToggleEl.setAttribute('aria-expanded', String(open)); panelToggleEl.setAttribute('aria-expanded', String(open));
} }
panelToggleEl.onclick = () => setPanelOpen(!panelEl.classList.contains('open')); panelToggleEl.onclick = () => setPanelOpen(panelEl.classList.contains('panel-hidden'));
panelBackdropEl.onclick = () => setPanelOpen(false); panelBackdropEl.onclick = () => setPanelOpen(false);
matchMedia('(max-width:768px)').addEventListener('change', e => { if (!e.matches) setPanelOpen(false); }); panelNarrowMQ.addEventListener('change', e => setPanelOpen(!e.matches));
setPanelOpen(!panelNarrowMQ.matches); // open on desktop/tablet, closed on phones, by default
// segmented mode control // segmented mode control
document.querySelectorAll('#modeSeg button').forEach(b => { document.querySelectorAll('#modeSeg button').forEach(b => {
@@ -1189,8 +1196,23 @@ function onError(err, isDefault, name) {
function escapeHtml(s) { return String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); } function escapeHtml(s) { return String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
// file input + drag & drop // file input + drag & drop
// Android's WebView runs the page in a separate renderer process with its own
// memory ceiling (independent of the app's Java heap) — a multi-GB .pcd blows
// through it and the renderer dies, taking the whole app down with no JS
// exception to catch. 300MB is an empirical line (233MB survived, 1.35GB
// didn't test on a Galaxy Z Fold) — adjust if it proves too strict or too loose.
const IS_ANDROID = /Android/i.test(navigator.userAgent);
const MOBILE_MAX_FILE_MB = 300;
function openFile(file) { function openFile(file) {
if (!/\.pcd$/i.test(file.name)) { onError(new Error('.pcd 파일이 아닙니다'), false, file.name); return; } if (!/\.pcd$/i.test(file.name)) { onError(new Error('.pcd 파일이 아닙니다'), false, file.name); return; }
if (IS_ANDROID && file.size > MOBILE_MAX_FILE_MB * 1024 * 1024) {
onError(new Error(
`${(file.size / 1024 / 1024).toFixed(0)}MB — 모바일 WebView 렌더러 메모리 한계(약 ${MOBILE_MAX_FILE_MB}MB)를 ` +
`넘습니다. 이대로 열면 앱이 강제 종료됩니다. 더 작은/다운샘플된 파일을 쓰거나 데스크톱에서 여세요.`
), false, file.name);
return;
}
loadPCD(URL.createObjectURL(file), file.name, { revoke: true }); loadPCD(URL.createObjectURL(file), file.name, { revoke: true });
} }
fileInput.onchange = e => { if (e.target.files[0]) openFile(e.target.files[0]); e.target.value = ''; }; fileInput.onchange = e => { if (e.target.files[0]) openFile(e.target.files[0]); e.target.value = ''; };