Skip to content

Add SurfAnnotate: cortical surface ROI delineation - #1

Merged
stebo85 merged 26 commits into
neurodesk:mainfrom
felenitaribeiro:surfannotate
Aug 4, 2026
Merged

Add SurfAnnotate: cortical surface ROI delineation#1
stebo85 merged 26 commits into
neurodesk:mainfrom
felenitaribeiro:surfannotate

Conversation

@felenitaribeiro

@felenitaribeiro felenitaribeiro commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Adds apps/surfannotate, a browser-native tool for delineating regions of
interest on cortical surfaces and exporting them. Everything runs client-side —
no surface, overlay or label leaves the machine.

What it does

  • Loads FreeSurfer surfaces, GIfTI .surf.gii, .mz3 and the other meshes
    NiiVue reads, plus per-vertex overlays (curvature, thickness, .annot,
    .label.gii, CIFTI .dscalar.nii). Several surfaces at once, one shown.
  • ROI borders are placed as clicks and joined by shortest paths along mesh
    edges, so a border follows the surface rather than cutting through it.
  • ROIs form an ordered parcellation: each is resolved on what the ones above
    it have left, so no vertex belongs to two, and each ROI's border becomes the
    edge the next can be closed against. Editing one re-derives the ones below it,
    so moving a shared boundary moves both sides.
  • On a cut surface — an unfolded flat patch — the patch edge closes an ROI for
    you, so two clicks can be enough.
  • Exports FreeSurfer .label, GIfTI .label.gii, and vertex lists as JSON.

Testing

109 unit tests (node --test, no browser) covering the geometry and the file
formats, and 56 Playwright tests against headless WebGL2. test:e2e downloads
and generates its own fixtures, so a clean checkout works.

Known limitations

  • .label coordinates come from the surface drawn on. The export panel warns
    when that surface is inflated, spherical or flat; the vertex indices are
    correct regardless. freeview substitutes the white surface here — we warn
    instead.
  • Each loaded surface keeps its own adjacency graph, pathfinder and spatial
    index, ~30 MB per 160k-vertex hemisphere.

Summary by CodeRabbit

  • New Features

    • Added SurfAnnotate, a browser-based cortical surface viewer and annotation workspace.
    • Supports surface and overlay loading, ROI drawing, filling, editing, parcellation, landmarks, and multiple surfaces.
    • Added exports for FreeSurfer labels, GIfTI labels, and annotated surface points.
    • Added drag-and-drop loading, coordinate warnings, citations, and responsive viewer controls.
  • Documentation

    • Added user guidance, supported formats, workflow instructions, licensing, and citation information.
  • Tests

    • Added comprehensive automated coverage for annotation workflows, file handling, rendering, and exports.

felenitaribeiro and others added 25 commits July 31, 2026 16:58
A browser-native tool for visualising cortical surfaces and drawing ROIs on
them. Two workflows: closed-boundary regions, and point-and-click vertex
selection. Everything runs locally; no data is uploaded.

Nothing in packages/components handles meshes — ViewerController is volume-only
and detectFiles classifies .gii/.mz3/.pial as unknown — so the surface layer is
new code here rather than an extension of the shared library.

Algorithms (framework-free, unit-tested under plain node --test):
- CSR 1-ring adjacency, A* shortest paths along mesh edges
- flood fill with Workbench's seed-outside-and-invert strategy, plus a seeded
  override, an escape guard, and interior-component reporting
- uniform-grid nearest-vertex lookup, 163x faster than NiiVue's linear scan
- FreeSurfer .label, GIfTI .label.gii / .shape.gii, and JSON/CSV point writers

Boundary paths deliberately follow mesh edges rather than a smoother geodesic:
consecutive vertices must be adjacent or the flood fill leaks through the
barrier. FreeSurfer and Connectome Workbench make the same trade-off, and
Workbench validates adjacency before filling for exactly this reason.

Measured on a 163,842-vertex lh.pial: adjacency 50 ms once, A* segments
0.007-0.23 ms, a 20-click ROI traced in 0.5 ms, fill 24 ms.

Pinned to @niivue/niivue 0.69.0 — npm latest, mesh code byte-identical to the
0.68.x used elsewhere in this monorepo, and unlike 1.0.0-rc.x it still has the
nearest-vertex helper. All NiiVue access is isolated in
src/niivue/meshAdapter.js so that migration stays a single-file change.

Registered as an experimental vite-webgpu / imaging-workspace app.
55 unit tests and 15 Playwright tests (headless WebGL2 via SwiftShader).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rename surfmark -> surfannotate throughout: registry id and public path,
package, workspace directory, window globals, ROI layer name, and docs.

Export files are now named <hemisphere>.<roi> rather than carrying the source
filename, so an ROI drawn on lh.sphere.reg.surf.gii saves as lh.V1.label
instead of lh.sphere.reg.surf.V1.label. Vertex indexing is shared across a
subject's surfaces, so naming a label after the particular surface it was
traced on misrepresents where it applies. The hemisphere is taken from GIfTI's
AnatomicalStructurePrimary when present, otherwise from the filename
(FreeSurfer lh./rh., BIDS hemi-L/R, or HCP .L./.R.); an unrecognised surface
yields no prefix rather than a guessed one.

Remove the .shape.gii export button — .label.gii covers the same ground and
carries the ROI name and colour. The writer stays in io/gifti.js, still tested,
as a general format function.

61 unit tests, 16 Playwright tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Delineating on an unfolded flat patch, an area often runs right up to the
cut, so its border is partly the drawn line and partly the edge of the
patch. Clicking along that edge to close the loop takes many clicks and
puts the border wherever the clicks land rather than on the cut itself.

"Close on surface edge" draws only the part of the border that crosses the
patch: both ends are extended to the nearest edge vertex by a multi-source
Dijkstra distance-to-cut field, and the edge closes the region. Two points
are enough, where a loop needs three.

Nothing is traced along the edge because nothing needs to be. Flood fill
walks the 1-ring vertex graph and no edge of that graph crosses the cut, so
the cut is already an impassable barrier; a border reaching it at both ends
separates the patch on its own. That separation is verified by counting
connected components rather than assumed — a line joining two *different*
cuts turns an annulus into a disk without dividing it, and is refused.

The smaller of the two sides is filled, since an area bounded by the cut is
the smaller piece essentially every time; "Other side" swaps in one click.
No 40%-of-surface refusal applies here, unlike the loop strategies: that
guard catches fills that leaked through a gap, and a leak is impossible
once the barrier is known to separate the graph.

Also fix a layout bug this surfaced. #controls sets flex-direction: column
while the shared .nd-imaging-controls class on the same element sets
flex-wrap: wrap; together they wrap anything taller than the panel into a
second column to the right of a 320px panel — invisible, unreachable, and
silent, because wrapping absorbs the overflow so overflow-y never scrolls.
Growing the tool section made every annotation button vanish as soon as a
cut surface was loaded. Pinned to nowrap, with an e2e test asserting one
column, and the [hidden] fix generalised from the drop hint to all elements.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Backspace and Delete undo the last border point, on a document-level
listener that calls preventDefault. It fired regardless of focus, so it
also swallowed every keystroke aimed at a text box: the ROI name and the
overlay colour-range fields could only be changed by selecting all and
overtyping.

Guard on the event target. Sliders, checkboxes and buttons deliberately do
not count as text entry — focus stays on the ROI opacity slider after you
drag it, and undo should still work there.

Escape now only reports "Cancelled" when a seed click was actually pending,
rather than on every press.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The app held exactly one surface and one overlay, which caused three
problems at once: a dropped surface rendered but left the panel looking
empty, because a native file input shows nothing for a drag-and-drop and
there was nowhere else for it to appear; a second surface silently replaced
the first; and there was no way to keep more than one overlay.

Surfaces and overlays are now lists. The lists, not the file inputs, are
the record of what is loaded, so a drop and a pick look the same. One
surface is shown at a time — not a preference, but because the depth picker
returns a position rather than an identity, so a click over two overlapping
meshes could not be attributed to either. Overlays belong to the surface
they were loaded onto and each keeps its own visibility, colour map and
range; hiding one rides on opacity, since NiiVue mesh layers have no
visibility flag, so the chosen opacity is stored separately rather than
being reset to opaque on re-show.

ROI sessions are keyed by topology rather than by file. Surfaces sharing a
vertex indexing — one subject's white, pial, inflated and sphere — share
border points, so they can be placed on the inflated surface and seen on
the folded one. RoiSession.rebind moves the session and discards the traced
chain and the fill: the shortest path between two vertices genuinely runs
differently over different geometry, so those must be rebuilt rather than
carried across. Unrelated meshes keep independent ROIs, preserved while
switching back and forth.

Drop routing no longer depends on drop order, which could not express "add
another surface". io/classify.js identifies each file from its magic number
— FreeSurfer's 0xFFFFFE against curvature's 0xFFFFFF, GIfTI's intent codes,
MZ3's attribute bitfield — falling back to extensions and FreeSurfer naming,
and to the old positional rule only when nothing is conclusive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Visual areas are delineated in order, and each one's border is mostly the
border of the one before it: V2 shares the whole V1/V2 boundary with V1, V3
shares its outer boundary with V2. Re-clicking that shared border for every
area is tedious and also wrong — the two areas end up with slightly
different boundaries and a sliver of unassigned cortex between them.

A filled region can now be saved to a list of completed ROIs, and ticked as
an edge. That reuses the machinery already built for flat patches rather
than adding a parallel one: a cut in a surface works as a barrier because
the 1-ring graph has no edges crossing it, so cutting a finished ROI out of
the graph gives its rim exactly the same standing. V1 becomes a hole, the
rim of that hole is reported as an edge, and closeOnEdge anchors to it with
nothing else changed. The pathfinder will not route through it and no fill
can cross it, both of which fall out of the vertices being isolated —
fill.js and isIsolated already skip vertices with no neighbours.

Excluded vertices keep their indices, since every label, click and export
refers to a vertex by index; only their edges go.

This works on closed surfaces too, where there was no edge to begin with:
once V1 is cut out, lh.pial is a sphere with a hole in it, and every edge
closure applies.

Completed ROIs are per topology like the sessions, painted from a palette,
and solid rather than translucent while acting as an edge — they are part
of the surface's structure at that point rather than an annotation over it.
Selecting one makes the export buttons write it. Both exports now go
through one function: the .label path read the session directly and would
have written an empty file after a save cleared it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Saving was one-way: a border could only be corrected by removing the ROI
and re-clicking it from scratch. The clicks were already stored in the
record, so the pencil now puts them back on the canvas.

Reopening is un-saving. The ROI leaves the list first, which matters
because an ROI cannot be an edge for its own border — while it is cut out
of the graph its own clicks are isolated and no path can reach them, so
retracing would fail with an unexplained gap.

The border is recomputed from the clicks rather than restored from the
saved chain, for the same reason the clicks are authoritative everywhere
else: the surface may have changed since, another ROI may have become an
edge, and the border should respect it. The record now also carries how it
was closed, so a loop is retraced as a loop and an edge closure as an edge
closure, along with the region index and the include-boundary setting.

A loop on a surface with an edge normally has to ask which side was meant.
The saved region already answers, so the refill is seeded from a vertex
inside it rather than re-running the geometric inside/outside guess. On the
flat-patch fixture the two always agree wherever a region can be saved at
all, so the e2e test covers the round trip rather than the difference; the
seed is kept because recorded fact beats a heuristic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reopening an ROI failed whenever a neighbour was also ticked as an edge:
the border points came back but could not be retraced, and the region came
back empty.

The cause is that an ROI's border points routinely lie *inside* the ROI
drawn next to it. The fill excludes the border row by default, so the row
V1 was clicked along is unclaimed until V2 is drawn against V1's rim — at
which point V2 owns it. Cutting V2 out of the graph isolates exactly the
vertices V1's border runs through, so A* cannot reach them.

Reopening now also unticks any edge ROI whose region covers part of the
border being restored, and names it in the status line rather than doing it
silently. Neighbours that do not touch the border keep their tick.

Repro before the fix, on the flat patch: V1 from row 2, V2 from row 6, both
ticked as edges; reopening V1 left both its clicks isolated and reported a
gap. After: the border retraces and refills to its original 82 vertices.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Independent masks are the wrong model for adjacent areas. V1 and V2 share
a boundary, so moving it changes both — but only the edited one changed,
leaving the other claiming vertices that were no longer its own, or leaving
a strip belonging to nobody. Editing V1 after completing V2 should hand V2
the vertices V1 gives up.

An area is no longer a mask. It is a definition — its border points, how
they were closed, which side was taken, and a vertex deep inside the region
— and the masks are derived by resolving the whole list in order, each area
cut out of the surface before the next is resolved. Disjointness holds by
construction rather than by checking, and editing re-derives everything
below: pull V1's border back and V2 grows into the space, because V2 was
always defined as "my line, and whatever lies between it and the area above
me". This is the rule the app already followed for clicks, one level up.

Consequences:

The per-area "edge" tick is gone. Every area above the one being drawn
constrains it, which is what a parcellation means; the tick was expressing
the same thing by hand and could be set inconsistently.

Order is now meaningful and adjustable. An area can be squeezed out by one
promoted above it, and moving it back up takes those vertices straight
back. An area that no longer resolves is struck through, reports why, and
claims nothing.

Reopening keeps the area's position, which removes the neighbour-release
special case added in the previous commit: an area is only ever blocked by
one above it, and editing in place means the one below is simply re-derived.

Filling by component size alone flips as neighbours grow and shrink, so
each area stores an anchor — the vertex furthest from its border by hop
count, the last one a neighbour would take — and the refill prefers the
component containing it.

Also fix the palette index, which came from the list length: re-saving an
edited area recoloured it, and could give it its neighbour's colour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things reported together.

The area name lived in the Export panel, below the Save button that uses
it, so naming an area meant scrolling past the button that saves it. Moved
to the Areas panel, directly above Save. Export keeps the filename preview.

A .label exported from here could not be dropped back in, while the
.label.gii could. NiiVue's readLayer has no case for a FreeSurfer .label:
the extension falls through to its curvature reader, which cannot parse
ASCII and returns a layer with zero values. A .label is also sparse — a
list of the vertices in the region — where every format NiiVue does read is
one value per vertex.

So it is expanded here instead. labelToValues scatters the vertex list into
a dense array, using the fifth column when it carries a statistic and a
binary mask when it does not, and refuses a label whose indices exceed the
surface rather than truncating it. classify.js now routes .label to the
overlay path, by extension and by its "#!ascii label" first line.

The display window is set just under the smallest marked value rather than
over the full range: a mask is mostly zeros, so a full-range window renders
the whole surface in the colour map's low end and the region does not stand
out.

A hand-built layer must set nFrame4D: 1. NVMeshLayerDefaults leaves it 0
and NiiVue computes the frame as min(max(frame4D, 0), nFrame4D - 1) = -1,
so it reads values[j - vertexCount], gets undefined, and every colour lookup
lands on NaN — which paints the entire surface black. Found by looking at
the render; the data assertions all passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Matches the rest of the Neurodesk webapps: a short explanation of what the
tool reads, writes and does, then a "Start annotating" button, rather than
dropping a visitor straight into an empty viewer.

Built the way calmar builds its own — a fixed-position section over the app
rather than a second document — so there is still one bundle, one entry and
one path in the composite site. The app is behind it the whole time, so the
canvas is already sized when the overlay goes away.

The shared shell always draws a one-glyph badge beside the title and has no
option to omit it, so it is hidden in this app's stylesheet rather than
changed in the component and every app with it.

Two things found while wiring the e2e suite through the new button:

Loads are now serialised. A file input fires `change` when the files are
set, not when the async handler finishes, so picking two files in quick
succession — or picking one while a drop was still parsing — started two
loadSurface calls at once, which interleaved on state.surfaces and on the
active-surface mirrors and could leave the app showing one surface while
the tools pointed at another.

Three e2e tests used window.__surfannotate immediately after setInputFiles,
which resolves on the change event rather than on the load. That raced into
a "session is null" failure in one test per full run, moving between tests.
The suite now passes four consecutive times.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An SVG in public/, referenced with %BASE_URL% the way dicompare does, so it
survives both the standalone build and the composite-site assembly whatever
the base path is set to.

The icon is the app's own picture of itself: the grey of an unlabelled
mesh, the gold of a traced border, the red of a filled region. Two shapes
and a line, because at 16px only the silhouette and the colour bands
survive. Checked by rendering it at 16, 24, 32 and 96.

The e2e test fetches it through the served path rather than just asserting
the tag: the failure mode worth catching is %BASE_URL% going out
unsubstituted, which 404s with no other symptom.

Also fixes three .label tests that were never running. An earlier append
landed at the repo root instead of the app directory, leaving a stray
test/io.test.js with no imports — which broke the root test glob — while
the app's own suite imported labelToValues without ever using it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the drawn placeholder with the mesh-and-pencil artwork.

The file arrived as a PNG named favicon.svg and declared image/svg+xml,
which would not have rendered at all. It was also 711x508 — a browser
stretches a non-square icon rather than padding it — and 233 kB.

So it is re-rendered: fitted centred into square tiles at 32, 48, 180 and
256, which is 1.9 kB for the one a tab actually uses. The artwork touches
all four edges, so there is no transparent margin to trim and squaring has
to letterbox; the alternative would be cropping the pencil.

The test now walks every declared icon rather than one, checks each is
served as a PNG, and measures it: square, and the size its tag claims.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The supplied SVG is now the source the PNGs are generated from, rather than
the flaticon raster. Rendering each size from vector instead of downscaling
a 711x508 bitmap is visibly cleaner at 32 and 48, and roughly halves the
files: 256 goes from 53 kB to 47 kB and 32 from 1.9 kB to 1.8 kB, with the
artwork filling more of the tile because the square is now fitted to the
painted bounds rather than to the bitmap's frame.

The SVG itself is not shipped. Measured, it is 202 kB raw and 87 kB gzipped
— 46 kB after rounding Inkscape's seven decimal places to two, which is
still a hundredth of a pixel at 256. Against 1.8 kB for the icon a tab
actually draws, that is a poor trade for no visible gain, and Safari would
need the PNG fallback anyway. It is kept in icon/ as the master, with
icon/render.mjs to regenerate from it.

Two things that had to be got right. The file arrived as an Inkscape A4
page, so the artwork occupied 40% of a tall portrait canvas and would have
rendered tiny and letterboxed; the viewBox is retargeted to a square around
the drawing. And those bounds come from rasterising and finding the painted
alpha box rather than from getBBox(), which ignores stroke width and would
have clipped the brain outline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
registry/apps.yml and the README both declared MIT, but the app carried no
LICENSE file and LICENSES.md did not list it — the NOASSERTION situation
that file exists to warn about, and one that would matter on the way
upstream. Every other MIT app here ships the text.

Also records the notices for what the bundle actually contains. A permissive
dependency does not dictate the licence of the work using it, so MIT here
against NiiVue's BSD-2-Clause is not a conflict. What BSD-2-Clause does
require is that its copyright notice and disclaimer accompany a binary
redistribution, and minification strips them: the built bundle carries
NiiVue's code with no notice left in it. THIRD-PARTY.md reproduces them.

Copyright line follows the house style used by qsmbly and seedseg
("<app> Contributors") rather than naming a person; change it if you would
rather it read otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The note said "original artwork", which claims more than is accurate: it
was drawn in Inkscape from source images generated with ChatGPT.

The conclusion is unchanged — no third-party licence attaches, because
OpenAI assigns whatever rights it may hold in output to the user and asks
for no attribution. That is the difference from a stock-library asset,
whose terms would survive whoever assembled the file.

Worth writing down all the same, since provenance is what the file is for,
and because purely machine-generated material may not attract copyright
everywhere: the protectable part is the drawing work, not the generated
inputs. That bears on what could be enforced, not on what anyone may do
with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same shape as calmar's: a Cite link in the header opening a list of what
should be cited, with the NiiVue entry worded exactly as calmar words it so
the two agree.

There is one entry, because there is one thing to cite — NiiVue does the
rendering and the mesh parsing, and nothing else third-party is involved in
producing a label. Sections are ready for more.

Two small things. The button is a native <dialog> opened with showModal(),
which puts it in the top layer, so the same dialog serves the start page and
the app without the start page's fixed positioning getting in the way, and
Escape closes it for free. And the shared shell builds its navigation with
only the catalog link and takes no list of extra items, so the app's button
is appended after mounting rather than by changing the component and every
app with it — which also means it needs `font: inherit`, since the shell's
link rule sets colour but not type and a bare <button> would otherwise sit
noticeably smaller beside the anchor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five agents exercised the app from different angles. Fixed here, each with a
regression test:

BLOCKERS

The e2e suite depended on lh.realflat.surf.gii, which nothing in the repo
produced — I generated it in a scratch directory. A maintainer checking out
the PR got ENOENT from five tests. Its generator is now committed alongside
make-flat-patch.mjs, and test:e2e fetches and builds every fixture it needs,
so a clean checkout works.

buildVertexIndex sized its grid by volume, so a strictly planar mesh — which
is what mris_flatten writes — collapsed the cell size to nothing and exploded
the grid across the two remaining axes. A 150k-vertex flat patch took 7.8 s
(against 6 ms for the same size folded) or failed outright with an allocation
error that loadSurface reported as a corrupt file. That is this app's headline
use case. Sizing from the axes that actually have extent, plus a cap on total
cells, takes it to 27 ms.

SEVERE

Saving an area selected it, and the export buttons prefer the selection while
the filename comes from the name box — so the next export wrote the saved
area's vertices under the new area's name, with a status line confirming the
wrong count. Saving no longer selects.

Reopening lifted an area out of the list into the session, and nothing put it
back: switching surface, clearing, or reopening another lost it for good.
restoreEdited() now returns it to its own position on every exit.

Removing the last surface left every control enabled while the state behind
them was null — eight uncaught TypeErrors. repaint() cannot fix this because
it returns early without a session, so teardown resets the controls itself.

FORMAT CORRECTNESS

GZipBase64Binary wrote a gzip container. GIfTI 1.0 s5.0 says ZLIB and
gifti_clib uses compress2(), so nibabel, wb_command and mris_convert all
refuse the file; NiiVue's decompressor is gzip-tolerant and so agreed with the
mistake, as did our own test, which gunzipped. Now a zlib stream, asserted by
magic byte.

An ROI name containing "]]>" closed the CDATA section early and produced XML
no parser would read.

hemispherePrefix took the first bare L or R it found, so MSM-L.R.midthickness
exported a RIGHT hemisphere as lh.<roi>. Ambiguous names now yield no prefix.

USABILITY

onCanvasHover ran a full depth pick — two scene renders and a synchronous
readPixels — on every pointermove, for a text readout. On a software renderer,
which is a realistic Neurodesk deployment and not just CI, that is ~500 ms per
mouse move and the app appears frozen. Throttled to one per frame.

setMode left awaitingSeed set, so a pending "click inside the region" ate the
first landmark click. recomputeParcellation now announces itself past a few
areas, where it blocks long enough to look hung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The header says `vox2ras=TkReg` and FreeSurfer's labelGetSurfaceRasCoords
takes that verbatim, but the coordinates were scanner RAS: NiiVue adds the
volume centre to every vertex on load — `cras` from a FreeSurfer footer,
`VolGeomC_R/A/S` from GIfTI — so meshes line up with volumes. Measured on
this repo's own lh.pial, vertex 0 was written 1.9991 mm off in x and z.

Nothing rejected the file, which is what made it dangerous. freeview,
mris_anatomical_stats and mris_label2annot all key on the vertex index and
were unaffected; mri_label2vol and mri_label2label --regmethod coords read
columns 1-3 and were silently wrong.

io/geometryOffset.js recomputes the translation from the same bytes NiiVue
parsed, and the writers subtract it. It mirrors NiiVue's quirks on purpose:
`cras` is applied even when the footer says `valid = 0` (which is exactly
the case in the shipped fixture), and GIfTI values are read only from a
CDATA value. A correction that does not match what was applied would be
worse than none.

points.json made the same claim in its coordinateSpace field and gets the
same correction.

Not fixed, and now recorded in AGENT.md: the coordinates still come from
whichever surface was drawn on, so an inflated or flat surface writes
inflated or flattened positions under a tkreg header. freeview substitutes
the white surface; we could too, since same-topology surfaces already load
together. The README says plainly which numbers to trust meanwhile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The coordinates in a .label are only meaningful if they came from a surface
sitting in the subject's anatomy, and nothing in the file records which
surface that was. freeview substitutes the white surface when the displayed
one is inflated; this app does not, so it has to say so before writing.

The export panel now names the surface the coordinates will come from, and
turns into a warning when that surface is inflated, spherical or flat —
pointing out that the vertex indices are still correct, since that is all
freeview and mris_anatomical_stats read. When another loaded surface shares
the vertex indexing and is anatomical, it names that one: same indexing
means the same ROI, so the fix is one click rather than a reload.

Detection is `naming.surfaceKind` on the filename plus a planarity check on
the geometry, so a flat patch is caught whatever it is called. An
unrecognised name counts as trustworthy on purpose — an oddly named
anatomical surface is likelier than a flattened one, and warning about
every unconventional filename would train the warning away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fill.js measured both its safety guards against graph.V. excludeVertices
keeps a completed area's vertices and their indices — every label and click
depends on them — and only strips their edges, so graph.V stops being the
size of the surface a flood can reach the moment any area is saved.

Measured against it, the ">half the surface, so take the other side" rule
fires spuriously. Reproduced on a 21x21 grid with an earlier area holding
rows 8-20: a small 4x4 loop drawn in what was left filled 152 vertices —
the exterior, including the far corner — and reported error: null. The same
loop on the uncut graph fills the correct 4. The 40% escape guard fails the
same way in reverse: a fill that leaked through a gap and swallowed 93% of
the reachable surface passed, being only 36% of the nominal count. That is
the end state a parcellation app is built to reach.

Both now measure the walkable count, and the swap compares the two sides
directly, which needs no denominator at all.

Also from the review:

activateSurface assigned entry.graph over what bindSession had just set to
the cut graph, so every activation defeated bindSession's short-circuit and
performed a second full excludeVertices plus pathfinder rebuild.

restoreEdited put the area back on the list but left its clicks in the
session, so a Save after switching surfaces appended the same area again
with a new id and colour; the duplicate then resolved as unresolvable
because the original already owned the territory. removeSurface also called
it for surfaces that were not the active one.

Dropped applyExclusion, dead since the parcellation rewrite, and cut
window.__surfannotateUi from 22 members to the 7 the e2e suite drives — a
shipping app should not export its internals wholesale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The coordinate warning told everyone to load lh.white or lh.pial and switch
to it. On an inflated or spherical surface that is right — the vertex
indexing is the native surface's, so the areas come across. On a flat patch
it is wrong twice over: a patch is a cut of the surface with its own
numbering and fewer vertices (152,893 against 163,842 here), so switching
shows none of the areas drawn on it, and the claim that "the vertex indices
are still correct" is not true of the native surface either.

The two cases now read differently, and the flat one says plainly that a
flat surface has a different number of vertices from the native surface it
was cut from, so its indices and its areas belong to the patch alone.

Switching to a surface with a different vertex indexing also now says so:
the areas are hidden rather than lost, and reappear on switching back, but
nothing said that and it looked exactly like the work had been discarded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The review found a set of exports, options and fields that only tests or
nothing at all referenced. Removing anything with no production caller:

  writeGiftiShape, maskToFloatArray   the .shape.gii button went long ago
  writePointsCsv, readPointsJson,     a points import path never built
    checkMeshIdentity
  haloMask, hatchMask({cross})        from the abandoned halo experiment
  patchVertexColors                   a VBO fast path with no caller
  writeGiftiLabel({gzip})             GZipBase64Binary, never requested
  state.layerIndex / entry.layerIndex written in six places, read in none
  ResolvedArea.chain                  copied on every recompute, never read
  __surfannotateFill in the e2e suite never assigned, so always undefined
  three unreferenced ids in index.html, one class with no rule

AGENT.md's carve-out for writeGiftiShape goes with it: it was kept as "a
general format writer", which is the argument that keeps every unused
export alive. It is one revert away in the history.

Two corrections to the review while doing this. It reported that nothing
passes writeFreeSurferLabel's `stat` option — a test does, and the fifth
column is a real part of the format, so that one stays. And removing the
gzip path let a bug through: base64() takes bytes, so handing it an
Int32Array made Buffer.from() read it as an array of numbers and truncate
each to one byte. The round-trip test caught it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The parcellation rewrite renamed the concept but not the identifiers, so
main.js carried 127 `roi*` tokens against 96 `area*` — both in one
expression in places — and `<h2>Areas</h2>` contained `id="roiName"`,
`id="saveRoi"` and `id="roiList"`. The ids were always right; the prose and
the locals had drifted.

Mechanical throughout: identifiers take the code form (`roi`, `rois`),
comments and user-facing strings take the written form ("ROI", "ROIs").
AreaDefinition, ResolvedArea, resolveArea and AREA_ERRORS follow.

Two things deliberately left alone, both of which a blind sweep would have
broken: `.area` is a FreeSurfer per-vertex file extension in classify.js and
in the overlay error message, and "surface area" in meshAdapter.js is
ordinary English.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

SurfAnnotate adds a browser-local cortical surface viewer with overlay handling, topology-aware ROI editing, parcellation, landmark selection, annotation exports, documentation, configuration, and automated tests.

Changes

SurfAnnotate application

Layer / File(s) Summary
Application foundation and packaging
apps/surfannotate/index.html, apps/surfannotate/package.json, apps/surfannotate/vite.config.js, apps/surfannotate/src/styles.css, apps/surfannotate/README.md, registry/apps.yml
Adds the application shell, user interface, build configuration, styling, documentation, licensing, and registry entry.
File classification and annotation exports
apps/surfannotate/src/io/*
Adds surface and overlay detection, FreeSurfer and GIfTI label I/O, geometry-offset handling, filename utilities, and points JSON export.
Surface topology and ROI algorithms
apps/surfannotate/src/surface/*
Adds adjacency graphs, nearest-vertex lookup, pathfinding, edge closure, exclusion, filling, hatching, parcellation, and ROI session state.
NiiVue integration and application workflow
apps/surfannotate/src/niivue/*, apps/surfannotate/src/main.js
Adds mesh and overlay loading, colormap registration, surface and ROI state management, interactions, rendering, and export wiring.
Unit, fixture, and end-to-end validation
apps/surfannotate/test/*, apps/surfannotate/e2e/smoke.spec.js
Adds unit tests, fixture generators, fixture downloads, and Playwright coverage for core algorithms and application workflows.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant SurfAnnotate
  participant NiiVue
  participant ROIEngine
  participant ExportIO
  User->>SurfAnnotate: Load surface and overlay files
  SurfAnnotate->>NiiVue: Create meshes and display layers
  User->>SurfAnnotate: Draw ROI or select landmarks
  SurfAnnotate->>ROIEngine: Trace paths and resolve regions
  ROIEngine-->>SurfAnnotate: Return masks and ROI state
  SurfAnnotate->>ExportIO: Serialize selected annotation
  ExportIO-->>User: Download label or points file
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 71.52% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the addition of SurfAnnotate and its main cortical surface ROI delineation function.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​@​niivue/​niivue@​0.69.0871008295100

View full report

ci.shared_runtime was false, which meant the app's 56 browser tests never
ran in automation — only `test` and `build` did.

The flag reads as "does this app use the shared runtime store", but it does
not track that: seedseg provides runtime assets and is false, while
browserqc, vesselboost and spinalcordtoolbox provide none and are true. What
it actually drives is whether ci.yml's shared-runtime-e2e job runs the app's
test:e2e. It is also what scripts/new-app.mjs emits for a new app.

Five of the six apps with a test:e2e script set it; browserqc is the closest
analogue — vite-webgpu, imaging-workspace, no runtime assets — and sets it.

Verified: app-plan now puts surfannotate in the shared-runtime-e2e matrix,
and test:e2e passes from a clean fixture directory, fetching lh.pial and
lh.curv and generating both flat patches before running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

🧹 Nitpick comments (15)
apps/surfannotate/scripts/fetch-fixtures.mjs (1)

42-55: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add a timeout to each fetch attempt.

fetchWithRetry has no timeout on the fetch(url) call. A stalled connection (for example, a misconfigured proxy) can hang indefinitely instead of failing and retrying. Add AbortSignal.timeout(...) so a stalled attempt fails fast and the retry loop proceeds.

♻️ Proposed fix to bound each fetch attempt
     try {
-      const response = await fetch(url);
+      const response = await fetch(url, { signal: AbortSignal.timeout(30_000) });
       if (!response.ok) throw new Error(`HTTP ${response.status}`);
       return Buffer.from(await response.arrayBuffer());
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/surfannotate/scripts/fetch-fixtures.mjs` around lines 42 - 55, Update
fetchWithRetry so each fetch attempt passes an AbortSignal.timeout(...) option
to fetch, using the script’s existing timeout configuration if available or an
appropriate bounded duration. Ensure timeout failures are caught by the existing
catch block so they trigger the current retry and final-error behavior.
apps/surfannotate/e2e/smoke.spec.js (2)

1266-1268: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the empty test.use({}).

The call sets no fixture overrides, so it has no effect. The comment above it suggests an intent that the code does not implement; both tests call page.reload() to get the initial state instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/surfannotate/e2e/smoke.spec.js` around lines 1266 - 1268, Remove the
empty test.use({}) call from the start page test.describe block, leaving the
existing tests and page.reload() behavior unchanged.

464-471: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Replace the fixed wait with a state-based wait.

waitForTimeout(1500) makes this render check timing-dependent. On a slow CI worker the screenshot can precede the first painted frame, and the 5,000-byte size assertion then fails intermittently. Poll a NiiVue render signal or a non-empty pixel sample instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/surfannotate/e2e/smoke.spec.js` around lines 464 - 471, Replace the
fixed wait in the “the surface renders visibly” test with a state-based wait
that polls a NiiVue render signal or verifies a non-empty pixel sample before
taking the screenshot. Keep the existing `#gl` screenshot and size assertion,
ensuring capture occurs only after the first rendered frame is detected.
apps/surfannotate/test/vertexLookup.test.js (1)

26-30: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

The probe generator loses the bits it then keeps.

seed * 1103515245 reaches about 2^61 for a 31-bit seed. Doubles carry only 53 significant bits, so the low-order bits are discarded before & 0x7fffffff masks the result. The mask keeps exactly those degraded bits, which can produce a short cycle and leave much of the volume unprobed. The sequence stays reproducible, so the weakness is silent.

Use Math.imul so the multiplication stays exact in 32-bit arithmetic.

♻️ Proposed change
   let seed = 12345;
   const random = () => {
-    seed = (seed * 1103515245 + 12345) & 0x7fffffff;
+    seed = (Math.imul(seed, 1103515245) + 12345) & 0x7fffffff;
     return seed / 0x7fffffff;
   };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/surfannotate/test/vertexLookup.test.js` around lines 26 - 30, Update the
random generator’s seed multiplication in the `random` function to use
`Math.imul`, preserving exact 32-bit arithmetic before applying the existing
mask and normalization. Keep the seed, constants, reproducibility, and returned
range unchanged.
apps/surfannotate/test/fixtures/make-real-patch.mjs (1)

30-57: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Generate lh.realflat.surf.gii as a single disk with consistent winding.

The radius cut keeps any face whose vertices fit inside the ball, so it can contain nonconnect components or interior holes. Keep only the largest connected component of the projected patch, then normalize all faces so their projected normals point toward -x before writing the fixture.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/surfannotate/test/fixtures/make-real-patch.mjs` around lines 30 - 57,
Update the patch-generation flow after constructing the projected vertices and
faces to retain only the largest connected face component, excluding
disconnected islands and interior-hole artifacts. Then normalize every retained
face’s winding using the projected coordinates so its normal points toward -x,
and use the filtered, consistently oriented faces when building the output index
array for lh.realflat.surf.gii.
apps/surfannotate/test/surface.test.js (1)

97-105: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Hoist the duplicated coordinate literal into one constant.

Lines 99 and 102-104 repeat the same 18 coordinates. buildAdjacency and SurfacePathfinder must see identical geometry. If one copy is edited later, the graph weights and the pathfinder positions diverge, and the failure is hard to read.

♻️ Proposed refactor
   // Two disjoint triangles: no path between components.
-  const split = buildAdjacency(
-    Float32Array.from([0, 0, 0, 1, 0, 0, 0, 1, 0, 5, 5, 0, 6, 5, 0, 5, 6, 0]),
-    Uint32Array.from([0, 1, 2, 3, 4, 5])
-  );
-  const splitFinder = new SurfacePathfinder(split, Float32Array.from(
-    [0, 0, 0, 1, 0, 0, 0, 1, 0, 5, 5, 0, 6, 5, 0, 5, 6, 0]
-  ));
+  const splitVertices = Float32Array.from(
+    [0, 0, 0, 1, 0, 0, 0, 1, 0, 5, 5, 0, 6, 5, 0, 5, 6, 0]
+  );
+  const split = buildAdjacency(splitVertices, Uint32Array.from([0, 1, 2, 3, 4, 5]));
+  const splitFinder = new SurfacePathfinder(split, splitVertices);
   assert.equal(splitFinder.path(0, 4), null);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/surfannotate/test/surface.test.js` around lines 97 - 105, Hoist the
repeated 18-coordinate Float32Array data in the split-geometry test into a
single named constant, then pass that same constant to both buildAdjacency and
SurfacePathfinder so they always use identical geometry.
apps/surfannotate/test/helpers.js (1)

33-55: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document that removed-block interior vertices remain in the mesh.

makeGridWithHole drops faces but keeps every vertex. For n = 9, a = 3, b = 5, vertex (4,4) ends up in no face, so it stays in vertices and becomes isolated in the adjacency graph. Tests that count vertices or components against graph.V depend on this. State it in the doc comment so a future change does not remove the orphan vertices silently.

♻️ Proposed doc update
  * The same grid with a square hole punched through it, so the patch is an
  * annulus rather than a disk. A line from the outer edge to the rim of the hole
  * does *not* separate an annulus — you can still walk round the other side —
  * which is the case edge closure has to detect and refuse.
  *
+ * Only faces are removed; every vertex is kept, so vertices strictly inside the
+ * removed block appear in no face and are isolated in the adjacency graph.
+ * `V` and `n` therefore stay the same as the intact grid.
+ *
  * `@param` {number} n vertices per side
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/surfannotate/test/helpers.js` around lines 33 - 55, Update the JSDoc for
makeGridWithHole to explicitly state that removing the selected cell faces does
not remove vertices, including interior vertices that become isolated or unused.
Leave the mesh construction logic unchanged.
apps/surfannotate/src/io/classify.js (1)

49-49: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the no-op ternary.

Both branches of head instanceof Uint8Array ? head : head return head, so the condition has no effect. The current code also copies an existing Uint8Array instead of reusing it.

♻️ Proposed simplification
-  const bytes = head ? new Uint8Array(head instanceof Uint8Array ? head : head) : null;
+  const bytes = head ? (head instanceof Uint8Array ? head : new Uint8Array(head)) : null;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/surfannotate/src/io/classify.js` at line 49, Update the bytes
initialization in the classify flow to remove the redundant ternary and reuse
head directly when present, while preserving the null result when head is
absent.
apps/surfannotate/src/surface/exclude.js (1)

103-110: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Do not skip a mask whose length does not match V.

unionMasks ignores any mask of the wrong length without a signal. If a caller passes a mask built for another surface, that ROI is silently left out of the exclusion set, and a later ROI can then claim vertices that already belong to it. Every other entry point in this module throws on a length mismatch, so keep the contract consistent.

🛡️ Proposed change
 export function unionMasks(V, masks) {
   const union = new Uint8Array(V);
   for (const mask of masks) {
-    if (!mask || mask.length !== V) continue;
+    if (!mask) continue;
+    if (mask.length !== V) throw new Error('mask length must equal vertex count');
     for (let v = 0; v < V; v++) if (mask[v]) union[v] = 1;
   }
   return union;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/surfannotate/src/surface/exclude.js` around lines 103 - 110, Update
unionMasks to throw when any provided mask has a length different from V instead
of silently skipping it; retain the existing handling for valid masks and align
its validation behavior with the module’s other entry points.
apps/surfannotate/src/surface/parcellation.js (1)

52-56: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Align the JSDoc names with the parameters and the return shape.

The parameter is rois, not ROIs, and the returned property is rois, as main.js shows with const { rois: resolved } = resolveParcellation(...). Line 87 has the same mismatch with roi. Editors resolve these tags by name, so the current tags document nothing.

📝 Proposed change
- * `@param` {RoiDefinition[]} ROIs
- * `@returns` {{ROIs: Array<RoiDefinition & ResolvedRoi>, owner: Int32Array,
+ * `@param` {RoiDefinition[]} rois
+ * `@returns` {{rois: Array<RoiDefinition & ResolvedRoi>, owner: Int32Array,
  *   assigned: number}} `owner` holds the id of the ROI owning each vertex, or
  *   -1 where nothing does.
- * `@param` {RoiDefinition} ROI
+ * `@param` {RoiDefinition} roi

Also applies to: 87-87

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/surfannotate/src/surface/parcellation.js` around lines 52 - 56, Update
the JSDoc in the parcellation resolver to use the actual parameter name rois
instead of ROIs, and document the returned property as rois to match
resolveParcellation’s return shape and its consumers. Also correct the
mismatched roi name in the JSDoc at the referenced later line, preserving the
existing type descriptions.
apps/surfannotate/src/surface/adjacency.js (1)

125-130: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Returned adjacency arrays retain their over-allocated buffers. Both graph builders allocate edge arrays sized for duplicate directed edges and then return subarray views. A view keeps the whole buffer alive, so roughly twice the final edge storage stays resident for every graph the app holds. The app keeps topology per loaded surface and builds one cut graph per ROI during resolveParcellation, so the waste multiplies.

  • apps/surfannotate/src/surface/adjacency.js#L125-L130: return adjNeighbor.slice(0, k) and adjWeight.slice(0, k).
  • apps/surfannotate/src/surface/exclude.js#L86-L91: return newNeighbor.slice(0, k) and newWeight.slice(0, k).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/surfannotate/src/surface/adjacency.js` around lines 125 - 130, Replace
the returned subarray views with copied slices so over-allocated adjacency
buffers can be released: update adjacency.js lines 125-130 to use
adjNeighbor.slice(0, k) and adjWeight.slice(0, k), and exclude.js lines 86-91 to
use newNeighbor.slice(0, k) and newWeight.slice(0, k). Preserve the existing
returned array contents and length.
apps/surfannotate/src/niivue/meshAdapter.js (1)

131-163: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Fix the @returns contract of attachLabelLayer.

The JSDoc promises the index of the new layer. The function returns undefined. Return the index, or drop the @returns line.

♻️ Proposed fix
- * `@returns` {number} index of the new layer
+ * `@returns` {void}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/surfannotate/src/niivue/meshAdapter.js` around lines 131 - 163, Update
attachLabelLayer to return the index of the layer it appends, preserving the
existing `@returns` contract and calculating the index from mesh.layers after the
push.
apps/surfannotate/src/main.js (1)

1040-1044: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Parenthesize the await in the conditional.

await isFreeSurferLabel(file) ? A : B parses as (await isFreeSurferLabel(file)) ? A : B, which is the intent. The precedence is easy to misread, and one branch is synchronous while the other is awaited. Add parentheses.

♻️ Proposed cleanup
-    const layer = await isFreeSurferLabel(file)
+    const layer = (await isFreeSurferLabel(file))
       ? attachValueLayer(state.nv, entry.mesh,
         labelToValues(await file.text(), entry.geometry.vertexCount).values,
         { ...display, name: file.name })
       : await loadOverlay(state.nv, entry.mesh, file, display);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/surfannotate/src/main.js` around lines 1040 - 1044, In the layer
assignment conditional, parenthesize the awaited `isFreeSurferLabel(file)`
expression so the condition is visually unambiguous, while preserving the
existing `attachValueLayer` and `loadOverlay` branches.
apps/surfannotate/src/surface/hatch.js (1)

30-40: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the stale comment and the trailing comma.

Line 40 describes a perpendicular stripe family, but hatchMask computes only one direction. The comment misleads a later reader. Line 34 also ends the destructuring with ,}.

♻️ Proposed cleanup
-    direction = [1, 0.6, 0.35],} = options;
+    direction = [1, 0.6, 0.35]
+  } = options;
 
   if (spacingMm <= 0) throw new Error('hatch spacing must be positive');
   if (duty <= 0 || duty >= 1) throw new Error('hatch duty must be between 0 and 1');
 
   const primary = normalize(direction);
-  // Any vector not parallel to `primary` gives a perpendicular family.
-
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/surfannotate/src/surface/hatch.js` around lines 30 - 40, In hatchMask,
remove the stale comment about a perpendicular stripe family and remove the
trailing comma before the closing brace in the options destructuring. Preserve
the existing option defaults, validation, and direction normalization.
apps/surfannotate/src/io/points.js (1)

40-67: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document the offset option.

Line 41 destructures options.offset, but the JSDoc block does not list it. Add it so the export contract stays discoverable next to coordinateSpace.

♻️ Proposed JSDoc addition
  * `@param` {string} [options.created] ISO timestamp; supplied by the caller so
  *   the writer stays deterministic and testable
+ * `@param` {number[]} [options.offset] subtracted from every coordinate, undoing
+ *   the volume-geometry translation the loader applied on the way in
  * `@returns` {string}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/surfannotate/src/io/points.js` around lines 40 - 67, Update the JSDoc
for writePointsJson to document the options.offset parameter alongside
coordinateSpace, including its purpose as the coordinate adjustment applied when
writing xyz values. Keep the existing function behavior unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/surfannotate/e2e/smoke.spec.js`:
- Around line 197-213: Update the smoke test around the evaluate callback so the
broken boundary is represented in session state or supplied through the fill
API, rather than mutating the temporary result of boundaryMask(). Ensure the
seeded fill tests escape through the intended gap, and remove the unused
geometry destructuring from window.__surfannotate.

In `@apps/surfannotate/src/io/geometryOffset.js`:
- Around line 25-35: Update niivueTranslation to handle gzip-compressed surface
buffers before magic-byte detection, decompressing them and passing the
uncompressed bytes to FreeSurfer/GIFTI detection; alternatively reject
compressed input with a clear error. Ensure meshAdapter’s offset calculation
cannot silently use [0,0,0] for supported .gz surfaces.

In `@apps/surfannotate/src/main.js`:
- Around line 1664-1672: Update the download function so URL.revokeObjectURL
runs in a later task after anchor.click(), rather than immediately in the same
task; preserve the existing blob URL creation and download behavior.
- Around line 836-908: Update loadSurface to retain the mesh returned by
loadMeshFromFile and, in the catch path, remove that mesh from the NiiVue scene
before reporting the error; ensure cleanup is safe when loading itself fails
before a mesh is available.
- Around line 1738-1746: Update exportPoints to pass writePointsJson an explicit
coordinateSpace derived from activeSurface(), rather than relying on its
tkreg-ras-white default; ensure inflated, spherical, flat, and white surfaces
are serialized with their corresponding coordinate-space value while preserving
the existing translation metadata and export behavior.

In `@apps/surfannotate/src/niivue/meshAdapter.js`:
- Around line 225-241: Update the validation around layer.values before calling
robustRange: reject missing values with the existing overlay error path, and
validate values.length against mesh.pts.length / 3 multiplied by (layer.nFrame4D
|| 1). Keep the robustRange and calibration assignments in the subsequent path,
ensuring overlays with valid single-frame or 4D data continue to register.

In `@apps/surfannotate/src/surface/parcellation.js`:
- Around line 97-100: Update the ROI validation around session.addClick so it
checks each roi.clicks vertex against the claimed/excluded vertex set directly
before or while adding clicks. Return the existing LOST_POINTS result when any
click was already claimed, while preserving session-based validation for invalid
or repeated vertices and the normal path for unclaimed clicks.

In `@apps/surfannotate/src/surface/roiSession.js`:
- Around line 259-268: Update the seeded fill path in the closure handling
around fillClosedRegion so CLOSURE_EDGE bypasses the DEFAULT_MAX_FRACTION 40%
guard, while preserving the guard for other closure types. Ensure edge fills
remain permitted only after the existing component-separation validation has
succeeded.
- Around line 366-374: Update the ROI session constructor and togglePoint so
default point names use a session-level monotonic counter rather than
points.length + 1. Increment the counter only when adding a point, ensuring
removals never allow generated names to repeat while preserving explicitly
supplied names.

In `@apps/surfannotate/test/fixtures/make-real-patch.mjs`:
- Around line 17-23: Update the lh.pial parsing flow to validate the 3-byte
FreeSurfer TRIANGLE_FILE magic before scanning the header, and bound the newline
search so p never advances beyond buf.length. Report an invalid or truncated
header instead of continuing indefinitely, while preserving the existing count
parsing for valid files.

In `@apps/surfannotate/test/geometryOffset.test.js`:
- Line 12: Update the geometryOffset test’s niivueTranslation input to use the
byte view returned by readFileSync directly, rather than its underlying .buffer,
so offsets and lengths match the fixture contents. Preserve the existing
translation assertion and fixture loading behavior.

In `@apps/surfannotate/test/surface.test.js`:
- Around line 240-243: Update the isolated-vertex test around fillClosedRegion
to explicitly assert both valid outcomes: verify result.inside[3] is 0 when
inside is returned, or assert the expected error when inside is null. Do not
leave the assertion conditional without validating the error path.

In `@apps/surfannotate/THIRD-PARTY.md`:
- Around line 38-41: Expand the `@neurodesk/webapp-components` entry in
THIRD-PARTY.md with the complete, exact MIT copyright and permission notice from
packages/components/LICENSE, replacing the repository-relative reference while
preserving the existing component description.

---

Nitpick comments:
In `@apps/surfannotate/e2e/smoke.spec.js`:
- Around line 1266-1268: Remove the empty test.use({}) call from the start page
test.describe block, leaving the existing tests and page.reload() behavior
unchanged.
- Around line 464-471: Replace the fixed wait in the “the surface renders
visibly” test with a state-based wait that polls a NiiVue render signal or
verifies a non-empty pixel sample before taking the screenshot. Keep the
existing `#gl` screenshot and size assertion, ensuring capture occurs only after
the first rendered frame is detected.

In `@apps/surfannotate/scripts/fetch-fixtures.mjs`:
- Around line 42-55: Update fetchWithRetry so each fetch attempt passes an
AbortSignal.timeout(...) option to fetch, using the script’s existing timeout
configuration if available or an appropriate bounded duration. Ensure timeout
failures are caught by the existing catch block so they trigger the current
retry and final-error behavior.

In `@apps/surfannotate/src/io/classify.js`:
- Line 49: Update the bytes initialization in the classify flow to remove the
redundant ternary and reuse head directly when present, while preserving the
null result when head is absent.

In `@apps/surfannotate/src/io/points.js`:
- Around line 40-67: Update the JSDoc for writePointsJson to document the
options.offset parameter alongside coordinateSpace, including its purpose as the
coordinate adjustment applied when writing xyz values. Keep the existing
function behavior unchanged.

In `@apps/surfannotate/src/main.js`:
- Around line 1040-1044: In the layer assignment conditional, parenthesize the
awaited `isFreeSurferLabel(file)` expression so the condition is visually
unambiguous, while preserving the existing `attachValueLayer` and `loadOverlay`
branches.

In `@apps/surfannotate/src/niivue/meshAdapter.js`:
- Around line 131-163: Update attachLabelLayer to return the index of the layer
it appends, preserving the existing `@returns` contract and calculating the index
from mesh.layers after the push.

In `@apps/surfannotate/src/surface/adjacency.js`:
- Around line 125-130: Replace the returned subarray views with copied slices so
over-allocated adjacency buffers can be released: update adjacency.js lines
125-130 to use adjNeighbor.slice(0, k) and adjWeight.slice(0, k), and exclude.js
lines 86-91 to use newNeighbor.slice(0, k) and newWeight.slice(0, k). Preserve
the existing returned array contents and length.

In `@apps/surfannotate/src/surface/exclude.js`:
- Around line 103-110: Update unionMasks to throw when any provided mask has a
length different from V instead of silently skipping it; retain the existing
handling for valid masks and align its validation behavior with the module’s
other entry points.

In `@apps/surfannotate/src/surface/hatch.js`:
- Around line 30-40: In hatchMask, remove the stale comment about a
perpendicular stripe family and remove the trailing comma before the closing
brace in the options destructuring. Preserve the existing option defaults,
validation, and direction normalization.

In `@apps/surfannotate/src/surface/parcellation.js`:
- Around line 52-56: Update the JSDoc in the parcellation resolver to use the
actual parameter name rois instead of ROIs, and document the returned property
as rois to match resolveParcellation’s return shape and its consumers. Also
correct the mismatched roi name in the JSDoc at the referenced later line,
preserving the existing type descriptions.

In `@apps/surfannotate/test/fixtures/make-real-patch.mjs`:
- Around line 30-57: Update the patch-generation flow after constructing the
projected vertices and faces to retain only the largest connected face
component, excluding disconnected islands and interior-hole artifacts. Then
normalize every retained face’s winding using the projected coordinates so its
normal points toward -x, and use the filtered, consistently oriented faces when
building the output index array for lh.realflat.surf.gii.

In `@apps/surfannotate/test/helpers.js`:
- Around line 33-55: Update the JSDoc for makeGridWithHole to explicitly state
that removing the selected cell faces does not remove vertices, including
interior vertices that become isolated or unused. Leave the mesh construction
logic unchanged.

In `@apps/surfannotate/test/surface.test.js`:
- Around line 97-105: Hoist the repeated 18-coordinate Float32Array data in the
split-geometry test into a single named constant, then pass that same constant
to both buildAdjacency and SurfacePathfinder so they always use identical
geometry.

In `@apps/surfannotate/test/vertexLookup.test.js`:
- Around line 26-30: Update the random generator’s seed multiplication in the
`random` function to use `Math.imul`, preserving exact 32-bit arithmetic before
applying the existing mask and normalization. Keep the seed, constants,
reproducibility, and returned range unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 036bfa81-161e-4876-a28f-a5369767ad3b

📥 Commits

Reviewing files that changed from the base of the PR and between 9d67330 and 17c733d.

⛔ Files ignored due to path filters (6)
  • apps/surfannotate/icon/surfannotate.svg is excluded by !**/*.svg
  • apps/surfannotate/public/favicon-180.png is excluded by !**/*.png
  • apps/surfannotate/public/favicon-256.png is excluded by !**/*.png
  • apps/surfannotate/public/favicon-32.png is excluded by !**/*.png
  • apps/surfannotate/public/favicon-48.png is excluded by !**/*.png
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (49)
  • LICENSES.md
  • apps/surfannotate/.gitignore
  • apps/surfannotate/AGENT.md
  • apps/surfannotate/CLAUDE.md
  • apps/surfannotate/LICENSE
  • apps/surfannotate/README.md
  • apps/surfannotate/THIRD-PARTY.md
  • apps/surfannotate/e2e/smoke.spec.js
  • apps/surfannotate/icon/render.mjs
  • apps/surfannotate/index.html
  • apps/surfannotate/package.json
  • apps/surfannotate/playwright.config.js
  • apps/surfannotate/scripts/fetch-fixtures.mjs
  • apps/surfannotate/src/io/classify.js
  • apps/surfannotate/src/io/freesurferLabel.js
  • apps/surfannotate/src/io/geometryOffset.js
  • apps/surfannotate/src/io/gifti.js
  • apps/surfannotate/src/io/naming.js
  • apps/surfannotate/src/io/points.js
  • apps/surfannotate/src/main.js
  • apps/surfannotate/src/niivue/colormaps.js
  • apps/surfannotate/src/niivue/meshAdapter.js
  • apps/surfannotate/src/styles.css
  • apps/surfannotate/src/surface/adjacency.js
  • apps/surfannotate/src/surface/edgeAnchor.js
  • apps/surfannotate/src/surface/exclude.js
  • apps/surfannotate/src/surface/fill.js
  • apps/surfannotate/src/surface/hatch.js
  • apps/surfannotate/src/surface/parcellation.js
  • apps/surfannotate/src/surface/pathfinder.js
  • apps/surfannotate/src/surface/roiSession.js
  • apps/surfannotate/src/surface/vertexLookup.js
  • apps/surfannotate/test/classify.test.js
  • apps/surfannotate/test/edgeAnchor.test.js
  • apps/surfannotate/test/edgeClosure.test.js
  • apps/surfannotate/test/exclude.test.js
  • apps/surfannotate/test/fixtures/make-flat-patch.mjs
  • apps/surfannotate/test/fixtures/make-real-patch.mjs
  • apps/surfannotate/test/geometryOffset.test.js
  • apps/surfannotate/test/hatch.test.js
  • apps/surfannotate/test/helpers.js
  • apps/surfannotate/test/io.test.js
  • apps/surfannotate/test/naming.test.js
  • apps/surfannotate/test/parcellation.test.js
  • apps/surfannotate/test/roiSession.test.js
  • apps/surfannotate/test/surface.test.js
  • apps/surfannotate/test/vertexLookup.test.js
  • apps/surfannotate/vite.config.js
  • registry/apps.yml

Comment thread apps/surfannotate/e2e/smoke.spec.js
Comment thread apps/surfannotate/src/io/geometryOffset.js
Comment thread apps/surfannotate/src/main.js
Comment thread apps/surfannotate/src/main.js
Comment thread apps/surfannotate/src/main.js
Comment thread apps/surfannotate/src/surface/roiSession.js
Comment thread apps/surfannotate/test/fixtures/make-real-patch.mjs
Comment thread apps/surfannotate/test/geometryOffset.test.js
Comment thread apps/surfannotate/test/surface.test.js
Comment thread apps/surfannotate/THIRD-PARTY.md
@stebo85

stebo85 commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

This is soooo cool :)

@stebo85
stebo85 merged commit 52026be into neurodesk:main Aug 4, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants