Files
duru_pcd_viewer/pcd_viewer.html
T
Dongubak 2f570681eb Fix rail rendering under the Android status bar
targetSdk 36 (Android 15+) forces edge-to-edge, so the fixed-position
top rail was drawing behind the status bar clock/battery icons on a
real device (title and DARK/LIGHT toggle both obscured) — invisible on
desktop since there's no inset there. Add env(safe-area-inset-top) via
a --rail-h variable used everywhere the rail's height was hardcoded as
42px, plus viewport-fit=cover so the inset actually gets populated.

Verified on a physical Galaxy Z Fold over adb.
2026-08-23 15:11:05 +09:00

1598 lines
74 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<!-- user-scalable=no: the canvas has its own pinch-to-zoom (OrbitControls) —
letting the OS page-zoom fight it over the same two-finger gesture is worse
than losing native page zoom, which this app has no text layout to need anyway. -->
<title>PCD Viewer — 포인트 클라우드 계측</title>
<script>
// Applied synchronously, before first paint, so a stored "light" preference
// doesn't flash dark UI for a frame. The module script re-applies this to
// the 3D viewport (scene background, ink colors) once Three.js is up.
try {
if (localStorage.getItem('pcdViewerTheme') === 'light')
document.documentElement.dataset.theme = 'light';
} catch (e) {}
</script>
<style>
/* ── Austere instrument.
Chrome is monochrome throughout: no blur, no shadow, no radius, no accent hue.
Hierarchy comes from hairlines, scale and space alone.
The only color in the app is marker identity (viewport + its legend). ── */
:root{
/* rail height + the device's own status-bar/notch inset (0 on desktop) —
every fixed element anchored below the rail must offset by this, not 42px */
--rail-h:calc(42px + env(safe-area-inset-top));
--bg:#0a0b0c; /* viewport ground, neutral near-black */
--panel:#0c0d0f;
--line:rgba(255,255,255,0.10);
--line-strong:rgba(255,255,255,0.22);
--inset:rgba(255,255,255,0.025);
--hover:rgba(255,255,255,0.05);
--hover-strong:rgba(255,255,255,0.07);
--overlay:rgba(10,11,12,0.88);
--ink-strong:#ffffff; /* .btn.primary hover — ink pushed to its extreme */
--txt:#e8e9ea; /* ink */
--muted:#8a8d92;
--faint:#5a5e64;
/* the app's only color: 3D marker identity, mirrored in the readout legend */
--pt-a:#d24b3f; --pt-b:#4a9e7f; --origin:#cf8a2f; --measure:#e8e9ea;
--mono:ui-monospace,"SF Mono",Menlo,"Cascadia Code",monospace;
}
/* Light theme — same austere structure, values inverted. Marker hues (pt-a/
pt-b/origin) stay fixed across themes: they're identity, not chrome. */
:root[data-theme="light"]{
--bg:#f4f5f6;
--panel:#eceeef;
--line:rgba(0,0,0,0.10);
--line-strong:rgba(0,0,0,0.22);
--inset:rgba(0,0,0,0.035);
--hover:rgba(0,0,0,0.05);
--hover-strong:rgba(0,0,0,0.07);
--overlay:rgba(244,245,246,0.88);
--ink-strong:#000000;
--txt:#17181a;
--muted:#6b6e73;
--faint:#a3a6ab;
--measure:#17181a;
}
*{ box-sizing:border-box; }
html,body{ margin:0; height:100%; overflow:hidden; overscroll-behavior:none; background:var(--bg); color:var(--txt);
font-family:var(--mono); -webkit-font-smoothing:antialiased; }
#app{ position:fixed; inset:0; }
canvas{ display:block; }
body.fp-locked, body.fp-locked canvas{ cursor:none; }
/* ── Top rail: identity, session telemetry ── */
#rail{
position:fixed; top:0; left:0; right:0; height:var(--rail-h); z-index:11;
padding-top:env(safe-area-inset-top); /* status bar/notch — the 42px content stays below it */
background:var(--panel); border-bottom:1px solid var(--line);
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; }
/* drawer toggle — only shown under the small-screen media query below */
#panelToggle{
display:none; 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;
}
#panelToggle:hover{ background:var(--hover-strong); }
#panelBackdrop{ display:none; }
#rail h1{ font-size:11.5px; font-weight:500; margin:0; letter-spacing:0.22em; text-transform:uppercase; }
#rail .tag{ font-size:9.5px; color:var(--faint); letter-spacing:0.14em; white-space:nowrap; }
#rail .rail-right{ display:flex; align-items:center; gap:16px; flex:none; }
#rail .tele{ display:flex; align-items:center; min-width:0; }
.theme-toggle{ display:flex; border:1px solid var(--line); flex:none; }
.theme-toggle button{
background:transparent; color:var(--faint); border:none; border-right:1px solid var(--line);
padding:4px 9px; font:inherit; font-size:9px; letter-spacing:0.14em;
cursor:pointer; transition:background .12s, color .12s;
}
.theme-toggle button:last-child{ border-right:none; }
.theme-toggle button:hover{ color:var(--txt); }
.theme-toggle button.active{ background:var(--txt); color:var(--bg); }
.chip{ display:flex; align-items:baseline; gap:8px; padding:0 14px; border-left:1px solid var(--line);
font-size:9.5px; color:var(--faint); letter-spacing:0.16em; white-space:nowrap;
font-variant-numeric:tabular-nums; }
.chip:last-child{ padding-right:0; }
.chip .v{ font-size:10.5px; color:var(--txt); letter-spacing:0.02em;
max-width:210px; overflow:hidden; text-overflow:ellipsis; }
/* live source: dark square until something actually connects */
.chip.live .v{ display:flex; align-items:center; gap:8px; color:var(--muted); }
.chip.live .v::before{ content:""; width:6px; height:6px; background:var(--faint); }
.chip.live.on .v{ color:var(--pt-b); }
.chip.live.on .v::before{ background:var(--pt-b); }
/* ── Docked instrument column ── */
#panel{
position:fixed; top:var(--rail-h); left:0; bottom:0; z-index:10; width:284px;
background:var(--panel); border-right:1px solid var(--line);
display:flex; flex-direction:column; overflow-y:auto;
}
.sect{ padding:16px 18px; border-bottom:1px solid var(--line); display:flex; flex-direction:column; gap:12px; }
.sect:last-child{ border-bottom:none; }
.lab{ font-size:9.5px; letter-spacing:0.18em; color:var(--faint); }
/* segmented mode control — inversion marks the active mode, not a hue */
.seg{ display:flex; border:1px solid var(--line); }
.seg button{
flex:1; background:transparent; color:var(--muted); border:none; border-right:1px solid var(--line);
padding:9px 0; font:inherit; font-size:10.5px; letter-spacing:0.06em;
cursor:pointer; transition:background .12s, color .12s;
}
.seg button:last-child{ border-right:none; }
.seg button:not(.active):hover{ color:var(--txt); background:var(--hover); }
.seg button.active{ background:var(--txt); color:var(--bg); }
/* live readout */
.readout{
background:var(--inset); border:1px solid var(--line);
padding:13px; min-height:86px; display:flex; flex-direction:column; gap:9px;
font-variant-numeric:tabular-nums;
}
.lead{ font-size:11px; color:var(--muted); line-height:1.65; word-break:keep-all; }
.lead.err{ color:var(--pt-a); }
.metric{ display:flex; align-items:baseline; gap:7px; }
.metric .big{ font-size:25px; font-weight:500; letter-spacing:-0.02em; }
.metric .unit{ font-size:11px; color:var(--muted); }
.metric.dist .big{ color:var(--measure); }
.rows{ display:flex; flex-direction:column; gap:4px; font-size:10.5px; }
.rows .r{ display:flex; justify-content:space-between; gap:10px; color:var(--txt); }
.rows .r .k{ color:var(--muted); }
.dot{ display:inline-block; width:6px; height:6px; margin-right:7px; vertical-align:middle; }
.dot.a{ background:var(--pt-a); } .dot.b{ background:var(--pt-b); } .dot.o{ background:var(--origin); }
.delta{ font-size:11px; color:var(--txt); }
.delta .axis{ color:var(--muted); }
/* live source: address, topic bindings */
.field{ display:flex; align-items:center; gap:8px; }
.field .k{ font-size:9.5px; color:var(--faint); letter-spacing:0.14em; width:46px; flex:none; }
input[type=text], select{
flex:1; min-width:0; background:var(--inset); color:var(--txt); border:1px solid var(--line);
padding:6px 7px; font:inherit; font-size:10.5px; appearance:none; -webkit-appearance:none;
}
select{ cursor:pointer; }
input[type=text]:focus, select:focus{ outline:none; border-color:var(--line-strong); }
.btn.slim{ flex:none; padding:6px 13px; }
.rowset{ display:flex; flex-direction:column; gap:7px; }
.rowset[hidden]{ display:none; }
/* telemetry — measured, never estimated */
.tele-rows{ display:flex; flex-direction:column; gap:5px; font-size:10.5px; font-variant-numeric:tabular-nums; }
.tele-rows .r{ display:flex; justify-content:space-between; gap:10px; color:var(--txt); }
.tele-rows .r .k{ color:var(--muted); letter-spacing:0.06em; }
.tele-rows .r .stale{ color:var(--faint); }
.meter{ height:1px; background:var(--line-strong); }
.meter > div{ height:100%; width:0; background:var(--txt); transition:width .3s; }
/* camera feed */
#cam{ position:fixed; right:0; bottom:0; z-index:9; width:340px; display:none;
background:var(--panel); border-left:1px solid var(--line); border-top:1px solid var(--line); }
#cam.on{ display:block; }
#cam .capbar{ display:flex; align-items:baseline; justify-content:space-between; gap:10px;
padding:9px 12px; border-bottom:1px solid var(--line);
font-size:9.5px; letter-spacing:0.16em; color:var(--faint); }
#cam .capbar .t{ font-size:10px; color:var(--muted); letter-spacing:0.02em;
max-width:210px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
#cam img{ display:block; width:100%; background:#000; }
/* controls */
.ctrl{ display:flex; align-items:center; justify-content:space-between; gap:12px; font-size:10.5px; }
.ctrl .lbl{ color:var(--muted); letter-spacing:0.04em; }
.ctrl .val{ font-size:10.5px; color:var(--txt); font-variant-numeric:tabular-nums; min-width:40px; text-align:right; }
input[type=range]{ -webkit-appearance:none; appearance:none; width:104px; height:12px;
background:transparent; cursor:pointer; }
input[type=range]::-webkit-slider-runnable-track{ height:1px;
background:linear-gradient(var(--txt),var(--txt)) no-repeat, var(--line-strong);
background-size:var(--fill,50%) 100%; }
input[type=range]::-webkit-slider-thumb{ -webkit-appearance:none; width:9px; height:9px;
margin-top:-4px; background:var(--txt); }
/* switch — a square that fills when on */
.switch{ position:relative; width:14px; height:14px; flex:none; }
.switch input{ opacity:0; width:0; height:0; position:absolute; }
.switch .track{ position:absolute; inset:0; border:1px solid var(--line-strong); transition:.12s; }
.switch input:checked + .track{ background:var(--txt); border-color:var(--txt); }
.switch input:checked + .track::before{ content:""; position:absolute; inset:3px; background:var(--bg); }
.switch input:disabled + .track{ opacity:0.35; }
.switch input:focus-visible + .track{ outline:1px solid var(--txt); outline-offset:2px; }
/* actions */
.actions{ display:flex; gap:8px; }
.btn{
flex:1; background:transparent; color:var(--txt); border:1px solid var(--line-strong);
padding:9px 0; font:inherit; font-size:10px; letter-spacing:0.06em;
cursor:pointer; transition:background .12s, border-color .12s, color .12s;
}
.btn:hover{ background:var(--hover-strong); border-color:var(--txt); }
.btn.primary{ background:var(--txt); border-color:var(--txt); color:var(--bg); }
.btn.primary:hover{ background:var(--ink-strong); border-color:var(--ink-strong); }
.btn:disabled{ opacity:0.35; cursor:not-allowed; }
.btn:disabled:hover{ background:transparent; border-color:var(--line-strong); }
/* preset-view grid — same visual language as .btn, smaller footprint (7 in a 284px column) */
.viewgrid{ display:grid; grid-template-columns:repeat(4,1fr); gap:6px; }
.viewgrid button{
background:transparent; color:var(--txt); border:1px solid var(--line-strong);
padding:7px 0; font:inherit; font-size:9px; letter-spacing:0.04em;
cursor:pointer; transition:background .12s, border-color .12s;
}
.viewgrid button:hover{ background:var(--hover-strong); border-color:var(--txt); }
/* footer hint — pinned to the foot of the column; the gap above is room for future modules */
.sect.foot{ margin-top:auto; border-top:1px solid var(--line); border-bottom:none; }
.hint{ font-size:9.5px; color:var(--faint); line-height:1.95; word-break:keep-all; }
.hint kbd{ font:inherit; font-size:9.5px; color:var(--muted); border:1px solid var(--line); padding:0 4px; }
:focus-visible{ outline:1px solid var(--txt); outline-offset:2px; }
/* ── loading overlay ── */
#loading{ position:fixed; inset:0; z-index:20; display:flex; flex-direction:column;
align-items:center; justify-content:center; gap:18px; background:var(--bg); }
#loading .title{ font-size:11.5px; color:var(--txt); letter-spacing:0.1em; }
#loading .sub{ font-size:10.5px; color:var(--muted); letter-spacing:0.04em; font-variant-numeric:tabular-nums; }
.bar{ width:240px; height:1px; background:var(--line-strong); overflow:hidden; }
.bar > div{ height:100%; width:0; background:var(--txt); transition:width .12s; }
#loading .load-live{ display:flex; flex-direction:column; align-items:center; gap:18px; }
#loading .prompt{ display:none; flex-direction:column; align-items:center; gap:16px; }
#loading .prompt .btn{ flex:none; width:180px; } /* no row to stretch into */
#loading.empty .load-live{ display:none; }
#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 */
#loading.empty{ top:var(--rail-h); left:284px; background:transparent; pointer-events:none; }
#loading .glyph{ font-size:10px; color:var(--faint); letter-spacing:0.5em; text-indent:0.5em; }
/* ── drag & drop overlay ── */
#drop{ position:fixed; inset:0; z-index:30; display:none; align-items:center; justify-content:center;
background:var(--overlay); }
#drop.show{ display:flex; }
#drop .frame{ border:1px dashed var(--line-strong); padding:46px 64px;
color:var(--txt); font-size:12px; letter-spacing:0.06em; text-align:center; }
#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) —
the instrument column becomes a drawer instead of eating a third of the
width, since it's a fixed 284px regardless of viewport size ── */
@media (max-width:768px){
#panelToggle{ display:block; }
#panel{
width:min(284px, 84vw);
transform:translateX(-100%);
transition:transform .18s ease;
}
#panel.open{ transform:translateX(0); }
#panelBackdrop{
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;
}
#panelBackdrop.show{ opacity:1; pointer-events:auto; }
#cam{ width:min(340px, 92vw); }
}
@media (max-width:480px){
#rail{ padding:0 10px; gap:8px; }
#rail .tag{ display:none; } /* subtitle — first to go */
#rail .tele .chip:nth-child(2), #rail .tele .chip:nth-child(3){ display:none; } /* keep LIVE only */
}
@media (prefers-reduced-motion:reduce){ *{ transition:none !important; } }
</style>
</head>
<body>
<div id="app"></div>
<div id="loading" class="empty">
<div class="load-live">
<div class="title" id="loadTitle">포인트 클라우드 로딩 중</div>
<div class="bar"><div id="barfill"></div></div>
<div class="sub" id="loadtxt"></div>
</div>
<div class="prompt">
<div class="glyph">PCD</div>
<div class="title">포인트 클라우드를 여세요</div>
<div class="sub">.pcd 파일을 창에 드래그하거나</div>
<button class="btn primary" id="openPrompt">파일 열기</button>
</div>
</div>
<div id="drop"><div class="frame">여기에 놓아 로드<span class="s">.pcd</span></div></div>
<div id="rail">
<div class="mark">
<button id="panelToggle" aria-label="패널 열기/닫기" aria-expanded="false" aria-controls="panel">MENU</button>
<h1>PCD Viewer</h1>
<span class="tag">FAST-LIVO2&nbsp;·&nbsp;POINT&nbsp;CLOUD&nbsp;METROLOGY</span>
</div>
<div class="rail-right">
<div class="tele">
<span class="chip live" id="liveChip">LIVE<span class="v" id="liveVal">미연결</span></span>
<span class="chip">PTS<span class="v" id="ptVal"></span></span>
<span class="chip">SOURCE<span class="v" id="srcVal"></span></span>
</div>
<div class="theme-toggle" role="group" aria-label="테마">
<button data-theme="dark" aria-label="다크 모드">DARK</button>
<button data-theme="light" aria-label="라이트 모드">LIGHT</button>
</div>
</div>
</div>
<div id="panelBackdrop"></div>
<div id="panel">
<div class="sect">
<div class="lab">LIVE · FAST-LIVO2</div>
<div class="field">
<input type="text" id="wsUrl" value="ws://localhost:9090" spellcheck="false" aria-label="rosbridge 주소">
<button class="btn slim" id="connBtn">연결</button>
</div>
<div class="rowset" id="topicRows" hidden>
<div class="field"><span class="k">CLOUD</span><select id="topCloud" aria-label="포인트 클라우드 토픽"></select></div>
<div class="field"><span class="k">ODOM</span><select id="topOdom" aria-label="오도메트리 토픽"></select></div>
<div class="field"><span class="k">PATH</span><select id="topPath" aria-label="궤적 토픽"></select></div>
<div class="field"><span class="k">IMAGE</span><select id="topImage" aria-label="카메라 토픽"></select></div>
</div>
<div class="ctrl">
<span class="lbl">복셀 누적</span>
<label class="switch"><input type="checkbox" id="voxOn" checked><span class="track"></span></label>
</div>
<div class="ctrl">
<span class="lbl">복셀 크기</span>
<input id="voxSize" type="range" min="0.01" max="0.5" step="0.01" value="0.05" aria-label="복셀 크기">
<span class="val" id="voxVal">0.05 m</span>
</div>
<div class="lead" id="liveMsg">rosbridge 서버 주소를 넣고 연결하세요.</div>
</div>
<div class="sect">
<div class="lab">TELEMETRY</div>
<div class="tele-rows">
<div class="r"><span class="k">CLOUD</span><span id="hzCloud" class="stale"></span></div>
<div class="r"><span class="k">ODOM</span><span id="hzOdom" class="stale"></span></div>
<div class="r"><span class="k">IMAGE</span><span id="hzImage" class="stale"></span></div>
<div class="r"><span class="k">복셀</span><span id="voxTxt" class="stale"></span></div>
<div class="r"><span class="k">버퍼</span><span id="bufTxt" class="stale"></span></div>
</div>
<div class="meter"><div id="bufFill"></div></div>
</div>
<div class="sect">
<div class="lab">MODE</div>
<div class="seg" id="modeSeg" role="tablist" aria-label="측정 모드">
<button data-mode="none" class="active" role="tab">탐색</button>
<button data-mode="measure" role="tab">거리</button>
<button data-mode="position" role="tab">포지션</button>
</div>
<div class="readout" id="readout"></div>
</div>
<div class="sect">
<div class="lab">VIEW · 정해진 시점</div>
<div class="viewgrid">
<button data-view="top">TOP</button>
<button data-view="bottom">BOTTOM</button>
<button data-view="front">FRONT</button>
<button data-view="back">BACK</button>
<button data-view="left">LEFT</button>
<button data-view="right">RIGHT</button>
<button data-view="iso">ISO</button>
</div>
</div>
<div class="sect">
<div class="lab">DISPLAY</div>
<div class="ctrl">
<span class="lbl">점 크기</span>
<input id="size" type="range" min="0.005" max="0.2" step="0.005" value="0.03" aria-label="점 크기">
<span class="val" id="sizeVal">0.030</span>
</div>
<div class="ctrl">
<span class="lbl" id="rgbLbl">원본 색상</span>
<label class="switch"><input type="checkbox" id="useRgb" checked><span class="track"></span></label>
</div>
</div>
<div class="sect">
<div class="lab">CLIP · 단면 보기</div>
<div class="ctrl">
<span class="lbl">활성화</span>
<label class="switch"><input type="checkbox" id="clipOn"><span class="track"></span></label>
</div>
<div class="rowset" id="clipRows" hidden>
<div class="seg" role="tablist" aria-label="단면 축">
<button data-axis="x" role="tab">X</button>
<button data-axis="y" role="tab">Y</button>
<button data-axis="z" class="active" role="tab">Z</button>
</div>
<div class="ctrl">
<span class="lbl">위치</span>
<input id="clipPos" type="range" min="0" max="1" step="0.001" value="0.5" aria-label="단면 위치">
<span class="val" id="clipPosVal"></span>
</div>
<div class="ctrl">
<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>
<div class="sect">
<div class="lab">WALK · 1인칭 이동</div>
<div class="actions">
<button class="btn primary" id="fpStart">시작</button>
<button class="btn" id="fpExit" disabled>종료</button>
</div>
<div class="ctrl">
<span class="lbl">속도</span>
<input id="fpSpeed" type="range" min="0.5" max="20" step="0.5" value="3" aria-label="이동 속도">
<span class="val" id="fpSpeedVal">3.0</span>
</div>
<div class="ctrl">
<span class="lbl">눈높이</span>
<input type="text" id="fpEye" value="1.6" inputmode="decimal" aria-label="눈높이(m)"
style="flex:0 0 56px; 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="lead" id="fpMsg">시작(또는 <kbd>V</kbd>)을 누르면 마지막 클릭 지점(없으면 중심)에서 걷기 시작합니다.</div>
</div>
<div class="sect">
<div class="lab">ACTIONS</div>
<div class="actions">
<button class="btn primary" id="openBtn">파일 열기</button>
<button class="btn" id="frameBtn">뷰 맞춤</button>
<button class="btn" id="clearBtn">초기화</button>
</div>
</div>
<div class="sect foot">
<div class="hint">
좌드래그 회전 · 우드래그 이동 · 커서 위치로 휠 확대 · 더블클릭 회전 중심 이동<br>
모드 ON → 점 클릭 · <kbd>Esc</kbd> 측정 초기화 · <kbd>F</kbd> 뷰 맞춤<br>
<kbd>V</kbd> WALK 시작 → 마우스 이동으로 시점 회전(안 되면 클릭 후 드래그) · <kbd>WASD</kbd> 이동 ·
<kbd>Q/E</kbd> 상하 · <kbd>Shift</kbd> 가속 · <kbd>Esc</kbd> 종료
</div>
</div>
</div>
<div id="cam">
<div class="capbar">CAMERA<span class="t" id="camTopic"></span></div>
<img id="camImg" alt="카메라 피드">
</div>
<input type="file" id="fileInput" accept=".pcd" style="display:none">
<script>
// Fallback: if the ES module (Three.js from CDN) fails to load/parse,
// show a readable message instead of a blank or broken page.
window.__pcdBooted = false;
function pcdFail(msg){
var l = document.getElementById('loading');
if (!l) return;
l.classList.remove('empty'); l.style.display = 'flex';
var live = l.querySelector('.load-live');
if (live) live.innerHTML =
'<div class="title" style="color:var(--pt-a)">뷰어를 불러오지 못했습니다</div>' +
'<div class="sub">' + msg + '</div>' +
'<div class="sub" style="color:var(--faint)">인터넷 연결(Three.js CDN)과 HTTP 서버 접속을 확인하세요.</div>';
}
window.addEventListener('error', function(e){
if (!window.__pcdBooted && (e.filename || (e.message && /import|module|three/i.test(e.message))))
pcdFail(e.message || '스크립트 로드 오류');
});
setTimeout(function(){ if (!window.__pcdBooted) pcdFail('로딩 시간이 초과되었습니다 (CDN 응답 없음).'); }, 12000);
</script>
<script type="importmap">
{
"imports": {
"three": "./vendor/three/three.module.js",
"three/addons/": "./vendor/three/addons/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { PCDLoader } from 'three/addons/loaders/PCDLoader.js';
window.__pcdBooted = true; // module + CDN loaded OK → cancels the boot fallback
const DEFAULT_URL = 'all_raw_points.pcd';
// Viewport palette — mirrors the :root marker vars; keep the two in step.
// bg/mono/measure are ink-tied and flip with the theme; a/b/origin/link are
// marker identity and stay fixed (see applyTheme() below).
const THEME_BG = { dark: 0x0a0b0c, light: 0xf4f5f6 };
const THEME_MONO = { dark: 0xaeb3ba, light: 0x4a4d52 }; // cloud tint when RGB is off
const THEME_MEASURE = { dark: 0xe8e9ea, light: 0x17181a }; // measure line + label: ink, not a hue
let currentTheme = document.documentElement.dataset.theme === 'light' ? 'light' : 'dark';
const MARK = {
a: 0xd24b3f, b: 0x4a9e7f, origin: 0xcf8a2f,
measure: THEME_MEASURE[currentTheme],
link: 0x8a8d92, // origin→point connector
mono: THEME_MONO[currentTheme]
};
// ─── renderer / scene / camera ───────────────────────────────────────────
const app = document.getElementById('app');
const scene = new THREE.Scene();
scene.background = new THREE.Color(THEME_BG[currentTheme]);
const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.01, 10000);
camera.position.set(0, 0, 10);
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.localClippingEnabled = true; // for the CLIP section's cross-section plane
app.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.zoomToCursor = true; // zoom toward the cursor, not the orbit target — matches CAD/CloudCompare
// ─── on-demand rendering (perf: idle frames cost nothing) ────────────────
let needsRender = true;
const invalidate = () => { needsRender = true; };
controls.addEventListener('change', invalidate);
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
invalidate();
});
// ─── state ───────────────────────────────────────────────────────────────
const SPHERE_GEO = new THREE.SphereGeometry(1, 16, 16); // shared unit sphere
let mode = 'none'; // 'none' | 'measure' | 'position'
let points = null; // the active cloud — from a file, or the live accumulator
let live = null; // { obj, pa, ca, count, dirty, full } while streaming
let measureMarkers = []; // [{ mesh, pos }]
let measureLine = null, measureLabel = null;
let originPoint = null;
let posObjects = []; // spheres (isMesh) + lines (isLine)
// ─── FP walk (Z-up: ROS ground vehicles move in XY, Z is altitude) ───────
let fpActive = false;
let fpYaw = 0, fpPitch = 0;
let fpSpeed = 3; // m/s
const fpMove = { f: false, b: false, l: false, r: false, u: false, d: false };
let fpSprint = false;
let lastPicked = null; // most recent point clicked (measure/position) — walk start
// ─── DOM ─────────────────────────────────────────────────────────────────
const $ = id => document.getElementById(id);
const readoutEl = $('readout'), ptVal = $('ptVal'), srcVal = $('srcVal');
const sizeEl = $('size'), sizeVal = $('sizeVal'), useRgbEl = $('useRgb'), rgbLbl = $('rgbLbl');
const fileInput = $('fileInput'), loadingEl = $('loading'), dropEl = $('drop');
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');
function applyTheme(name) {
currentTheme = name === 'light' ? 'light' : 'dark';
document.documentElement.dataset.theme = currentTheme === 'light' ? 'light' : '';
try { localStorage.setItem('pcdViewerTheme', currentTheme); } catch (e) {}
themeButtons.forEach(b => b.classList.toggle('active', b.dataset.theme === currentTheme));
scene.background.setHex(THEME_BG[currentTheme]);
MARK.mono = THEME_MONO[currentTheme];
MARK.measure = THEME_MEASURE[currentTheme];
if (points && !useRgbEl.checked) points.material.color.setHex(MARK.mono);
if (measureLine) measureLine.material.color.setHex(MARK.measure);
if (odomObj) odomObj.material.color.setHex(MARK.measure);
invalidate();
}
themeButtons.forEach(b => b.onclick = () => applyTheme(b.dataset.theme));
// ─── small-screen drawer: the instrument column overlays instead of docking ──
const panelEl = $('panel'), panelToggleEl = $('panelToggle'), panelBackdropEl = $('panelBackdrop');
function setPanelOpen(open) {
panelEl.classList.toggle('open', open);
panelBackdropEl.classList.toggle('show', open);
panelToggleEl.setAttribute('aria-expanded', String(open));
}
panelToggleEl.onclick = () => setPanelOpen(!panelEl.classList.contains('open'));
panelBackdropEl.onclick = () => setPanelOpen(false);
matchMedia('(max-width:768px)').addEventListener('change', e => { if (!e.matches) setPanelOpen(false); });
// segmented mode control
document.querySelectorAll('#modeSeg button').forEach(b => {
b.onclick = () => setMode(b.dataset.mode);
});
function setMode(m) {
mode = m;
document.querySelectorAll('#modeSeg button').forEach(b =>
b.classList.toggle('active', b.dataset.mode === m));
renderReadout();
}
$('clearBtn').onclick = () => { clearMeasurement(); clearPosition(); renderReadout(); };
$('frameBtn').onclick = frameView;
$('openBtn').onclick = () => fileInput.click();
$('openPrompt').onclick = () => fileInput.click();
// sliders (with visual fill)
function fillSlider(el) {
el.style.setProperty('--fill', ((el.value - el.min) / (el.max - el.min) * 100) + '%');
}
function syncSlider() { fillSlider(sizeEl); sizeVal.textContent = parseFloat(sizeEl.value).toFixed(3); }
sizeEl.oninput = () => {
syncSlider();
if (points) { points.material.size = parseFloat(sizeEl.value); invalidate(); }
};
syncSlider();
// ─── BUGFIX 1: re-enabling color must reset tint back to white ───────────
useRgbEl.onchange = () => {
if (!points) return;
const mat = points.material;
mat.vertexColors = useRgbEl.checked;
mat.color.set(useRgbEl.checked ? 0xffffff : MARK.mono);
mat.needsUpdate = true;
invalidate();
};
// ─── CLIP: axis-aligned cross-section — cut into the cloud to check registration
// (does two passes over the same area line up?) without hiding the whole side ──
let clipAxis = 'z';
const clipPlane = new THREE.Plane();
const AXIS_VEC = { x: new THREE.Vector3(1, 0, 0), y: new THREE.Vector3(0, 1, 0), z: new THREE.Vector3(0, 0, 1) };
function syncClipRange() {
const bb = points?.geometry.boundingBox;
if (!bb) return;
const min = bb.min[clipAxis], max = bb.max[clipAxis];
if (!isFinite(min) || !isFinite(max) || max <= min) return;
clipPosEl.min = min; clipPosEl.max = max; clipPosEl.step = (max - min) / 500;
const v = parseFloat(clipPosEl.value);
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`
// is replaced (new material has no clippingPlanes of its own)
function applyClip() {
if (!points) return;
if (!clipOnEl.checked) { points.material.clippingPlanes = []; invalidate(); return; }
const dir = clipFlipEl.checked ? 1 : -1;
clipPlane.normal.copy(AXIS_VEC[clipAxis]).multiplyScalar(dir);
clipPlane.constant = -dir * (parseFloat(clipPosEl.value) || 0);
points.material.clippingPlanes = [clipPlane];
invalidate();
}
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();
applyClip();
};
});
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 => {
if (fpActive) { if (e.button === 0) fpDragging = true; syncFpCursor(); return; }
downPos = { x: e.clientX, y: e.clientY };
});
renderer.domElement.addEventListener('pointerup', e => {
if (fpActive) return;
if (!downPos) return;
const moved = Math.hypot(e.clientX - downPos.x, e.clientY - downPos.y);
downPos = null;
if (moved > 5 || mode === 'none' || !points || e.button !== 0) return;
pickPoint(e);
});
addEventListener('pointerup', () => { fpDragging = false; syncFpCursor(); });
// double-click: re-center the orbit pivot on the clicked point (camera stays put) —
// the CAD/CloudCompare convention for "look at this" instead of dragging the pan.
renderer.domElement.addEventListener('dblclick', e => {
if (fpActive || !points) return;
const p = raycastPoint(e);
if (!p) return;
controls.target.copy(p);
controls.update();
invalidate();
});
// ─── FP look: real pointer lock (mouse-move-only, cursor hidden) when the
// platform grants it; click-drag fallback when it doesn't (some WKWebView
// builds accept requestPointerLock() but never actually engage it) ──────
let fpDragging = false;
function fpLocked() { return document.pointerLockElement === renderer.domElement; }
function syncFpCursor() { document.body.classList.toggle('fp-locked', fpActive && (fpLocked() || fpDragging)); }
renderer.domElement.addEventListener('mousemove', e => {
if (!fpActive || !(fpLocked() || fpDragging)) return;
fpYaw -= e.movementX * 0.004;
fpPitch -= e.movementY * 0.004;
fpPitch = THREE.MathUtils.clamp(fpPitch, -1.5, 1.5);
fpApplyLook();
invalidate();
});
document.addEventListener('pointerlockchange', () => {
syncFpCursor();
if (fpActive && !fpLocked()) exitFP();
});
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
// world-space position of the cloud point nearest the cursor, or null
function raycastPoint(e) {
if (!points) return null;
const rect = renderer.domElement.getBoundingClientRect();
mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
// screen-constant pick radius
raycaster.params.Points.threshold = camera.position.distanceTo(controls.target) * 0.02;
const hits = raycaster.intersectObject(points);
if (!hits.length) return null;
// point visually nearest the cursor (min perpendicular dist to ray)
let best = hits[0];
for (const h of hits) if (h.distanceToRay < best.distanceToRay) best = h;
const pos = points.geometry.attributes.position;
return new THREE.Vector3(pos.getX(best.index), pos.getY(best.index), pos.getZ(best.index))
.applyMatrix4(points.matrixWorld);
}
function pickPoint(e) {
const p = raycastPoint(e);
if (!p) return;
lastPicked = p.clone();
if (mode === 'measure') addMeasure(p);
else if (mode === 'position') addPosition(p);
invalidate();
}
function pickRadius() { return camera.position.distanceTo(controls.target) * 0.006; }
// shared-geometry sphere; scaled each frame → constant screen size
function makeSphere(p, color, mult = 1) {
const s = new THREE.Mesh(SPHERE_GEO, new THREE.MeshBasicMaterial({ color }));
s.userData.mult = mult;
s.scale.setScalar(pickRadius() * mult);
s.position.copy(p);
scene.add(s);
return s;
}
// ─── measure mode ────────────────────────────────────────────────────────
function addMeasure(p) {
if (measureMarkers.length >= 2) clearMeasurement();
const color = measureMarkers.length === 0 ? MARK.a : MARK.b;
measureMarkers.push({ mesh: makeSphere(p, color), pos: p });
if (measureMarkers.length === 2) drawMeasure();
renderReadout();
}
function drawMeasure() {
const a = measureMarkers[0].pos, b = measureMarkers[1].pos;
const d = a.distanceTo(b);
measureLine = new THREE.Line(
new THREE.BufferGeometry().setFromPoints([a, b]),
new THREE.LineBasicMaterial({ color: MARK.measure }));
scene.add(measureLine);
measureLabel = makeLabel(d.toFixed(3) + ' m', a.clone().lerp(b, 0.5));
scene.add(measureLabel);
}
// ─── position mode ───────────────────────────────────────────────────────
function addPosition(p) {
if (!originPoint) {
originPoint = p.clone();
posObjects.push(makeSphere(p, MARK.origin, 1.3));
renderReadout();
return;
}
posObjects.push(makeSphere(p, MARK.b));
const line = new THREE.Line(
new THREE.BufferGeometry().setFromPoints([originPoint, p]),
new THREE.LineBasicMaterial({ color: MARK.link }));
scene.add(line); posObjects.push(line);
lastPos = p.clone();
renderReadout();
}
let lastPos = null;
// ─── labels (BUGFIX 3: dispose texture + material on clear) ──────────────
const LABEL_FONT = '500 44px ui-monospace,"SF Mono",Menlo,monospace';
function makeLabel(text, pos) {
const cv = document.createElement('canvas');
const ctx = cv.getContext('2d');
ctx.font = LABEL_FONT;
cv.width = ctx.measureText(text).width + 40; cv.height = 68;
ctx.font = LABEL_FONT; // resizing the canvas resets the context
ctx.fillStyle = 'rgba(10,11,12,0.88)';
ctx.fillRect(0, 0, cv.width, cv.height);
ctx.strokeStyle = 'rgba(255,255,255,0.22)'; ctx.lineWidth = 2;
ctx.strokeRect(1, 1, cv.width - 2, cv.height - 2);
ctx.fillStyle = '#e8e9ea'; ctx.textBaseline = 'middle';
ctx.fillText(text, 20, cv.height / 2 + 2);
const spr = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(cv), depthTest: false }));
spr.position.copy(pos);
const scale = camera.position.distanceTo(controls.target) * 0.05;
spr.scale.set(scale * cv.width / cv.height, scale, 1);
return spr;
}
function disposeLabel(l) { if (!l) return; scene.remove(l); l.material.map?.dispose(); l.material.dispose(); }
function clearMeasurement() {
for (const m of measureMarkers) { scene.remove(m.mesh); m.mesh.material.dispose(); }
measureMarkers = [];
if (measureLine) { scene.remove(measureLine); measureLine.geometry.dispose(); measureLine.material.dispose(); measureLine = null; }
disposeLabel(measureLabel); measureLabel = null;
invalidate();
}
function clearPosition() {
for (const o of posObjects) {
scene.remove(o);
if (o.isLine) o.geometry.dispose(); // spheres share SPHERE_GEO — don't dispose
o.material.dispose();
}
posObjects = []; originPoint = null; lastPos = null;
invalidate();
}
// ─── BUGFIX 2: resize BOTH measure and position markers each frame ───────
function updateSizes() {
const r = pickRadius();
for (const m of measureMarkers) m.mesh.scale.setScalar(r * m.mesh.userData.mult);
for (const o of posObjects) if (o.isMesh) o.scale.setScalar(r * o.userData.mult);
if (odomObj) odomObj.scale.setScalar(r * odomObj.userData.mult);
if (measureLabel) {
const scale = camera.position.distanceTo(controls.target) * 0.05;
const cv = measureLabel.material.map.image;
measureLabel.scale.set(scale * cv.width / cv.height, scale, 1);
}
}
// ─── live readout (information design per mode) ──────────────────────────
const fmt = n => (n >= 0 ? '+' : '') + n.toFixed(3);
const xyz = p => `(${p.x.toFixed(3)}, ${p.y.toFixed(3)}, ${p.z.toFixed(3)})`;
function renderReadout() {
if (!points) { readoutEl.innerHTML = `<div class="lead">포인트 클라우드를 여세요.</div>`; return; }
if (mode === 'none') {
readoutEl.innerHTML = `<div class="lead">측정하려면 <b style="color:var(--txt)">거리</b> 또는 <b style="color:var(--txt)">포지션</b> 모드를 선택하세요.</div>`;
return;
}
if (mode === 'measure') {
if (measureMarkers.length === 0) { readoutEl.innerHTML = `<div class="lead"><span class="dot a"></span>첫 점을 클릭하세요.</div>`; return; }
if (measureMarkers.length === 1) {
readoutEl.innerHTML = `<div class="rows"><div class="r"><span class="k"><span class="dot a"></span>점 1</span><span>${xyz(measureMarkers[0].pos)}</span></div></div><div class="lead"><span class="dot b"></span>두 번째 점을 클릭하세요.</div>`;
return;
}
const a = measureMarkers[0].pos, b = measureMarkers[1].pos;
readoutEl.innerHTML =
`<div class="metric dist"><span class="big">${a.distanceTo(b).toFixed(4)}</span><span class="unit">m</span></div>` +
`<div class="rows"><div class="r"><span class="k"><span class="dot a"></span>점 1</span><span>${xyz(a)}</span></div>` +
`<div class="r"><span class="k"><span class="dot b"></span>점 2</span><span>${xyz(b)}</span></div></div>`;
return;
}
// position
if (!originPoint) { readoutEl.innerHTML = `<div class="lead"><span class="dot o"></span>클릭해 기준점을 설정하세요.</div>`; return; }
let html = `<div class="rows"><div class="r"><span class="k"><span class="dot o"></span>기준점</span><span>${xyz(originPoint)}</span></div></div>`;
if (!lastPos) { html += `<div class="lead"><span class="dot b"></span>측정점을 클릭하면 ΔXYZ를 표시합니다.</div>`; }
else {
const d = { x: lastPos.x - originPoint.x, y: lastPos.y - originPoint.y, z: lastPos.z - originPoint.z };
const dist = Math.hypot(d.x, d.y, d.z);
const ax = (k, v) => `<div class="r"><span class="k">Δ${k}</span><span>${fmt(v)} m</span></div>`;
html += `<div class="rows delta">${ax('X', d.x)}${ax('Y', d.y)}${ax('Z', d.z)}` +
`<div class="r" style="border-top:1px solid var(--line);padding-top:4px;margin-top:2px"><span class="k">직선거리</span><span style="color:var(--measure)">${dist.toFixed(4)} m</span></div></div>`;
}
readoutEl.innerHTML = html;
}
// ─── FP walk ─────────────────────────────────────────────────────────────
function fpApplyLook() {
const cp = Math.cos(fpPitch), sp = Math.sin(fpPitch);
const dir = new THREE.Vector3(cp * Math.cos(fpYaw), cp * Math.sin(fpYaw), sp);
camera.up.set(0, 0, 1);
camera.lookAt(camera.position.clone().add(dir));
}
function startFP() {
if (!points) return;
const bs = points.geometry.boundingSphere;
const start = lastPicked ? lastPicked.clone() : (bs ? bs.center.clone() : new THREE.Vector3());
start.z += parseFloat(fpEyeEl.value) || 1.6;
camera.position.copy(start);
const d = new THREE.Vector3(); camera.getWorldDirection(d);
fpYaw = Math.atan2(d.y, d.x);
fpPitch = Math.asin(THREE.MathUtils.clamp(d.z, -1, 1));
fpApplyLook();
fpActive = true;
fpDragging = false;
controls.enabled = false;
try { renderer.domElement.requestPointerLock?.(); } catch (err) { console.warn('pointer lock unavailable', err); }
syncFpCursor();
fpStartBtn.disabled = true; fpExitBtn.disabled = false;
fpMsgEl.textContent = '이동 중 · 마우스로 시점 회전(안 되면 클릭 후 드래그) · WASD · Q/E 상하 · Shift 가속 · Esc 종료';
invalidate();
}
function exitFP() {
if (!fpActive) return;
fpActive = false;
fpDragging = false;
if (fpLocked()) { try { document.exitPointerLock?.(); } catch (err) {} }
syncFpCursor();
const d = new THREE.Vector3(); camera.getWorldDirection(d);
const r = points?.geometry.boundingSphere?.radius || 5;
controls.target.copy(camera.position).addScaledVector(d, Math.max(r, 5));
camera.up.set(0, 1, 0);
controls.enabled = true;
controls.update();
fpStartBtn.disabled = false; fpExitBtn.disabled = true;
fpMsgEl.textContent = '시작(또는 V)을 누르면 마지막 클릭 지점(없으면 중심)에서 걷기 시작합니다.';
invalidate();
}
function fpUpdate(dt) {
if (!fpActive) return;
const fwd = new THREE.Vector3(Math.cos(fpYaw), Math.sin(fpYaw), 0);
const right = new THREE.Vector3(Math.sin(fpYaw), -Math.cos(fpYaw), 0);
const move = new THREE.Vector3();
if (fpMove.f) move.add(fwd);
if (fpMove.b) move.sub(fwd);
if (fpMove.r) move.add(right);
if (fpMove.l) move.sub(right);
if (fpMove.u) move.z += 1;
if (fpMove.d) move.z -= 1;
if (move.lengthSq() > 0) {
move.normalize().multiplyScalar(fpSpeed * (fpSprint ? 3 : 1) * dt);
camera.position.add(move);
invalidate();
}
}
fpStartBtn.onclick = startFP;
fpExitBtn.onclick = exitFP;
function syncFpSpeed() { fpSpeedVal.textContent = parseFloat(fpSpeedEl.value).toFixed(1); }
fpSpeedEl.oninput = () => { fpSpeed = parseFloat(fpSpeedEl.value); syncFpSpeed(); };
syncFpSpeed();
// ─── keyboard ────────────────────────────────────────────────────────────
addEventListener('keydown', e => {
if (fpActive) {
switch (e.code) {
case 'KeyW': case 'ArrowUp': fpMove.f = true; break;
case 'KeyS': case 'ArrowDown': fpMove.b = true; break;
case 'KeyA': case 'ArrowLeft': fpMove.l = true; break;
case 'KeyD': case 'ArrowRight': fpMove.r = true; break;
case 'KeyE': case 'Space': fpMove.u = true; break;
case 'KeyQ': fpMove.d = true; break;
case 'ShiftLeft': case 'ShiftRight': fpSprint = true; break;
case 'Escape': exitFP(); return;
default: return;
}
e.preventDefault();
return;
}
if (e.key === 'Escape') { clearMeasurement(); clearPosition(); renderReadout(); }
else if (e.key === 'f' || e.key === 'F') frameView();
else if ((e.key === 'v' || e.key === 'V') &&
!['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) startFP();
});
addEventListener('keyup', e => {
if (!fpActive) return;
switch (e.code) {
case 'KeyW': case 'ArrowUp': fpMove.f = false; break;
case 'KeyS': case 'ArrowDown': fpMove.b = false; break;
case 'KeyA': case 'ArrowLeft': fpMove.l = false; break;
case 'KeyD': case 'ArrowRight': fpMove.r = false; break;
case 'KeyE': case 'Space': fpMove.u = false; break;
case 'KeyQ': fpMove.d = false; break;
case 'ShiftLeft': case 'ShiftRight': fpSprint = false; break;
}
});
// ─── loading / frame ─────────────────────────────────────────────────────
function frameView() {
if (!points) return;
if (live && points === live.obj && live.count === 0) return;
const c = points.geometry.boundingSphere.center, r = points.geometry.boundingSphere.radius;
controls.target.copy(c);
camera.position.set(c.x + r * 0.8, c.y - r * 0.8, c.z + r * 0.8);
camera.near = r / 1000; camera.far = r * 50;
camera.updateProjectionMatrix(); controls.update(); invalidate();
}
// ─── preset views (Z-up: matches the ROS/FAST-LIVO2 data, same convention as
// WALK — not the app's incidental default Y-up orbit pole) ──────────────
const VIEW_PRESETS = {
top: [new THREE.Vector3(0, 0, 1), new THREE.Vector3(0, 1, 0)],
bottom: [new THREE.Vector3(0, 0, -1), new THREE.Vector3(0, 1, 0)],
front: [new THREE.Vector3(0, -1, 0), new THREE.Vector3(0, 0, 1)],
back: [new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, 0, 1)],
left: [new THREE.Vector3(-1, 0, 0), new THREE.Vector3(0, 0, 1)],
right: [new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 0, 1)],
iso: [new THREE.Vector3(0.8, -0.8, 0.8).normalize(), new THREE.Vector3(0, 0, 1)],
};
function setView(offsetDir, up) {
if (!points) return;
if (live && points === live.obj && live.count === 0) return;
const c = points.geometry.boundingSphere.center, r = Math.max(points.geometry.boundingSphere.radius, 0.01);
controls.target.copy(c);
camera.up.copy(up);
camera.position.copy(c).addScaledVector(offsetDir, r * 2.2);
camera.near = r / 1000; camera.far = r * 50;
camera.updateProjectionMatrix(); controls.update(); invalidate();
}
document.querySelectorAll('.viewgrid button').forEach(b => {
b.onclick = () => setView(...VIEW_PRESETS[b.dataset.view]);
});
const loader = new PCDLoader();
function showLoading(name) {
loadingEl.classList.remove('empty');
loadingEl.style.display = 'flex';
$('loadTitle').textContent = name ? `${name} 로딩 중` : '포인트 클라우드 로딩 중';
$('barfill').style.width = '0%'; $('loadtxt').textContent = '';
}
function showPrompt() { loadingEl.classList.add('empty'); loadingEl.style.display = 'flex'; }
function loadPCD(url, name, opts = {}) {
showLoading(name);
loader.load(url, mesh => {
if (opts.revoke) URL.revokeObjectURL(url);
setPoints(mesh, name);
}, xhr => {
if (xhr.lengthComputable) {
$('barfill').style.width = (xhr.loaded / xhr.total * 100) + '%';
$('loadtxt').textContent = `${(xhr.loaded / 1e6).toFixed(1)} / ${(xhr.total / 1e6).toFixed(1)} MB`;
} else $('loadtxt').textContent = `${(xhr.loaded / 1e6).toFixed(1)} MB`;
}, err => {
if (opts.revoke) URL.revokeObjectURL(url);
onError(err, opts.isDefault, name);
});
}
function setPoints(mesh, name) {
// replace any existing cloud — including a live accumulator, if that's what's showing
if (points) { scene.remove(points); points.geometry.dispose(); points.material.dispose(); }
if (live) { live = null; voxSet.clear(); liveBox.makeEmpty(); }
clearMeasurement(); clearPosition();
points = mesh;
points.material.size = parseFloat(sizeEl.value);
points.material.sizeAttenuation = true;
const hasColor = !!points.geometry.attributes.color;
points.material.vertexColors = hasColor && useRgbEl.checked;
if (!(hasColor && useRgbEl.checked)) points.material.color.set(hasColor ? 0xffffff : MARK.mono);
scene.add(points);
points.geometry.computeBoundingBox();
points.geometry.computeBoundingSphere();
frameView();
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;
ptVal.textContent = n.toLocaleString();
srcVal.textContent = name || '—';
srcVal.title = name || '';
useRgbEl.disabled = !hasColor;
rgbLbl.style.opacity = hasColor ? '1' : '0.5';
rgbLbl.textContent = hasColor ? '원본 색상' : '원본 색상 (없음)';
loadingEl.style.display = 'none';
renderReadout();
}
function onError(err, isDefault, name) {
if (isDefault) { showPrompt(); return; } // no default file → friendly prompt, not an error
loadingEl.classList.remove('empty');
loadingEl.querySelector('.load-live').innerHTML =
`<div class="title" style="color:var(--pt-a)">${escapeHtml(name || '파일')} 로드 실패</div>` +
`<div class="sub">${escapeHtml(err?.message || String(err))}</div>` +
`<div class="sub" style="color:var(--faint)">HTTP 서버로 열었는지, 유효한 .pcd인지 확인하세요.</div>`;
}
function escapeHtml(s) { return String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
// file input + drag & drop
function openFile(file) {
if (!/\.pcd$/i.test(file.name)) { onError(new Error('.pcd 파일이 아닙니다'), false, file.name); return; }
loadPCD(URL.createObjectURL(file), file.name, { revoke: true });
}
fileInput.onchange = e => { if (e.target.files[0]) openFile(e.target.files[0]); e.target.value = ''; };
let dragDepth = 0;
addEventListener('dragenter', e => { e.preventDefault(); dragDepth++; dropEl.classList.add('show'); });
addEventListener('dragover', e => e.preventDefault());
addEventListener('dragleave', () => { if (--dragDepth <= 0) { dragDepth = 0; dropEl.classList.remove('show'); } });
addEventListener('drop', e => {
e.preventDefault(); dragDepth = 0; dropEl.classList.remove('show');
if (e.dataTransfer.files[0]) openFile(e.dataTransfer.files[0]);
});
// ═════════════════════════════════════════════════════════════════════════
// LIVE — rosbridge (ROS 2 Humble)
// Messages arrive as plain JSON; uint8[] fields (PointCloud2.data,
// CompressedImage.data) are base64. No roslibjs: the protocol is four ops.
// ═════════════════════════════════════════════════════════════════════════
// Preallocated once: ~90 MB of buffers. Frames append into it, so the growing
// map is a single draw call instead of one Points object per scan.
const LIVE_CAP = 6_000_000;
const normType = t => String(t).replace('/msg/', '/');
const SLOTS = {
cloud: { el: $('topCloud'), types: ['sensor_msgs/PointCloud2'], prefer: /cloud_registered|cloud_map|laser_map/ },
odom: { el: $('topOdom'), types: ['nav_msgs/Odometry'], prefer: /aft_mapped|odom/ },
path: { el: $('topPath'), types: ['nav_msgs/Path'], prefer: /path/ },
image: { el: $('topImage'), types: ['sensor_msgs/CompressedImage'], prefer: /image|rgb|color/ }
};
const bound = {}; // slot → currently subscribed topic
let ws = null, wsState = 'off'; // off | connecting | on | error
const subs = new Map(); // topic → handler
const svcPending = new Map(); // service call id → resolve
let svcSeq = 0;
const camEl = $('cam'), camImg = $('camImg'), camTopic = $('camTopic');
// ─── measured rates; a source that goes quiet reads stale, never stale-but-plausible
class Rate {
constructor() { this.ema = 0; this.last = 0; this.at = 0; }
tick() {
const t = performance.now();
if (this.last) { const dt = t - this.last; this.ema = this.ema ? this.ema * 0.8 + dt * 0.2 : dt; }
this.last = this.at = t;
}
hz() { return (!this.ema || performance.now() - this.at > 2000) ? null : 1000 / this.ema; }
}
const rates = { cloud: new Rate(), odom: new Rate(), image: new Rate() };
// ─── socket ──────────────────────────────────────────────────────────────
function rosSend(o) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(o)); }
function rosConnect(url) {
rosDisconnect(false);
setLive('connecting', '연결 중…');
try { ws = new WebSocket(url); }
catch (e) { setLive('error', `주소가 올바르지 않습니다: ${e.message || e}`); return; }
ws.onopen = () => { setLive('on', '연결됨. 토픽 목록을 읽는 중…'); listTopics(); };
ws.onerror = () => setLive('error', `연결 실패 — ${url} 에서 rosbridge가 실행 중인지 확인하세요.`);
ws.onclose = () => { subs.clear(); if (wsState !== 'error') setLive('off', '연결이 끊어졌습니다.'); };
ws.onmessage = e => {
let m; try { m = JSON.parse(e.data); } catch { return; }
if (m.op === 'publish') subs.get(m.topic)?.(m.msg);
else if (m.op === 'service_response') svcPending.get(m.id)?.(m.values);
else if (m.op === 'status' && m.level === 'error') setLive('error', `rosbridge: ${m.msg}`);
};
}
function rosDisconnect(byUser = true) {
if (ws) { ws.onclose = null; ws.onerror = null; ws.close(); ws = null; }
subs.clear();
for (const k of Object.keys(SLOTS)) bound[k] = '';
if (byUser) setLive('off', '연결을 해제했습니다.');
}
function rosService(service, args = {}) {
return new Promise((res, rej) => {
const id = `svc-${++svcSeq}`;
const t = setTimeout(() => { svcPending.delete(id); rej(new Error('응답 없음')); }, 5000);
svcPending.set(id, v => { clearTimeout(t); svcPending.delete(id); res(v); });
rosSend({ op: 'call_service', service, args, id });
});
}
function rosSubscribe(topic, type, cb, opts = {}) {
subs.set(topic, cb);
rosSend({ op: 'subscribe', topic, type, queue_length: opts.queue ?? 0, throttle_rate: opts.throttle ?? 0 });
}
function rosUnsubscribe(topic) {
if (!topic) return;
subs.delete(topic);
rosSend({ op: 'unsubscribe', topic });
}
// ─── topic discovery: bind by type, so topic names never have to be guessed
async function listTopics() {
let r;
try { r = await rosService('/rosapi/topics'); }
catch { setLive('error', 'rosapi 응답이 없습니다 — rosbridge에 rosapi 노드가 함께 떠 있는지 확인하세요.'); return; }
const all = (r.topics || []).map((t, i) => ({ topic: t, raw: r.types[i], type: normType(r.types[i]) }));
$('topicRows').hidden = false;
for (const [k, s] of Object.entries(SLOTS)) {
const opts = all.filter(o => s.types.includes(o.type));
s.el.innerHTML = '<option value="">사용 안 함</option>' + opts.map(o =>
`<option value="${escapeHtml(o.topic)}" data-type="${escapeHtml(o.raw)}">${escapeHtml(o.topic)}</option>`).join('');
const pick = opts.find(o => s.prefer.test(o.topic)) || opts[0];
s.el.value = pick ? pick.topic : '';
bindSlot(k);
}
const n = Object.values(bound).filter(Boolean).length;
setLive('on', n ? `토픽 ${n}개 구독 중.` : '해당 타입의 토픽이 없습니다 — FAST-LIVO2가 실행 중인지 확인하세요.');
}
const HANDLERS = {
cloud: msg => { rates.cloud.tick(); appendCloud(msg); },
odom: msg => { rates.odom.tick(); setOdom(msg); },
path: msg => setPath(msg),
image: msg => { rates.image.tick(); setCam(msg); }
};
function bindSlot(k) {
const s = SLOTS[k];
if (bound[k]) rosUnsubscribe(bound[k]);
const topic = s.el.value;
bound[k] = topic;
if (!topic) { if (k === 'image') camOff(); return; }
const type = s.el.selectedOptions[0].dataset.type;
// images are the one source worth throttling; dropping a scan would lose map
const opts = k === 'image' ? { throttle: 100, queue: 1 } : k === 'cloud' ? {} : { queue: 1 };
rosSubscribe(topic, type, HANDLERS[k], opts);
if (k === 'image') camTopic.textContent = topic;
}
for (const [k, s] of Object.entries(SLOTS)) s.el.onchange = () => bindSlot(k);
$('connBtn').onclick = () => {
if (wsState === 'on' || wsState === 'connecting') rosDisconnect();
else rosConnect($('wsUrl').value.trim());
};
function setLive(state, msg) {
wsState = state;
$('liveChip').classList.toggle('on', state === 'on');
$('liveVal').textContent = { off: '미연결', connecting: '연결 중', on: '연결됨', error: '오류' }[state];
$('connBtn').textContent = (state === 'on' || state === 'connecting') ? '해제' : '연결';
const el = $('liveMsg');
el.textContent = msg;
el.classList.toggle('err', state === 'error');
if (state === 'off' || state === 'error') { $('topicRows').hidden = true; camOff(); }
}
// ─── live cloud: one preallocated buffer the scans append into ───────────
const liveBox = new THREE.Box3();
const _v = new THREE.Vector3();
// While a live map grows, keep it framed — until the user takes the camera, and then never again.
let camTouched = false, autoFrameAt = 0;
controls.addEventListener('start', () => { camTouched = true; });
// ─── voxel accumulation ──────────────────────────────────────────────────
// /cloud_registered re-sends whole scans that overlap heavily. Kept raw, the buffer
// fills in about a minute. Keeping one point per voxel makes the map's cost scale
// with the space covered rather than with how long the session has been running.
const voxOnEl = $('voxOn'), voxSizeEl = $('voxSize'), voxVal = $('voxVal');
const voxSet = new Set(); // packed keys of the voxels already occupied
const VOX_BITS = 17, VOX_HALF = 1 << (VOX_BITS - 1), VOX_MAX = (1 << VOX_BITS) - 1;
// 3 × 17 bits = 51 → still an exact float64 integer, so the key needs no string.
// ±65,536 voxels per axis (±3.2 km at 5 cm); a point beyond that is kept
// un-deduped rather than dropped — a rare, honest fallback.
function voxKey(x, y, z, inv) {
const ix = Math.floor(x * inv) + VOX_HALF;
const iy = Math.floor(y * inv) + VOX_HALF;
const iz = Math.floor(z * inv) + VOX_HALF;
if (ix < 0 || iy < 0 || iz < 0 || ix > VOX_MAX || iy > VOX_MAX || iz > VOX_MAX) return -1;
return ix * 17179869184 + iy * 131072 + iz; // ix<<34 | iy<<17 | iz, in float64
}
function syncVox() { fillSlider(voxSizeEl); voxVal.textContent = parseFloat(voxSizeEl.value).toFixed(2) + ' m'; }
syncVox();
voxSizeEl.oninput = syncVox;
voxSizeEl.onchange = reindexLive; // on release, not on every drag step
voxOnEl.onchange = reindexLive;
// Re-grid what is already accumulated. Raising the size thins the map on the spot;
// lowering it cannot bring back points already discarded — later scans refill the detail.
function reindexLive() {
voxSet.clear();
if (!live) return;
const on = voxOnEl.checked, inv = 1 / parseFloat(voxSizeEl.value);
const pos = live.pa.array, col = live.ca.array;
let w = 0;
for (let i = 0; i < live.count; i++) {
const j = i * 3, x = pos[j], y = pos[j + 1], z = pos[j + 2];
if (on) {
const k = voxKey(x, y, z, inv);
if (k >= 0) { if (voxSet.has(k)) continue; voxSet.add(k); }
}
if (w !== i) {
const o = w * 3;
pos[o] = x; pos[o + 1] = y; pos[o + 2] = z;
col[o] = col[j]; col[o + 1] = col[j + 1]; col[o + 2] = col[j + 2];
}
w++;
}
live.count = w; live.full = false;
live.dirty = { s: 0, e: w };
live.obj.geometry.setDrawRange(0, w);
liveBox.makeEmpty();
for (let i = 0; i < w; i++) liveBox.expandByPoint(_v.set(pos[i * 3], pos[i * 3 + 1], pos[i * 3 + 2]));
liveBox.getBoundingSphere(live.obj.geometry.boundingSphere);
invalidate();
}
function ensureLiveCloud() {
if (live) return;
const geom = new THREE.BufferGeometry();
const pa = new THREE.BufferAttribute(new Float32Array(LIVE_CAP * 3), 3);
const ca = new THREE.BufferAttribute(new Uint8Array(LIVE_CAP * 3), 3, true);
pa.setUsage(THREE.DynamicDrawUsage); ca.setUsage(THREE.DynamicDrawUsage);
geom.setAttribute('position', pa); geom.setAttribute('color', ca);
geom.setDrawRange(0, 0);
geom.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 0);
const mat = new THREE.PointsMaterial({ size: parseFloat(sizeEl.value), sizeAttenuation: true,
vertexColors: useRgbEl.checked });
if (!useRgbEl.checked) mat.color.set(MARK.mono);
const obj = new THREE.Points(geom, mat);
obj.frustumCulled = false; // drawRange grows; a stale bound must not cull the map
// the live map becomes THE cloud: measurement, point size and RGB all act on it
if (points) { scene.remove(points); points.geometry.dispose(); points.material.dispose(); }
clearMeasurement(); clearPosition();
scene.add(obj);
points = obj;
live = { obj, pa, ca, count: 0, recv: 0, dirty: null, full: false };
voxSet.clear();
liveBox.makeEmpty();
loadingEl.style.display = 'none'; // going live is another way to have a cloud
autoFrameAt = 0;
useRgbEl.disabled = false; rgbLbl.style.opacity = '1'; rgbLbl.textContent = '원본 색상';
srcVal.textContent = bound.cloud || 'LIVE'; srcVal.title = bound.cloud || '';
stopClipSweep(); applyClip(); // new material has no clippingPlanes of its own
renderReadout();
}
function b64ToBytes(b64) {
const bin = atob(b64), out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function appendCloud(msg) {
ensureLiveCloud();
if (live.full) return;
const bytes = typeof msg.data === 'string' ? b64ToBytes(msg.data) : Uint8Array.from(msg.data);
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const le = !msg.is_bigendian;
const f = {};
for (const fd of msg.fields) f[fd.name] = fd;
if (!f.x || !f.y || !f.z) { setLive('error', 'PointCloud2에 x/y/z 필드가 없습니다.'); return; }
const step = msg.point_step;
const n = Math.min(msg.width * msg.height, (bytes.byteLength / step) | 0);
const pos = live.pa.array, col = live.ca.array;
const voxOn = voxOnEl.checked, inv = 1 / parseFloat(voxSizeEl.value);
const start = live.count;
let w = start;
for (let i = 0; i < n; i++) {
const o = i * step;
const x = dv.getFloat32(o + f.x.offset, le);
const y = dv.getFloat32(o + f.y.offset, le);
const z = dv.getFloat32(o + f.z.offset, le);
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) continue; // is_dense == false
live.recv++;
if (voxOn) {
const k = voxKey(x, y, z, inv);
if (k >= 0) { if (voxSet.has(k)) continue; voxSet.add(k); } // already have this voxel
}
if (w >= LIVE_CAP) { live.full = true; break; }
const j = w * 3;
pos[j] = x; pos[j + 1] = y; pos[j + 2] = z;
if (f.rgb || f.rgba) {
// packed rgb: same 4 bytes whether the field is typed float32 or uint32
const v = dv.getUint32(o + (f.rgb || f.rgba).offset, le);
col[j] = (v >> 16) & 255; col[j + 1] = (v >> 8) & 255; col[j + 2] = v & 255;
} else if (f.intensity) {
const t = Math.min(1, Math.max(0, dv.getFloat32(o + f.intensity.offset, le) / 255));
const g = 40 + (t * 215) | 0;
col[j] = col[j + 1] = col[j + 2] = g;
} else {
col[j] = col[j + 1] = col[j + 2] = 200;
}
liveBox.expandByPoint(_v.set(x, y, z));
w++;
}
const added = w - start;
if (!added) return;
live.count = w;
live.dirty = live.dirty
? { s: Math.min(live.dirty.s, start), e: Math.max(live.dirty.e, w) }
: { s: start, e: w };
live.obj.geometry.setDrawRange(0, live.count);
liveBox.getBoundingSphere(live.obj.geometry.boundingSphere);
live.obj.geometry.boundingBox = liveBox;
if (live.full) setLive('on', `버퍼가 가득 찼습니다 (${(LIVE_CAP / 1e6).toFixed(0)}M) — 이후 스캔은 누적되지 않습니다. 복셀 크기를 키우면 즉시 솎아집니다.`);
const now = performance.now();
if (!camTouched && now - autoFrameAt > 1500) { autoFrameAt = now; frameView(); }
invalidate();
}
// upload only the appended slice, not the whole 6M-point buffer
function flushLive() {
if (!live || !live.dirty) return;
const { s, e } = live.dirty; live.dirty = null;
const off = s * 3, cnt = (e - s) * 3;
for (const attr of [live.pa, live.ca]) {
if (attr.addUpdateRange) { attr.clearUpdateRanges(); attr.addUpdateRange(off, cnt); }
else { attr.updateRange.offset = off; attr.updateRange.count = cnt; }
attr.needsUpdate = true;
}
}
// ─── sensor pose + trajectory (structure, not measurement → no marker hue)
let odomObj = null, pathObj = null;
function setOdom(msg) {
const p = msg.pose?.pose?.position; if (!p) return;
if (!odomObj) odomObj = makeSphere(new THREE.Vector3(p.x, p.y, p.z), MARK.measure, 1.6);
else odomObj.position.set(p.x, p.y, p.z);
invalidate();
}
function setPath(msg) {
if (pathObj) { scene.remove(pathObj); pathObj.geometry.dispose(); pathObj.material.dispose(); pathObj = null; }
const pts = (msg.poses || []).map(ps => new THREE.Vector3(
ps.pose.position.x, ps.pose.position.y, ps.pose.position.z));
if (pts.length >= 2) {
pathObj = new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts),
new THREE.LineBasicMaterial({ color: MARK.link }));
pathObj.frustumCulled = false;
scene.add(pathObj);
}
invalidate();
}
// ─── camera feed ─────────────────────────────────────────────────────────
function setCam(msg) {
const fmt = String(msg.format || '').toLowerCase();
camImg.src = `data:image/${fmt.includes('png') ? 'png' : 'jpeg'};base64,${msg.data}`;
camEl.classList.add('on');
}
function camOff() { camEl.classList.remove('on'); camImg.removeAttribute('src'); camTopic.textContent = '—'; }
// ─── telemetry tick ──────────────────────────────────────────────────────
setInterval(() => {
for (const k of ['cloud', 'odom', 'image']) {
const el = $('hz' + k[0].toUpperCase() + k.slice(1));
const hz = rates[k].hz();
el.textContent = hz ? `${hz.toFixed(1)} Hz` : '—';
el.classList.toggle('stale', !hz);
}
const t = $('bufTxt'), vt = $('voxTxt');
if (!live) {
t.textContent = '—'; t.classList.add('stale'); $('bufFill').style.width = '0%';
vt.textContent = '—'; vt.classList.add('stale');
return;
}
if (!voxOnEl.checked) { vt.textContent = '꺼짐'; vt.classList.add('stale'); }
else if (!live.recv) { vt.textContent = '—'; vt.classList.add('stale'); }
else {
vt.classList.remove('stale');
vt.textContent = `${(parseFloat(voxSizeEl.value) * 100).toFixed(0)}cm · 유지 ${(live.count / live.recv * 100).toFixed(1)}%`;
}
ptVal.textContent = live.count.toLocaleString();
const pct = live.count / LIVE_CAP;
$('bufFill').style.width = `${(pct * 100).toFixed(2)}%`;
t.classList.remove('stale');
t.textContent = live.full
? `가득 참 · ${(LIVE_CAP / 1e6).toFixed(0)}M`
: `${(live.count / 1e6).toFixed(2)}M / ${(LIVE_CAP / 1e6).toFixed(0)}M`;
}, 500);
// ─── render loop (on-demand; keeps rendering while damping settles) ──────
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
fpUpdate(clock.getDelta());
const moving = fpActive ? false : controls.update(); // true while inertia/damping active
if (needsRender || moving) {
flushLive();
updateSizes();
renderer.render(scene, camera);
needsRender = false;
}
}
animate();
// try the default cloud; if absent, show the open/drop prompt
applyTheme(currentTheme); // syncs the toggle's active state at boot
renderReadout();
loadPCD(DEFAULT_URL, 'all_raw_points.pcd', { isDefault: true });
</script>
</body>
</html>