diff --git a/pcd_viewer.html b/pcd_viewer.html index 08b02a3..ee20388 100644 --- a/pcd_viewer.html +++ b/pcd_viewer.html @@ -371,6 +371,27 @@ try { 반전 +
+ 스윕 시작 + +
+
+ 스윕 끝 + +
+
+ 스윕 속도 + + 0.50 m/s +
+
+ + +
@@ -534,6 +555,9 @@ const fpStartBtn = $('fpStart'), fpExitBtn = $('fpExit'), fpSpeedEl = $('fpSpeed'), fpSpeedVal = $('fpSpeedVal'), fpEyeEl = $('fpEye'), fpMsgEl = $('fpMsg'); const clipOnEl = $('clipOn'), clipRowsEl = $('clipRows'), clipPosEl = $('clipPos'), clipPosVal = $('clipPosVal'), clipFlipEl = $('clipFlip'); +const clipSweepStartEl = $('clipSweepStart'), clipSweepEndEl = $('clipSweepEnd'), + clipSweepSpeedEl = $('clipSweepSpeed'), clipSweepSpeedVal = $('clipSweepSpeedVal'), + clipPlayBtn = $('clipPlayBtn'), clipStopBtn = $('clipStopBtn'); // ─── theme (dark default; light persisted via localStorage) ────────────── const themeButtons = document.querySelectorAll('.theme-toggle button'); @@ -605,6 +629,9 @@ function syncClipRange() { if (!(v >= min && v <= max)) clipPosEl.value = (min + max) / 2; fillSlider(clipPosEl); clipPosVal.textContent = parseFloat(clipPosEl.value).toFixed(3) + ' m'; + // full-extent default sweep range; the user can narrow it by editing the fields + clipSweepStartEl.value = min.toFixed(3); + clipSweepEndEl.value = max.toFixed(3); } // applied to whichever material is current — must be re-called whenever `points` @@ -621,11 +648,13 @@ function applyClip() { clipOnEl.onchange = () => { clipRowsEl.hidden = !clipOnEl.checked; + if (!clipOnEl.checked) stopClipSweep(); if (clipOnEl.checked) syncClipRange(); applyClip(); }; document.querySelectorAll('#clipRows .seg button').forEach(b => { b.onclick = () => { + stopClipSweep(); clipAxis = b.dataset.axis; document.querySelectorAll('#clipRows .seg button').forEach(x => x.classList.toggle('active', x === b)); syncClipRange(); @@ -633,12 +662,51 @@ document.querySelectorAll('#clipRows .seg button').forEach(b => { }; }); clipPosEl.oninput = () => { + stopClipSweep(); // dragging the handle manually takes over from the sweep fillSlider(clipPosEl); clipPosVal.textContent = parseFloat(clipPosEl.value).toFixed(3) + ' m'; applyClip(); }; clipFlipEl.onchange = applyClip; +// ─── CLIP sweep: animate the plane start↔end like a CT scan, so registration +// drift between passes shows up as the section moves through the cloud ── +let clipSweepRAF = null, clipSweepT0 = 0; +function syncClipSweepSpeed() { clipSweepSpeedVal.textContent = parseFloat(clipSweepSpeedEl.value).toFixed(2) + ' m/s'; } +clipSweepSpeedEl.oninput = syncClipSweepSpeed; +syncClipSweepSpeed(); + +function stopClipSweep() { + if (clipSweepRAF == null) return; + cancelAnimationFrame(clipSweepRAF); + clipSweepRAF = null; + clipPlayBtn.disabled = false; clipStopBtn.disabled = true; +} + +function startClipSweep() { + // clipPlayBtn only exists inside #clipRows, which is only shown while clip is + // on (see clipOnEl.onchange) — so clip is already enabled whenever this runs. + if (!points) return; + const start = parseFloat(clipSweepStartEl.value), end = parseFloat(clipSweepEndEl.value); + if (!isFinite(start) || !isFinite(end) || start === end) return; + const speed = parseFloat(clipSweepSpeedEl.value) || 0.5; // m/s + const durationMs = Math.abs(end - start) / speed * 1000; // one-way leg + clipSweepT0 = performance.now(); + clipPlayBtn.disabled = true; clipStopBtn.disabled = false; + const tick = now => { + const t = ((now - clipSweepT0) / durationMs) % 2; // 0..2, one full there-and-back + const frac = t <= 1 ? t : 2 - t; // triangle wave: 0→1→0, ping-pong + clipPosEl.value = start + (end - start) * frac; + fillSlider(clipPosEl); + clipPosVal.textContent = parseFloat(clipPosEl.value).toFixed(3) + ' m'; + applyClip(); + clipSweepRAF = requestAnimationFrame(tick); + }; + clipSweepRAF = requestAnimationFrame(tick); +} +clipPlayBtn.onclick = startClipSweep; +clipStopBtn.onclick = stopClipSweep; + // ─── click vs drag ─────────────────────────────────────────────────────── let downPos = null; renderer.domElement.addEventListener('pointerdown', e => { @@ -1042,7 +1110,7 @@ function setPoints(mesh, name) { points.geometry.computeBoundingBox(); points.geometry.computeBoundingSphere(); frameView(); - syncClipRange(); applyClip(); // new material has no clippingPlanes of its own + stopClipSweep(); syncClipRange(); applyClip(); // new material has no clippingPlanes of its own // rail telemetry: point count, filename, color availability const n = points.geometry.attributes.position.count; @@ -1312,7 +1380,7 @@ function ensureLiveCloud() { autoFrameAt = 0; useRgbEl.disabled = false; rgbLbl.style.opacity = '1'; rgbLbl.textContent = '원본 색상'; srcVal.textContent = bound.cloud || 'LIVE'; srcVal.title = bound.cloud || ''; - applyClip(); // new material has no clippingPlanes of its own + stopClipSweep(); applyClip(); // new material has no clippingPlanes of its own renderReadout(); }