daa2257865
camera.up defaulted to three.js's Y-up while the actual data (ROS/FAST- LIVO2) and WALK mode are Z-up. Orbiting with the pole misaligned from the cloud's real vertical made yaw look like a diagonal tumble instead of a clean spin around what's visually "up" in the rendered cloud. Set Z-up at camera creation and on WALK exit (previously reverted to Y-up). OrbitControls only tracks 1-2 simultaneous touches — a 3rd pointer makes it go idle rather than doing anything. Added a three-finger pan on top, replicating OrbitControls' own (private, unexported) screen-space pan math so it feels identical to the existing right-drag/two-finger-pan.
1671 lines
78 KiB
HTML
1671 lines
78 KiB
HTML
<!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; }
|
||
/* panel show/hide — usable at any viewport width, not just small screens */
|
||
#panelToggle{
|
||
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;
|
||
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: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;
|
||
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; }
|
||
|
||
/* ── 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 defaults to hidden (see boot code) and dims the
|
||
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){
|
||
#panel{ width:min(284px, 84vw); }
|
||
#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 · POINT CLOUD 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);
|
||
// Z-up everywhere, matching the actual data (ROS/FAST-LIVO2) and WALK mode —
|
||
// orbiting with camera.up misaligned from the cloud's real vertical makes yaw
|
||
// look like a diagonal tumble instead of a clean spin. See also exitFP().
|
||
camera.up.set(0, 0, 1);
|
||
|
||
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
|
||
// two-finger touch: pinch still zooms, but the drag also free-rotates (any direction the
|
||
// fingers move, not locked to one axis) — OrbitControls only handles 1-2 simultaneous
|
||
// touches (a 3rd pointer makes it go idle), so three-finger pan below covers panning.
|
||
controls.touches.TWO = THREE.TOUCH.DOLLY_ROTATE;
|
||
|
||
// ─── three-finger pan — replicates OrbitControls' own (private, unexported) screen-
|
||
// space pan math so it feels identical to right-drag/two-finger-pan elsewhere ──
|
||
const panTouches = new Map(); // pointerId -> {x,y}
|
||
function touchCentroid(map) {
|
||
let x = 0, y = 0;
|
||
for (const p of map.values()) { x += p.x; y += p.y; }
|
||
return { x: x / map.size, y: y / map.size };
|
||
}
|
||
function panByScreenDelta(deltaX, deltaY) {
|
||
const offset = new THREE.Vector3().copy(camera.position).sub(controls.target);
|
||
const targetDistance = offset.length() * Math.tan((camera.fov / 2) * Math.PI / 180);
|
||
const panLeftDist = 2 * deltaX * targetDistance / renderer.domElement.clientHeight * controls.panSpeed;
|
||
const panUpDist = 2 * deltaY * targetDistance / renderer.domElement.clientHeight * controls.panSpeed;
|
||
|
||
const panOffset = new THREE.Vector3().setFromMatrixColumn(camera.matrix, 0).multiplyScalar(-panLeftDist);
|
||
const yCol = new THREE.Vector3();
|
||
if (controls.screenSpacePanning) yCol.setFromMatrixColumn(camera.matrix, 1);
|
||
else { yCol.setFromMatrixColumn(camera.matrix, 0); yCol.crossVectors(camera.up, yCol); }
|
||
panOffset.add(yCol.multiplyScalar(panUpDist));
|
||
|
||
camera.position.add(panOffset);
|
||
controls.target.add(panOffset);
|
||
controls.update();
|
||
invalidate();
|
||
}
|
||
let panCentroid = null;
|
||
renderer.domElement.addEventListener('pointerdown', e => {
|
||
if (e.pointerType !== 'touch') return;
|
||
panTouches.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||
panCentroid = panTouches.size === 3 ? touchCentroid(panTouches) : null;
|
||
});
|
||
renderer.domElement.addEventListener('pointermove', e => {
|
||
if (e.pointerType !== 'touch' || !panTouches.has(e.pointerId)) return;
|
||
panTouches.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||
if (panTouches.size !== 3) return;
|
||
const c = touchCentroid(panTouches);
|
||
if (panCentroid) panByScreenDelta(c.x - panCentroid.x, c.y - panCentroid.y);
|
||
panCentroid = c;
|
||
});
|
||
function releaseTouchPan(e) {
|
||
if (e.pointerType !== 'touch' || !panTouches.has(e.pointerId)) return;
|
||
panTouches.delete(e.pointerId);
|
||
panCentroid = panTouches.size === 3 ? touchCentroid(panTouches) : null;
|
||
}
|
||
renderer.domElement.addEventListener('pointerup', releaseTouchPan);
|
||
renderer.domElement.addEventListener('pointercancel', releaseTouchPan);
|
||
|
||
// ─── 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));
|
||
|
||
// ─── 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 panelNarrowMQ = matchMedia('(max-width:768px)');
|
||
function setPanelOpen(open) {
|
||
panelEl.classList.toggle('panel-hidden', !open);
|
||
panelBackdropEl.classList.toggle('show', open);
|
||
panelToggleEl.setAttribute('aria-expanded', String(open));
|
||
}
|
||
panelToggleEl.onclick = () => setPanelOpen(panelEl.classList.contains('panel-hidden'));
|
||
panelBackdropEl.onclick = () => setPanelOpen(false);
|
||
panelNarrowMQ.addEventListener('change', e => setPanelOpen(!e.matches));
|
||
setPanelOpen(!panelNarrowMQ.matches); // open on desktop/tablet, closed on phones, by default
|
||
|
||
// 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, 0, 1); // back to the app-wide Z-up default, not three.js's Y-up
|
||
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
|
||
|
||
// 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) {
|
||
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 });
|
||
}
|
||
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>
|