Skip to content

feat(capi): callbacks C embedders can render primitives#2

Merged
beetlebugorg merged 19 commits into
mainfrom
feat/c-callbacks
Jul 8, 2026
Merged

feat(capi): callbacks C embedders can render primitives#2
beetlebugorg merged 19 commits into
mainfrom
feat/c-callbacks

Conversation

@beetlebugorg

Copy link
Copy Markdown
Owner

…selves

Add tile57_chart_render_view_cb — render_view's GPU/vector twin. It runs the SAME view portrayal (resolve → flatten → declutter → paint-order) but forwards every resolved primitive through a C function-pointer table (tile57_canvas_cb) instead of rasterising to PNG. A C library that draws with its own renderer (GPU or vector) feeds these straight into it.

  • cb_canvas.zig: a third cv.Canvas implementation (alongside RasterCanvas and PdfCanvas) that flattens each ring set into a flat C vertex array + ring offsets and calls fill_path / stroke_path / fill_pattern / draw_glyphs.
  • pixel.zig: new Output.callback branch emits the NODTA base fill then replays paintOps through the callback canvas; returns an empty buffer.
  • renderView gains an optional cb_table arg; PNG/PDF callers pass null.
  • include/tile57.h: the tile57_point/tile57_rgba/tile57_rings/tile57_canvas_cb ABI and tile57_chart_render_view_cb, same INVERTED return convention as render_view (0 ok / -1 bad handle / -2 render failure / -3 unsupported).

Geometry is emitted in canvas pixel space (y down), in final paint order, with colours resolved for the active palette.

…selves

Add tile57_chart_render_view_cb — render_view's GPU/vector twin. It runs the
SAME view portrayal (resolve → flatten → declutter → paint-order) but forwards
every resolved primitive through a C function-pointer table (tile57_canvas_cb)
instead of rasterising to PNG. A C library that draws with its own renderer
(GPU or vector) feeds these straight into it.

- cb_canvas.zig: a third cv.Canvas implementation (alongside RasterCanvas and
  PdfCanvas) that flattens each ring set into a flat C vertex array + ring
  offsets and calls fill_path / stroke_path / fill_pattern / draw_glyphs.
- pixel.zig: new Output.callback branch emits the NODTA base fill then replays
  paintOps through the callback canvas; returns an empty buffer.
- renderView gains an optional cb_table arg; PNG/PDF callers pass null.
- include/tile57.h: the tile57_point/tile57_rgba/tile57_rings/tile57_canvas_cb
  ABI and tile57_chart_render_view_cb, same INVERTED return convention as
  render_view (0 ok / -1 bad handle / -2 render failure / -3 unsupported).

Geometry is emitted in canvas pixel space (y down), in final paint order, with
colours resolved for the active palette.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@beetlebugorg beetlebugorg changed the title feat(capi): callback Canvas so C embedders can render primitives them… feat(capi): callback Canvas so C embedders can render primitives Jul 7, 2026
@beetlebugorg beetlebugorg changed the title feat(capi): callback Canvas so C embedders can render primitives feat(capi): callbacks C embedders can render primitives Jul 7, 2026
beetlebugorg and others added 8 commits July 7, 2026 10:14
A Surface implementation (src/render/vector.zig) that emits portrayal as a
world-space tagged stream over a C callback instead of resolving to pixels:
area/line geometry in web-mercator [0,1], point symbols and text as a world
anchor + a local reference-px outline, each draw tagged with its feature
SCAMIN. This lets a GPU host tessellate once and transform + SCAMIN-cull per
frame — constant-size symbols, re-portray-free pan/zoom — like a native SENC.

- chart.zig: renderSurfaceView drives VectorSurface over both the .reader
  (baked pmtiles) and .cell (live .000) backends.
- capi.zig: tile57_chart_render_surface_cb export + world/local point/ring
  structs; tile57.h carries the callback ABI.
- vector.zig honours show_inform_callouts for INFORM01 in the live path
  (the chartstyle.zig gate only applies to the baked-tile style path).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a draw_sprite callback to the VectorSurface vtable. When the host sets it,
point symbols and sounding glyphs emit as a symbol name + world anchor +
rotation + pivot-relative half-extent (reference px) instead of tessellated
outlines, so the host draws them as pre-rendered atlas quads — antialiased and
far cheaper than tessellation. The pivot-centred atlas cell drawn centred on
the anchor reproduces the vector placement exactly, including multi-glyph
soundings whose per-glyph pivots lay out the number.

- vector.zig: emitSprite helper shared by drawSymbol + drawSounding; a null
  draw_sprite falls back to tessellating the outline.
- capi.zig: tile57_bake_sprite_mln returns the MapLibre sprite-mln atlas
  (pivot-centred cells + {name:{x,y,width,height,pixelRatio}} JSON) from the
  embedded catalogue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add draw_pattern to the VectorSurface vtable: fillPattern emits the pattern
name + world rings so the host can tile the real S-101 pattern cell across the
polygon (from the sprite-mln atlas's pat: entries) at a constant screen size
instead of a flat tint. A null draw_pattern keeps the flat-tint fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a greedy screen-box occupancy pass (Declutter) driven off the engine's
draw-priority feature order, so the first (highest-priority) box placed wins —
the S-52 collision rule. Behind the VectorSurface so it's a shared feature
other surfaces (PixelSurface) can adopt in place of their own copy.

- Navaid point symbols place unconditionally and reserve their box.
- Soundings declutter as a whole number (all digits or none), thinning the
  crowding at low zoom.
- Text labels yield when their estimated box collides.
- Boxes are screen px at the portray zoom (VectorSurface.view_zoom).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Line-placed symbols (SymbolPlacement.line) are a repeating pattern the engine
lays down at a native tile-space spacing. Sizing them by the display refDev
(the "2x density" fix) inflated them past that spacing, so they overlapped and
smashed together. Draw .line marks at NATIVE size (dev 1.0) so they tile like
bricks, and skip declutter for them — they're an even pattern, not point
labels. Point navaids and soundings keep the display size + declutter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follow the spec (as tile57's PixelSurface already does): only text participates
in the collision declutter — symbols are icon-allow-overlap and soundings are
symbols, so both always draw (density governed by SCAMIN). The first cut also
decluttered symbols (force-reserving boxes that blocked nearby labels) and
soundings, which was far too aggressive. Now navaids/soundings all draw and
only overlapping text yields, highest draw-priority first (emission order).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add tile57_chart_query(lon,lat,cb): replays the finest tile covering the point
through a QuerySurface that tests each feature (area point-in-polygon; line/point
within a small radius) and reports the hits' S-57 class + attribute JSON + source
cell via a callback — everything the §10.8 pick report needs, straight off the
FeatureMeta contract, no S-57 decode in the host.

- render/query.zig: QuerySurface (point-in-feature Surface backend).
- chart.zig: Chart.queryPoint drives it over the reader (baked) and cell (live)
  backends.
- scene.zig: metaFromProps now reads the baked s57/cell tile properties into
  FeatureMeta (the reader path was dropping them, so a baked chart's query
  returned class but empty attributes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
c-api.md gains three sections in plain language: "Render to a host surface"
(tile57_chart_render_surface_cb + the tile57_surface_cb vtable, including the
draw_sprite/draw_pattern atlas callbacks), "Query the features under a point"
(tile57_chart_query + tile57_query_cb, the S-52 cursor pick), and a note on
tile57_bake_sprite_mln in the portrayal-assets section. CHANGELOG records all
three plus the reader-path s57/cell FeatureMeta fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
beetlebugorg and others added 10 commits July 7, 2026 14:28
The S-101 ColorFill instruction is "NAME[,transparency]" — e.g. CHGRF,0.5 (50%
see-through built-up area), TRFCF,0.75 (traffic), NODTA,0.5 (no data). The parser
kept the whole "CHGRF,0.5" as the colour token, so it failed to resolve (rendered
as the magenta FALLBACK) AND dropped the transparency.

Add surface.fillToken to split "NAME[,transparency]" and map the transparency to
alpha = (1 - transparency) * 255. Both the vector surface (GPU host) and the pixel
surface (raster reference) now resolve the colour and apply the alpha, so those
fills draw semi-transparent instead of opaque magenta. Line/text tokens have no
comma and pass through unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The query always used the finest tile, so it (1) reported features that SCAMIN
hides at the view zoom and (2) used a pick radius that, in ground terms, was tiny
when zoomed out — buoys and tracks were nearly impossible to tap. It also ignored
SCAMIN entirely (tiles carry every feature; the display culls in-shader), so a
zoomed-out click returned hundreds of finer-scale features.

tile57_chart_query now takes the view zoom: it replays the tile at that zoom (pick
tolerance tracks on-screen distance) and the QuerySurface applies resolve.scaminVisible
against the view zoom, so it reports what's actually drawn. Same point at z12 went
from 42 hits to 6; z16 stays an accurate 3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Vendor stb_truetype.h (public domain, same source as the existing stb_image_write)
and add a tg_glyph_sdf wrapper in svgraster.c over stbtt_GetCodepointSDF + metrics.
src/sprite/glyph.zig builds an SDF atlas for a codepoint set: one signed-distance
field per glyph, shelf-packed into an RGBA texture, with per-glyph EM-space metrics
(offset/size/advance + UV). This is the standard MapLibre/game text pipeline and
replaces per-frame glyph-outline tessellation (the profiled tess_local_even_odd /
182k-vert text hotspot) with 6-verts-per-glyph textured quads that stay crisp at
any size — the reason we need SDF (multiple font sizes from one atlas).

Lives in the sprite module (shares its stb/nanosvg C glue + libc) as a baked asset,
like the sprite atlas. Tested: valid field (edge + inside values), glyph metrics,
and PNG export. Next: bake export + wire drawText/plugin to draw the quads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
tile57_bake_glyph_sdf exports the SDF atlas (sprite_png) + a compact metrics JSON
(sprite_json: em_px, pad, per-codepoint [u0,v0,u1,v1,off_x,off_y,w,h,advance] in EM
units) for a host to load like the sprite atlas.

VectorSurface.drawText now, when the host provides the new draw_text_str callback,
sends the label as a STRING plus the alignment-applied baseline-left origin (px)
and glyph pixel size — the host lays it out from the atlas metrics and draws SDF
quads, instead of tile57 tessellating glyph outlines. Declutter now uses the real
advance width. Falls back to draw_text (tessellation) when draw_text_str is null,
so existing hosts are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
tile57_chart_open now opens a single .000 (+ its .001.. update chain) as a live
.cell backend — the one render_surface_cb supports — instead of the lazy .cells
backend (bake-only, so render_surface_cb returned -3). Add tile57_charts_open for
the ENC_ROOT / lazy path.

CellBackend now assembles the cell's M_COVR(CATCOV=1) data-coverage polygons, and
tile57_chart_coverage reports them (exterior ring as interleaved lon,lat doubles)
so a host can quilt gaps to coarser cells instead of over-claiming the bounding
box. Verified: opening US5MD1MC.000 renders (34 fills) and returns its coverage
(1 polygon, 123 points).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A live cell re-portrayed per view is ~1500ms/frame (every tile re-parses S-101
instructions). Instead, tile57_chart_open now bakes the cell to an in-memory PMTiles
ONCE and serves it via the fast .reader path (8-12ms/view, a ~150x speedup), with
the cell's real M_COVR coverage and compilation scale (CSCL) captured from the parse
and attached (coverage_override / cscl_override) since the baked tiles carry neither.

For a host to stay responsive:
- tile57_chart_open_header: metadata only (bbox + native_scale + coverage) via a
  cheap parse, no bake (~18ms) — for a chart-DB/header scan that must not bake every
  cell.
- tile57_chart_open_zoom(min,max): bake a narrow band; a host bakes an overview fast
  for first paint then re-opens the full range in the background (progressive load).

Adds native_scale to tile57_chart_info; keeps the .cell backend (openCell / raw
bytes) with the geo cache for its live path. Verified: header 18ms, full bake 1859ms,
render 8-12ms, coverage 1 polygon / 123 pts, native_scale 1:12000.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… hosts

A host that bakes/renders cells from multiple threads needs the two process-global
read-only registries (S-100 catalogue + complex linestyles) fully populated first,
since their lazy init isn't itself synchronized. warmup() does that on the calling
thread; thereafter both are read-only, so with the thread-safe smp allocator and the
threadlocal portrayal context, concurrent bake/render is race-free.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bake ONE cell (+ updates, read from disk) to PMTiles bytes in [minzoom, maxzoom],
returned for the host to write to a disk cache and reopen with
tile57_chart_open_pmtiles — so the slow single-cell bake becomes one-time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Complex (symbolised) linestyles were tessellated at BAKE time in native 72-dpi px
(period/dash/brick offsets never multiplied by size_scale) and frozen into tiles,
while at render the bricks were redrawn display-scaled (refDev) — so on a HiDPI
display you got display-sized bricks crammed at native spacing ("too condensed").

Move the period-walk to render time so spacing AND brick size both scale with the
display, keeping baked tiles display-independent (the host's disk cache survives a
display change):

- surface.zig: two optional vtable methods (default null) — size_scale() (a render
  surface's physical multiplier) and store_complex_run() (bake encoder only).
- scene.zig: split emitComplexLine into stage A (clip into phased runs, unchanged)
  and walkComplexRun (stage B: the period walk, now scaled by the surface's
  size_scale, with the [0,EXTENT) symbol seam-ownership preserved). At bake each
  clipped run is STORED un-tessellated in the lines layer tagged ls_style/ls_arc0;
  on a render surface it is walked live. replayTile re-walks stored runs at the
  render surface's size_scale (arc0 keeps dash phase continuous across tile seams).
- vector.zig / pixel.zig: .line bricks draw at refDev (match the render-scaled
  period); both surfaces expose size_scale so VectorSurface (OpenCPN) and the
  png/pdf CLI (--scale) render complex lines consistently.

Other Surface impls leave the new fields null and keep tessellating natively — no
regression, and it's a no-op at size_scale=1 by construction.

Note: the MVT/JS web client renders the baked lines layer directly and does not
re-tessellate ls_style runs (draws them plain); the Zig replay path is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… crowd them

Soundings are S-52 icon-allow-overlap and never decluttered, but the ×size_scale
enlargement on a HiDPI display crowds them into an unreadable mass. Give each sounding
NUMBER a single declutter box (the union of its digit glyphs' extents) placed once in
the VectorSurface's screen-px declutter grid: keep the first-emitted (higher priority)
number, drop later numbers it overlaps. The digits of a kept number still draw ungated
so the number stays intact. VectorSurface-only (the OpenCPN render path); the raster
CLI PixelSurface is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@beetlebugorg beetlebugorg merged commit c6b9d73 into main Jul 8, 2026
5 checks passed
@beetlebugorg beetlebugorg deleted the feat/c-callbacks branch July 8, 2026 12:13
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.

1 participant