Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions docs/bugfix-overprint-cut-meridian-tilt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Bugfix: overprint cut slits skewed by meridian tilt

## Symptom

Automatic circle slits on the course editor / map viewer were rotated a
few degrees clockwise of the black feature they were meant to clear.
On `Ny_karta_test` the bias was ~3.3° — enough that a boulder on the
north rim of a control looked like it sat in the middle of purple ink.

Print/PDF maps were fine: map and overlay share one
`windowRotationDeg`, so cut angles and map features stay co-rotated.

## Cause

Stored cut angles are paper-relative (compass degrees in the OCAD
millimetre plane). The viewer must rotate them by the same amount it
rotates the base map so slits land on features after true-north
alignment.

`MapViewer` added `northOffset` to every cut angle. That value is
`displayNorthOffsetDeg` = paper-to-true-north bearing **plus** the
median meridian-line tilt (`meridian.medianTiltDeg`). The tilt is needed
so drawn 601.x north lines stand vertical on screen, but cuts only need
the paper→true-north part. Folding the tilt in rotated every slit by
that extra angle.

## Fix

- Pure helper `cutRotationDeg(northOffset, tiltDeg)` in
`packages/api/src/map-north.ts`:
`northOffset - (tiltDeg ?? 0)`, null when `northOffset` is null.
- `course.mapMetadata` returns `cutRotationDeg`.
- `MapViewer` uses `cutRotationDeg` for the slit `adj` computation;
map/tile rotation still uses `northOffset`.

## Tests

- Unit: `map-north.test.ts` (`cutRotationDeg`).
- Integration: `mapMetadata.cutRotationDeg` asserted alongside profile
changes in `map-tiles.test.ts`.
- E2E: existing cut/gap assertions in `course-editor.spec.ts` still
pass; they plant a boulder due north of control 79 so any residual
tilt bias would miss the slit.
46 changes: 46 additions & 0 deletions docs/bugfix-small-object-overprint-cuts-and-label-halo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Bugfix: small-object cuts and control-number contrast

## Problem

The first automatic overprint-cut implementation opened purple over
every configured black line and area object. After IOF colour stacking
landed, those cuts were unnecessary: cliffs, paths, walls and buildings
already render above lower purple. Cutting them fragmented circles and
legs without improving readability.

Knolls had the opposite inconsistency. They slit control circles but
were explicitly skipped by leg gaps, so a leg could still hide the same
small feature.

Control numbers also had no knockout. Purple text over dense map ink
could be hard to read, especially at print scale.

## Fix

- Automatic circle slits and leg gaps now apply only to OCAD point
objects with ISOM numbers 109, 110, 203, 204, 205 or 207.
- Knolls and compact rocks use the same rule for circles and legs.
- Long line and area objects never create automatic cuts.
- Interactive and print/PDF control numbers receive an always-on white
halo with stroke width 12 % of the number font size. SVG
`paint-order="stroke fill"` keeps the purple glyph crisp.

## Existing events

Cuts are stored in editor course GeoJSON, so changing only the algorithm
would leave old building/path cuts visible indefinitely.
`events.overprint_cuts_version` solves that:

- migration marks existing events as v1;
- new events default to v2;
- the first geometry read for a v1 event rebuilds every
`geometrySource: "editor"` course and advances the event to v2;
- imported OCD/XML geometry is never rewritten.

## Coverage

- Unit tests reject line/area cuts and verify knoll gaps.
- Integration tests verify building legs stay whole, boulder legs gap,
and v1 stored geometry migrates lazily.
- Shared print-overlay tests assert the white halo.
- Course-editor E2E asserts the interactive label halo.
78 changes: 48 additions & 30 deletions docs/course-editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,52 +208,70 @@ missing suggestion is cheaper than a wrong one.

### Automatic overprint cuts

The purple overprint must not hide important map detail, so course
setters cut it: **circle slits** where a control circle crosses black
features (rock and man-made symbols) or knolls, and **leg gaps** where a
leg line passes over black features. Oxygen computes both automatically
— there is no manual cut UX (yet); the cuts simply follow the map.
The purple overprint must not hide compact map detail, so course setters
cut it around **small point objects only**: control-circle slits and leg
gaps reveal knolls, elongated knolls, rocky pits, boulders, large
boulders and boulder clusters. Long objects (cliffs, paths, walls,
buildings and areas) remain uncut; the IOF colour stack keeps their ink
readable without fragmenting the purple. Oxygen computes the cuts
automatically — there is no manual cut UX (yet).
An event-level **Auto cuts** toolbar toggle
(`course.getOverprintCuts` / `course.setOverprintCuts`,
`events.auto_overprint_cuts`, default on) skips
`decorateOverprintCuts` for editor geometry when off; imported
OCD/XML geometry keeps its file-authored cuts either way.

- **When**: at every geometry rebuild (`rebuildCourseGeometry` — course
create/update, control move/delete) and after a map upload, which
rebuilds all `geometrySource: "editor"` courses so cuts follow the new
map. Imported (`ocd`/`xml`) geometry keeps its file-authored cuts and
is never touched.
is never touched. Toggling auto cuts rebuilds every editor course.
- **Where**: `packages/api/src/overprint-cuts.ts`, pure and unit-tested.
It reuses the slimmed base-map object cache built for the description
autodetect (`event-map-objects.ts`) and stores the result *in* the
course GeoJSON: `properties.cuts` (`{start, end}` compass degrees, the
same convention the OCD importer writes) on control point features,
`properties.gaps` (`{from, to}` fractions of the leg) on leg features.
No schema change — it all rides in the existing `geometry` JSONB.
- **Which symbols**: a static ISOM-number table (`CUT_KINDS`) — rock
(201–207, 215), black man-made (502–518, 521–532), and knolls
(109/110, slits only — a leg over a knoll is normal). Pattern-fill
areas (boulder fields, stony ground) are excluded: their dots are
symbol fill, not objects, so a cut at the invisible area boundary
would look random. Blue/green/yellow symbols never cut.
The cuts ride in the existing `geometry` JSONB.
- **Which symbols**: `CUT_SYMBOLS` contains ISOM 109/110 and
203/204/205/207. The object must also be an OCAD point object. Knolls
are treated exactly like rocks for both circle slits and leg gaps.
Cliffs (201/202), gigantic boulder areas (206), trenches, black
man-made objects, pattern fills, water and vegetation never cut.
- **Geometry rules**: cuts are deliberately *tight* — the overprint
stroke is 0.35 mm wide and a compact ISOM point symbol ~0.5 mm across,
so clearing much more than the feature itself just fragments the
circle without revealing more map. Point features cut the rim (or the
leg, centered on the projection) when they sit within **0.45 mm** of
it, opening **±0.4 mm** of ink — about 18° of rim, 0.8 mm of leg. Line
features cut **±0.35 mm** at each rim/leg crossing, wider for oblique
leg crossings (`half-width / sin θ`, capped at 1.2 mm). Solid black
areas (buildings, canopies, ruins, gigantic boulders) cut the whole
stretch of rim/leg *inside* them (rim sampled every 4°, leg via
entry/exit intersection parameters). Overlapping cuts merge; slivers
(< 4° / < 0.6 mm) drop; sanity caps keep a circle whole when > 300°
would vanish and never erase more than 70 % of a leg. Legs also keep
it, opening **±0.4 mm** of ink — about 18° of rim, 0.8 mm of leg.
Overlapping cuts merge and slivers (< 4° / < 0.6 mm) drop. Legs keep
3 mm (1.2 × circle radius) at each end — the viewer clips that zone
around circles anyway.
- **Stored-geometry migration**: `events.overprint_cuts_version` marks
which algorithm decorated every editor course. Existing v1 events are
rebuilt lazily on their first geometry read and advanced to v2;
imported geometry is untouched.
- **Screen rotation**: stored cut angles are paper-relative. The viewer
adds `cutRotationDeg` (= `northOffset − meridianTiltDeg`) when drawing
slits so they stay aligned with map features after the map is rotated
to true north. Using full `northOffset` (which includes meridian tilt)
skewed every slit by the tilt (see
[bugfix-overprint-cut-meridian-tilt.md](bugfix-overprint-cut-meridian-tilt.md)).
- **Rendering**: circle `cuts` were already consumed by the viewer's
`drawBrokenCircle` (OCD-imported slits used the same path). Leg `gaps`
are new: `subtractLegGaps` in `MapViewer.tsx` splits the screen-space
polyline into kept sub-polylines (fractions survive the projection —
a leg is locally linear) before the usual circle clipping; gapped
segments carry `data-leg-gapped="true"` for tests.

### Control-number contrast

Control numbers always render with a narrow opaque white halo
(`paint-order="stroke fill"`, stroke width 12 % of the font size). The
purple glyph remains unchanged, while the small knockout separates it
from dense map ink. The same default is used by the interactive SVG and
the print/PDF overlay.

Placement and dragging work in **map millimetres** (the `xpos`/`ypos`
paper coordinate space) — the viewer converts screen pixels via an
affine transform. When `course.mapMetadata` carries **calibration
Expand Down Expand Up @@ -420,12 +438,11 @@ their hit targets so "click → Add to course" still works on them.
distance, area containment, per-column-D-code dedupe, all eight
bearings, unmapped symbols ignored.
`packages/api/src/__tests__/overprint-cuts.test.ts` — automatic cuts
over synthetic objects: rim slits for boulders/knolls (not for a
feature under the circle centre), both crossings of a line, buried
rim stretches inside a building, wrap-around slits, non-black symbols
ignored, leg gaps for points / oblique line crossings / building
interiors, end-zone preservation, merging and both sanity caps, and
the geometry decorator (start/finish untouched, stale cuts removed).
over synthetic objects: rim slits and leg gaps for compact boulders /
knolls (not for a feature under the circle centre), long line and area
objects ignored, wrap-around slits, end-zone preservation, merging,
and the geometry decorator (start/finish untouched, stale cuts
removed).
- **E2E**: `e2e/course-editor.spec.ts` — imports `test.ocd` for
coordinates + map, then: place a control via the contextual **Add
control** action, read the suggested code from the toolbar, drag it to
Expand Down Expand Up @@ -466,9 +483,10 @@ their hit targets so "click → Add to course" still works on them.
boulder and building (see
[e2e-test-ocd-fixture.md](e2e-test-ocd-fixture.md));
`integration/overprint-cuts.test.ts` proves the cuts land in the
stored geometry (rim slit over the fixture boulder, leg gap through
the building), recompute when a control moves away, and that a map
upload rebuilds editor-course geometry with fresh cuts.
stored geometry (rim slit and leg gap over fixture boulders, no gap
through the building), recompute when a control moves away, lazily
migrate v1 geometry, and that a map upload rebuilds editor-course
geometry with fresh cuts.

Printing goes through
[iof-coursedata-export.md](iof-coursedata-export.md): the Courses page
Expand Down
16 changes: 10 additions & 6 deletions docs/course-maps.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,17 +102,20 @@ Structured JSON is validated at every tRPC boundary with the Zod schemas in
```text
map_files.file_data
-> readOcad
-> applyIofColorStack (rewrite colour renderOrder)
-> discard objects outside print window + 20 mm
-> ocadToSvg
-> nested SVG clipped to map frame
-> white-outs
-> course overlay
-> ocadToSvg (full) + ocadToSvg (ink, toColor)
-> nested SVGs clipped to map frame:
full map → lower purple → ink → whiteouts → upper purple
-> IOF control descriptions
-> text / lines / rectangles
-> rsvg-convert --format=pdf
-> pdf-lib page merge
```

See [`map-color-stack.md`](map-color-stack.md) for IOF lower/upper purple
stacking (no blend modes).

Course symbols start from norm dimensions in output millimetres: 2.5 mm
control radius and 0.35 mm stroke by default. When a base map is enlarged
for printing, the overprint is enlarged by the same
Expand Down Expand Up @@ -262,8 +265,9 @@ objects rather than UUIDs.
The layout editor and PDF composer use the same course-overlay geometry as the
regular map view: legs are clipped around controls, imported overprint gaps
and control-circle slits are retained, and control numbers avoid symbols and
course lines. Print output uses multiply blending so map detail remains
visible beneath the purple ink.
course lines. Print (and the live map) use IOF colour stacking — lower purple
under the map ink layer, upper purple on top — instead of blend modes; see
[`map-color-stack.md`](map-color-stack.md).

Appearance dimensions (circle radius, line width, number height) are ISOM
dimensions **at the base map scale** — the defaults match ISOM 2017-2:
Expand Down
4 changes: 2 additions & 2 deletions docs/e2e-test-ocd-fixture.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ none of that.
(the overprint-cut targets above).
- Integration tests: `course-import-class-fallback`, `course-import-coords`,
`map-tiles`, `course-export`, `description-autodetect` (asserts the
boulder/building coordinates above), `overprint-cuts` (rim slit over
the 68/42 boulder, leg gap through the building).
boulder/building coordinates above), `overprint-cuts` (rim slit and
leg gap over the 68/42 boulder; no gap through the building).

## Regenerating

Expand Down
2 changes: 1 addition & 1 deletion docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ The Course Editor (overflow menu) turns the map into an editing surface with con

Descriptions get a head start from the map itself: place a control and the same menu proposes what it sits on — boulder, path, marsh, building — read straight out of the uploaded OCAD map's objects, with a side-of direction ("N side") when the control is beside the feature rather than on it. One click fills the description, `Ctrl+Z` takes it back — and moving a control re-offers suggestions for the new spot, since the old description no longer applies. The course panel floats over the map itself, so course building keeps working in fullscreen, and with no course selected the description sheet lists every control under an "All controls" title instead of going blank. See [course-editor.md](course-editor.md) for the architecture and [control-descriptions-and-editor-geometry.md](control-descriptions-and-editor-geometry.md) for the backend contract.

The overprint also cuts itself automatically, the way a careful course setter would by hand: control circles get slits where they would hide black map features (boulders, cliffs, walls, paths, buildings) or knolls, and leg lines get gaps where they pass over black features — recomputed on every edit and after a map upload, with no manual cut tool needed. The cuts are kept tight, clearing the feature and no more, so circles stay readable instead of fragmenting. Imported OCAD courses keep the slits authored in the file.
The overprint also cuts itself automatically, the way a careful course setter would by hand: control circles get slits and leg lines get gaps only for compact knolls and rock objects (rocky pits, boulders, large boulders and clusters). Long cliffs, paths, walls, buildings and areas stay uncut because the colour stack already keeps their ink readable. An **Auto cuts** toolbar toggle turns the automatic decoration off for the whole event when you want unbroken purple. Cuts are kept tight, clearing the small feature and no more, and existing editor courses migrate to the current cut algorithm on first view. Imported OCAD courses keep the slits authored in the file. Control numbers always have a narrow white halo for contrast over dense map ink in both the viewer and printed/PDF maps. Map ink that should sit above purple follows IOF colour-stack **profiles** (ISOM / ISSprOM / ISSkiOM / ISMTBOM, or auto from the file's Lower purple colour / map scale), with optional north lines kept under the course purple — see `docs/map-color-stack.md`. Events that share a club map share the tile cache via a content `renderKey`.

### Course maps and export for printing

Expand Down
Loading
Loading