Skip to content

Commit 4b5ffbf

Browse files
jackgallantclaude
andcommitted
Fix the sulcus SVG export, which pycortex could not read
A code review checked sulci.svg against cortex/svgoverlay.py for the first time. The v0.4.0 export was broken three ways, none of which its ten unit tests could see, because they all matched the output string rather than parsing it. 1. Not well-formed XML: the inkscape: prefix with no namespace declaration and no <svg> root. ET.parse -> ParseError: unbound prefix. 2. The <text data-ptidx> labels crashed db.get_overlay(). Labels.__init__ does an unguarded float(text.get('x')) over every <text> in a labels layer; ours had no x/y, because a vertex index cannot supply one. 3. data-ptidx is pycortex's output, not its input. SVGOverlay.set_coords computes it by kd-tree from each label's x/y. A real overlays.svg has zero <text> elements: Shape.get_labelpos() derives one label per path, so a two-hemisphere sulcus is labelled twice for free. So: emit a standalone SVG (both namespaces declared, viewBox from the overlay), and no labels at all -- but keep the labels layer, empty, since _find_layer raises ValueError without it. The file now carries an XML comment warning that its shape groups must be copied into the subject's existing #sulci_shapes: a second inkscape:label="sulci" layer silently replaces the subject's own. test/test_sulci_svg.py generates the writer's real output with node and parses it with ElementTree, using svgoverlay.py's own namespaces and findall queries. It needs neither cortex nor a subject, so it runs in CI. Every new assertion was mutation-checked against the code it guards. Also, from the same review: - All five bezier edit ops now share one out-of-range contract (unchanged copy). setAnchorSmooth and splitSegment threw TypeError, deleteAnchor silently no-op'd, and setAnchorSmooth on a closed curve grew smooth[] past anchors[] with holes -- the exact desync inRange was added to prevent. - loadJSON deep-copies, as its comment already claimed. It aliased r.bezier and every outline {h,g}. - _sync retries when setOverlayLayer reports the SVG overlay isn't loaded; a shape used to sit listed and counted in the panel, never drawn. - _editToggle refuses a bezier-less shape, which used to leave editingId naming a shape the overlay wasn't editing. - exportSulciMarkup separates "overlay not loaded" (null) from "no usable curve" (""). - Timers and the adapter's setData listener are released by destroy(). - The live sulcus stroke and the exported one read one pair of constants. - Export byte counts use TextEncoder, not String.length. - The lasso rejects a degenerate stroke, as the trace already did. - fitHomography requires dst to span 2D, not just src. - roi -> shape in the edit overlay; several stale header comments corrected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d5fd762 commit 4b5ffbf

19 files changed

Lines changed: 859 additions & 290 deletions

README.md

Lines changed: 50 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ Two kinds of shape, each exported in the format its consumer expects:
1111
- **ROIs** are *closed* curves. They carry per-hemisphere vertex membership and export to a
1212
portable JSON vertex set. The bezier is stored alongside it, so reloaded ROIs re-edit by dragging
1313
their control points.
14-
- **Sulci** are *open* curves. They carry **no** vertex membership and export as an
15-
`overlays.svg`-compatible SVG fragment — the way pycortex itself stores sulci.
14+
- **Sulci** are *open* curves. They carry **no** vertex membership and export as a standalone SVG
15+
whose `sulci` layer drops straight into a subject's `overlays.svg` — the way pycortex itself
16+
stores sulci.
1617

1718
The whole feature ships as one self-contained script (`dist/roidraw.bundle.js`, CSS included), so
1819
it can be dropped into **any** pycortex viewer — a static one (like a `make_static` export) or a
@@ -72,7 +73,8 @@ count column shows enclosed vertices for an ROI, anchors for a sulcus. Below the
7273
in one toggleable overlay layer (Surface → overlays → "drawn ROIs") alongside the built-in
7374
rois/sulci.
7475

75-
A stray click can't create a shape: a trace must span more than a few pixels before it registers.
76+
A stray click can't create a shape: any stroke, lasso or trace, must span more than a few pixels
77+
before it registers.
7678

7779
### Editing a shape — full bezier controls
7880

@@ -132,36 +134,61 @@ appear in this file; it is an ROI format.
132134

133135
### Sulcus export format — `sulci.svg`
134136

135-
Sulci are **not** exported as JSON. **Export sulci (SVG)** downloads an `overlays.svg`-compatible
136-
fragment — pycortex's own storage format for sulci — which `quickflat.add_sulci`, the WebGL viewer,
137-
and Inkscape all read natively. Paste or merge it into the subject's `overlays.svg`:
137+
Sulci are **not** exported as JSON. **Export sulci (SVG)** downloads a standalone SVG whose `sulci`
138+
layer is exactly the layer pycortex's `cortex/svgoverlay.py` reads — no roidraw-specific format
139+
involved.
138140

139141
```xml
140-
<g inkscape:groupmode="layer" id="sulci" inkscape:label="sulci" style="display:inline">
141-
<g inkscape:groupmode="layer" id="sulci_shapes" inkscape:label="shapes">
142-
<g inkscape:groupmode="layer" inkscape:label="CS">
143-
<path style="fill:none;stroke:white;stroke-width:6;stroke-opacity:0.6;stroke-linecap:round"
144-
d="M412.55,301.90C…" /> <!-- left hemisphere -->
145-
<path style="fill:none;stroke:white;stroke-width:6;stroke-opacity:0.6;stroke-linecap:round"
146-
d="M598.31,297.44C…" /> <!-- right hemisphere -->
142+
<?xml version="1.0" encoding="UTF-8"?>
143+
<svg xmlns="http://www.w3.org/2000/svg"
144+
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
145+
version="1.1" width="1024" height="768" viewBox="0 0 1024 768">
146+
<g inkscape:groupmode="layer" id="sulci" inkscape:label="sulci" style="display:inline">
147+
<g inkscape:groupmode="layer" id="sulci_shapes" inkscape:label="shapes">
148+
<g inkscape:groupmode="layer" inkscape:label="CS">
149+
<path style="fill:none;stroke:white;stroke-width:6;stroke-opacity:0.6;stroke-linecap:round"
150+
d="M412.55,301.90C…" /> <!-- left hemisphere -->
151+
<path style="fill:none;stroke:white;stroke-width:6;stroke-opacity:0.6;stroke-linecap:round"
152+
d="M598.31,297.44C…" /> <!-- right hemisphere -->
153+
</g>
147154
</g>
155+
<g inkscape:groupmode="layer" id="sulci_labels" inkscape:label="labels" />
148156
</g>
149-
<g inkscape:groupmode="layer" id="sulci_labels" inkscape:label="labels">
150-
<text data-ptidx="48213" …>CS</text>
151-
</g>
152-
</g>
157+
</svg>
153158
```
154159

155-
Three things follow from matching pycortex rather than inventing a format:
160+
#### Installing it into a subject
161+
162+
> **Copy the `<g inkscape:label="…">` groups out of `#sulci_shapes` and into the subject's
163+
> *existing* `#sulci_shapes` group.**
164+
165+
Do **not** paste the whole `<g id="sulci">` layer. `SVGOverlay` keys its layers by
166+
`inkscape:label`, so a second layer labelled `sulci` silently replaces the subject's own — every
167+
sulcus already in that file disappears. The downloaded file repeats this warning in an XML comment.
168+
169+
Then `db.get_overlay(subject)` exposes the new curves under `svg.sulci`, and
170+
`quickflat.make_figure(…, with_sulci=True)`, the WebGL viewer, and Inkscape all render them.
171+
172+
#### Why the file looks the way it does
156173

157174
- **The paths never close.** A missing trailing `Z` is the *only* on-disk marker separating a sulcus
158175
from an ROI in `overlays.svg` — both are `fill:none`.
159176
- **Same-named sulci merge into one group.** Trace a sulcus on each hemisphere and give both strokes
160177
the same name; they become one `<g inkscape:label="CS">` with a `<path>` child each, exactly as
161178
pycortex's own `CaS` is stored. Duplicate names are the intended workflow, not a mistake.
162179
- **Sulci carry no vertex membership.** pycortex stores none either — there is no `get_sulci_verts`;
163-
sulci are display geometry. The only path→vertex mapping is the label's `data-ptidx`, which is
164-
precisely what pycortex's own `set_coords` computes.
180+
sulci are display geometry.
181+
- **The `labels` layer is present and empty, and that is deliberate.** pycortex computes each
182+
sulcus's label position from its path geometry at load (`Shape.get_labelpos`, one label per path,
183+
so a two-hemisphere sulcus is labelled twice for free) and writes `data-ptidx` itself from that
184+
position. `data-ptidx` is pycortex's *output*, not its input: a real `overlays.svg` contains zero
185+
`<text>` elements, and `Labels.__init__` reads `float(text.get('x'))` off every `<text>` it finds,
186+
so a label carrying only a vertex index would make `db.get_overlay()` raise `TypeError`. The empty
187+
layer itself is mandatory — `_find_layer(layer, "labels")` raises `ValueError` without it.
188+
(roidraw's *live, in-browser* overlay does place labels by `data-ptidx`; that is the WebGL viewer's
189+
own convention, and it stops at the browser.)
190+
- **The on-disk `style` is for Inkscape's benefit only.** pycortex overwrites every path's style at
191+
load from `[overlay_paths]`, and `quickflat` re-applies `[sulci_paths]` at render time.
165192

166193
Export is one-way: sulci are not re-imported from SVG.
167194

@@ -179,7 +206,7 @@ core/ pure JS — no DOM, no THREE, no host globals (unit-tested under node
179206
bezier.js fit an editable bezier — closed (ROI ring) or open (sulcus trace) — and edit it
180207
transform.js uv↔px homography (place/grab edit knots; map a traced stroke back to uv)
181208
shape-model.js the shape collection (ROIs + sulci) + the vertexset-v2 ROI export/import
182-
svg-export.js pure writer for pycortex overlays.svg sulci markup
209+
svg-export.js pure writer for pycortex overlays.svg sulci markup (paths only, never labels)
183210
draw-mode.js the flat-only Draw state machine (the "reached flat" latch)
184211
185212
adapter/ the ViewerAdapter CONTRACT + one host implementation
@@ -191,7 +218,8 @@ ui/ host-agnostic DOM components (talk only to core + adapter)
191218
overlay-geom.js pure hit-testing math for the edit overlay (no DOM; unit-tested)
192219
193220
draw-pipeline.js ROI: lasso → select → fit bezier → re-derive membership.
194-
Sulcus: trace → px→uv via homography → fit open bezier → label vertex.
221+
Sulcus: trace → px→uv via homography → fit open bezier → label vertex
222+
(the label is for the live overlay only; the export carries none).
195223
(pure; uses core + an adapter)
196224
index.js controller wiring core + adapter + ui; exposes window.ROIDraw
197225
build.mjs esbuild → dist/roidraw.bundle.js (CSS inlined)

TESTING.md

Lines changed: 61 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,24 @@ prints the seed and reproduces deterministically):
2727
The example-based core tests (`geom`, `selection`, `bezier`, `transform`, `shape-model`, `outline`,
2828
`uv-membership`) remain as targeted cases.
2929

30-
### Sulcus SVG export — unit (pure writer)
31-
`core/svg-export.js` (`test/svg-export.test.js`) is the pure writer for the `overlays.svg`-compatible
32-
sulci fragment. It guarantees: a sulcus path never closes with a trailing `Z` (the only on-disk
33-
marker separating a sulcus from an ROI); curves that share a name merge into a single
34-
`<g inkscape:label="…">` rather than colliding, exactly as pycortex's own multi-hemisphere `CaS`
35-
does; and names are XML-escaped before landing in an attribute or a text node.
30+
### Sulcus SVG export — unit (pure writer) + a real XML parser
31+
`core/svg-export.js` is the pure writer for the sulci layer of an `overlays.svg`.
32+
33+
`test/svg-export.test.js` pins the writer's decisions by string: a sulcus path never closes with a
34+
trailing `Z` (the only on-disk marker separating a sulcus from an ROI); curves that share a name
35+
merge into a single `<g inkscape:label="…">` rather than colliding, exactly as pycortex's own
36+
multi-hemisphere `CaS` does; names are XML-escaped; the labels layer exists and holds no `<text>`.
37+
38+
**String tests were not enough, and had already shipped a broken file.** Every assertion above
39+
passes against a bare fragment that uses the `inkscape:` prefix with no namespace declaration —
40+
which no XML parser will open. So `test/test_sulci_svg.py` generates the writer's real output with
41+
node and parses it with Python's ElementTree, using `cortex/svgoverlay.py`'s own namespaces and
42+
`findall` queries. It asserts the document parses, that `_find_layer` locates the `sulci` layer and
43+
both its sublayers, that same-named curves round-trip through the parser as one group of two paths,
44+
that no `d` closes, and that the labels layer holds no `<text>` — because `Labels.__init__` reads
45+
`float(text.get('x'))` off every `<text>` it finds, so a label carrying only a vertex index makes
46+
`db.get_overlay(subject)` raise `TypeError`. It needs neither pycortex nor a subject, so it runs in
47+
CI. It is the closest thing to the round-trip below that can be automated here.
3648

3749
### Draw-mode state machine — unit (the flat-only latch)
3850
`core/draw-mode.js` (`test/draw-mode.test.js`) is the extracted pure state machine behind Draw mode.
@@ -57,13 +69,21 @@ assert real properties rather than tautologies — no browser, no pycortex:
5769
`data-ptidx` at the original midpoint.
5870

5971
### Edit-op index guards — unit
60-
`moveAnchor`/`moveHandle` (`test/bezier.test.js`) refuse an out-of-range anchor index. The edit
61-
overlay holds a drag target across pointer events, so an anchor list that shrinks under it (Delete
62-
pressed mid-drag) leaves a stale index behind; writing through one used to append past the end of
63-
the handle arrays, silently desynchronizing their lengths from `anchors`. The overlay separately
64-
drops its drag and hover targets whenever the anchor count changes, which is the only place that can
65-
catch the other half of that bug — a stale index that is still *in range* names a different anchor,
66-
and no pure function can tell.
72+
All five edit ops (`moveAnchor`, `moveHandle`, `setAnchorSmooth`, `splitSegment`, `deleteAnchor`)
73+
share one contract, pinned in `test/bezier.test.js`: an out-of-range anchor or segment index is a
74+
no-op returning an unchanged copy. Nothing throws, nothing half-applies, and the four parallel
75+
arrays keep the same length.
76+
77+
The edit overlay holds drag, hover, and selection targets across pointer events, so an anchor list
78+
that shrinks under one (Delete pressed mid-drag) leaves a stale index behind. Writing through it
79+
used to append past the end of the handle arrays, silently desynchronizing their lengths from
80+
`anchors`. The ops used to *disagree* about this — two refused, two threw a `TypeError`, and
81+
`setAnchorSmooth` on a closed curve grew `smooth[]` past `anchors[]` with holes in it — which meant
82+
each caller had to know which op it was calling. The test now sweeps every op × every bad index.
83+
84+
The overlay separately drops its drag and hover targets whenever the anchor count changes, which is
85+
the only place that can catch the other half of that bug — a stale index that is still *in range*
86+
names a different anchor, and no pure function can tell.
6787

6888
### Edit-overlay hit-testing — unit (pure helpers)
6989
`ui/overlay-geom.js` (`test/overlay-geom.test.js`) holds the grab-an-anchor / grab-a-handle math the
@@ -109,28 +129,38 @@ Closed by that check, against a real pycortex viewer:
109129
`setOverlayLayer` produced 8 `<path>` elements (halo + stroke per shape): the 6 sulcal ones carry
110130
no trailing `Z`, the 2 ROI ones do.
111131
- `exportSulciMarkup` on the live overlay: 3 paths, none closing; two same-named `CS` curves merged
112-
into one `<g inkscape:label="CS">` with a `<path>` each; a hostile name XML-escaped in both the
113-
attribute and the text node; style exactly
114-
`fill:none;stroke:white;stroke-width:6;stroke-opacity:0.6;stroke-linecap:round`.
132+
into one `<g inkscape:label="CS">` with a `<path>` each; a hostile name XML-escaped; style exactly
133+
`fill:none;stroke:white;stroke-width:6;stroke-opacity:0.6;stroke-linecap:round`. (That run predates
134+
the export rewrite: it saw the old bare fragment, `<text data-ptidx>` labels and all. Re-run it.)
115135
- No sulcus leaks into the `vertexset-v2` JSON, and its `format` string is unchanged.
116136

117-
### Nothing roidraw writes has ever been read back outside these tests
137+
### What roidraw writes, and how far it has been read back
118138

119139
Worth stating plainly, because it is easy to mistake the property tests for end-to-end coverage.
120-
The two export formats have *different* gaps:
121-
122-
- **`sulci.svg` has a foreign consumer that has never been asked.** The whole point of matching
123-
pycortex's format is that `cortex/svgoverlay.py`, `quickflat`, the WebGL viewer, and Inkscape read
124-
it. None of them ever has. Merging a fragment into a real subject's `overlays.svg`, confirming
125-
`db.get_overlay()` parses it, and rendering with `quickflat` is the check that would substantiate
126-
the claim this feature is built on. It needs a subject and an importable `cortex`.
127-
- **`rois.json` has no foreign consumer at all.** `pycortex-roidraw/vertexset-v2` is a roidraw-native
128-
format; no Python reader exists here or in pycortex, deliberately (`get_roi_masks` does not read
129-
it). So "read it back" can only mean re-importing into roidraw. The **format** round-trip is
130-
strong — `test/properties.test.js` runs `toJSON``JSON.stringify``JSON.parse``loadJSON`
131-
over 300 seeded trials and compares vertices, outline, label, and bezier. But the README's
132-
"re-imports in any viewer on the same surface" has never been demonstrated across two viewers or
133-
against real surface data.
140+
The two export formats have *different* gaps.
141+
142+
**`sulci.svg` has a foreign consumer.** The whole point of matching pycortex's format is that
143+
`cortex/svgoverlay.py`, `quickflat`, the WebGL viewer, and Inkscape read it.
144+
145+
- *Covered:* `test/test_sulci_svg.py` parses the real output with an XML parser and reproduces
146+
`svgoverlay.py`'s layer/shape/label queries against it (see above). This closed three defects that
147+
the string-matching tests could not see: an undeclared `inkscape:` namespace prefix (nothing would
148+
parse the file), `<text data-ptidx>` labels with no `x`/`y` (`db.get_overlay()` raised `TypeError`
149+
on them), and the absence of the mandatory-but-empty `labels` layer.
150+
- *Still open:* `svgoverlay.py` itself has never run on it. Merging the shape groups into a real
151+
subject's `overlays.svg`, confirming `db.get_overlay()` exposes them under `svg.sulci`, and
152+
rendering with `quickflat.make_figure(…, with_sulci=True)` needs a subject and an importable
153+
`cortex` — neither is available here.
154+
155+
**`rois.json` has no foreign consumer at all.** `pycortex-roidraw/vertexset-v2` is a roidraw-native
156+
format; no Python reader exists here or in pycortex, deliberately (`get_roi_masks` does not read
157+
it). So "read it back" can only mean re-importing into roidraw. The **format** round-trip is
158+
strong — `test/properties.test.js` runs `toJSON``JSON.stringify``JSON.parse``loadJSON`
159+
over 300 seeded trials and compares vertices, outline, label, and bezier — and `loadJSON`'s deep
160+
copy is pinned in `test/shape-model.test.js` (an imported bezier is mutated in place by the edit
161+
overlay; aliasing the parsed document would let an edit reach back into it). But the README's
162+
"re-imports in any viewer on the same surface" has never been demonstrated across two viewers or
163+
against real surface data.
134164

135165
Still open, in both directions:
136166

0 commit comments

Comments
 (0)