The style tools CLI
@massif-maps/style-tools is one command, massif-style, carrying every style conversion the
project ships. It is an npm package so a style author needs no toolchain, and the conversions that
must agree with the SDK run the SDK's own C++ compiled to WebAssembly, so there is no second
implementation to keep in step.
| Subcommand | Runs | What it does |
|---|---|---|
css2xml | wasm | compiles a CartoCSS style project to the mapnik XML the decoder reads |
mapbox2css | TypeScript | translates a MapBox/MapLibre style JSON to a CartoCSS project |
carto2css | — | not written yet |
mvt2xml stays a standalone host tool and is not in massif-style: it needs compiled
Boost.Serialization, and its std::wstring(path.c_str()) only compiles where
std::filesystem::path::value_type is wchar_t, so it does not build on POSIX today.
Why the split
A conversion is either compilation or translation, and they want opposite homes.
css2xml is the CartoCSS compiler — it calls CartoCSSMapLoader and MapGenerator directly. A
reimplementation in another language would drift from the decoder the moment a symbolizer property
is added, and the drift would only surface as a style that renders differently on device. It stays
C++.
mapbox2css is a mapping table: a few hundred rules from MapBox property names and expressions onto
CartoCSS ones. It needs no SDK code, it changes constantly, and a contributor fixing one rule should
not need a C++ toolchain. It is TypeScript.
WebAssembly is what lets both live behind one command. The alternative — prebuilt native binaries
per platform, fetched at install time — needs a build matrix (macOS, Linux, Windows, x64, arm64), a
hosting story and a fallback when a platform is missing. One .wasm in the tarball has none of
that.
mapbox2css --validate closes the loop: it runs its own output back through css2xml in the same
process, so a translation that produces CartoCSS the compiler rejects fails at conversion time
rather than on device.
Layout
tools/style-cli/
package.json @massif-maps/style-tools, bin: massif-style
src/
cli.ts subcommand dispatch
wasm.ts loads massif-style.mjs, calls main() with argv
mapbox2css/ the translator
variables.ts hoists the literals into the palette (see below)
config.ts a style's own `config`/`schema`/`imports` (Mapbox Standard)
fold.ts resolves those config reads to constants and evaluates what follows
generated/
properties.json the CartoCSS allowlist, from scripts/gen-cartocss-properties.py
wasm/ build output, gitignored — CI fills it
massif-style.mjs
massif-style.wasm
The C++ lives in the libs-massif submodule, under cartocss/util/, where massif-style.cpp
dispatches to css2xml.cpp.
The property allowlist
mapbox2css translates onto a fixed set of CartoCSS properties, and that set is not written down
anywhere in the sources — it lives in the bindProperty() calls across
libs-massif/mapnikvt/src/mapnikvt/*Symbolizer.h and in CartoCSSMapnikTranslator's name map.
scripts/gen-cartocss-properties.py reads both and emits
the reference page and src/generated/properties.json. It
carries each property's live and baked flags, so the converter can tell at translation time
whether a param:: it emits will change the map with a redraw or force a re-decode.
--check fails when either output is stale; the release workflow runs it before publishing.
The wasm build
emcmake cmake -B build-wasm -DCMAKE_BUILD_TYPE=MinSizeRel
emmake make -C build-wasm massif-style
Link options:
-sNODERAWFS=1 -sEXIT_RUNTIME=1 -sALLOW_MEMORY_GROWTH=1
-sMODULARIZE=1 -sEXPORT_ES6=1 -sINVOKE_RUN=0
-sEXPORTED_RUNTIME_METHODS=callMain
NODERAWFS=1 is the load-bearing one. css2xml's AssetLoader opens style assets with fopen
against a folder path, and it writes its result with pugi::xml_document::save_file — with the raw
Node filesystem both work unchanged, so the C++ needs no virtual-filesystem preloading and no I/O
rewrite.
Its cost is that the build is Node-only: NODERAWFS replaces the emscripten filesystem with
Node's fs, so this artifact cannot run in a browser. A browser playground would need a second
build with MEMFS and the assets pushed in from JavaScript.
Size, measured on the first CI build: 1.68 MB of .wasm plus 73 KB of .mjs loader — a
little under the 2.2 MB native binary at MinSizeRel, and a long way under the ~86 MB unoptimised.
The bulk is boost.spirit and vt template code that mapnikvt and vt hand over as object
libraries.
The link pulls in cartocss mapnikvt vt pugixml tess2 brotli miniz mlt zlib freetype harfbuzz bidi
plus Boost headers. All of them build under emcc — but two needed fixing first, and both were
latent portability bugs rather than anything to do with the tools:
stdext'sunistringwasbasic_string<uint32_t>, andstd::char_traitshas no primary template on a libc++ new enough to have dropped that extension. Nowchar32_t.- harfbuzz's
hb.hhpromotes-Wunusedto an error for its own CI, and a clang that counts-Wunused-templatein that group fails harfbuzz on harfbuzz's own templates.HB_NO_PRAGMA_GCC_DIAGNOSTIC_ERRORis upstream's opt-out for packagers.
Apple's libc++ and clang have neither behaviour, which is why every native build passed and the first emcc build did not.
Releasing
.github/workflows/release-style-tools.yml,
workflow_dispatch with version and publish inputs — the same shape as
build.yml.
- build-wasm — checkout with submodules, install emsdk, fetch Boost headers, build, upload the two artifacts.
- publish — build the TypeScript, drop the wasm into the package,
npm publish --provenance, and attach the same wasm to the GitHub Release for anyone not using npm.
Two things this repository does not have yet and this workflow is the first to need:
- an
NPM_TOKENsecret.@massif-maps/typesunderbindings/typescript/isprivate: trueand has never been published, so nothing has authenticated to npm from here before. permissions: id-token: writeon the publishing job, which is what--provenancesigns with.
Boost comes from .github/actions/prepare-boost,
the same action build.yml uses, so the wasm is built against the same Boost as the SDK. That
is not housekeeping: the wasm compiles the style compiler, and boost.spirit.karma resolves an
ambiguous grammar differently between versions and between compilers. A generator alternative
that wrote a nested or without parentheses under emcc, and with them under clang, corrupted every
converted style's filters while every native check passed — see
06-labels for how that surfaced. Two toolchains compiling
one grammar is how it hid; one pinned Boost is half of not repeating it, and the other half is
massif-maps-libs#78, which took the
choice out of the grammar. Only headers are needed, never a compiled Boost.
build_css2xml.yml stays as it is — it builds native binaries for local debugging, which is a
different job from shipping the tool.
The palette: variables.mss
mapbox2css writes three files, not two. style.mss holds the rules and is generated — the next
conversion overwrites it. Every colour, font face and shared size comes out into variables.mss
instead, as @name: value;, and project.json lists it first:
{ "styles": ["variables.mss", "style.mss"], "layers": ["…"] }
That order is load-bearing: CartoCSSCompiler::buildPropertyLists keeps the first declaration
of a variable it reads, so a stylesheet ahead of the palette wins. A dark or eink version is
therefore a copy of the palette plus a project of its own, and never a fork of the rules:
{ "extends": "./project.json", "styles": ["dark.mss", "style.mss"] }
This is the shape the hand-written styles under scripts/android-dev/.../assets/style already use:
eink/style.less is nothing but @name: value;, the rules live in shared/*.less, and eink.json
lists the palette ahead of them.
What gets a name. Every colour and every font face, however few layers use it — a variant that
still has to edit style.mss for one colour is not a variant. Numbers only when shared by two
layers or more, only for the roles a variant retunes (size, stroke-width, halo-radius,
spacing, opacity, …) and never when the value only restates the property's own default. Without
those two filters the palette filled with @airport_labels_placement_priority: 9400000 and
@polygon_fill_opacity: 1.
What it is named. A font by its face (@font_roboto_medium), because what a variant swaps is
the family. Everything else by the layers using it and the property's mapnik role:
| Used by | Name | Example |
|---|---|---|
| one layer | its id + the role | @glacier_fill, @ferry_stroke |
| layers sharing a prefix | the prefix + the role | Minor road, Minor road bridge → @minor_road_stroke |
| layers sharing one token | that token + the role | four railways and three railway tunnels → @railway_stroke |
| nothing in common | the symbolizer + the role | a white halo on 19 label layers → @labels_halo_fill |
Collisions take a _2, _3 suffix, most-used first, so the colour a variant most wants to change
gets the bare name. The middle two rules are what earn the palette: naming by prefix alone left a
quarter of topo-v4's entries as @line_stroke_2 … @line_stroke_9.
A colour inside a zoom ramp is hoisted; the ramp is not. linear([view::zoom], (8, @contour_stroke), (16, @contour_stroke_2)) keeps the style's own animation in style.mss and puts its two ends in the
palette, which is the granularity a recolour needs. Spelling is not a difference either —
hsl(0, 0%, 100%) and hsl(0,0%,100%) are one entry, or a variant would recolour half its map.
--no-variables turns the whole pass off and leaves every literal inline.
The check that matters is that a palette compiles to the same map. css2xml on topo-v4 with
and without the pass produces byte-identical mapnik XML, and test/variables.test.js pins that
against the fixture style.
A configurable style: Mapbox Standard
Standard is not a style document in the sense the rest of this page assumes. Fetched plainly it is a
root document with no layers — one imports entry pointing at the basemap fragment and a
config block overriding its defaults — and converting that yields nothing. Ask for it the way a
renderer does and the server flattens it:
https://api.mapbox.com/styles/v1/mapbox/standard?sdk=js-3.27.0&access_token=…
That returns 150 layers, a schema of 44 configurable values, and a lights block. mapbox2css
reports the import-only case by name rather than emitting an empty style.
The config has to be resolved, not carried
Standard is written against its own config: 878 ["config", …] reads across its 150 layers, and
nearly every colour among them goes through one idiom — take the configured colour apart with
to-hsla, bind the channels with let, adjust them, and rebuild with hsl:
["let", "l_colorLand", ["at", 2, ["to-hsla", ["config", "colorLand"]]],
["hsl", 20, 20, ["-", ["var", "l_colorLand"], 10]]]
CartoCSS has none of let, var, at or to-hsla. A style parameter would keep them in the
expression, so the config is resolved to constants before translation (fold.ts):
substitute, then evaluate as far as the constants reach. That recovered 74 colour properties on its
own and took coverage from 46% to 54%. --config key=value overrides any of them.
Folding only removes branches whose test is now decided; nothing is reordered or rewritten. That
is load-bearing, not tidiness — it is what makes every preset emit the same rules in the same order,
which is what lets them share one style.mss.
measure-light, and where day/night actually lives
["measure-light", "brightness"] appears 113 times, always as a two-stop ramp switching a colour
between a lit and an unlit form ([0.25, 0.3] in 66 of them). It is how the style says "night"
without naming the preset, and our renderer has nothing that measures its own light back into a
style value.
The number is taken from the style's own lights block, which is config-driven like everything
else: the ambient light's lightness times its intensity. That is a proxy, not Mapbox's internal
formula — but it is read from the style rather than invented, and it separates the four presets the
way they are written:
| preset | dawn | day | dusk | night |
|---|---|---|---|---|
| brightness | 0.70 | 0.80 | 0.23 | 0.06 |
Against the style's own 0.25/0.3 thresholds that puts dawn and day on the lit side, dusk and night on the unlit one. Where a ramp's stops are still per-feature expressions there is nothing to blend, so the value snaps to the nearer end and the coverage report says so.
The camera terms, which cost 27 whole layers
road-label's filter is
["case", ["<=", ["pitch"], 40], true,
["step", ["pitch"], true, 40, ["<", ["distance-from-center"], 1], 55, …]]
— true below 40 degrees of pitch, progressively stricter above it. Mapbox thins its labels by where a tile falls on a pitched screen; nothing feeds a camera angle back into a style here, and our own label culler does that job.
Left unresolved this is an untranslatable filter, which drops the whole LAYER — 27 of them,
every label in the style, while the coverage report still counted their properties as converted.
pitch, distance-from-center and line-progress therefore resolve to what a flat, centred view
sees, the clause folds to true, and the filter around it drops it.
Folding is conservative by construction: a node is simplified only where a substitution actually happened. Without that rule it rewrote expressions in styles that have no config at all, and quietly removed four layers from a MapTiler style whose render was already verified.
A MapBox zoom is not an SDK zoom
Both span 2^z tiles, but a MapBox tile is 512 style pixels and the SDK's tileDrawSize is
256, so the same ground scale is one level higher here:
m/CSSpx = WORLD_SIZE / (2^z * tileDrawSize)
Measured on the emulator by shifting the camera 0.01 deg and cross-correlating the two frames: SDK z15 = 3.144 m/CSS px, mapbox-gl z15 = 1.573. Exactly 2.
So every zoom the style names is shifted a level on the way in — [view::zoom] - 1 as the input to
every ramp (one constant, ZOOM_INPUT), and +1 on minzoom/maxzoom. Read straight, a road at
z15 is drawn with z15's width on a z14 view: a street comes out 1.74x too wide, while a service
road on a flat ramp moves under a pixel, so the symptom is "only the big roads are too fat".
Going the other way — tileDrawSize 512 — fixes the zoom and breaks the widths, because
normalizedResolution = 2 * tileDrawSize * dpToPX doubles with it and halves every line. The style
unit as it stands already equals a CSS pixel, DPI-independent, which is what MapBox means by one.
line-offset runs the other way
MapBox offsets a line to the right of its direction of travel, mapnik to the left, so the
value is negated. Left alone, a cycleway drawn beside its road sits on the wrong side of it. Emitted
as 0 - x, not -x: the grammar has no unary minus before a parenthesised value.
A casing is not a line: line-gap-width
A *-case layer draws TWO strips either side of a gap, and the gap is not drawn: the gap is the
road the casing runs along, line-width is the strip on one side.
The SDK draws this itself now (line-gap-width, and line-blur with it), so the property passes
straight through and one rule stays one rule: the quad is extruded to the outer edge and the middle
is cut in the fragment shader, which costs no extra geometry and keeps the line's own joins and
caps. It replaced a two-rule form that offset one strip per side — 142 rules on Standard against
114 now, and twice the line geometry for those layers.
Its arithmetic is worth stating once, because getting it wrong is invisible on a straight road and
nowhere else: the strip is a full line-width beyond the half gap on each side, so the
outer edge is gap/2 + width. Adding the half-gap to the half width instead drew every casing at
half thickness, which reads as the road having no outline rather than as a bug.
line-blur widens the antialias ramp rather than moving either edge, and its inner ramp fades INTO
the gap — opaque at the gap edge, gone one ramp inside it. Fading the other way eats a whole ramp
off the strip, which at Standard's line-blur: 10 bridge shadows is a 27-device-pixel bite and
turns a soft shadow into nothing. Dropped entirely, that same shadow is a hard dark bar with a
visible butt cap at each end, which is what "the outlines do not join" turned out to be.
*-emissive-strength, approximated by the colour
Mapbox lights its 2D layers with the same scene lights as its 3D ones. emissive-strength is how
much of a colour is EMITTED rather than lit: at 1 it is drawn as authored, at 0 it is at the mercy
of the ambient light and goes dark at night. Standard sets it on 103 properties, and that, not
different colours, is how its night preset gets dark.
Our renderer draws every 2D colour as authored — emissive-strength 1 everywhere. So it is folded into the colour at conversion time:
shown = authored x (emissive + (1 - emissive) x lit)
lit is the preset's brightness over the DEFAULT preset's. That ratio is the load-bearing part:
sceneBrightness is an ambient-only proxy whose absolute value is not a light level, but the ratio
between two presets of one style is meaningful — and normalising this way is what keeps the default
preset at factor 1, so a converted day render still matches the style it came from.
Measured on the Paris z15 bench, mean screen brightness went 225/255 by day to 56/255 by night, with labels staying bright because Standard marks them fully emissive.
No directional term, no per-vertex normal, no colour cast from the light: only lightness moves. It is applied only where the style STATES an emissive strength — an unstated one is left as authored rather than assuming a default. A zoom-ramped strength takes the mean of its stops.
Standard does most of its day/night with the 3D lighting, not with different colours: 103
*-emissive-strength properties, which have no CartoCSS equivalent and are dropped. The converted
night palette is a real recolour — hillshade, ferries, landuse and labels all change — but it is
not the whole difference, and a converted Standard will not look as dark as Mapbox's own.
One style, four palettes
Every value of lightPreset is converted, and all of them are hoisted together, so a variable
stands for the same sites in each. The output is one set of rules and a palette per preset:
style.mss the rules, shared
variables.mss lightPreset = day (the schema's default)
dawn.mss dusk.mss night.mss the same variable names, that preset's values
dawn.json dusk.json night.json { "extends": "./project.json", "styles": ["night.mss", "style.mss"] }
Hoisting each preset separately is what this replaced, and it was subtly wrong: a colour that two
layers share by day and not by night named itself differently in the two files, so the night palette
declared variables style.mss never mentioned. Keying each entry on what it is in every pass
fixes that by construction. A preset whose rules do not line up is refused and named in the coverage
report rather than half-written — --no-presets skips them all.
Its sprite is named mapbox://sprites/mapbox/standard/<hash>, which is not a URL anything can
fetch — it resolves to https://api.mapbox.com/styles/v1/<user>/<style>/sprite, and the hash is a
cache token the path does not need.
Measured with sprites: standard 69% (660/958). Every preset project compiles with css2xml.
What is left is mostly genuine: *-emissive-strength (approximated, not carried) and
feature-state (runtime interaction state, which the SDK has no notion of).
Its tiles are a separate problem. Standard reads mapbox-streets-v8, whose layer names are not
OpenMapTiles' — road against transportation, place_label against place. Pointed at an OMT
source only the layers whose names coincide draw (water, waterway, building, landuse), so a
real check needs a Mapbox token with tiles scope; a styles-scoped one returns 403 for both
/v4/<tileset>/{z}/{x}/{y}.vector.pbf and its TileJSON. Retargeting the schema the way --schema openmaptiles does for MapTiler would lift that, and is not written.
To try it on the bench, convert into a folder on the device and name the preset:
adb push out/ /sdcard/alpimaps_mbtiles/mbstd
adb shell am start -n com.massifmaps.MassifDemo/.BenchActivity \
--es style dir --es styleDir mbstd --es lightPreset night
--es lightPreset day|dawn|dusk|night picks which project of the package CompiledStyleSet
compiles, and it is a DemoLive key, so am broadcast … --es lightPreset day switches it without
a relaunch. The package needs a fonts/ folder: Standard asks for DIN Pro, which
assets/style/fonts already carries.
Buildings get a switch
Every converted style that draws buildings declares a buildings style parameter and gates its
rules on it, in the three states the hand-written styles under assets/style already use:
| value | draws |
|---|---|
0 | nothing |
1 | the footprint only |
2 | the footprint and its extrusion (the default) |
#building['param::buildings'>0]::footprint { polygon-fill: @building_fill; }
#building['param::buildings'>1]::extrusion { building-height: [height]; }
A fill-extrusion layer takes >1; a plain fill on a source layer named like buildings takes >0.
It defaults to 2, so a converted style keeps drawing what its source drew until an app says
otherwise — and an app that cannot afford the 3D pass on a given device turns it off with one
parameter instead of editing the CartoCSS. A style with no buildings declares nothing.
A property MapLibre will not accept goes in metadata
A hand-written source style has to stay a valid MapLibre style — the preview draws it with
maplibre beside the SDK, and that comparison is the point of the file. But a good deal of what is
worth taking from Mapbox Standard is GL v3 only: fill-extrusion-edge-radius, -vertical-scale,
-ambient-occlusion-intensity, -ambient-occlusion-ground-radius, -rounded-roof. Written into
paint, maplibre rejects the whole style and the reference pane goes blank — it does not skip the
property, it refuses the file.
metadata is the style spec's own escape hatch: arbitrary, and ignored by every renderer. So those
go there, under massif:paint and massif:layout, and applyMassifExtras merges them back over
the real blocks before anything else runs:
{ "id": "building-3d", "type": "fill-extrusion",
"paint": { "fill-extrusion-height": ["get", "render_height"] },
"metadata": {
"massif:layout": { "fill-extrusion-edge-radius": 0.4 },
"massif:paint": { "fill-extrusion-ambient-occlusion-intensity": 0.15 }
} }
The converter then treats them exactly as if Standard had stated them. Converting a real MapBox
style is unaffected: it states these in paint, where they are legal for it.
massif:filter is the same hatch for a TEST maplibre refuses. It is ANDed onto the layer's own
filter, and the one test that needs it is a live config — see below.
A config the style keeps LIVE, and a filter that reads it
Every ["config", name] is normally folded to a constant before translation (see Standard's
config, above): CartoCSS has no let/to-hsla, so a colour left reading its config converts to
nothing at all. A style can exempt one by name:
{ "metadata": { "massif:live-config": ["poiRanking"] },
"schema": { "poiRanking": { "default": "category", "values": ["category", "rank"] } } }
The name is then left alone by the fold, reaches CartoCSS as [param::poiRanking], and its schema
entry is declared verbatim in project.json — default and enum both — so an app sets it at runtime.
In a filter it BRACKETS. ["==", ["config", "poiRanking"], "rank"] becomes
['param::poiRanking' = 'rank'], and PredicatePreEvaluator decides a style-parameter comparison
with no feature in hand, so the decoder prunes the losing rules whole instead of testing the mode
per feature. That is what lets a style carry two layer sets and switch between them; setting the
parameter is a re-decode rather than a repaint, which is what a mode switch is anyway.
Maplibre rejects ["config", …] in a filter outright — it is legal only inside an imported
fragment — so the test goes in massif:filter and the source style stays one maplibre can draw. A
layer that belongs only to the non-default mode says "visibility": "none" and turns itself back on
with "massif:layout": { "visibility": "visible" }.
…and their opacity is the style's, as a parameter
fill-extrusion-opacity becomes building-fill-opacity: [param::building_opacity], with the
style's own value as the default. It used to be forced to 1, because the 3D pass draws with
blending off and MapTiler's 0.4 turned a city into a wash of half-buildings showing through each
other. But forcing it also threw away what the style meant: maplibre draws OpenFreeMap Liberty's
buildings at 0.8, blending a fifth of the pale background back through every wall, and that is a
good part of why ours read darker than the browser's.
A ramped opacity still flattens to one number — Standard fades an extrusion in by ramping it alongside the height, and the shadow map is drawn from the building's full cast whatever its alpha, so a half-transparent wall shows the shadow it is itself casting. The height ramp alone is the better fade. Standard's ramp ends at 1, so a converted Standard is opaque exactly as before.
One parameter covers every extrusion in the style. A style asking for two different alphas keeps the first and the coverage report says so; no source style does this.
A recolourable icon: the glyph is a field, the disc is a plate
Mapbox Standard names its POI and transit icons ["image", <name>, { params: { background, background-stroke, icon, icon-stroke } }] and colours those four slots per feature — the disc by
the POI's class, the ring and the glyph by the light preset. The sprite sheet cannot carry that: it
ships one flat render per icon, with the icon's own default colours baked in (a grey disc, a
lighter ring, a black glyph).
Two dead ends came first, both of them visible on a device:
- Declare it an SDF anyway.
shield-sdfmakes the renderer read the RED channel as signed distance, so a blue disc reads as outside and a white glyph as inside: the icons drew inverted, disc gone, and the user reported it as "inversed colors". - Draw the flat render as it comes. Correct shape, but every POI is the sheet's neutral grey where the browser draws it orange, blue or pink.
What works is splitting the artwork (extractIconPlate). The flats are told apart by their distance
from the transparent surround — the outermost texel row is the ring, everything past it the disc —
and each becomes a different thing:
| artwork | becomes | takes its colour from |
|---|---|---|
| the glyph | a distance field, shield-file + shield-sdf | shield-icon-fill ← the icon param |
| the disc | the shield's icon PLATE | shield-icon-background-fill ← background |
| the ring | that plate's border | shield-icon-background-border-fill ← background-stroke |
Which colour inside the disc is the glyph is the part that took two tries, because MapBox composes
an icon as icon-stroke under icon and the sheet renders both:
- Not the one with the most pixels. An outlined glyph has more outline than fill, so
ⓘdrew as a white ring with the disc showing through the middle — the "no white in the centre" report. - The one the other ENCLOSES. Measured as the mean distance from the disc, because the two are parted by an antialiased row and neither actually touches it.
- And a blend of the disc and the ring is not a flat at all: it lies on the segment between them, which is the test. Counted as one, a 32-texel roundel has more antialiasing than glyph.
How much ink a texel holds is then its position along the disc → ink axis, not its nearest flat. A partly covered texel is a linear blend of the two, so the projection is the coverage. Snapping to the nearest flat instead cost every thin stroke: a bicycle's spokes never reach the ink colour anywhere, each of their texels is a blend, and on a blue roundel that blend is also a blend of the disc and the ring — so each read as "not ink" and the wheels drew as a ring of dots.
icon-stroke — the outline MapBox draws under the glyph — is what the SDK grows from a distance
field, so it becomes the icon halo (shield-icon-halo-fill / -radius, the radius measured off
the artwork), not the plate's border: that one is the disc's ring. Standard sets it transparent
while its POI background is a circle, so it is what a style asking for
backgroundPointOfInterestLabels: none gets — a coloured glyph with a white outline and no disc.
A border is only written with a colour to draw it in. shield-icon-background-border-fill
defaults to black and LabelPlateStyle::hasBorder is colour-AND-width, so a width on its own drew
an opaque black ring round the artwork: Standard's natural_point_label states no
background-stroke at all, and every peak came out as a mountain glyph in a black circle.
The field is cropped to the disc's own box, so the plate needs no padding and its border lands
exactly where the ring was. Its radius is measured rather than assumed: a rounded rect of side S
with corner radius r covers S² - (4 - π)r², which reads 9 (a circle) off Standard's 20 px POI
icons and 3 (a roundel) off its 16 px transit ones, without fitting an arc to 40 texels.
Nothing is baked. The written PNG is greyscale — R=G=B=distance, alpha 255 — and all three
colours are ordinary declarations, so they go through the palette pass and a lightPreset swaps
them at runtime over the same files.
Resolution is the other half. A notch one texel wide never reaches full coverage and closes at the
size the icon is drawn, so the sheet is taken at the densest variant the provider serves —
@4x for MapBox, where a POI icon is 80 texels rather than 40 and a fork keeps the gaps between its
tines. MapTiler stops at @2x and the probe just falls through. It costs size: Standard's glyph
fields go from 0.5 MB to 1.3 MB.
The style's own antialias ramp had to be fixed with it, in the SDK — see labels.
The params need not cover the whole layer. Standard's transit label recolours seven networks by
name and lets every other one through to the sheet's artwork, so paris-metro keeps its own roundel
— white disc, blue ring, blue M — where the recoloured ones are blue squares. shield-sdf is a
BoolProperty read with an expression context like everything else, so ONE rule says both: the test
picks the field or the raster for shield-file, gates shield-sdf, and makes the plate's fill and
border transparent for the features it does not cover (a plate with neither draws nothing).
Two limits, both reported:
- One rule states one plate geometry, so the radius and border are the median of what that rule's
icons measure. A
matchgives its sprite names as labels even when the branch resolves per feature, and those are used in preference to the whole sheet — otherwise the transit rule takes the POI circles' radius. - Artwork that is not a disc with something on it is left out of the lookup entirely: a rule that
declares
shield-sdfdeclares it of every file it can name, and a raster cut left in the table is read as a field and drawn as a blob. MapBox's generic pin is the one that costs something — a plate is a rounded rect and cannot be a pin, so a POI with no icon of its own draws its label alone where the browser draws a coloured pin.
Draw order: a project entry may name one attachment
A bare entry in a project's layers pulls every attachment of that source-layer, so it places
the whole layer at one depth. MapBox interleaves: Standard draws its pedestrian areas at index 3,
its parks at 4 and its road casings at 60 — two source-layers, and no single depth for road works.
Ordering by the median put the pedestrian slab over the Jardin Nelson-Mandela; ordering by the first
index sank all 82 road layers under landuse.
CartoCSSMapLoader now accepts an entry that names ONE attachment, so a source-layer can be drawn
at several depths:
"layers": ["road::road_pedestrian_polygon_fill", "landuse", "…", "road"]
A bare entry keeps every attachment no other entry claims, so a project that splits nothing is unaffected and none of the existing styles change. This is a loader feature, not a converter one — a hand-written project can use it too.
mapbox2css emits one entry per RUN of consecutive attachments, which reproduces MapBox's order
exactly and leaves a layer that interleaves with nothing on its bare entry. Standard goes from
about 35 entries to 127, and six source-layers end up at more than one depth (building,
landuse, natural_label, place_label, road, structure) — the coverage report names them.
Compiling is the expensive step and does not depend on the attachment (the cascade's cost), so a layer several entries name is compiled once.
Turning a marker on the map: icon-rotate
Standard's crosswalks are points carrying a direction in degrees, drawn with
icon-rotate: ["get", "direction"] and icon-rotation-alignment: map. Dropped, every crossing laid
its stripes on the same axis whatever way its street ran.
marker-transform: rotate(<expression>) carries it: the angle is an expression, so it is read per
feature, and MarkersSymbolizer switches a marker with a rotation to the POINT orientation — flat
on the map, turning with it, which is exactly icon-rotation-alignment: map. A layer that rotates
against the VIEWPORT is reported instead; the SDK has no screen-space rotation for a billboard.
No sign flip. MapBox's angle is clockwise from north, and rotate() is applied in the tile's
own frame where y grows downward, so a positive angle already reads clockwise. Negated, every
crossing sat at twice its own angle off its street — which looked plausible enough to ship.
A marker that overlaps is still a label: marker-clip
MarkersSymbolizer defaults clip to allow-overlap, and the two paths it selects between are not
variations of one another: a clipped marker is tile geometry, drawn under the stencil tile mask
like a road fill, while an unclipped one is a label. The mask is what keeps an over-zoomed
source tile from painting outside the target tile it was handed to, so a glyph whose quad overhangs
the tile edge loses that half — permanently, since the edge is fixed in the world.
That is how Standard's oneway arrows drew: the arrow whose anchor sat 11 cm from the z16 boundary on Rue du Pont Neuf (lon 2.34492 / lat 48.86112, z20.18, tilt 90) lost the head that overhung north, at every zoom, and reordering the layer changed nothing because the cover comes from the mask, not from a layer above.
So marker-clip: false is emitted with every marker-allow-overlap. A MapBox symbol is a symbol
whatever its collision setting; only the SDK's default couples the two. Markers that do not state
overlap already took the label path.
Which labels a building may hide
text-occlusion-opacity and icon-occlusion-opacity are carried, and only where the source
states them. That is MapBox's own meaning: absent is "occluded by the terrain alone" — the
SDK's default, and free — while a stated value is "occluded by 3D content too". Standard sets 0
on fifteen layers, all of them line and water names plus the road shields at 0.1, and says
nothing on poi-label or transit-label. Converted, its road names go behind a building and its
POI labels stay drawn, which is the granularity the browser has.
Both land on one CartoCSS property, because the SDK's is a TextSymbolizer one and a symbol's
icon and text are one label: text-occlusion-opacity, renamed with the rest of the rule when the
layer is a shield. icon-occlusion-opacity is taken only where the text states none (Standard
states the pair together), and dropped on an icon-only layer — a marker is not a label.
Defaulting them instead of translating only what is stated would turn the occlusion pass on for every label of every converted style; it costs ~0.85 ms a frame and both scopes above it default to off. See labels for the three scopes.
What mapbox2css does not carry
Every skipped property is counted and named — mapbox2css prints a coverage report, and --strict
turns any drop into a non-zero exit. What it refuses, and why:
- Layer types with no symbolizer:
heatmap. - The gap list —
line-blur,line-gradient,fill-extrusion-pattern, every*-translate, mostraster-*adjustments. These are the CartoCSS gaps, not converter bugs. - Expressions with no CartoCSS form:
feature-state,within,number-format,image,%(absent from the grammar),abs/floor/ceil(absent from_basicFuncMap), and anyinterpolateover something other than zoom or with an exponential base other than 1. - A model layer. Standard plants its trees as glTF models from
mapbox-models-v1; nothing here draws one.
Three traps the CartoCSS grammar sets, all of which the tests pin:
- expression-level booleans are
&&and||;and/orare predicate syntax only; ^is XOR, so MapBox's exponent has to becomepow();- a namespaced field is quoted in a predicate (
['mapnik::geometry_type' = 2]) and bare in an expression ([view::zoom]).
Labels: three MapBox properties, one CartoCSS placement
MapBox splits the label's orientation over symbol-placement and the two *-rotation-alignment /
*-pitch-alignment pairs, both defaulting to auto. The spec resolves auto rotation as map for
a line placement and viewport otherwise, and auto pitch as whatever the rotation is — so a plain
point label is screen-facing, which CartoCSS spells billboard, not point (flat in the ground
plane, only swivelling). placement.ts
resolves the triple once, for the text and the icon separately:
symbol-placement | pitch | rotation | CartoCSS |
|---|---|---|---|
| point (or unset) | viewport | — | billboard |
| point (or unset) | map | — | point |
| line / line-center | map | map | line |
| line / line-center | viewport | map | billboard-line |
| line / line-center | — | viewport | billboard-line-repeat |
MapTiler's topo-v4 sets text-pitch-alignment: viewport on all 17 of its line-placed layers, so
dropping the alignments left every road name lying flat on the terrain.
Everything MapBox measures in ems — text-max-width (10 by default), text-offset,
text-letter-spacing, text-line-height — is pixels in CartoCSS, so each is multiplied by
text-size (16 when the layer states none). Taken literally, that 10-em default wrapped every name
at 10 pixels, one word per line, and the lines then overlapped. A zoom-driven text-size stays
an expression ((10 * linear([view::zoom], …))) rather than being pinned to one zoom. A line-placed
label is laid out along its line and never wrapped, matching MapLibre.
Two more that only look like gaps: text-overlap is the modern spelling of text-allow-overlap
(cooperative has no equivalent and is taken as never), and symbol-sort-key is
text-placement-priority negated — MapBox places the lowest key first, the culler takes the
highest priority.
A field in a value reads the feature, and an unset one takes the default
Corrected 2026-08-27. This section used to say a property value cannot read a feature field at
all, and split.ts exists because of it. It can. The decoder binds the feature before it builds the
processor (TileReader::processLayer
calls exprContext.setFeatureData(symbolizerFeatureData)), and Rule::calculateReferencedFields
gathers the fields a symbolizer property references so they are in that data. Pinned by
tests/style/DataDrivenPropertyTest.cpp.
What actually failed was the unset field, and the two halves failed differently:
| the value | before | now |
|---|---|---|
| a colour, field absent | parseColor("") throws Color parsing failed; TileReader catches it and caches a null processor, so the geometry goes with the colour | the property's declared default |
| a width, field absent | silently 0, which drops the line just as effectively and is harder to see | the property's declared default |
| a malformed non-empty value | throws | still throws — a style bug worth reporting |
Property::evalExpression is the one place that decides it: an evaluation yielding unset falls
back to the value the property was constructed with, which is what the style would have got had it
never set the property at all. An explicit guard — [color] <> null ? [color] : '#0000ff' — still
wins, and is still the clearer thing to write when the fallback is not the default.
Measured on MapTiler topo-v4, cleared cache, z14: two line-color declarations reading [class]
and [paved] produced 3 Color parsing failed. Not every feature carries every field, which is why
it looked like "some tiles have no roads, others do".
Worth knowing for anything built on this: a field-driven value folds to a constant per feature,
so two features answering alike hand back equal ColorFunctions and
TileLayerBuilder
dedups them into one of the geometry's 16 style slots. A field-driven colour costs slots, not
batches.
split.ts
turns a case/match over a field into one attachment per branch, each with a constant value
and the branch's condition added to the filter. Later branches exclude the earlier ones, because
MapBox takes the first match. On topo-v4 that is 17 layers and +24 attachments.
It was written for the wrong reason above and the decoder no longer requires it — a field-driven value renders, and a missing field takes the default instead of losing the feature. It has not been removed: whether emitting the field expression beats 24 extra attachments is unmeasured, and the branch cap below is what a review of that should start from.
- A
matchover a plain["get", f]uses the legacy filter spelling, which lands in brackets ([class = 'motorway']) instead of awhen(). - Past 8 variants a layer is left whole and its field-driven values keep only their fallback. Now an over-approximation: the decoder would have evaluated the branches per feature.
- A value with no fallback (
["get", "width"]) is dropped with that reason, not emitted. This is the one to revisit first — a guarded emission renders, a drop cannot. text-fieldis exempt: the text is evaluated per feature inside the processor rather than through aProperty, so it reads fields correctly.
How far apart labels stay
MapBox pads a label's collision box by text-padding on every side — 2 px on any layer that
states nothing — and tests the grown boxes against each other. That is text-collision-padding /
shield-collision-padding, which the label folds into the culler's own buffer
(Label::calculateEnvelope): the box grows, the glyphs do not. --label-spacing N scales it for a
map that wants thinning beyond what the style asks; 1 is the style's own value.
It used to arrive as text-min-distance, which is a different thing and was wrong twice over:
minimum-distanceonly separates labels of the same GROUP, and for a line-placed label the group is the TEXT HASH (ShieldSymbolizer). So it held twoD 1508shields apart and did nothing at all between aD 1508and aD 5— which is what a tilted view shows worst, since it packs a lot of far-field map into a thin band.- An unstated
minimum-distanceis also what makes the decoder floor the repeat at the label's own size, so a line-placed label could not be given one without disabling that floor. It was skipped there for exactly that reason, leaving line labels unpadded.
collision-padding has neither problem: it is per label, applies between any two, and leaves the
repeat floor alone. An icon-only layer still drops icon-padding — that is a marker, which carries
no collision box of its own.
Rendering a converted style: match the TILES to the style
A converted style has to be judged against the tileset it was authored for, or it looks broken when
it is not. A Mapbox style read against OpenMapTiles tiles draws water and buildings and no roads
at all — mapbox-streets-v8 calls the layer road, OpenMapTiles calls it transportation — and
that reads as a style regression rather than as the wrong tiles.
Push the output to the device and point the bench at the matching source:
massif-style mapbox2css style.json out/ --no-sprite
adb push out /sdcard/alpimaps_mbtiles/mystyle
adb shell am start -n com.massifmaps.MassifDemo/.BenchActivity --es ui false \
--es style dir --es styleDir mystyle \
--es vectorUrl 'https://api.mapbox.com/v4/mapbox.mapbox-streets-v8/{z}/{x}/{y}.vector.pbf?access_token=<token>' \
--es vectorMaxZoom 16 --es vectorZoomBias -1 --es tilt 90
vectorMaxZoomis the TILESET's own maxzoom (16 formapbox-streets-v8, 15 for MapTiler v4, 14 for the akylas France tiles). Rendering above it overzooms a parent tile and the decoder's clip box then drops most long features — a handful of POIs where the tile carries hundreds.vectorZoomBias -1for a 512 px tileset, as the next section explains.- A Mapbox tiles token is a tiles-scoped one; a styles-scoped token 403s on
/v4/.
Folding a casing into its fill: --fold-casings
--fold-casings merges a casing layer into the fill layer it runs under, as one line-border-*
rule (the border pass).
It only fires on a pair that describes ONE road — same source layer, filter, zoom range and
join/cap, no dash or pattern, and the same opacity — and it reports every pair it folded.
It is opt-in because it moves the casing in the draw order. A style writes every casing layer before every fill layer, so a minor road's fill covers a major road's casing at a junction; folded, each class carries its own casing and the major road's casing runs unbroken across the minor road. Both are ordinary map looks, but only one is what the source style said.
Comparing against mapbox-gl: the zoom AND the tile level
Two things are a level apart, not one. The style expressions are shifted ([view::zoom] - 1)
because MapBox's tiles are 512 px wide and this SDK's are 256 — that part is handled. What is not
is which tile LEVEL gets fetched: at the same view, mapbox-gl asks for level floor(z) and the SDK
for floor(z)+1, and a level deeper carries a level's worth of extra POIs. At mapbox z13.67 we drew
bicycle parkings its z13 tile does not even contain, which reads as "the ranking is wrong".
TileLayer::setZoomLevelBias(-1) lines the two up — --es vectorZoomBias -1 in the bench. It is a
property of the SOURCE, not of the style, so the converter cannot emit it.
The bench's readout prints z=<sdk> (mb <sdk-1>) and wasm/mbref.html prints z=<mb> (sdk <mb+1>),
so a side-by-side is not read a level off.
A dash is a multiple of the line width
MapBox's line-dasharray lengths are scaled by line-width; CartoCSS's are pixels, and CartoCSS
takes ONE pattern where MapBox takes a ramp. Two rules follow from that:
- The pattern is the LAST stop that actually dashes. MapTiler's disputed border ramps from a
solid
[1, 0], so the base is not the answer; Standard's steps go[0.2, 0.2]at z17 then[0.1, 0.1]at z19, and between two dashing stops the last one has the widest line under it. A pattern with no gap at all writes nothing —[1, 0]scaled by a width was a 430 px "dash", which is a 430 px bitmap rasterized to draw an unbroken line. - The width is read AT that stop's zoom, not averaged over the ramp. Standard's steps ramp to 80 px by z22, so the mean is 43 px — a width nothing on screen ever has — and the 0.1 dash came out at 4.3 px where gl-js draws 1.5. The stairs drew as coarse bands instead of fine treads.
- A width stop that is itself data-driven resolves to its FALLBACK branch. Standard's cycleway
width is
interpolate(zoom, 12, match(type, piste, 0.5, 0), 18, match(…, 4, 2), 22, match(…, 40, 20)). Reading the ramp used to fail on the first non-number and fall back to the mean of every literal in it — 13.3 px, against the 1.3 gl-js draws at the zoom the pattern starts, so the green cycleway dashes came out two and a half times too long with gaps to match. Thematch/casefallback is the width nearly every feature has; a piste is the exception, and one pattern cannot serve both.
A PLAIN dash over a ramped width becomes one rule per zoom band
The rules above pick the one zoom to read the width at. When the style ramps the dash, that zoom
is the pattern's own stop and the answer is already targeted. When the dash is a plain literal there
is no such zoom, and one scale has to cover the whole width ramp — which it cannot. Liberty's rail
hatching is [0.2, 8] over a width running 3 px at z15 to 8 px at z20: scaled at 5.5 it drew
1.8× too long at z15 and 0.7× too short at z20, which reads as "the dashes are twice the size".
splitDashByZoom cuts such a layer into one attachment per band, each scaling its dash by the width
in the MIDDLE of its own band. Bands are cut where the width doubles (ceil(log2(ratio)),
capped at 4), so the worst error inside one is √2 rather than the ramp's whole range — the hatching
becomes 0.75,30 below z18 and 1.25,49.96 above, against a single 1.1,44.
Three things keep it from doing harm. It measures from the first stop whose width is positive —
that hatching ramp starts (14.5, 0), and below it there is no width to be in proportion to — and
no further than the last stop, above which the width is flat and a band would read the same number
twice. The outer bands keep the layer's own minzoom/maxzoom, so banding never narrows what is
drawn. And band edges are whole zooms, because zoomPredicates floors the min and ceils the max: a
fractional edge would round outwards on both sides and draw the seam twice.
The light, not the colours: how a preset gets dark
Standard's night preset uses the same authored colours as day — colorLand is
hsl(20, 20%, 95%) in both. What changes is the scene light, and gl-js applies it at draw time. The
SDK draws every 2D colour as authored, so the light is folded into the colour at conversion
(emissive.ts):
shown_c = authored_c x (emissive + (1 - emissive) x brightness^(2/3) x cast_c)
Three things that each cost a round to get right:
-
Unstated emissive is not "leave it alone". MapBox's default is 1 for a label and 0 for geometry, so a name stays legible at night and a road does not. Read as fully emissive, Standard's
roads-case— which states nothing — painted a white casing over the whole night map, and its trees stayed bright green. -
The light has a colour. Night's ambient is
hsl(217, 100%, 11%)with the directional light at intensity 0, so the only light in the scene is blue; with the lightness alone the land came out brown.castis this preset's ambient chroma over the DEFAULT preset's — 1 for a white light, so the default preset is still exactly as authored — floored at neutral, because a light tints and does not subtract. -
The ambient-only proxy under-reads the light, and both gammas are fitted to a MEASURED gl-js night render (
wasm/mbref.htmlover Les Halles, 2.34580 / 48.86300, z16.78):surface authored gl-js draws emissive land hsl(20, 20%, 95%)rgb 38, 40, 51 0 park hsl(115, 60%, 80%)rgb 72, 91, 86 0.25 Solving both gives a lit term near (0.18, 0.175, 0.27) — luminance 0.18 where the proxy says 0.075, and a blue lift of 1.5x where the raw chroma ratio says 2.9x. A gamma on each keeps the default preset at exactly 1 (
1^g = 1), which no additive floor can do.
Still an APPROXIMATION: no directional term and no per-vertex normal.
Where a label breaks: text-wrap-before
MapBox puts a word on the current line only if it fits and starts a new line otherwise. CartoCSS
defaults the other way: TextFormatter::splitLines measures the word it has just finished, so
the line overshoots by one word — and since the test only runs at a wrap character, the last word
never moves at all. "10TH ARRONDISSEMENT" and "Strasbourg – Saint-Denis" stayed on one line where
mapbox-gl draws two.
text-wrap-before: true is the greedy rule, and it is emitted wherever a non-zero wrap width is
(shields included, where it is renamed with the rest of the rule). The width itself was already
right: text-max-width ems × the text size.
A tree is a canopy dot
Standard draws the whole tree source-layer as 3D models from z15, and a dropped model layer left
every park a bare green slab. There is no model to port, so the canopy is what is left: a filled
marker in the layer's own model-color, ringed by that colour darkened 18 points so two touching
trees still read as two.
- The size is a ground size.
exponential(2, …, (15, 2), (20, 64))— base 2 across five levels is the doubling curve, so the dots keep their spacing as the map zooms. A real crown is wider, but a flat disc reads heavier than MapBox's textured canopy. allow-overlapwithclipON, the one place the two are not emitted together (above): a tile carries hundreds of trees and the label culler must not see them.random(lo, hi, seed)folds to the middle of its range. Standard tints each tree fromhsl(random(…), 50, random(…))around the greenspace colour; CartoCSS has no seed, so the bounds are what carries the intent and every tree gets the one green.- The tiles must carry it:
treeis inmapbox.mapbox-models-v1, not inmapbox-streets-v8, so the bench needs the composite tileset in--es vectorUrlor the layer is simply empty.
Every other model layer — buildings, wind turbines — has no such reduction and is still dropped.
Buildings grow by ZOOM, not on appear
fill-extrusion-vertical-scale is how Standard raises its buildings out of the ground — 0 at z15,
1 at z15.3. It is a zoom ramp, so it looks the same at every visit; it is carried now as
building-height-scale, a Map setting.
A converted style also sits out the tile's fade: gl-js has no timed fade for an extrusion, it
ramps fill-extrusion-opacity over zoom, so the SDK's own tile blend was a second fade on top of
that one. building-fade-on-appear: 0 turns it off for extrusions alone; it stays on everywhere
else, because every other kind of geometry fades in with its tile.
The SDK had an animation of its own in the same place: uHeightScale was the tile's fade-in blend,
so every building rose each time its tile faded in, wherever the camera was. That is off unless a
style asks for it (building-grow-on-appear), and the shadow caster keeps the stated height
either way, so a fade cannot shrink a building's shadow out from under it.
…and they flatten by TILT, which the shadows ignore
Tilt 90 is straight DOWN in this SDK, and at that angle a tall building covers the streets it stands in. The converter adds a ramp of its own — no gl-js equivalent — that scales the extrusions away as the camera turns onto the map:
building-height-view-scale: linear([view::tilt], (80, 1), (90, 0.5));
It starts late (a 3D camera at tilt 55–75 is untouched) and stops at half, so a flattened building still shows its storeys instead of collapsing into its footprint.
Two properties, because the shadow treats them differently, and that is the whole reason the second one exists:
| property | means | shadow caster |
|---|---|---|
building-height-scale | how much building is there — Standard's zoom ramp | follows it: no building, no shadow |
building-height-view-scale | how it is drawn for this camera | ignores it: the building is there, its shadow keeps its length |
Folding the tilt term into building-height-scale and excluding the whole thing from the caster was
tried first, and gave shadows of invisible buildings at z15.1 — the extrusions were still growing in
while their shadows were already full length. view::tilt is a view-state variable, so neither ramp
costs a re-decode: the properties are re-read every frame (MapRenderer::collectStyleEnvironment).
Where the sun is, not just how strong
The lights block carries a direction as well as an intensity — Standard's day preset is
[180, 20] — and only the intensities were read. The app's own sun lit the buildings instead: at
the bench's default the roofs measured 86% of their colour where gl-js draws them at ~100%,
which reads as "the building colour is wrong" and is not. The colours themselves are exact —
hsl(30, 53%, 93%) on both sides.
direction is [azimuthal, polar]: the azimuth runs clockwise from north, as sun-azimuth does,
and the polar angle is measured from straight up, so sun-altitude is its complement.
Known limit: the Map block is written from the default config, and these two are not hoisted
into the per-preset palettes, so switching lightPreset to night keeps the day sun. Standard's dawn
is [120, 50], so this is visible.
MapBox's ground-attenuation is not the SDK's
fill-extrusion-ambient-occlusion-ground-attenuation is 0..1 in gl-js, "lower is crisper", default
0.69. The SDK's building-ao-ground-attenuation is the exponent of (1 - d)^k over the distance
from the wall, so a higher value is the crisp one and its own default is 1.75. Carried verbatim,
0.69 landed as a very low exponent: the band spread to the full radius and lost its contrast.
A stated value is now mapped k = 1.75 × 0.69 / a, which puts each renderer's default on the
other's; where the style states nothing — Standard's 3d-building does not — the SDK's own 1.75 is
written instead of gl-js's number.
A zoom ramp holds past its last stop
MapBox's interpolate clamps outside its stop range; cglib's fcurve, which
InterpolateExpression evaluates through, extrapolates. So Standard's POI collision padding —
["interpolate", ["linear"], ["zoom"], 16, 6, 17, 4] — reached -0.36 px at z19 and the culler
stopped thinning anything. Every ramp read past its last stop was wrong the same way, line widths
and opacities included.
InterpolateExpression::evaluate now clamps its input to the first and last key, when both are
constants (a computed key cannot be clamped and still extrapolates). That is a CartoCSS-wide
change, not a converter one: a hand-written linear() holds past its stops too, which is the only
reading a zoom ramp ever had.
sizerank hides an icon, and its plate with it
Standard drives icon-opacity from sizerank — from z17 a POI with sizerank < 13 shows its
name alone, no icon. ShieldSymbolizer faded the glyph and kept the disc behind it, so every
prominent POI drew as a bare coloured dot beside its name ("Forum des Halles" blue, the Jardin
Nelson Mandela green). The plate is the icon's background, so shield-icon-opacity scales it and
its border — per frame, not baked at decode: the opacity is a zoom ramp, so a tile decoded on
the other side of the step kept its disc until it was decoded again, which is the coloured square
that flashed while zooming.
Every sprite gets a glyph
The disc/glyph split only accepts a composite, and 433 of Standard's 595 sprites are not one —
marker, MapBox's generic pin, among them, which ~150 POIs name. Left out of the whole-sheet table,
those labels drew with no icon at all.
A sprite that does not split becomes a field of its own silhouette, and the rule's own plate
stands in for the disc — which is how MapBox composes a POI icon anyway (background + icon). It
flattens a multi-colour sprite to one shape, which is what a POI rule's icon-fill means.
What actually has to be split into attachments
Only what names a resource: icon-image (one file) and text-font (one loaded face, resolved
when the symbolizer builds its formatter — an unsplit match left the face literally named
match). Everything else stays one expression, because a property value that reads a feature field
is evaluated per feature: GenericFunctionProperty::getFunction rebuilds the function from the
bound context whenever the expression has context variables, and only memoises when it does not.
This net used to be far wider, on the strength of a measurement: two line-color declarations
reading [class] and [paved] produced Color parsing failed and lost their whole rule for that
tile. Re-measured on the same style at the same camera with a cold cache, it is 0 — and a plate
colour reading [iso_a2] plus a regex on [ref] picks the right country's shield colour per
feature. Which of this converter's later fixes cured it is not established (InterpolateExpression
reading string keyframes as colours is the likeliest); what is established is that the observation
justifying the workaround no longer reproduces.
The workaround cost real fidelity, because splitting is a cartesian product over every
field-driven property. MapTiler streets-v4's Road shields has icon-color (28 branches),
text-color (27), icon-halo-color (10) and text-font (2) — 15,120 attachments against a cap of
8, so every one fell back and every road shield drew white. Narrowing it took topo-v4 from 43 branch
attachments to 18 and its "kept only its fallback" count from 8 to 0.
A prefix test, and the two spellings of a regex
["==", ["slice", ["get","ref"], 0, 1], "D"] is how a style tests a prefix, and every
country-specific road shield in MapTiler streets-v4 is gated on one. CartoCSS has no substring, but
it has a full regex match, so a prefix is D.* — and a slice from 0 is the only use of slice that
survives; anything else is still refused.
The trap is that the same operator has two spellings. CartoCSS writes it =~
(CartoCSSParser), and the mapnik XML the compiler emits writes it .match(...)
(ExpressionGenerator). Emitting the XML form into a .mss does not warn — the whole style fails
to load, and the app throws CartoCSS style loading failed: Syntax error with a line number and
nothing else. mapbox2css --validate exists to catch exactly this by compiling its own output, and
it could not run here because css2xml overflows the wasm stack on a style this size.
Room for an icon's halo
MapBox's distance field only describes what its tiny-sdf radius reached — 2 texels outside the ink at cutoff 0.25 — and a MapTiler sprite cell is cut tight around its artwork, so the field is still well above "fully outside" where the bitmap ends. The renderer draws a halo wherever the field is within the halo width of the edge, so it drew one along the quad border: a white rectangle round every POI icon and a white outline round every tree.
An SDF icon is therefore padded by SDF_PADDING texels, with the ramp continued outward at the
field's own rate from the nearest border pixel. Filling a constant does not work — that just moves
the same step outward and the halo follows it there.
It grows the quad, and with it the label's collision box, so the padding is kept to what a typical
icon-halo-width: 2 needs rather than the widest a style could ask for.
Trying each side: text-variable-anchor
MapBox tries each anchor in text-variable-anchor until the label fits on one, and falls back to
drawing the icon alone when text-optional allows it. ShieldSymbolizer does the same from the
same list, so the four properties that describe it map straight across — all of them shield-only,
because a side to place the text on presupposes an icon to place it beside:
| MapBox | CartoCSS |
|---|---|
text-variable-anchor: ["right", "left", …] | shield-anchors: 'right,left,…' (corners lose the hyphen) |
text-optional: true | shield-text-optional: true |
text-radial-offset | shield-text-dx — stated once, MIRRORED onto whichever side wins |
text-justify | shield-text-horizontal-alignment (center is middle; auto follows the side) |
--shield-anchors: giving a style one it never asked for
Mapbox Standard states text-variable-anchor on no layer, so every POI name sits in the one
place the style puts it and is dropped with its icon when it does not fit. --shield-anchors
adds the list the style is missing, plus shield-text-optional, to every shield that declares
neither:
massif-style mapbox2css --shield-anchors standard.json out/ # right,left,top,bottom
massif-style mapbox2css --shield-anchors top,bottom standard.json out/
A layer that states its own text-variable-anchor keeps it. The shield-dy top-up below is
dropped when anchors are on: the culler places the text against the icon's own edge and mirrors
dx/dy as the gap, so moving the image as well would separate the two twice.
--icon-font: a glyph instead of a sprite
shield-icon-name + shield-icon-face-name draw the icon as a glyph of an icon font, shaped
into the label's own atlas through the font fallback chain. --icon-font FACE --icon-font-map FILE
converts a style that way: the map is {"mountain": ""} (a character, a U+E90A/0xE90A/hex
codepoint, or a number — all three are read), and it replaces the sprite lookup value-for-value,
so a data-driven icon-image resolves through the same style-parameter table it always did, holding
characters instead of file paths.
Two things it does not cover, deliberately:
- a name the face has no glyph for draws no icon, and the coverage report names it. Country artwork — an RER roundel, a national motorway plate — has no font equivalent, and one font against a sheet of several hundred PNGs is the trade the mode is for;
- a marker keeps its sprite. A oneway arrow or a crossing is not a label and has no glyph run.
No MapTiler style uses a variable anchor today; text-optional alone covers 17 layers of
streets-v4 and 25 of outdoor-v4. That is the common case, and it was the one the SDK dropped:
buildLabelVariants returned early on an empty anchor list and so never reached the icon-only
fallback, which is why those styles drew no POI icon at all where MapTiler drops the name and keeps
the pin. text-optional is now a layout list on its own — the style's own placement, then the icon
alone.
Where an icon sits relative to its label
The image is moved clear of the text by half its height, but only when the style says nothing
about where the text goes. A stated text-offset IS the style's own answer, and the vertical
alignment beside it already puts the text clear; adding half an icon on top doubled the gap, so a
POI name floated well below its pin where MapTiler draws the two nearly touching.
Two things about which height, both of which made the gap too big:
- it is the artwork's, not the quad's. An SDF sprite is written with
SDF_PADDINGof transparent margin on each side (see above), and measuring that put an 8 px icon at 20; - for a per-feature
icon-imageit is the median of the sprites the layer can name, not whichever one the walk reached first. Standard's POI sheet spans a ~20 px glyph and a ~54 px badge, and the first one taken hung everynatural_point_labelname a badge's height off a peak's mountain glyph.
Two operators that cost whole layers
Both were found rendering MapTiler streets-v4 and both failed silently — the coverage report named them, the map just looked wrong.
["in", needle, ["literal", [...]]] is the expression operator, a different thing from the
legacy ["in", key, v1, v2] filter, and only the second was handled. It is how a modern style
says "class is one of these", so a layer whose filter used it was dropped whole: streets-v4 lost
every minor-road FILL and drew its outline alone, which reads as grey roads. It becomes the or-chain
it is.
["interpolate", ["exponential", b], …] had no CartoCSS form, so the property was dropped and
the line fell back to its default width — a pathway drew as a fat solid grey line instead of a thin
one. CartoCSS has linear and cubic and no base, so the curve is resampled into extra linear
stops (4 per stop interval): it agrees at every original stop and stays close between them, where
substituting a plain linear is out by about a third at the midpoint at base 2. A stop that is not a
plain number has no curve to sample and still falls back to linear, reported.
Retargeting at another tile schema (--schema)
--schema openmaptiles rewrites a style to read an OpenMapTiles tileset (self-hosted, or MapTiler's
own v3). Two source vocabularies are understood — MapTiler planet_v4 and MapBox Streets
v8 — and which one a style is written in is read off its source-layer names, with
--source-schema mapbox|maptiler to say it outright. A style whose names belong to neither
clearly is refused, not guessed at. The tables are in
schema.ts.
Only what is actually equivalent is listed. A source layer with no entry is dropped and named in the coverage report rather than guessed at: a wrong guess draws the wrong features, which is worse than drawing none and much harder to notice.
MapTiler planet_v4: a rename and a class test
planet_v4 splits by source layer what OpenMapTiles splits by a class field: it has a
forest layer and a grass layer where OpenMapTiles has one landcover with
class = 'wood' | 'grass'. So that half is a rename plus an extra filter clause, per layer. On
MapTiler topo-v4 two layers have no equivalent — archipelago_label and country_disputed_label,
both depending on fields OpenMapTiles has no counterpart for.
Verified at La Clusaz z14.5 against an OpenMapTiles tileset: landcover, roads, buildings, water, water names, road names, and village and hamlet labels all render.
MapBox Streets v8: three more moves
MapBox Streets is further away, and a rename is not enough for any of it:
| What differs | Example | How it is expressed |
|---|---|---|
| the field name | height → render_height, type → subclass, maki → class | fields — renamed in the filter, in every expression, and in a {token} text field |
| the field values | class = 'street' → 'minor', admin_level 0/1/2 → 2/4/6, oneway = "true" → 1 | values, applied in the filter and in the paint — a style picks a road's colour with a match on the same field it filters on |
| a value the target expresses with a second field | class = 'motorway_link' → class = 'motorway' AND ramp = 1; structure = 'none' → no brunnel at all | predicates, wherever the value is tested |
| a field the target lacks whose absence is not the answer | a shield's sprite is named shield-reflen, and OpenMapTiles has neither | constants pins it — here to Standard's default-4 plate, which draws a shield where dropping the fields drew none |
| one source layer, several targets | road is both transportation and transportation_name; landuse is both landuse and landcover; natural_label is water_name, mountain_peak and place | a list of mappings, each emitted only where its filter can still match |
The ramp case is the one worth reading twice. OpenMapTiles has no *_link class — a ramp is the base
class with ramp = 1 — so the base classes have to exclude ramps as well, or Standard's link
layers draw every ramp twice, once at link width and once at motorway width.
The multi-target case is what sorts a MapBox layer between the target ones: a values table with a
'*' entry answers for everything not listed, so natural_label's water-name layer maps every class
it tests to a water_name one, maps them all to null for mountain_peak, and that mapping's
filter collapses to false and is simply not emitted.
Not carried over at all, and reported: structure (gates, fences, crosswalks, land structures),
motorway_junction, depth, hillshade (use a DEM layer), the indoor layers, tree and
wind_turbine.
Fields the target does not carry
This is the second half of the problem and the harder one. MapTiler gates every place label on
iso_a2 and MapBox Standard gates every POI on filterrank; against a tileset that carries neither,
the test can only fail and the layer draws nothing at all — strictly worse than not testing.
MISSING_FIELDS lists them per source vocabulary and target layer, and a test on one is dropped and
reported. An existence test gets its honest answer (has → true, !has → false); a comparison
is answered true, so the layer draws too much rather than nothing, which is visible and named in
the report instead of reading as a broken style.
Substituting a bare true is not enough: ["all", true, …] is not a filter and the whole layer is
then dropped as malformed, so the constant is folded into its parent instead, and a filter that
collapses to false drops the layer with that reason.
Two renames are approximations rather than equivalences, and both say so in the report:
symbolrank → rank (both "lower is more important", not the same scale — but dropping it would
make Standard's major and minor settlement layers draw the same labels twice), and maki → class
(OpenMapTiles' POI class names are already maki-ish, which is what the sprite is keyed by).
A filter that loses EVERY test is the dangerous case, so the converter names it. MapBox selects
its transit stops with mode and stop_type; OpenMapTiles has neither, so with both dropped the
layer had nothing left to select on and labelled every POI on the map in the transit style, drawing
over the real POI layer. The mapping now carries a class test in their place, and any other layer
this happens to is reported as "every test in its filter was on a field the target does not carry,
so it now draws the whole layer".
POIs: one field, two jobs
OpenMapTiles puts on class what MapBox reads off two fields — the icon NAME (maki) and the
coarse CATEGORY that picks the label colour (MapBox's own class). One field serves both, because a
category test becomes a test for the OpenMapTiles classes that belong to it: class = 'food_and_drink'
becomes class in (restaurant, fast_food, cafe, bar, …). Each class belongs to exactly one category
— a match takes its first branch, so a class listed twice would answer with whichever MapBox wrote
first.
The icon is looked up by the field's value at DRAW time, so a class OpenMapTiles spells town_hall
cannot be rewritten in the style, only answered for: ICON_ALIASES adds a second sprite-table entry
under the name the tiles carry (town_hall → the sheet's town-hall, railway → rail).
Draw order is the part that cannot be preserved, and it is separate from placement. A CartoCSS
project entry pulls every attachment of its source-layer together, so MapBox layers of different
source-layers that interleave collapse into one block — the converter counts and names them ("N
layer(s) draw out of order"). --schema makes that worse by construction, since many MapBox layers
now share one target source-layer. Label placement does not go through that: the priority is
computed straight from the MapBox layer index (see below), so which label wins a collision is exact
even where draw order is approximate.
A ramp cannot hold another ramp
CartoCSS's linear()/exponential() take constant stop values. A stop whose value is itself an
interpolation parses and then draws nothing at all — no warning, no fallback colour, just an
empty polygon. Mapbox Standard writes its water fill exactly that way:
interpolate linear zoom
13 -> hsl(200,100%,80%)
14 -> interpolate linear ["measure-light","brightness"] 0 -> dark, 0.02 -> hsl(200,100%,80%)
and under --live-light — where measure-light stays live as [view::brightness] instead of being
resolved to a constant — every lake and river came out empty. Confirmed on device by replacing the
INNER ramp with a constant, which brought the water straight back while the outer ramp stayed.
The outer ramp now collapses onto the inner one and the swap is reported. The inner is the one
kept because it is what carries the day/night difference, which is the whole point of --live-light;
the outer's own variation over zoom is lost. Four declarations in Standard are affected: the water
fill, the background emissive, and two polygon opacities.
Bracketed predicates, not when()
A CartoCSS selector is *predicate, and the two spellings cost different things: [class = 'x']
is a plain filter the decoder decides per rule, while when(...) carries a whole expression it
evaluates per feature. The converter used to bracket only the LEGACY filter forms
(["==", "class", "x"]), so a style written in expressions — which is every modern one — paid for a
when() on tests that are ordinary comparisons. The expression spelling now brackets too:
["==", ["get", "class"], "x"], ["geometry-type"] and ["id"], for every comparison operator.
["match", input, [...], true, false] is how MapTiler spells "input is one of these". The generic
match translation wrapped it in ? true : false, which the decoder re-evaluates for every feature;
as a filter it is just the or-chain, and a one-label match is an equality that brackets.
Measured on MapTiler topo-v4: 389 when() down to 191, all 91 ? true : false wrappers gone,
declarations byte-identical, and 0.06% of pixels different at La Clusaz z14.5 (label jitter).
What makes a when() cost more than a rule
The compiler, not just the decoder. PredicateContainsChecker and PredicateIntersectsChecker
(libs-massif/cartocss/src/cartocss/PredicateUtils.h) compare two OpPredicates and can say one
implies or contradicts the other, so contradictory rules are dropped and redundant tests fold away.
Against a WhenPredicate they return boost::indeterminate — every rule survives, and the whole
expression is evaluated per feature at decode. So when() is a last resort, and where a style
forces one, change the STYLE.
Narrowing: a rule that pins a field should say so once
narrow.ts reads a layer's own filter for what it proves — field = value, field ≠ value, and
the closed set an in states — and then restates both the filter and every property value against
it. Three things fall out:
- A closed set minus its exclusions is an equality. The fallback branch of an expanded
matcharrives as "one of these seven, and none of the other six", which is[class = 'service']. - The set test and the negations drop, because the equality that replaces them implies each.
Only equalities may retire a clause: the exclusions were read off those very clauses, so letting
them judge had every
[subclass != 'junction']prove itself and vanish. matchandcasein a VALUE take their branch. Aline-widththat was a three-stop exponential with a six-deep class ternary at each stop becomesexponential(1.5, [view::zoom], (5, 0.6), (14, 3.5), (18, 13))— no feature read at all.
coalesce is seen through, since a missing field already compares unequal
(mapnikvt/Predicate.cpp: NEQ on a type mismatch is MismatchResult = true, EQ is false) — but
not when a value compared against it is one of the guard's own defaults, where
coalesce(f, '') = '' is true for a missing field and f = '' is not.
Splitting a set filter, and when it backfires
A positive set test is a disjunction, so it has no bracketed form; a NEGATED one is a conjunction
and brackets one test per value. expandSetFilter turns the positive case into one attachment per
value, on either of two grounds: the paint branches on that field, so each attachment folds down to
a constant — or the set is the WHOLE filter, so each attachment is one bracketed test and nothing
else. The second was added because a layer that paints one way over a class list is common and left
a when() behind for no gain: across the six reference styles it is 442 → 426 when() for
1176 → 1257 rules, and it takes Massif Streets to zero.
A set of more than MAX_VARIANTS values normally stays whole, since splitting copies the rest of
the filter into every attachment. Where the set IS the whole filter there is no rest, so the cap
rises to MAX_SET_VALUES — one bracketed rule per value and nothing copied, which is what a POI
category of sixteen classes needs.
Both grounds are still subject to the REST of the filter bracketing. Splitting copies that rest into
every attachment, so without the gate the one when() it removes comes back N times: MapTiler
topo-v4 went 142 → 239 before the gate, 134 after.
A palette in project.json, not in the rule
"metadata": { "massif:params": ["text-color"] } on a layer turns that property's match on one
field into a style-parameter LOOKUP — [param::poi-fill-[class]], one parameter per label, the
match's fallback left in the rule for ?? to land on. The palette is then editable in
project.json without touching the generated stylesheet, and a sixty-branch ternary the decoder
walked per feature becomes one lookup.
Opt-in per property, because only the author knows which is which: a table is worth it for a
palette meant to be tuned and not for the two-branch colour ramp on a road. metadata is ignored by
every renderer, so a layer asking for it stays a valid MapLibre style, and every other converted
style is byte-identical. ["icon-image"] covers a recolourable icon's own params — the disc, its
ring and the glyph — so an icon palette lands in the same place as the label's.
A set test's labels are constants, and a geometry name is a NUMBER
mapnik::geometry_type is a long long (mapnikvt/ExpressionContext.cpp), so comparing it against
'LineString' is a type mismatch — which EQ answers false (Predicate.cpp,
ComparisonOperator). A bracketed test always mapped the name to its code; the or-chain the set
tests fell back to did not, so when(([mapnik::geometry_type] = 'LineString' || … = 'Polygon')) was
false for every feature and the rule never drew: 20 rules in OpenFreeMap Liberty and 11 in
MapTiler streets-v4, which is why Liberty's minor roads rendered as a dark casing with no fill.
setTest now recognises a set test in every spelling a style writes one in — ["match", input, labels, true, false], the same with the operands reversed (its negation), and
["in", input, ["literal", labels]] — and runs the labels through the same constant translation a
bracketed test uses. Two consequences beyond the fix: several labels can name ONE constant
(LineString and MultiLineString are both type 2), and collapsed to one the test brackets; and a
reversed match is a conjunction, so it brackets one != per value instead of a ? false : true
ternary.
Across the reference styles, when() before → after: mapbox-standard 221 → 135, MapTiler
openstreetmap 119 → 52, streets-v4 220 → 191, outdoor-v4 152 → 133, topo-v4 142 → 128, ofm-liberty
73 → 28. 927 → 667 in total, and all 66 dead geometry comparisons gone.
A fill's outline
fill-outline-color has no polygon property to land on, so it becomes a second symbolizer — a line
on the same rule. The width is the trap: MapBox draws that outline with gl.LINES, which is one
DEVICE pixel whatever the display, while a CartoCSS width is multiplied by the pixel scale. 1
therefore came out ~3 px on a 2.75x phone, and at z14.5 a village building is 10-25 px across, so the
outline covered it: measured at La Clusaz, 7456 outline pixels against 1747 of fill — buildings
read as solid dark grey instead of the light fill the style asks for. The converter emits 0.4,
which reads as a hairline from 2x up, and reports it as an approximation because no constant is right
at every dpi.
How often a line label repeats
symbol-spacing is text-spacing, and the two defaults are opposites: MapBox repeats a line label
every 250 px, CartoCSS's 0 means one label for the whole line. So the default has to be
written out, like text-max-width — without it a long road got its name once and a contour ring got
it once. Measured at La Clusaz, z14.5: one contour label on screen before, ten after.
symbol-placement: line-center is excluded — it draws one label at the middle whatever the spacing
says, and 0 is how CartoCSS spells that.
An icon-only line layer needs the same thing, and got none of it. symbol-spacing maps to
text-spacing, and a layer with no text-field throws away every text-* declaration it built — so
the property reached the oneway arrows nowhere and MarkersSymbolizer's own default of 100 stood
in for MapBox's 250. Every arrow was drawn two and a half times too often. It is now emitted as
marker-spacing, a static property, so a zoom-driven symbol-spacing is read at one representative
stop and said so in the report.
Honest limits. Porting text-padding changed placement only slightly. A deliberate A/B at
text-min-distance: 24 removed roughly a third of the labels, so the lever works and its unit is
device pixels — but a converted style at z10 still shows far more places than MapTiler's own render
of the same style at the same zoom, and the padding is not what accounts for that. The cause is not
yet found; --label-spacing is a knob, not the answer.
Which label wins a collision
symbol-sort-key orders symbols within a MapBox layer; between layers the style's own order
decides, later winning. The SDK's culler compares one *-placement-priority across every layer at
once and only falls back to the layer index — so translating the sort key literally let a village
with rank 1 (priority −1) beat a town with rank 12 (−12) whatever layer each came from. Annecy's
neighbours were drawn and Rumilly was not.
The layer's position is therefore the leading term:
text-placement-priority: (11200000 - (0 + [rank]));
layerIndex × 100000, minus the sort key (MapBox places the lowest first, the culler takes the
highest). The stride only has to exceed the range a sort key spans — MapTiler's widest is the
capital's -1000. A layer with no sort key still gets its base, so layer order alone is honoured.
Folding a casing and ordering roads do not mix
--fold-casings puts the casing in the fill rule, which is right while the road is ONE rule: the
renderer draws a line-border from the same buffer, one draw before the fill. The sort-key
expansion above makes seven rules of it, and each then draws its own casing — over the fill of the
road beside it, which is the one thing a casing LAYER never did. It is the only difference left
between a converted Massif Streets and the maplibre render of its source.
So the fold SKIPS a pair whose fill states a line-sort-key, and reports it. Those roads convert
as seven casing rules followed by seven fill rules: every casing before every fill, mapbox's order.
It costs a second pass over the road geometry — measured at +0.64 ms of the layers section on the
Crosscall, against the folded rules it replaces.
Making the casing ONE unsplit rule instead of seven looks like a free win and is not: measured at 2.5M geometry indices a frame against 5.2k, and 44 ms a frame against 32. Unexplained; do not retry it without a bench.
A zoom test in a FILTER is decided per tile
ExpressionContext::getVariable answers view::zoom with the tile's own zoom plus 0.5 when there
is no view state - which is where a FILTER is evaluated, at decode. So a zoom gate in a filter
behaves as maplibre's does, decided once per tile: >= 13 is off for a z12 tile and on for a z13
one. In a VALUE the same variable is the live camera zoom, re-read per frame.
Massif Streets gates its road shields that way, bringing the classes in over several levels. It has
to: the converter drops symbol-avoid-edges, so a tertiary ref that maplibre never placed - too
short a stub of road, or one crossing a tile edge - drew at z12 here.
Pick the thresholds against the class the TILE reports, not the one the road has when you look it
up: OpenMapTiles promotes a road's class as the zoom drops. D 106B is minor in the z14 tile and
secondary in the z12 one, so a gate letting secondary through at z11 still drew it - and gating
secondary at all is nearly free, because by z12 everything worth drawing has been promoted into
it.
Write it as one LAYER PER BAND, not as an any of zoom-and-class branches. A layer's minzoom
becomes [zoom >= n], which the compiler decides per tile and can prune - so at z9 the later bands
do not exist. The any is a when() every feature is dragged through at every zoom, and it cannot
bracket. Massif Streets' three plate layers each exclude the classes an earlier band already drew,
which is what stops a motorway shield being placed twice from z13.
?? binds looser than a comparison
CartoCSSParser puts ?? in term0, with && and ||; the comparisons are in term1 and bind
TIGHTER. So [x] ?? '' = 'y' parses as [x] ?? ('' = 'y') - the coalesce of a field with a
boolean, which is truthy for any feature that carries the field at all.
Emitted bare, that made every filter written over a possibly-absent field pass EVERYTHING, silently:
motorway exits (subclass = 'junction') drew as road shields, and the guard excluding US networks
became !([network] ?? false || ...), false for every road that had a network - so on a source
carrying network no road shield drew at all, while the same style was fine on a source without it.
A coalesce is therefore parenthesised WHOLE, not just per operand.
A prefix is a regex, in a match as well as an ==
A style picks a road shield's colour from the first letter of its ref - A is an autoroute, D a
departmental road - written ["match", ["upcase", ["slice", ref, 0, 1]], "A", ..., "D", ...].
CartoCSS has no substring, but =~ is a full std::regex_match (Predicate::applyOp), so a prefix
is A.*. == and in already took that path; match did not, and threw on its own input - which
dropped the plate colour for every country and left every shield on the neutral plate.
upcase folds onto the WHOLE string rather than the slice: uppercase([ref]) =~ 'A.*' says the
same thing about a prefix, and leaves a shape the regex can take. A label that is not a string of
the slice's own length can never match and becomes false.
A plate's border is measured off the raster, ramp included
describeFlatPlate reads a shield plate's fill, border and radius off the artwork, so the SDK can
draw it with no sprite at all. The border is the run of border-coloured texels the middle row
crosses, and that run starts at the OPAQUE box - which excludes the stroke's outermost texels,
because a stroke is antialiased against nothing and they fall under FLAT_ALPHA.
So the walk started inside the stroke: a 1.3 px stroke rendered at @2x spans 2.75 texels (one at alpha 192, two solid) and measured 2, a border a fifth thin against what maplibre draws from the same sprite. The alpha of those outer texels IS their coverage, and is added back.
The inner edge needs no equivalent: a stroke meets the fill it covers at full alpha, and the colour step there is sharp.
A style carries its own fonts
--fonts DIR copies the faces in DIR into the project's fonts/ and names them in project.json.
The decoder registers a style's own fonts AHEAD of the system ones and finds them by scanning
<style>/fonts/, so it needs no list — the list is for whoever has to carry the project, and the
web preview reads it to know what to fetch, since a project served over HTTP cannot be listed.
This is the only way a face reaches the web build, which has no system fonts at all: without it
shield-face-name: 'Noto Sans Bold' fell back to whatever the build preloaded and shields came out
regular. Preloading the face instead costs every page load — see web/fonts/README.md.
Carry a SUBSET. A shield draws a ref, so printable ASCII is 132 glyphs and 14 KB against the full
face's 569. Keep the name table (pyftsubset --name-IDs="*"): a face is resolved by the name in its
own table, and a subset that drops it stops answering to the name the style asks for.
A zoom stop is relative to a tile size
The SDK's zoom number sits log2(512 / TileDrawSize) levels above MapBox's — a level at the default
256, none at all for an app that adopted maplibre's 512. Every zoom stop and every zoom predicate
carries that shift, so --tile-draw-size has to state what the style will be DRAWN at.
Converted at 256 and drawn at 512, a trunk casing measured 5.2 px where maplibre gave 7.6 at the same camera: the whole style renders a level behind, which reads as roads that are simply too thin rather than as a zoom error.
Which road is drawn on top
line-sort-key has no such trick available: a CartoCSS rule draws its features in the order the
TILE lists them, and nothing in a declaration can reorder them. It becomes rule order instead —
one attachment per key value, emitted lowest first, so the highest is drawn last (expandSortKey,
split.ts). A road style that states the key once for every class is 7 rules where it was 1.
Without it a residential road painted over the motorway it crosses wherever the tile happened to carry it later, which maplibre never shows because it honours the key natively.
Only a match/case over the feature with numeric outcomes expands; anything else is reported as
approximated and the layer keeps tile order. The cap is MAX_VARIANTS, shared with the resource
splitting above.
An icon and its text are ONE label
MapBox draws a symbol's icon and text as a single symbol that never collides with itself. Emitted as
a markers symbolizer beside a text one they are two labels, they collide, and the marker
wins: a city dot appeared with no name beside it, and removing the marker by hand brought "Annecy"
straight back. ShieldSymbolizer is the one-label construct, so a symbol layer with both becomes a
shield and every text-* declaration is renamed into it.
Most names just take the prefix. The exceptions exist because shield-dx/dy move the image:
the text's own offset is shield-text-dx/dy, and text-opacity/text-transform become
shield-text-* for the same reason.
Two things the shield cannot carry, both baked into the bitmap instead:
- No
sdf. The distance field is resolved and the style'sicon-colorpainted in, so one sprite drawn in two colours is two files (circle-dot-000000.png). - No image size. There is no shield equivalent of
marker-width, so a staticicon-sizeis resampled into the PNG. A zoom ramp is carried live byshield-image-scaleinstead.
shield-unlock-image is always on, and the image is moved clear of the text by half its height —
text-anchor: bottom anchors the text's bottom edge, so the name sits above and the dot below it,
which is what MapBox's anchoring produces without ever needing the two to be separate labels.
A text-offset always ships its alignment. MapBox's offset is a pure translation, but with no
alignment stated TextSymbolizer::getFormatterOptions reads a non-zero dx/dy as the anchor
itself — so MapTiler's text-offset: [0, 0.05] hung every road ref off the bottom edge of its
shield. A layer with no text-anchor now emits MapBox's default, middle/middle, beside the
offset.
Road shields draw the real artwork
A MapBox road shield is a sprite drawn BEHIND its ref, and the sprite is picked per feature —
concat('transportation:', concat('road_', to-string([ref_length]))). Two things make that name
reachable, and together they get the country artwork itself rather than an imitation of it:
- Every sheet is extracted under bare names, not just the default one — MapTiler keeps its 70
shield shapes in a separate
transportationsheet, and a qualifiedtransportation_road_3.pngis a file no interpolation can land on. The default sheet still wins a collision. - The name becomes a path, because mapnik interpolates every
[field]in a string:icons/[iso_a2]-highway_[ref_length].png. A numeric field interpolates as readily as a string one, soref_lengthreachesroad_5.png. Where the name branches on several fields at once, each branch spells its own path and the case becomes a ternary. Thetransportation:prefix only chose a sheet and is dropped.
What is left below is the fallback, for a name CartoCSS cannot assemble at all — MapBox's own
shields slice the ref for some countries, and there is no slice here. The sprite is an SDF
tinted by icon-color and outlined by icon-halo-*, and CartoCSS draws that shape with no
image:
| MapBox | CartoCSS |
|---|---|
icon-color | text-background-fill |
icon-opacity | text-background-opacity |
icon-halo-color | text-background-border-fill |
icon-halo-width | text-background-border-width |
| the sprite's rounding | text-background-radius: 2 |
The artwork is lost in that case; the ref stays readable, and every note says so.
What makes a layer take the plate (shield.ts):
the sprite name reads the feature, cannot be spelled as a path, and the text sits on the
icon. A POI icon is per-feature too but its text is pushed below it (text-anchor: top,
text-offset: [0, 0.8]); a town's circle is centred but picked by zoom, not by the feature.
Both stay markers. An icon-color is required — without a fill there is only a border round
nothing.
Two consequences worth knowing:
- The plate colours are usually a
caseoveriso_a2/networkwith dozens of branches, so they pass the split cap and keep their fallback — one plate colour for the world. - A shield whose
text-fieldcannot be translated draws nothing. A plate is a background for text; on its own it is a floating box. MapBox's own shieldsslicethe ref per country and CartoCSS has noslice, so a text-field that branches is retried with its fallback branch —[ref]for everything outside Brazil — rather than losing the label.
MapTiler streets-v4 draws its shields from 486 extracted icons; topo-v4 has no shields at all.
Three traps that only show on a device
None of these fail a compile, and each looked like an SDK bug until the style was read.
/ between two INTEGERS truncates. Expression.cpp's DivOperator has a long long overload,
so a shield's ((1) / 2) — the default icon-size over a 2x sheet — evaluated to 0 and every
icon of thirteen POI layers drew at zero size. The divisor is written 2.0. CartoCSS numbers look
like JavaScript's and are not; a hand-written style hits this too.
An SDF sprite has to be re-encoded, not copied. MapBox's distance field (tiny-sdf: cutoff
0.25, radius 8) puts the edge at 0.75 with 1/8 per texel; the SDK's BitmapCanvas::drawSDFPixel
puts it at 0.5 with 1/16. Copied straight across, the SDK reads MapBox's edge as four texels
inside the shape: the hole in circle-dot fills in, every icon comes out fat and solid, and the
halo is squeezed to a quarter of its width. (v - 0.75) * 8 * 16 + 127.5 is the whole fix.
text-opacity fades the halo too — in MapBox. In CartoCSS it fades only the fill and the halo
keeps text-halo-opacity. MapTiler hides a label with step(zoom, 0, …, 13, 1), so the fill went
to 0 and the halo stayed at 1: a solid white ghost of the name at every zoom it should have been
absent from. Both are now emitted from the one MapBox value, and icon-opacity likewise reaches
marker-halo-opacity.
A pattern is a FILE, and a sprite name is not one. fill-pattern: "misc:construction_pattern"
reached the decoder verbatim, and no such file has ever existed — the misc: only said which sheet
to look in. Every construction area drew as a bare outline. Patterns now go through the same
extraction a marker's sprite does and come out as url('icons/construction_pattern.png').
And a pattern has its own opacity. polygon-pattern-* is a different symbolizer from
polygon-*, so MapBox's fill-opacity sent to polygon-opacity faded a solid layer under the
hatch and left the hatch itself at full strength — MapTiler's 0.15 construction areas drew
saturated orange. It goes to polygon-pattern-opacity, and fill-color is dropped, because MapBox
disables it under a pattern.
A dash ramped over zoom is not a dash the decoder can read. CartoCSS takes ONE pattern, and the
converter only understood a literal array — so MapTiler's step(zoom, [1, 1], 22, [1, 1.5]) was
dropped and every footway drew solid. It now takes the first stop that actually dashes: the base
is not always it, since the disputed border ramps from [1, 0], which IS a solid line.
A lone marker-* declaration builds a marker with no file, and MarkersSymbolizer's default
fill is #0000ff. Emitting marker-allow-overlap from icon-overlap while the sprite itself had
been dropped put a blue ellipse on every airport. Anything marker-* belongs inside the block
that emits marker-file, never in the property loop.
One project, one datasource — and a style may use several
A CartoCSS project has ONE datasource; a MapBox style says per layer which tileset it draws from. Flattened into one project that difference disappears, and every source-layer belonging to another tileset silently draws nothing. MapTiler topo-v4 uses three vector sources:
| source | source-layers |
|---|---|
maptiler_planet_v4 | roads, places, water, buildings … |
contours (contours-v2) | contour |
landform | peak, volcano |
This is why peaks never appeared at any zoom — not a filter and not placement: a z13 planet tile
carries no peak layer at all. The conversion is right; the wiring is the missing half. The
coverage report now names each extra tileset, its layers and its URL, so the app can point a layer
or a composite slot at each one.
Contours are the worked example: the demo puts a ContourTileDataSource over the DEM into the
style's contour slot (--es base composite --es contour true), so the converted #contour rules
draw contours traced live from elevation instead of from MapTiler's contour tileset.
Contours: --contour-schema div
MapTiler's contour layers say "every 5th or 10th line" (nth_line); tiles built with the gdal
ladder say "this line is a multiple of N metres" (div). Neither style states the other's unit -
the base interval is nowhere in the style - so the schemas cannot be mapped exactly, and only the
major/minor split survives:
massif-style mapbox2css topo-v4.json out/ --contour-schema div --contour-major-div 100
#contour[zoom >= 10][nth_line ...] -> #contour[zoom >= 10][div >= 100]
#contour[zoom >= 11][!nth_line ...] -> #contour[zoom >= 11][div < 100]
Only the predicates change. Colours, widths and zoom ranges stay whatever the source style asked for, and every rewrite is counted in the coverage report — MapTiler's 5-vs-10 distinction collapsing to one threshold is a real loss and is reported as such.
What could be better
- No browser build.
NODERAWFSrules it out. AMEMFSvariant would give a web playground that compiles a style in the page, which is the natural home for a "does my style still work" check. carto2cssdoes not exist. The reverse direction (mapnik XML back to CartoCSS) has no generator;MapGeneratoronly goes one way.- Only the size is measured. Startup time and peak memory for a large style project are still unknown; a slow load would push the design towards keeping the module warm across subcommands.
- A large style project compiles very slowly, then stops compiling at all. Three MapTiler styles
compile in well under a second; a 212-layer OpenMapTiles one had not finished after 25 minutes,
natively as well as under wasm. Streets-v4 (140 attachments) aborts the wasm with
memory access out of bounds, and bisecting the attachment list puts the threshold at 105 — no single rule fails, so the cost is in the total.buildMaprunscompileLayerover the full zoom range per layer, so something there is super-linear in attachment count. It affects any app loading a large style project, not just this tool.