Skip to content

Commit 8e296d1

Browse files
LimHyungTaeclaude
andcommitted
Polish: per-chapter about + parameters · click-to-pick · pair lines · author toggle
Per-chapter "About this demo" + Parameters list - Every chapter now ships a 1–2 sentence overview (DemoAbout) under the header and a sidebar list of every input parameter with what it means and how it behaves (DemoParams). Examples: leaf size — Larger → sparser; stddev mult — Smaller → stricter (removes more). Home redesign - Headline now wraps as two lines (en: "A Point Cloud Library tutorial / you can play with"; ko: "손으로 직접 해보는 / Point Cloud Library Tutorial"). Trailing period dropped. - Intro replaced with "Must-to-know techniques for 3D point clouds — reimplemented in TypeScript and running live in your browser." - Topic pills updated: ROS removed; Perception and Registration added. - "How's Hyungtae Lim?" expandable bio block at the top, sourced from the author's index.md, with chip links to his open-source libraries (GenZ-ICP, Patchwork, Patchwork++, TRAVEL, KISS-Matcher) and contact links (blog, GitHub, email). Click-to-pick on Radius Search and KNN - The viewer now exposes onPick; clicking any point sets the query (qx/qy/qz) for those two demos. Adaptive raycaster threshold scales with the cloud's bbox so picking works for the Bunny (~10 cm) and KITTI (~100 m) alike. ICP correspondence visualization - icpStep now returns pairCoords (matched src↔tgt endpoints). Lec11 samples up to 250 of them and the viewer draws yellow LineSegments, so the shrinking match distances are visible iteration by iteration. Picker: scene chip overhangs the box - The (CAD)/(Indoor)/(Outdoor) chip is positioned absolutely on the top edge of each preset button as a label tag — no more crowding the name. Buttons also display sensor names (VLP-16 / HDL-64) per request. Misc - Blog-post link is hidden in English mode (the posts are in Korean). - Slight English/Korean preset hint cleanup ("VLP-16 · NaverLabs · ~29k pts" etc.). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9721053 commit 8e296d1

20 files changed

Lines changed: 747 additions & 128 deletions
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { useState } from "react";
2+
import { useT } from "../i18n";
3+
4+
export default function AuthorToggle() {
5+
const t = useT();
6+
const [open, setOpen] = useState(false);
7+
8+
return (
9+
<div className="mt-8 flex flex-col items-center">
10+
<button
11+
onClick={() => setOpen((o) => !o)}
12+
aria-expanded={open}
13+
className={`code-font rounded-full border px-4 py-1.5 text-[11px] font-semibold uppercase tracking-[0.2em] transition ${
14+
open
15+
? "border-[color:rgba(0,212,170,0.5)] bg-[color:rgba(0,212,170,0.1)] text-[var(--accent)]"
16+
: "border-[var(--border-strong)] bg-[var(--surface)] text-[var(--dim)] hover:border-[var(--accent)] hover:text-[var(--accent)]"
17+
}`}
18+
>
19+
{open ? ${t.author.toggle}` : t.author.toggle}
20+
</button>
21+
22+
{open && (
23+
<div className="fade-up mt-5 w-full max-w-3xl rounded-xl border border-[var(--border)] bg-[var(--surface)] p-6 text-left">
24+
<div className="flex items-baseline justify-between gap-3">
25+
<h3 className="heading-mono text-xl font-bold text-[var(--text-strong)]">
26+
{t.author.title}
27+
</h3>
28+
<span className="code-font text-[11px] text-[var(--dim)]">
29+
{t.author.role}
30+
</span>
31+
</div>
32+
<p className="mt-3 text-[13.5px] leading-relaxed text-[var(--dim)]">
33+
{t.author.bio}
34+
</p>
35+
36+
<div className="mt-5">
37+
<div className="code-font mb-2 text-[10px] font-semibold uppercase tracking-[0.2em] text-[var(--mut)]">
38+
{t.author.libsLabel}
39+
</div>
40+
<div className="flex flex-wrap gap-2">
41+
{t.author.libs.map((lib) => (
42+
<a
43+
key={lib.name}
44+
href={lib.url}
45+
target="_blank"
46+
rel="noreferrer"
47+
className="code-font inline-flex items-center gap-1 rounded-md border border-[var(--border-strong)] bg-[var(--surface-2)] px-2.5 py-1 text-[11px] font-bold text-[var(--text)] transition hover:border-[var(--accent)] hover:text-[var(--accent)]"
48+
>
49+
{lib.name}
50+
<span className="text-[var(--mut)]"></span>
51+
</a>
52+
))}
53+
</div>
54+
</div>
55+
56+
<div className="mt-5 flex flex-wrap gap-2">
57+
<ContactLink label={t.author.blog} href={t.author.blogUrl} />
58+
<ContactLink label={t.author.github} href={t.author.githubUrl} />
59+
<ContactLink
60+
label={t.author.email}
61+
href={`mailto:${t.author.emailAddress}`}
62+
/>
63+
</div>
64+
</div>
65+
)}
66+
</div>
67+
);
68+
}
69+
70+
function ContactLink({ label, href }: { label: string; href: string }) {
71+
return (
72+
<a
73+
href={href}
74+
target="_blank"
75+
rel="noreferrer"
76+
className="code-font inline-flex items-center rounded-md border border-[var(--border-strong)] bg-[var(--surface-2)] px-2.5 py-1 text-[11px] text-[var(--dim)] transition hover:border-[var(--accent-2)] hover:text-[var(--accent-2)]"
77+
>
78+
{label}
79+
<span className="ml-1 text-[var(--mut)]"></span>
80+
</a>
81+
);
82+
}

web/src/components/ChapterHeader.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { ChapterMeta, statusColor } from "../chapters";
2-
import { useT } from "../i18n";
2+
import { useLocale, useT } from "../i18n";
33

44
const REPO = "https://github.com/LimHyungTae/pcl_tutorial/blob/main";
55

66
export default function ChapterHeader({ chapter }: { chapter: ChapterMeta }) {
77
const t = useT();
8+
const { locale } = useLocale();
89
const tc = t.chapters[chapter.slug as keyof typeof t.chapters];
910
return (
1011
<header className="border-b border-[var(--border)] pb-7 fade-up">
@@ -36,7 +37,7 @@ export default function ChapterHeader({ chapter }: { chapter: ChapterMeta }) {
3637
{chapter.source}
3738
</a>
3839
)}
39-
{chapter.blog && (
40+
{chapter.blog && locale !== "en" && (
4041
<a
4142
href={chapter.blog}
4243
target="_blank"

web/src/components/DataSourcePicker.tsx

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -105,36 +105,35 @@ export default function DataSourcePicker({
105105
<div className="mb-1.5 flex items-baseline justify-between">
106106
<span className="text-sm text-slate-200">{t.source.label}</span>
107107
</div>
108-
<div className="grid grid-cols-3 gap-1.5">
108+
<div className="grid grid-cols-3 gap-1.5 pt-2">
109109
{presetIds.map((id) => {
110110
const isActive = active === id;
111111
const sceneTone = SCENE_TONES[id];
112112
return (
113113
<button
114114
key={id}
115115
onClick={() => pick(id)}
116-
className={`rounded-md border px-2 py-2 text-left transition ${
116+
className={`relative rounded-md border px-2 pt-3 pb-2 text-left transition ${
117117
isActive
118118
? "border-[color:rgba(0,212,170,0.5)] bg-[color:rgba(0,212,170,0.08)] text-[var(--text-strong)]"
119119
: "border-[var(--border-strong)] bg-[var(--surface-2)] text-[var(--dim)] hover:border-[var(--border-strong)] hover:text-[var(--text)]"
120120
}`}
121121
>
122-
<div className="flex items-center gap-1.5">
123-
<span className="code-font text-[11px] font-bold">
124-
{t.source.presets[id].name}
125-
</span>
126-
<span
127-
className="code-font rounded-sm px-1 py-px text-[9px] font-semibold ring-1 ring-inset"
128-
style={{
129-
color: sceneTone.fg,
130-
background: sceneTone.bg,
131-
boxShadow: `inset 0 0 0 1px ${sceneTone.bd}`,
132-
}}
133-
>
134-
{t.source.presets[id].scene}
135-
</span>
122+
{/* Scene chip overhanging the top edge as a label tag. */}
123+
<span
124+
className="code-font absolute -top-2 left-1.5 rounded px-1.5 py-px text-[9px] font-semibold leading-none ring-1 ring-inset"
125+
style={{
126+
color: sceneTone.fg,
127+
background: sceneTone.bg,
128+
boxShadow: `inset 0 0 0 1px ${sceneTone.bd}, 0 0 0 2px var(--surface)`,
129+
}}
130+
>
131+
{t.source.presets[id].scene}
132+
</span>
133+
<div className="code-font truncate text-[11px] font-bold leading-tight">
134+
{t.source.presets[id].name}
136135
</div>
137-
<div className="code-font mt-1 text-[9px] leading-tight text-[var(--mut)]">
136+
<div className="code-font mt-1 truncate text-[9px] leading-tight text-[var(--mut)]">
138137
{t.source.presets[id].hint}
139138
</div>
140139
</button>

web/src/components/DemoAbout.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { useT } from "../i18n";
2+
3+
/** Brief paragraph describing what this demo does — main area, under the chapter header. */
4+
export default function DemoAbout({ slug }: { slug: string }) {
5+
const t = useT();
6+
const c = t.chapters[slug as keyof typeof t.chapters] as
7+
| { about?: string }
8+
| undefined;
9+
if (!c?.about) return null;
10+
return (
11+
<p className="mt-5 max-w-4xl text-[14px] leading-relaxed text-[var(--dim)]">
12+
{c.about}
13+
</p>
14+
);
15+
}

web/src/components/DemoParams.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { useT } from "../i18n";
2+
3+
type Param = { name: string; desc: string; effect: string };
4+
5+
/** Compact "Parameters" list for the sidebar — explains each control + effect. */
6+
export default function DemoParams({ slug }: { slug: string }) {
7+
const t = useT();
8+
const c = t.chapters[slug as keyof typeof t.chapters] as
9+
| { params?: Param[] }
10+
| undefined;
11+
if (!c?.params || c.params.length === 0) return null;
12+
return (
13+
<div>
14+
<div className="code-font mb-2 text-[10px] font-semibold uppercase tracking-[0.2em] text-[var(--mut)]">
15+
{t.demo.parameters}
16+
</div>
17+
<ul className="space-y-2 text-[12px] leading-relaxed">
18+
{c.params.map((p, i) => (
19+
<li key={i}>
20+
<span className="code-font font-bold text-[var(--accent)]">
21+
{p.name}
22+
</span>
23+
<span className="text-[var(--text)]">{p.desc}</span>
24+
{p.effect && (
25+
<span className="text-[var(--accent-2)]"> {p.effect}</span>
26+
)}
27+
</li>
28+
))}
29+
</ul>
30+
</div>
31+
);
32+
}

web/src/components/PointCloudViewer.tsx

Lines changed: 85 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,28 @@ type Layer = {
1313
opacity?: number;
1414
};
1515

16+
/** Line-segment overlay (e.g. ICP correspondence lines).
17+
* positions: flat Float32Array [x1,y1,z1, x2,y2,z2, ...] — every two
18+
* consecutive vertices form a segment. */
19+
type LineLayer = {
20+
positions: Float32Array;
21+
color: string;
22+
opacity?: number;
23+
};
24+
1625
type Props = {
1726
layers: Layer[];
27+
lines?: LineLayer[];
1828
background?: string;
29+
/** Called with [x, y, z] when the user clicks a point in any layer. */
30+
onPick?: (xyz: [number, number, number]) => void;
1931
};
2032

2133
export default function PointCloudViewer({
2234
layers,
35+
lines,
2336
background = "#0a0f1a",
37+
onPick,
2438
}: Props) {
2539
const [sizeMult, setSizeMult] = useState(1);
2640
const { center, radius } = useMemo(() => unionBounds(layers), [layers]);
@@ -31,9 +45,6 @@ export default function PointCloudViewer({
3145
center.z + radius * 1.4,
3246
];
3347

34-
// Apply the multiplier on the way down so PointsLayer doesn't need to know
35-
// about it. This keeps the slider state local to the viewer and means
36-
// pages stay unchanged.
3748
const scaledLayers = useMemo(
3849
() =>
3950
layers.map((l) => ({
@@ -57,15 +68,21 @@ export default function PointCloudViewer({
5768
>
5869
<ambientLight intensity={0.6} />
5970
{scaledLayers.map((l, idx) => (
60-
<PointsLayer key={idx} layer={l} />
71+
<PointsLayer
72+
key={idx}
73+
layer={l}
74+
onPick={onPick}
75+
/>
6176
))}
77+
{lines?.map((l, idx) => <LinesLayer key={`l${idx}`} layer={l} />)}
6278
<axesHelper args={[Math.max(0.05, radius * 0.2)]} />
6379
<ViewFrame
6480
cx={center.x}
6581
cy={center.y}
6682
cz={center.z}
6783
radius={radius}
6884
/>
85+
<PickConfig radius={radius} pickable={!!onPick} />
6986
<OrbitControls enableDamping dampingFactor={0.08} makeDefault />
7087
<GizmoHelper alignment="bottom-right" margin={[64, 64]}>
7188
<GizmoViewport
@@ -76,6 +93,15 @@ export default function PointCloudViewer({
7693
</Canvas>
7794

7895
<PointSizeControl value={sizeMult} onChange={setSizeMult} />
96+
{onPick && <PickHint />}
97+
</div>
98+
);
99+
}
100+
101+
function PickHint() {
102+
return (
103+
<div className="absolute right-3 top-3 z-10 rounded-md border border-[color:rgba(250,204,21,0.4)] bg-[color:rgba(250,204,21,0.08)] px-2 py-1 text-[10px] code-font text-[#facc15]">
104+
click to set query
79105
</div>
80106
);
81107
}
@@ -90,7 +116,6 @@ function PointSizeControl({
90116
return (
91117
<div
92118
className="absolute left-3 top-3 z-10 flex items-center gap-2 rounded-md border border-[var(--border)] bg-[color:rgba(10,15,26,0.7)] px-2.5 py-1 backdrop-blur-sm"
93-
// Drag/click on the slider should not start an orbit gesture below.
94119
onPointerDown={(e) => e.stopPropagation()}
95120
onWheel={(e) => e.stopPropagation()}
96121
>
@@ -114,13 +139,6 @@ function PointSizeControl({
114139
);
115140
}
116141

117-
/**
118-
* Side effect: whenever the bounds-defining numbers change, re-frame the
119-
* camera and re-target the orbit controls so the cloud fills the view.
120-
*
121-
* We depend on primitives (cx, cy, cz, radius) instead of a Vector3 so the
122-
* effect doesn't refire on every render — only when bounds actually change.
123-
*/
124142
function ViewFrame({
125143
cx,
126144
cy,
@@ -162,7 +180,27 @@ function ViewFrame({
162180
return null;
163181
}
164182

165-
function PointsLayer({ layer }: { layer: Layer }) {
183+
/** Adjust the raycaster's Points threshold to the current scale so picking
184+
* works whether the cloud is the Stanford Bunny (~10 cm) or a KITTI scan
185+
* (~100 m). */
186+
function PickConfig({ radius, pickable }: { radius: number; pickable: boolean }) {
187+
const raycaster = useThree((s) => s.raycaster);
188+
useEffect(() => {
189+
if (!raycaster) return;
190+
raycaster.params.Points = {
191+
threshold: pickable ? Math.max(radius * 0.008, 0.0005) : 0,
192+
};
193+
}, [raycaster, radius, pickable]);
194+
return null;
195+
}
196+
197+
function PointsLayer({
198+
layer,
199+
onPick,
200+
}: {
201+
layer: Layer;
202+
onPick?: (xyz: [number, number, number]) => void;
203+
}) {
166204
const geom = useMemo(() => {
167205
const g = new THREE.BufferGeometry();
168206
g.setAttribute("position", new THREE.BufferAttribute(layer.cloud.positions, 3));
@@ -191,7 +229,40 @@ function PointsLayer({ layer }: { layer: Layer }) {
191229
});
192230
}, [layer.color, layer.size, layer.opacity]);
193231

194-
return <points geometry={geom} material={mat} />;
232+
return (
233+
<points
234+
geometry={geom}
235+
material={mat}
236+
onClick={
237+
onPick
238+
? (e) => {
239+
e.stopPropagation();
240+
onPick([e.point.x, e.point.y, e.point.z]);
241+
}
242+
: undefined
243+
}
244+
/>
245+
);
246+
}
247+
248+
function LinesLayer({ layer }: { layer: LineLayer }) {
249+
const geom = useMemo(() => {
250+
const g = new THREE.BufferGeometry();
251+
g.setAttribute("position", new THREE.BufferAttribute(layer.positions, 3));
252+
return g;
253+
}, [layer.positions]);
254+
255+
const mat = useMemo(
256+
() =>
257+
new THREE.LineBasicMaterial({
258+
color: new THREE.Color(layer.color),
259+
transparent: (layer.opacity ?? 1) < 1,
260+
opacity: layer.opacity ?? 1,
261+
}),
262+
[layer.color, layer.opacity],
263+
);
264+
265+
return <lineSegments geometry={geom} material={mat} />;
195266
}
196267

197268
function unionBounds(layers: Layer[]) {

0 commit comments

Comments
 (0)