Skip to content

feat(paint): style.material and a scene light - #357

Merged
LeadcodeDev merged 1 commit into
mainfrom
feat/glossy-material
Sep 27, 2026
Merged

LeadcodeDev merged 1 commit into
mainfrom
feat/glossy-material

Conversation

@LeadcodeDev

Copy link
Copy Markdown
Owner

Closes #355 — its second half. #356 already landed the depth of field.

The decision I said this needed

I held this back because it needed a lighting model chosen, which is a product call. Here it is:

A scene-level light with a sensible default, plus material on the node.

What makes a tile read as rendered is not the highlight on its own — it is that several shapes are lit the same way. A per-node baked preset cannot agree with its neighbours about where the light is. But requiring every scenario to declare a light would be friction for a generator, so:

  • "material": "glossy" alone works, and assumes upper-left — where an eye expects it without being told
  • scene.light overrides it for everything in the scene at once
"scenes": [{
  "light": { "x": -0.4, "y": -0.85, "intensity": 1.0 },
  "children": [
    { "type": "div", "style": { "background": "#6D28D9", "border-radius": 28,
                                "material": "glossy" } }
  ]
}]

Three layers, three presets

A radial highlight on the lit side, a specular edge along it, a soft shade opposite — all three derived from one direction vector.

Preset Highlight Specular edge Shade
glossy broad, bright marked soft, opposite
metal tighter, dimmer harder deeper
matte none none the only layer

matte exists so a flat surface can sit beside glossy ones without looking unlit: it takes light and returns none.

"material": { "preset": "glossy", "intensity": 0.6 } doses one node. The node's intensity is multiplied by the scene's, so light.intensity: 0 flattens a whole scene without editing any node.

The direction convention, and the bug the tests caught

x/y point toward the light, not along its travel — the natural reading of "negative is on the left". I got it backwards first, and the test said so:

the default light is upper-left, so the upper-left of the tile must be
clearly brighter than the lower-right: lit=98.0, shaded=167.0

The boundary, and why it decides layout

The material follows the node's box, clipped by border-radius and by clip-path. It is not clipped by what a shape component draws, because shape paints its own geometry and the paint pass has no access to it.

So a sphere is a div with border-radius: "50%", not a shape: circle whose material would spill into the corners of its bounding square. An octagon is a clip-path polygon. Give the silhouette to the box — the rule file leads with that rather than burying it.

Verification

Six tests. Five fail without the paint call. The sixth asserts equality and passes both ways on purpose:

  • a_node_without_a_material_is_untouched — intensity: 0 is byte-identical to no material at all
  • glossy_is_brighter_on_the_lit_side_than_opposite_it
  • turning_the_light_around_turns_the_gradient_around — a light from the lower-right lights the lower-right; this is the coherence the whole design is for
  • matte_shades_without_a_highlight — lit side must not brighten, far side must darken
  • the_scene_light_intensity_scales_every_material_at_once
  • a_material_never_paints_outside_its_clip_path

Rendered check: four tiles on one scene — glossy, metal, matte, and glossy on a border-radius: 100 box that reads as a sphere — all lit consistently from upper-left.

cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace (1582) all clean.

What it deliberately is not

No cast shadows between elements, no occlusion, no inter-reflection. It is a per-node surface treatment, not a renderer. Separating planes is depth of field's job (#356), which reads the same style.depth.

Written comment-free, per the codebase-wide rule from #345.

@LeadcodeDev LeadcodeDev added the enhancement New feature or request label Sep 27, 2026
@LeadcodeDev LeadcodeDev self-assigned this Sep 27, 2026
#355's other half. It was held back because it needed a decision first, and the
decision is: **a scene-level light with a sensible default, plus `material` on
the node.**

What makes a tile read as rendered is not the highlight on its own, it is that
several shapes are lit the *same way*. A per-node preset cannot agree with its
neighbours about where the light is, so a baked look was the wrong shape.
Requiring every scenario to declare a light would be friction for a generator,
so `material` alone works and assumes upper-left — where an eye expects it
without being told — and `scene.light` overrides it for everything at once.

Three layers, all derived from one direction vector: a radial highlight on the
lit side, a specular edge along it, a soft shade opposite. Three presets:

    glossy   broad bright highlight, marked edge, soft shade
    metal    tighter dimmer highlight, harder edge, deeper shade
    matte    shade only — takes light, returns none

`matte` exists so a flat surface can sit beside glossy ones without looking
unlit. `"material": { "preset": "glossy", "intensity": 0.6 }` doses a single
node; the node's intensity is multiplied by the scene's, so `light.intensity: 0`
flattens a whole scene without editing any node.

`x`/`y` point **toward** the light, not along its travel. That is the natural
reading of "negative is on the left", and getting it backwards the first time is
what the tests caught: `lit=98.0, shaded=167.0` on a tile that was supposed to be
brighter upper-left. The schema doc now says which way it points.

## The boundary, and why it decides layout

The material follows the node's **box**, clipped by `border-radius` and by
`clip-path`. It is not clipped by what a `shape` component draws, because a
`shape` paints its own geometry and the paint pass has no access to it.

So a sphere is a `div` with `border-radius: "50%"`, not a `shape: circle` whose
material would spill into the corners of the bounding square. An octagon is a
`clip-path` polygon. Give the silhouette to the box — `rules/material-and-light.md`
leads with that.

Six tests. Five fail without the paint call. The sixth asserts equality and
passes both ways on purpose: `intensity: 0` is byte-identical to declaring no
material, so nothing written before this renders differently.

Closes #355
@LeadcodeDev
LeadcodeDev merged commit ec18901 into main Sep 27, 2026
4 checks passed
LeadcodeDev added a commit that referenced this pull request Sep 27, 2026
#399)

The three presets from #357 compute their highlight on the node's **box** and
then clip it. On a star cut out of a rectangle that gives one band of light
across the box, not a relief per branch — the shape stays a flat varnished
plane, which is what #385 measured against a reference.

`inflated` derives the shading from the silhouette instead. The clipped path is
rasterised and blurred, and the **gradient of that blurred mask is the surface
normal**, lit by the scene's own `light` from #357. Each branch has its own
edge, so each gets its own highlight and its own hollow. `bevel` sets how far
in the rounding reaches, `softness` the profile from a hard chamfer to a
cushion.

The first version banded visibly: with a large `bevel` the mask's gradient is
shallow, and sampling adjacent pixels quantises it into concentric steps on
8-bit alpha. The gradient stencil now widens with `bevel`, which is the same
reason the artefact existed — measure a shallow slope over a longer baseline.

Three tests. The decisive one counts separate bright runs along a row cutting
two branches, and fails without the relief with `glossy=0, inflated=0`. The
other two assert equality and pass both ways on purpose: nothing paints outside
the silhouette, and `intensity: 0` is byte-identical to no material at all.

Also here, because it obstructed this work four times:
`dot_map_terminates_on_a_zero_dot_spacing` guards against a runaway loop with a
10-second wall-clock budget, and exceeded it under parallel build load while
painting perfectly well. The budget is 120 seconds now. The guard answers
"does this terminate", and 10 versus 120 seconds makes no difference to that
question while making every difference to whether the suite is trustworthy
during a chantier.

Closes #385
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A glossy 3D material for shapes and icons, and depth of field

1 participant