PrivategetPrivategetPrivategetResolve every data-leaf affected by changes to a layer at path.
Data-node layers map to themselves; group layers fan out to all
descendant points/lines/gsplats.
PrivateliveCompute the layer's current live composable attributes. Thin wrapper
around deriveLiveLayerAttrs so composeEffective keeps its
compact this.liveLayerAttrs(...) shape.
PrivatecomposeRecompose the effective attrs for a single data-leaf by walking the
scene-graph ancestry, substituting panel state for every layer=true
node in the chain.
layerPath is the layer whose control was just used. Inside that layer's
own subtree — and ONLY when the edited layer actually OWNS a mode
(blendingModeExplicit) — the LAYER owns blending_mode: a mode authored on a
descendant that is not itself a layer is dropped. blending_mode is
nearest-setter-wins and a layer exposes exactly one Blend control, so
without this a kind=partition / kind=lod layer whose parts carry
their own stamped mode has an inert control — every part shadows the
wrapper (the graft_gsplat_node stamping bug, and every scene already
written by it). A wrapper that owns NO mode has no control value to impose,
so it must NOT suppress its descendants' authored modes — otherwise a plain
layer=true group over a mesh authored additive would snap the mesh to
its opaque type-default on any non-blend edit (#1275). A nested node that
IS a layer keeps its live value: it has its own control. Only
blending_mode is affected — the multiplicative attrs still compose and
offset still sums, so a part's authored opacity/gamma/κ is preserved.
identityLayerWindow substitutes the IDENTITY for the edited layer's own
display window (intensity/offset) — used by applyComposed for a leaf
that renders direct colour while the layer's window is a SCALAR window
(a mixed group layer), so the scalar window is never applied as a colour
gain. Ancestor/leaf-authored windows still compose.
precomputedAncestors lets a caller that already walked the chain (both
fan-out loops do) hand it over instead of paying for a second walk.
collectAncestorNodes resolves each step with a linear children.find, so
one fan-out over a P-part wrapper costs ~P²/2 path comparisons — and
applyComposed runs on every slider tick.
OptionalprecomputedAncestors: readonly SceneNode[]PrivatecomposedMay the composed window for this leaf be re-stated in the leaf's OWN range?
Two independent questions, both of which must answer yes:
kind=lod.LayerInfo.scalarDataRange), so that reading it as a position inside
that range is legitimate?kind=lodA LOD level and a partition part are not the same kind of sibling.
Levels are alternative representations of the WHOLE object, and gsplat LOD merging SUMS amplitudes — a coarse level's amplitude is the same physical signal at a different numeric SCALE. Re-expressing the window per level is exactly the correction that makes every level render one physical value identically, which is #1753's own repro.
Parts are disjoint spatial subsets of ONE field at the SAME scale, and
packages/luxar/src/luxar/io/_compiler/gsplat_assembly.py derives
amplitude_data_range = [min, p99.9] per splat set — so two tiles differ
purely by CONTENT. Windowing each part on its own range is per-tile
auto-contrast: the same physical value renders as a different colour in
different tiles and the colormap goes non-monotone, with a visible
discontinuity at every BSP seam. On the repo's own
tests/fixtures/test_partition_layer.luxar.zarr (layer=True, kind=partition — and layer=True is the default of luxar gsplat convert), part_0 is [0.5000, 0.7455] and part_1 [0.7542, 0.9998]:
remapping would ramp black→white across BOTH, so the field would step
0.7455 (white) → 0.7542 (black) at the seam. Saturated-but-monotone is the
correct failure. The producers say the same thing twice —
core/group/adders/mesh.py::_shared_scalar_window ("a level or a part
that stamps its own subset min/max renders the same value as a different
colour … which is exactly the discontinuity this helper exists to
prevent") and gsplats/lift.py, where beads share the finest node's
scalar_data_range rather than a per-segment one.
So every GROUP node from the edited layer down to (excluding) the leaf must
be kind === 'lod'. Consequences, all deliberate: adaptive (a partition
of per-tile lod groups) declines outright, because the right reference for a
tile's ladder is that TILE's finest level rather than the layer's, and
building that is more than #1753 asks for; and a plain group layer over
several colormapped leaves (two channels, say) declines too — different
physical fields, not one field at two scales.
An overview tree (an lod group whose coarse cap is a leaf and whose fine
branch is a nested partition) is a NO-OP in both branches, and it is worth
being precise about why rather than claiming half a win. The tiles decline
on the partition, as above. The cap is structurally eligible — but on a
measured luxar gsplat lod --recipe overview store the cap and all four
parts carry 432 splats each, and deriveScalarRangeFromDescendants breaks
that tie with a strict count > bestCount while visiting the cap FIRST, so
the cap IS the reference and remapWindowToLeafRange's equality
short-circuit returns its window untouched. The fine parts therefore keep
rendering on the cap's window (measured: part_2's own
[0.00059, 0.19962] on the cap's [0.000116, 0.44551], so its brightest
splat lands at LUT 0.45 instead of 1.0). Fixing that needs a per-branch
reference, which is the same change adaptive would need.
Once #1691 / PR #1752 lands (it harmonizes amplitude_data_range across a
gsplat structure so siblings SHARE a window) partition parts will carry
equal ranges and remapWindowToLeafRange's equality short-circuit would
make this a no-op anyway. The kind gate is the safety net until then, and
for every store already written.
composeEffective multiplies intensity and sums offset over the WHOLE
ancestry, substituting live panel state for every layer=true node, so
several reachable shapes hand back a window in a completely different
basis. Remapping one of those does not refine a correct window, it corrupts
it — hence a predicate rather than a best-effort. Every arm below is pinned
by a test in tests/unit/ui/layers/layer-apply-per-leaf-window.test.ts:
layerDepth < 0).
Nothing can be said about the basis, so nothing is done.intensity/offset are
compositing attrs, so add_gsplats_from_file(…, layer=True, intensity=0.5) / luxar gsplat convert --intensity 0.5 stamps them on
the kind=lod wrapper itself. walkSceneGraph then seeds
displayMin/Max from computeDisplayRange(0.5, 0) = [0, 2] — a window
in the normalized-GAIN basis, with no relation to a scalarDataRange of,
say, [0, 0.02]. That window was already wrong before this change (100x
too wide); remapping it would multiply the error by leafSpan / refSpan
on top. Declining leaves the pre-existing behaviour exactly as it was.[0, 1] composes
computeUniforms(0, 1) = {1, -0}, indistinguishable from "no window
authored", so a gain test passed it and remapped a window that was
already correct. deriveScalarRangeFromDescendants is the one derivation
in layer-state.ts that does NOT stop at a nested layer, so the outer
reference can be a sibling's range: over ch0 ([0, 1], 1e3 splats) and
ch1 ([0, 5], 1e4 splats) it is [0, 5], and dragging the wrapper's
opacity composed ch0's own correct [0, 1] — which remapping turned into
[0, 0.2], 5x too narrow, with nothing re-applying ch0 afterwards.
[0, 1] is not exotic: normalized scalars, probabilities, masks,
fractions.resolveColormapWindow's first branch, which
says the same thing at load time: a non-identity RAW LEAF gain means the
composed gain IS the window and the data range is not consulted.Ancestors ABOVE the edited layer are deliberately NOT gated — but they are
not folded into the remap either. leafScalarWindow re-expresses the
LAYER'S OWN window and re-applies the ancestor gain afterwards, which is
what makes the panel match resolveColormapWindow's ancestor-only branch
exactly (ancestor intensity = 2, reference [0, 2], leaf [0, 8] → both
give [0, 4]) — see the note there for why remapping the COMPOSED window
instead only agrees when the two ranges happen to share a relative origin.
OptionalprecomputedAncestors: readonly SceneNode[]PrivateleafThe scalar LUT window for ONE colormap-active leaf: the layer's composed window, re-expressed in that leaf's own data range.
A layer composes a single window, but a kind=lod layer fans out over
LEVELS whose scalars need not share a range — LOD merging sums amplitudes,
so a coarsened level carries its own amplitude_data_range for the same
physical signal, and the layer's reference range is merely whichever
descendant deriveScalarRangeFromDescendants picked. Pushing the composed
window verbatim rendered every level on the reference level's window,
discarding exactly the per-level differentiation the producer stamped, and
made a lazily-created level's window depend on load order (#1753).
The remap runs only when the layer↔leaf relation is one a range change means
something across, AND the composed window really is stated in the reference
basis (composedWindowIsInReferenceBasis) — otherwise it would corrupt
a window that was already correct, or auto-contrast a spatial partition tile
by tile. The remap itself, and the range-shaped cases in which it must not
happen, live in the pure remapWindowToLeafRange; the common single-range
layer short-circuits there and gets the composed window back bit-exact.
What gets remapped is the LAYER'S OWN window, not the composed one, and the
ancestor gain is re-applied to the result. Only the layer's own window is a
position inside layer.scalarDataRange; the composed window is that
position already transformed by whatever gain the ancestry above the layer
contributes, and reading it as a reference-basis position is a basis error
of exactly the kind composedWindowIsInReferenceBasis exists to
refuse. It cancels out when the two ranges share a relative origin
(ref₀/refSpan === leaf₀/leafSpan — notably when both start at 0) and not
otherwise: with ref = [1, 3], leaf = [0, 8] and an ancestor
intensity = 2, remapping the composed window gives [-2, 2] where node
creation gives [0, 4]. Swapping the layer's contribution reproduces
resolveColormapWindow's ancestor-only branch identically for every range
pair.
Only meaningful for a colormap-active material: a direct-colour leaf has no
scalar window (its gain/offset are a colour GOG), which is the same condition
applyColorAdjustments routes on. That is also why eff here is never the
identityLayerWindow composition — that substitution is made only for
direct-colour leaves, which never reach this helper.
Optionalancestors: readonly SceneNode[]PrivateapplyPrivateapplyPush each composed effective attribute (except colormap, which is per-leaf and doesn't chain through ancestors) to every affected leaf material. Colormap is handled separately because textures don't compose — the nearest ancestor's colormap wins.
Push the composed draw order onto the meshes the depth-sort coordinator reads it from.
Does not go through applyComposed — an order is a cross-node SORT KEY,
not a material uniform, so there is no mat.updateX to call and nothing in
the shader to refresh. But it DOES compose: the value written to each leaf
is composeEffective's, not this layer's raw one.
That distinction is the bug this replaced. Assigning layer.layerOrder
directly to every affected leaf clobbered the order of a nested leaf that
is ITSELF a layer with its own authored order — getAffectedDataLeaves
returns every data descendant, including nested layers, and
nearest-setter-wins says the nested one should win. Composing per leaf
restores that, and lets an order on a non-layer intermediate group
participate too.
This is render-only session state. It must not be written into
userData.attrs, which is the loaded SceneNode attrs object for lines and
gsplats and would make a panel edit look authored on the next composition.
Push the mesh shading values (§6.2) to the layer's mesh leaves.
Deliberately NOT routed through applyComposed, which is what every other
control here uses, because these values do not compose along the ancestry:
opacity/gamma/intensity multiply and offset sums, so an ancestor's value
has to fold into a descendant's, whereas a shade floor is a per-surface appearance
choice with no composition rule — multiplying two ambients would mean nothing.
It still has to FAN OUT like applyComposed does, though. A mesh layer is no
longer always a leaf: add_mesh(partition=…) writes a kind=partition wrapper and
the panel presents that wrapper as one mesh layer, so layer.path resolves to a
THREE.Group with no material of its own. Writing only there left all mesh appearance
sliders visible and completely inert on a partitioned surface. A non-mesh leaf
needs no extra gate — it simply has no updateAmbient, so the optional chaining
below is the type check.
alphaCutoff also rides to the PICK material, because the pick pass applies the
identical cutout (§6.5): a threshold that moved on screen but not in the pick
buffer would make a freshly-dissolved region still hoverable.
Push a physical layer's live knobs (LayerInfo.physicalKnobs, in material space)
onto every material="physical" leaf beneath it. The optional-chained
updatePhysicalKnob is the type gate, as for the house knobs above: a house or
emissive leaf simply lacks it. A no-op for a layer with no knob record.
Colormap applies per-leaf (not composed). For a group-layer we push the selected colormap to every data descendant that accepts one.
Returns whether the layer now actually renders through a colormap — i.e.
at least one leaf accepted the LUT. Clearing a colormap always "takes", so
that returns false (no colormap in effect). The caller needs this because
the C1 fail-closed guard below can suppress the colormap on every leaf
(a group layer over scalar-less points still offers the dropdown): the
layer then keeps rendering DIRECT COLOUR, so its display window must stay
the direct-colour identity rather than move to a scalar range.
A leaf whose mesh/material is not in the scene yet (partition parts and LOD levels stream in) is NOT a guard suppression — when no leaf material was reachable at all, the request is taken at face value so the caller keeps the user's pick instead of reverting it mid-load.
Clone-on-first-use for the material at a data-leaf, registering the clone with MaterialManager so camera-dependent uniforms stay current. Non-luxar materials return null.