-
2026-07-30 — MapFactory: packed maps declare their per-channel layout, and dropping one can no longer lose a channel. Two structural upgrades to the textures engine plus three bugs found in the sweep.
MapType.channels— new SSoT for what each packed map carries per channel in its canonical layout (ORM{R: AO, G: Roughness, B: Metallic}, MSAO{R: Metallic, G: AO, B: Detail_Mask?, A: Smoothness}, …; trailing?marks filler channels coverage must not demand). The packed-map contract extends to it (__post_init__requires it alongsideis_packed/replaces/config_key), and a new consistency rule — every carried type must appear inreplaces— caught a live data bug on arrival: MSAO carried Smoothness in its alpha but didn't replace it, so a loose Smoothness map stayed wired beside a mask map. Fixed.filter_redundant_mapsis now lossless and reports its decisions. In an unpacked workflow it judges each packed map's channels for coverage dynamically — a channel counts covered when its type, or any loose type the conversion registry can derive it from (loose Roughness covers a Smoothness channel), survives the drop. Channels nothing covers are extracted to real loose files from the packed source before it drops, via the same conversion registrations andTextureProcessorsave pipelineprepare_mapsuses (sodry_runplans without writing). When extraction is unavailable, the packed map is kept and its components retire — the lossless direction either way. A real loose map already on disk under the canonical output name is reused, never overwritten with extracted channel data (TextureProcessor.output_path_fornow exposes the naming conventionsave_mapwrites with, so the probe can't drift from the writer). The flagged failure this kills: an unpacked preset meeting an MSAO beside loose Metallic/Roughness but no separate AO silently lost the AO channel. Returns{"dropped": {type: reason}, "extracted": {type: path}}(wasNone); a packed map with no loose components present stays the sole source, as before. Mat Updater and mayatk's Game Shader both get the recovery for free.- Bug: unpack helpers clobbered provided loose maps. Asking a packed map for one channel (
get_ao_from_msao) cached all its channels over the inventory unconditionally — replacing a real loose Metallic file with the MSAO R-channel extraction. All five unpack helpers now fill gaps only (_cache_unpacked); provided files always win. - Bug: a Bump-sourced normal swallowed the Height map.
NormalMapHandlermarked bothBumpandHeightused after generating from one, so a real Height map (its own parallax/displacement slot) never passed through. Only the actual source is marked now. - Bug: inventory "specificity" was judged on full-path length.
_build_map_inventorysorted by whole-path length to preferMixed_AOoverAO, letting a longer directory name decide the winner; now basename length.test_map_factory_grouping.py+7,test_map_registry_register.py+3,test_map_factory.py+3.
-
2026-07-30 —
Albedo_TransparencyandMetallic_Smoothnessdeclared noreplaces, so packing a channel into them left the map it packed in the set — and both then wired into the same material slot. User-reported: after Mat Updater packed the opacity into the albedo, the old standalone Opacity map was still connected to the shader.filter_redundant_mapsresolves packed-vs-loose redundancy from eachMapType'sreplaceslist, and only ORM / MSAO / MRAO had one — so nothing ever retired the separateOpacity(orRoughness) map its packed counterpart had absorbed, and the loose map re-connected alongside it. Both now declare what they carry (Albedo_Transparency→Base_Color/Diffuse/Opacity, and it's markedis_packed;Metallic_Smoothness→Metallic/Roughness/Smoothness/Glossiness), so the existingconfig_keydirection logic applies to them like every other packed map: packed wins when the preset asks for it, the separate maps win when it doesn't.test_map_factory_grouping.py+3.- The contract is now structural, not conventional.
MapType.__post_init__rejects any definition with a partial packed-map contract —is_packedorreplacespresent without the full trio (is_packed+replaces+config_key). Guarding onreplacestoo matters:filter_redundant_mapskeys its precedence rules offreplaces(neveris_packed), and a map withreplacesbut noconfig_keyskips the direction gate and silently supersedes its components in every preset, unpacked ones included. Each omission now fails at definition time (in-tree or a consumer'sMapRegistry().register(...)) instead of shipping this bug class again. Loose maps are exempt.test_map_registry_register.py+6 (TestPackedMapContract), including a live-registry sweep.
- The contract is now structural, not conventional.
-
2026-07-29 —
StepToggle— the timed multi-step press cycle behind repeat-press hotkeys (core_utils/step_toggle.py,ptk.StepToggle).CoreUtils.cyclerotates a sequence forever, which is wrong for "press again to go further": there is a home state to come back to, and a paused cycle must not step deeper.StepTogglemodels0 (home) -> 1 -> ... -> steps -> 0with an injected clock — a press older thantimeoutnever steps deeper, it returns home (so a multi-step toggle degrades to a plain on/off toggle once the user stops tapping), and a press whosecontextchanged restarts at step 1 instead of undoing something the user just left.payloadcarries whatever the caller must restore on the way home (a camera snapshot) andbegan_cyclemarks the press that should capture it — that is not simply "was at home": a stale retarget starts a new session whose home is the state being left now, while retargeting inside the timeout keeps the original home so a quick re-aim still unwinds to where it began.get(name)keeps the state for hotkey handlers that have no instance to hang it on, andscales(steps)is the per-step magnitude ramp (more steps -> gentler start, same per-step gain). Backsm_framein both DCC packages.test_step_toggle.py+25. -
2026-07-29 —
MathUtils.next_clear_offset(bounds, blockers, axis, direction, margin=0.0)— park a box in the next gap along an axis. Backs the Shell Xform move pad's new snap to shell mode, which both DCC packages ship, so the geometry cannot live in either without duplicating it. Candidate landing spots are one margin short of each blocker's near edge and one margin past its far edge; the nearest spot strictly ahead whose landed box overlaps nothing wins. Validating fit is the whole point — a naive nearest-edge rule lands in a gap too small for the box and overlaps the very shell it was walking past (measured: a 0.2-tall box stepping through a 0.1 gap). Blockers not overlapping on the perpendicular axis are dropped up front: travel along axis never changes that overlap, so one lane filter serves both candidate generation and the fit test. Returns None when nothing lies ahead, leaving the fallback rule to the caller.test_math.py+7 (park, skip-too-small-gap, reversible walk, out-of-lane, nothing-ahead, already-parked-advances-past, u axis).