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();
}