Skip to content

Evaluate feature-state in a paint property - #351

Merged
jwinarske merged 1 commit into
mainfrom
evaluate-feature-state
Oct 6, 2026
Merged

jwinarske merged 1 commit into
mainfrom
evaluate-feature-state

Conversation

@jwinarske

Copy link
Copy Markdown
Owner

The operator half of #337. The store and the C setter are the other half and are not here; see the
end.

A correction to the issue, measured rather than reasoned

The issue says a style using ["feature-state", …] "draws every feature in its default state". It
does not. "feature-state" was in the generated registry -- which is what makes
looks_like_expression treat the array as a call rather than as data -- and the parser had no arm
for it, so it reached the unknown-operator path. A paint property reading it failed to resolve, and a
layer whose paint will not resolve is rejected, so the layer drew nothing at all.

Both halves of the same fill layer, through resolve_paint:

before: REFUSED: `fill-color`: unknown expression operator `feature-state`
after:  RESOLVES

So the first thing this fixes is a style that was being dropped.

The operator

One argument, reads the state of the feature being evaluated, and answers null for a key the
feature has no state for -- as an absent property does, and for the same reason: a style writes
["case", ["feature-state", "hover"], …, …] and expects the default arm, so erroring would make
every unhighlighted feature a failed evaluation.

State hangs off the Feature trait beside property, with a default answering None. That is
the shape the operator has -- state belongs to the feature being evaluated, as its tags do -- and the
default keeps every implementation written before this one compiling while answering exactly what a
feature with no state means. The alternative was a sixth parameter on an evaluate chain that is
already five deep.

Dependency::STATE is a bit of its own, always joined with FEATURE and never alone. The
feature bit is what makes the property a per-vertex attribute rather than a uniform, so that
behavior is unchanged; the state bit is what a caller deciding how much to redo for a change reads.
mbgl keeps the same distinction for the same reason, as Dependency::FeatureState.

Paint only, enforced where each half is compiled

The specification allows the operator in a paint property and nowhere else, because a filter decides
which features exist and a layout property decides their geometry -- each answered once, when the
tile is cut. A filter over state would report whatever the state was then and never change, which
reads as a highlight that works until the first pan.

  • Filters: parse_filter refuses it, naming the rule.
  • Layout properties: resolve_layout refuses it.

That second one has a hole, which is named rather than papered over: layout_specs has no table
for a symbol layer
, so resolve_layout answers an empty map for one and the refusal never sees
text-field. The function that reads those properties is layout_value, one property at a time,
and it has no error channel -- its contract is already "no value, so the caller's own default
applies". So that is what it does for state, and the test says so for text-field while checking
that text-size beside it is unaffected.

Mutations

mutation caught by
the evaluate arm reads property() instead of state() three tests, including state and a tag of the same name answering differently
the STATE bit dropped from the classification five, including both refusals
the filter refusal removed the filter test
the layout refusal removed the layout test
layout_value's check removed the symbol test

What is left of #337

The store and the C setter: per-feature state keyed by source, source layer and feature id, and
tessella_set_feature_state. Nothing supplies state yet, so the operator answers null for every
feature -- which is the default arm, and is what the layer now draws instead of not drawing.

The issue's "re-evaluated on change without re-laying-out the tile" is a third piece and deserves to
be scoped on its own: a bucket is keyed by the style revision precisely because a changed filter
admits different features, and deciding per bucket whether a state change could have affected it
needs a per-layer input hash. The STATE bit added here is what such a decision would read.

Gate: clippy pinned and stable, rustdoc on default features and all features, the wasm32 check, the
no_std lane, 2268 tests, fmt last, and the pixel gate with probes rebuilt -- every sweep row at its
documented value (the two unstable ones settled back to 0 and 29 this run), zero holes, quad
22 / 22 / 22 / 32 with image_stable 1.

`"feature-state"` was in the generated operator registry and nothing
parsed it, and what that cost is worse than the issue says: the operator
reached the parser's unknown arm, so a paint property reading it was
refused and the layer was dropped. Measured both ways --

    before: REFUSED: `fill-color`: unknown expression operator `feature-state`
    after:  RESOLVES

-- so a style highlighting a feature drew no layer at all rather than
every feature in its default state.

The operator is one argument, reads the state of the feature being
evaluated, and answers null for a key the feature has no state for, as an
absent property does: a style writes `["case", ["feature-state", "hover"],
…, …]` and expects the default arm, and erroring would make every
unhighlighted feature a failed evaluation.

State hangs off the `Feature` trait beside `property`, with a default
answering None. That is the shape the operator has -- state belongs to
the feature being evaluated, as its tags do -- and it keeps every
implementation written before this one compiling and answering what a
feature with no state means.

`Dependency::STATE` is a bit of its own, always joined with FEATURE. The
feature bit is what makes the property a per-vertex attribute rather than
a uniform, and the state bit is what a caller deciding how much to redo
for a change reads. mbgl keeps the same distinction for the same reason.

The specification allows the operator in a paint property only, and both
halves of that are now enforced where they are compiled: a filter is
refused by `parse_filter`, and a layout property by `resolve_layout`. The
second has a hole worth naming -- `layout_specs` has no table for a
symbol layer, so `resolve_layout` answers an empty map for one and the
refusal never sees it. `layout_value` is what reads those properties and
has no error channel, so it answers None there, which is the caller's own
default. Both are tested, including the symbol case.

Still open: the store and the C setter. Nothing supplies state yet, so
the operator answers null for every feature -- which is the default arm,
and is what the layer now draws instead of not drawing.

Signed-off-by: Joel Winarske <joel.winarske@linux.com>
@jwinarske
jwinarske merged commit 140a509 into main Oct 6, 2026
9 checks passed
@jwinarske
jwinarske deleted the evaluate-feature-state branch October 6, 2026 15:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant