Skip to content

Commit ebf2dac

Browse files
jackgallantclaude
andcommitted
Code review: one definition per rule, ESLint gate, teardown fixes, docs reconciled
Full review per CODE.REVIEW.md (style, language conformity, doc/code consistency, and shared-data redundancy). Bugs fixed: - ROIDrawer.destroy() now clears the baked overlay layer, so autoAttach's destroy-then-attach no longer leaves an orphan #drawnrois layer + stale svgo.layers entry (the adapter's comment claimed this already happened). - One isTextEntry() rule (ui/dom-utils.js) for every keyboard guard: the edit overlay's Delete guard blocked ALL inputs, so Delete went dead while the Import file input held focus. - LassoOverlay's 800 ms settle re-measure is tracked and cancelled on destroy. - test fake's setOverlayLayer returns the contract's boolean. Redundancy collapsed to single homes: - Bezier segment topology (segCount/segControls/minAnchors/hasCurve in core/bezier.js) now feeds both samplers, both nearest-point searches, deleteAnchor, the edit overlay, and the adapter's SVG path writer; test/bezier-topology.test.js pins that they agree. - uvPxCorrespondences()+ALL_UV_BOUNDS (viewer-adapter.js) replaces two copies; ndcToPixel replaces the adapter's _ndc; polygonBounds replaces two bbox loops; TimerSet (core/timer-set.js) replaces two _later/Set copies; CanvasOverlay (ui/overlay-canvas.js) is the shared overlay base; MODE/TOOL/asTool name the mode+tool vocabulary; ShapeSet.defaultName unifies prompt defaults with the import fallback; fitRingBezier, _promptName, _export dedupe the controller; make_viewer.py imports bake.bake(); test:py uses unittest discover. Style gate: eslint flat config (recommended + repo conventions), run first by npm test and CI; tree is lint-clean; README documents the conventions. Docs reconciled across 13 stale claims (viewer-adapter header, TESTING.md phantom test, ROI-only wording, the "Not here" pointer at a private repo from this now-public one, etc.). Tests 161 -> 174 JS + 12 Python, all green; bundle rebuilt and smoke-tested headless (Firefox) against a real baked viewer, including destroy + re-attach with no orphan layer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 0be7026 commit ebf2dac

31 files changed

Lines changed: 1635 additions & 371 deletions

.github/workflows/test.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,6 @@ jobs:
2424
# Reproducible install from the committed package-lock.json.
2525
- run: npm ci
2626

27-
# `npm test` runs `pretest` (build the bundle) then the JS core/pipeline/contract/bundle
28-
# tests and the Python tooling tests.
27+
# `npm test` lints, runs `pretest:js` (build the bundle), then the JS
28+
# core/pipeline/contract/bundle tests and the Python tooling tests.
2929
- run: npm test

README.md

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -200,22 +200,29 @@ Three layers; only the adapter knows the host viewer.
200200

201201
```
202202
core/ pure JS — no DOM, no THREE, no host globals (unit-tested under node)
203-
geom.js point-in-polygon, RDP simplify, Chaikin smooth, ndc↔pixel, centroid
203+
geom.js point-in-polygon, bounds, RDP simplify, Chaikin smooth, ndc↔pixel, centroid
204204
selection.js projected vertices + polygon → selected vertex set (works in px OR uv)
205205
outline.js polygon → ordered boundary ring of vertices (+ label vertex)
206-
bezier.js fit an editable bezier — closed (ROI ring) or open (sulcus trace) — and edit it
206+
bezier.js fit an editable bezier — closed (ROI ring) or open (sulcus trace) — sample it,
207+
edit it; owns the segment topology (segCount/segControls/minAnchors) that the
208+
samplers, the editor and the adapter's path writer all read
207209
transform.js uv↔px homography (place/grab edit knots; map a traced stroke back to uv)
208-
shape-model.js the shape collection (ROIs + sulci) + the vertexset-v2 ROI export/import
210+
shape-model.js the shape collection (ROIs + sulci), default names, the vertexset-v2 ROI export/import
209211
svg-export.js pure writer for pycortex overlays.svg sulci markup (paths only, never labels)
210-
draw-mode.js the flat-only Draw state machine (the "reached flat" latch)
212+
draw-mode.js the flat-only Draw state machine (the "reached flat" latch) + the MODE names
213+
timer-set.js tracked setTimeouts that one destroy() cancels (controller + adapter share it)
211214
212215
adapter/ the ViewerAdapter CONTRACT + one host implementation
213-
viewer-adapter.js documented interface the core/ui depend on
216+
viewer-adapter.js documented interface the core/ui depend on, + uvPxCorrespondences()
217+
(the one place projectVerticesInUvBounds is flattened for a homography fit)
214218
pycortex-adapter.js the ONLY file that touches pycortex internals
215219
216220
ui/ host-agnostic DOM components (talk only to core + adapter)
221+
overlay-canvas.js base class: the transparent canvas over the surface (size/position sync,
222+
event→px, teardown) that both overlays below extend
217223
lasso-overlay.js bezier-edit-overlay.js draw-panel.js mode-toggle.js roidraw.css
218224
overlay-geom.js pure hit-testing math for the edit overlay (no DOM; unit-tested)
225+
dom-utils.js isTextEntry(): the one "is the user typing?" rule every key handler uses
219226
220227
draw-pipeline.js ROI: lasso → select → fit bezier → re-derive membership.
221228
Sulcus: trace → px→uv via homography → fit open bezier → label vertex
@@ -240,23 +247,38 @@ viewBox coords, label `data-ptidx` convention, control-panel internals) is quara
240247
## Building
241248

242249
```bash
243-
npm install # one-time (esbuild)
250+
npm install # one-time (esbuild + eslint)
244251
npm run build # -> dist/roidraw.bundle.js
245252
```
246253

247254
## Testing
248255

249256
```bash
250-
npm test # builds the bundle, then runs the JS suite (node) + Python tooling tests
257+
npm test # lints, builds the bundle, then runs the JS suite (node) + Python tooling tests
258+
npm run lint # eslint alone (eslint.config.js: recommended rules + this repo's conventions)
251259
```
252260

253261
The JS suite layers property-based geometry invariants (closed *and* open curves), the Draw-mode
254262
state machine, the draw pipeline (driven headless against a synthetic-surface adapter), the pure
255-
`overlays.svg` writer, the edit-overlay hit-testing, an adapter-contract guard, a host preflight,
256-
and a smoke test of the built bundle. CI (`.github/workflows/test.yml`) runs it on every push. See
263+
`overlays.svg` writer, the edit-overlay hit-testing, the bezier segment-topology agreement between
264+
the samplers/editor/adapter path writer, an adapter-contract guard, a host preflight, and a smoke
265+
test of the built bundle. CI (`.github/workflows/test.yml`) runs it on every push. See
257266
[TESTING.md](TESTING.md) for what each layer guarantees — and the gaps (live-browser integration)
258267
it can't.
259268

269+
### Conventions
270+
271+
- **JS**: ES modules, `const`/`let`, 4-space indent, double quotes, semicolons; `camelCase` for
272+
functions and variables, `UPPER_SNAKE` for module constants, a leading `_` for private methods
273+
and for deliberately unused parameters. A file starts with a block comment saying what it owns
274+
and what it must not know about. `npm run lint` enforces the mechanical part.
275+
- **Python** (`bake.py`, `examples/`, `test/test_*.py`): PEP 8, stdlib only, `unittest`, the same
276+
4-space/snake_case conventions as pycortex itself.
277+
- **One definition per rule.** Anything two code paths must agree on — the bezier's segment
278+
topology, the overlay's coordinate mapping, the "is the user typing?" test, default shape names,
279+
the timer bookkeeping — lives in exactly one module and is imported from there (see the
280+
Architecture notes above). If you find the same rule restated in two places, that is the bug.
281+
260282
## Requirements
261283

262284
- **Node** ≥ 18 to build/test the JS.
@@ -265,10 +287,7 @@ it can't.
265287

266288
## Not here
267289

268-
General pycortex **viewer-modernization** tooling — `reengine.py` (re-emit a static viewer's engine
269-
from a current pycortex checkout), `fixups.py` (correct long-standing static-viewer UI bugs),
270-
`add_help.py` (inject a help menu into viewers built without one), and `convert_huth.py` — used to
271-
live in this repo. They were moved out on 2026-07-13, because this repo is the ROI-drawing project
272-
and that tooling applies to any pycortex viewer. They now live in `jackgallant/pycortex-viewer-tools`.
273-
274-
`bake.py` stayed: it injects the ROI-drawing bundle, so it *is* part of this project.
290+
General pycortex viewer-maintenance tooling (`reengine.py`, `fixups.py`, `add_help.py`,
291+
`convert_huth.py`) once lived here and was moved out in July 2026 to a separate tooling repository
292+
(`jackgallant/pycortex-viewer-tools`, currently private). This repo is the ROI/sulcus-drawing
293+
project only; `bake.py` stays because it injects the drawing bundle.

TESTING.md

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
# Testing & correctness
22

3-
`npm test` builds the bundle (via `pretest:js`) and runs the JS suite (`node --test`) plus the Python
4-
tooling tests. CI runs the same on every push/PR to `main` (`.github/workflows/test.yml`).
3+
`npm test` lints (`eslint`), builds the bundle (via `pretest:js`), runs the JS suite (`node --test`)
4+
and then the Python tooling tests (`unittest discover` over `test/test_*.py`, so a new Python test
5+
file is picked up without editing `package.json`). CI runs the same on every push/PR to `main`
6+
(`.github/workflows/test.yml`).
57

68
The suite is layered by how strong a guarantee each layer can give.
79

@@ -24,8 +26,27 @@ prints the seed and reproduces deterministically):
2426
(move/smooth-toggle/delete/split), and `isClosed` reports `false` throughout — a sulcus can never
2527
silently acquire a closing segment.
2628

27-
The example-based core tests (`geom`, `selection`, `bezier`, `transform`, `shape-model`, `outline`,
28-
`uv-membership`) remain as targeted cases.
29+
The example-based core tests (`geom`, `selection`, `bezier`, `transform`, `shape-model`,
30+
`uv-membership`, `timer-set`) remain as targeted cases.
31+
32+
### One definition, read everywhere — agreement tests
33+
A recurring class of bug here has been two code paths restating the same rule and drifting. Three
34+
tests pin that the restatements are gone:
35+
36+
- `test/bezier-topology.test.js`: the closed/open segment topology (how many segments, which
37+
anchors each joins, the 3/2 anchor floor) is defined once in `core/bezier.js`
38+
(`segCount`/`segControls`/`minAnchors`/`hasCurve`) and read by *both* samplers, *both*
39+
nearest-point searches, `deleteAnchor`, and the adapter's SVG path writer. The test checks
40+
`evalBezier`/`nearestOnBezier` dispatch to the explicit forms, that the adapter emits one `C` per
41+
segment and a `Z` only when closed, and that every consumer honors the same floor.
42+
- `test/draw-pipeline.test.js` (`uvPxCorrespondences`): the edit overlay and the sulcus trace
43+
pipeline fit their homographies from the same flattening of `projectVerticesInUvBounds`.
44+
- `test/shape-model.test.js` (`defaultName`): the name a prompt offers and the name an unnamed
45+
import gets come from one rule.
46+
47+
`test/dom-utils.test.js` pins `isTextEntry`, the single "is the user typing?" predicate behind
48+
every keyboard handler (controller Shift/Esc and the editor's Delete): a file input, button,
49+
checkbox or slider holding focus must not swallow a gesture.
2950

3051
### Sulcus SVG export — unit (pure writer) + a real XML parser
3152
`core/svg-export.js` is the pure writer for the sulci layer of an `overlays.svg`.

adapter/pycortex-adapter.js

Lines changed: 20 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@
1616
* - dat.GUI control panel (gui.__folders) + LandscapeControls (setTarget/setRadius) + viewer.animate.
1717
*/
1818
import { ViewerAdapter } from "./viewer-adapter.js";
19-
import { chaikin } from "../core/geom.js";
20-
import { isClosed, segCount } from "../core/bezier.js";
19+
import { chaikin, ndcToPixel } from "../core/geom.js";
20+
import { isClosed, segCount, segControls, hasCurve } from "../core/bezier.js";
2121
import { exportSulciSvg, SULCI_STROKE_WIDTH, SULCI_STROKE_OPACITY } from "../core/svg-export.js";
22+
import { TimerSet } from "../core/timer-set.js";
2223

2324
const SVGNS = "http://www.w3.org/2000/svg";
2425
const HEMIS = ["left", "right"];
@@ -110,7 +111,7 @@ export function surfaceReady(viewer) {
110111
}
111112

112113
export class PycortexAdapter extends ViewerAdapter {
113-
constructor(viewer, { layerName = "drawnrois", animSpeedFallback = 0.6 } = {}) {
114+
constructor(viewer, { animSpeedFallback = 0.6 } = {}) {
114115
super();
115116
this.THREE = globalThis.THREE;
116117
this.mriview = globalThis.mriview;
@@ -124,16 +125,15 @@ export class PycortexAdapter extends ViewerAdapter {
124125
this.surface = findSurface(viewer);
125126
this.posdata = (this.surface.picker && this.surface.picker.posdata) || this._buildPosdata();
126127

127-
this._layerName = layerName;
128128
this._animSpeedFallback = animSpeedFallback;
129129
this._v = new this.THREE.Vector3();
130130
this._thickmix = DEFAULT_THICKMIX;
131131
this._drawn = null; // { layerEl, labels } for the current overlay layer
132132
this._layerHidden = false;
133133
this._labelsHidden = false;
134134
this._uiFolderAdded = false;
135-
this._timers = new Set(); // pending setTimeout ids, cancelled by destroy()
136-
this._onSetData = null; // host listener installed by applyHostDefaults()
135+
this._timers = new TimerSet(); // every poll/deferred teardown; destroy() cancels them
136+
this._onSetData = null; // host listener installed by applyHostDefaults()
137137
}
138138

139139
// --- surface identity -------------------------------------------------------------
@@ -215,7 +215,7 @@ export class PycortexAdapter extends ViewerAdapter {
215215
const v = this._worldOf(pd, mw, i, surfmix, foy).project(cam);
216216
if (v.z < -1 || v.z > 1) continue; // behind camera / outside frustum
217217
out[h].idx.push(revIdx[i]);
218-
out[h].px.push(this._ndc(v, W, H));
218+
out[h].px.push(ndcToPixel(v, W, H));
219219
}
220220
}
221221
return out;
@@ -272,14 +272,12 @@ export class PycortexAdapter extends ViewerAdapter {
272272
const p = this._worldOf(pd, mw, i, surfmix, foy).project(cam);
273273
if (p.z < -1 || p.z > 1) continue;
274274
out[h].uv.push([u, v]);
275-
out[h].px.push(this._ndc(p, W, H));
275+
out[h].px.push(ndcToPixel(p, W, H));
276276
}
277277
}
278278
return out;
279279
}
280280

281-
_ndc(v, W, H) { return [(v.x * 0.5 + 0.5) * W, (-v.y * 0.5 + 0.5) * H]; }
282-
283281
// --- view framing primitive -------------------------------------------------------
284282

285283
// Center of mass (world) + the camera radius that fills `fillTarget` of the viewport.
@@ -301,7 +299,7 @@ export class PycortexAdapter extends ViewerAdapter {
301299
sx += w.x; sy += w.y; sz += w.z; count++;
302300
const nd = w.clone().project(cam);
303301
if (nd.z < -1 || nd.z > 1) continue;
304-
const px = this._ndc(nd, W, H);
302+
const px = ndcToPixel(nd, W, H);
305303
if (px[0] < minx) minx = px[0];
306304
if (px[0] > maxx) maxx = px[0];
307305
if (px[1] < miny) miny = px[1];
@@ -492,7 +490,7 @@ export class PycortexAdapter extends ViewerAdapter {
492490
svgo.svg.appendChild(layerEl);
493491

494492
// occlusion-aware label sprites, reusing pycortex's own Labels; degrade gracefully
495-
let labels = null;
493+
let labels;
496494
try {
497495
labels = new this.svgoverlay.Labels(labelsEl, svgo.posdata, !!this._labelsHidden);
498496
labels.shader.uniforms.depth.value = svgo.depth;
@@ -549,18 +547,16 @@ export class PycortexAdapter extends ViewerAdapter {
549547
// sulcus) has n-1 segments and MUST NOT close — the missing `Z` is exactly what distinguishes
550548
// a sulcus from an ROI on disk.
551549
_bezierSvgPath(bez, W, H) {
552-
const { anchors, inHandles, outHandles } = bez;
553-
const n = anchors.length;
554-
const closed = isClosed(bez);
555-
if (n < (closed ? 3 : 2)) return null;
550+
if (!hasCurve(bez)) return null;
556551
const P = (uv) => (uv[0] * W).toFixed(2) + "," + ((1 - uv[1]) * H).toFixed(2);
557-
let d = "M" + P(anchors[0]);
558-
const segs = segCount(bez); // n when closed (the wrap), n-1 when open
559-
for (let i = 0; i < segs; i++) {
560-
const j = closed ? (i + 1) % n : i + 1;
561-
d += "C" + P(outHandles[i]) + " " + P(inHandles[j]) + " " + P(anchors[j]);
552+
let d = "M" + P(bez.anchors[0]);
553+
// segControls/segCount own the wrap rule (n segments closed, n-1 open) — the same walk the
554+
// samplers use, so what is baked here is exactly the curve the editor shows.
555+
for (let i = 0, segs = segCount(bez); i < segs; i++) {
556+
const [, c1, c2, p3] = segControls(bez, i);
557+
d += "C" + P(c1) + " " + P(c2) + " " + P(p3);
562558
}
563-
return closed ? d + "Z" : d;
559+
return isClosed(bez) ? d + "Z" : d;
564560
}
565561

566562
/*
@@ -647,7 +643,7 @@ export class PycortexAdapter extends ViewerAdapter {
647643
const svg = this.surface && this.surface.svg;
648644
if (!svg || !svg.layers || !(svg.rois || svg.sulci)) {
649645
if (tries > OVERLAY_RETRY_MAX) return;
650-
this._later(() => trySetOverlays(tries + 1), OVERLAY_RETRY_MS);
646+
this._timers.later(() => trySetOverlays(tries + 1), OVERLAY_RETRY_MS);
651647
return;
652648
}
653649
if (svg.rois) { svg.rois.showhide(false); if (svg.rois.labels) svg.rois.labels.showhide(false); }
@@ -656,25 +652,17 @@ export class PycortexAdapter extends ViewerAdapter {
656652
};
657653
trySetOverlays(0);
658654
// the datasets folder is built open after data loads (post-attach); re-collapse a few times
659-
COLLAPSE_SCHEDULE_MS.forEach((ms) => this._later(() => this.collapseControlPanel(false), ms));
655+
COLLAPSE_SCHEDULE_MS.forEach((ms) => this._timers.later(() => this.collapseControlPanel(false), ms));
660656
const t0 = Date.now();
661657
if (this.viewer.addEventListener) {
662658
this._onSetData = () => { if (Date.now() - t0 < COLLAPSE_WINDOW_MS) this.collapseControlPanel(false); };
663659
this.viewer.addEventListener("setData", this._onSetData);
664660
}
665661
}
666662

667-
/* setTimeout, remembered, so destroy() can cancel it. */
668-
_later(fn, ms) {
669-
const id = setTimeout(() => { this._timers.delete(id); fn(); }, ms);
670-
this._timers.add(id);
671-
return id;
672-
}
673-
674663
// Release everything applyHostDefaults() started. The overlay layer itself is left in place:
675664
// the controller clears it (setOverlayLayer(name, [])) before it tears the adapter down.
676665
destroy() {
677-
for (const id of this._timers) clearTimeout(id);
678666
this._timers.clear();
679667
if (this._onSetData && this.viewer.removeEventListener)
680668
this.viewer.removeEventListener("setData", this._onSetData);

adapter/viewer-adapter.js

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55
* never to a specific viewer's internals. To run ROI drawing on a different WebGL surface
66
* viewer, implement this interface (see pycortex-adapter.js for the reference implementation).
77
*
8-
* It is a documented duck-typed interface, not a base class — implementers provide their own
9-
* object with these methods. The methods below `throw` so an incomplete adapter fails loudly.
8+
* It is a documented interface. The class is a convenience base (both PycortexAdapter and the test
9+
* fake `extends` it, and `REQUIRED` below drives a conformance test), but any object with these
10+
* methods works — the required methods `throw` here so an incomplete adapter fails loudly.
1011
*
1112
* Coordinate conventions:
1213
* - "screen px": CSS pixels relative to the surface canvas's top-left (what the lasso uses).
@@ -133,3 +134,24 @@ ViewerAdapter.REQUIRED = [
133134
"setOverlayLayer", "setLayerVisible", "flatten",
134135
"setCameraTarget", "setCameraRadius", "cameraRadius", "requestRender", "onMixChange",
135136
];
137+
138+
/* Everything: the bounds that select the whole flatmap for projectVerticesInUvBounds. */
139+
export const ALL_UV_BOUNDS = Object.freeze({ minu: -Infinity, maxu: Infinity, minv: -Infinity, maxv: Infinity });
140+
141+
/*
142+
* uv->px correspondences for a homography fit, from the adapter's projectVerticesInUvBounds
143+
* flattened across both hemispheres into parallel `src` (uv) / `dst` (px) arrays. The edit overlay
144+
* (LOCAL bounds around the shape) and the sulcus trace pipeline (the whole flatmap) both fit the
145+
* same kind of transform from the same kind of data — this is the one place that data is shaped.
146+
* `bounds` defaults to the whole flatmap.
147+
*/
148+
export function uvPxCorrespondences(adapter, bounds = ALL_UV_BOUNDS) {
149+
const proj = adapter.projectVerticesInUvBounds(bounds);
150+
const src = [], dst = [];
151+
for (const h of ["left", "right"]) {
152+
const p = proj[h];
153+
if (!p) continue;
154+
for (let i = 0; i < p.uv.length; i++) { src.push(p.uv[i]); dst.push(p.px[i]); }
155+
}
156+
return { src, dst };
157+
}

0 commit comments

Comments
 (0)