Add CLIP sweep animation (start↔end ping-pong)

Lets the cross-section plane sweep back and forth between two positions
like a CT scan, so registration drift between passes shows up as the
section moves through the cloud instead of needing manual dragging.
This commit is contained in:
Dongubak
2026-08-23 14:32:21 +09:00
parent 6d995dfe44
commit 75d54de763
+70 -2
View File
@@ -371,6 +371,27 @@ try {
<span class="lbl">반전</span>
<label class="switch"><input type="checkbox" id="clipFlip"><span class="track"></span></label>
</div>
<div class="ctrl">
<span class="lbl">스윕 시작</span>
<input type="text" id="clipSweepStart" inputmode="decimal" aria-label="스윕 시작 위치(m)"
style="flex:0 0 68px; background:var(--inset); color:var(--txt); border:1px solid var(--line);
padding:6px 7px; font:inherit; font-size:10.5px; text-align:right">
</div>
<div class="ctrl">
<span class="lbl">스윕 끝</span>
<input type="text" id="clipSweepEnd" inputmode="decimal" aria-label="스윕 끝 위치(m)"
style="flex:0 0 68px; background:var(--inset); color:var(--txt); border:1px solid var(--line);
padding:6px 7px; font:inherit; font-size:10.5px; text-align:right">
</div>
<div class="ctrl">
<span class="lbl">스윕 속도</span>
<input id="clipSweepSpeed" type="range" min="0.05" max="5" step="0.05" value="0.5" aria-label="스윕 속도">
<span class="val" id="clipSweepSpeedVal">0.50 m/s</span>
</div>
<div class="actions">
<button class="btn primary" id="clipPlayBtn">재생</button>
<button class="btn" id="clipStopBtn" disabled>정지</button>
</div>
</div>
</div>
@@ -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();
}