feat(paint): style.material and a scene light - #357
Merged
Merged
Conversation
#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
force-pushed
the
feat/glossy-material
branch
from
September 27, 2026 10:29
e9345dc to
c10ca95
Compare
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
materialon 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 toldscene.lightoverrides it for everything in the scene at onceThree 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.
glossymetalmattematteexists 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, solight.intensity: 0flattens a whole scene without editing any node.The direction convention, and the bug the tests caught
x/ypoint 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 boundary, and why it decides layout
The material follows the node's box, clipped by
border-radiusand byclip-path. It is not clipped by what ashapecomponent draws, becauseshapepaints its own geometry and the paint pass has no access to it.So a sphere is a
divwithborder-radius: "50%", not ashape: circlewhose material would spill into the corners of its bounding square. An octagon is aclip-pathpolygon. 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: 0is byte-identical to no material at allglossy_is_brighter_on_the_lit_side_than_opposite_itturning_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 formatte_shades_without_a_highlight— lit side must not brighten, far side must darkenthe_scene_light_intensity_scales_every_material_at_oncea_material_never_paints_outside_its_clip_pathRendered check: four tiles on one scene —
glossy,metal,matte, andglossyon aborder-radius: 100box 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.