-
2026-08-06 — FBX→GLB: the post-conversion repairs read the whole file three times and rewrote it twice to edit a few JSON fields. The conversion subprocess is the floor, but everything after it was paying by the size of the geometry.
_read_glbslurped the entire file and_write_glbwrote all of it back, and the preview path runs three repairs in a row on the same GLB —fix_glb_phantom_opaque_alphainsidefbx_to_glb, thenset_glb_base_color, thenset_glb_emissive— each opening, parsing and rewriting for itself. Every one of them edits the JSON chunk and nothing else; the BIN chunk was just being carried along.MeshConvert.GlbEdit+open_glbcollapse that to one read and one write. Each repair now takes either a path or an open session, so the standalone path form is unchanged whilePreviewDeliverer._apply_sidecarwraps every section in oneopen_glband the whole sidecar costs a single write. The session is also where the two duplicate alpha-probe closures merged into one cachedalpha_extrema, so an atlas shared by twenty materials decodes once across repairs rather than once inside each, and where the texture-embed cache moved — a map assigned as both base colour and emissive used to be base64'd into the file twice.restis lazy andbin_datais a view. Nothing in the alpha check needs the BIN chunk until a material already looks wrong, so a GLB with nothing to fix is never read past its JSON chunk — which is the case that runs after every conversion, including the scene exporter's, where the repair had been reading a hundreds-of-megabytes file to decide it had no work.bin_datawas abytesslice, i.e. a full copy of the geometry alongside the buffer it came from, putting peak memory at twice the file size; it is amemoryviewnow, and every consumer only ever read it.A JSON-only edit no longer rewrites the file. This module re-serializes compactly while most producers do not, so the new JSON usually still fits the chunk it came from; it is padded back to exactly that length and written in place at a fixed offset, leaving the total size and every byte after it untouched. Growing edits (a base64 texture embed) still take the full-rewrite path. Padding out rather than shrinking to fit is what keeps it safe — the chunk header, the total-length field and every following byte stay correct only if the chunk keeps its size.
_embed_imagenow warns and skips an unreadable PNG/JPEG instead of raising. Found reviewing the above: the re-encode branch caught(OSError, ValueError)and returned None, but the direct read for a format glTF accepts natively did not — so a texture that exists yet cannot be read (permissions, a network path that dropped, deleted between theisfilecheck and the open) aborted the whole channel writer, while the same failure on a TGA warned and skipped. Extension alone decided which. It also mattered more after the change above: that read was the one file operation left inside an applier, and with the sections now sharing one session an exception escaping mid-mutation is the one way a partly-applied edit could reach disk. Its record already gated on the embed result, so the material's colour still writes and the record correctly reports no texture.Behaviour is otherwise unchanged for every caller (all pass paths positionally;
glb_path→glbrenames no keyword anyone used). Ten new tests pin the invariants that made the collapse safe: one write per session, two for two path-form calls, no write with nothing to match, in-place edits leaving file size and BIN bytes identical, the full-rewrite fallback returning the geometry whole, a clean GLB never touchingrest,bin_databeing a view, one embed across both channels, a raising body writing nothing, and an unreadable texture skipping rather than raising. Also folded onto the session:_extract_image_bytes(asimage_bytes), the material→pbr→texture→image walk both alpha repairs open with (asbase_color_image— written twice it had already drifted, one copy bounds-checking the image index and the other leaving it to the probe), and the name/uri/index fallback each used to label a finding (asimage_label); the three copies of the empty-container cleanup became_prune_empty_containers. pythontk 2732 green; extapps 566 green. -
2026-08-06 — Map taxonomy: one normal convention resolved and its twin didn't, non-tangent normal bakes were wired as tangent normals, and a compound suffix split a texture set in two. Four defects in
MapRegistry, found probing the classifier against real-world suffix conventions rather than reading the alias table.OGLwas not an alias whileDXwas, sorock_NRML_DX.pngresolved to Normal_DirectX androck_NRML_OGL.pngresolved to nothing — one half of a bake pair dropped as an unknown map.GLcould not cover it: the short-alias boundary rule rejects an uppercase predecessor (theAgilent_E4419Bguard), and theOinOGLis exactly that. The convention spellings now come fromMapType.compose_aliases, a cross-product of normal tokens (Normal/NormalMap/Norm/NRML/NRM/NML/N) with convention tags (GL/OGL/OpenGL,DX/DirectX) over both separators, so neither side can be more complete than the other again.A compound
<type>_<convention>suffix came off in pieces.get_suffix_strip_patternstrips ONE trailing alias, sorock_NRML_DX.pngclassified correctly but based torock_NRML— while its siblingrock_DIFF.pngbased torock, putting the two halves of one bake in different texture sets. Enumerating the compounds is deliberate, and the alternative is why: stripping in a loop until nothing matches eats material names, turningGold_Metal_DiffuseintoGold(Metalis a registered alias of Metallic). A test pins that.Bakes that are not tangent-space normals classified as
Normal. Every one of them ends in the tokenNormaland longest-first matching had no longer candidate to prefer, somat_WorldNormal.pngandmat_BentNormal.pngwere wired into the tangent-normal slot and rendered wrong with no warning.Normal_Object,Normal_WorldandBent_Normalare now their own types, and deliberately absent fromNORMAL_TYPES—select_normal_typemust never offer one as the shader's normal map, since they need a different shading setup rather than a different handedness.Vector_Displacementjoins them for a different reason: it classified asDisplacement, whose declared mode isL, so a VDM's XYZ offsets were flattened to grayscale and two of three axes discarded. Utility bakes with no such hazard (Curvature,Cavity,Position) stay unregistered — unknown routes to passthrough, which is already correct, andCurvaturein particular is the registry's documented custom-type example.Backlogged 2026-08-05, closed here:
_short_alias_boundaryaccepted_ - .and space while the strip pattern's delimited branch was_-only, sorock-ao.pngclassified as Ambient_Occlusion but based torock-ao, androck-basecolor.pngbased torock-(the boundary-free attached branch matched one character later, stranding the delimiter). All four readings of "does this filename end in a map-type suffix" now derive from oneMapRegistry.SEPARATORSconstant — the third,MapFactory.resolve_map_type(key=False), had its own hardcoded"_"and would have renamed a--delimited file on round-trip; the fourth is the joiner set inside a compound suffix, which review caught covering only_and glued, leavingrock-nrml-oglclassifying as Normal_OpenGL while it based torock-nrml— the same split surviving under a different delimiter. The trailing-delimiter collapse was_-only for the same reason (rock_.png→rock, butrock-.png→rock-).Two callers paired the DirectX and OpenGL alias tuples by INDEX, found reviewing the above —
MapFactory.convert_normal_map_format(live) andMapCompositor._try_invert_normal. Walkingindex(typ)in one tuple and subscripting the other silently required both lists to stay the same length and in lockstep order. They never were:DXNsat past the end of the shorter tuple, raising IndexError in one caller and falling back in the other. Generating the convention spellings broke it outright —im_NDX.pngconverted toim_NRMGL.png,Norm_DXtoNormalMap_OGL. Both now callMapRegistry.counterpart_normal_spelling, which swaps the trailing convention tag and keeps everything before it verbatim, so a converted map keeps its source file's naming style (rock_NormalDX→rock_NormalGL) and a spelling carrying no tag falls back to the canonical name. The existing conversion tests used canonical names only — index 0 in both tuples, the one position where the old pairing was correct — so the alias spellings are now covered too.Structural: the two
get_base_texture_nametwins are one implementation. Both docstrings claimed the shared registry pattern kept them from drifting, and they had drifted anyway — only theMapFactoryside dropped the UDIM/UV-tile token, so a tiled filename produced two different base names depending on the entry point, and_map_factoryitself reaches theImgUtilsone when naming packed outputs.MapFactory.get_base_texture_namenow delegates. Also added:NRML/NML/Normals(Normal),Col/Alb(Base_Color),Specularity(Specular), and a standing guard that no spelling is claimed by two map types. A bareBentalias was withdrawn during review — aliases over 3 chars match with no boundary check, so it claimedmat_absorbent.png; that boundary-free long-alias rule is a wider latent issue and is backlogged, not silently widened here. Also dropped a deadRGHalias on Roughness (case-insensitively identical to itsRghneighbour in every matching path). pythontk 2710 green; downstream mat suites green (extapps 566, mayatk 596, blendertk 354). -
2026-08-06 - The RPC pair now ships both ends, and
HandoffBridgegrew the return leg. Two structural gaps in the app-bridge stack, closed together. (1)net_utils/rpc/plugin_core.py-RpcPlugin/OpRegistry/MainThreadMarshaller: the server that runs inside a host application, beside theRpcClientthat drives it from outside. It existed only as five near-copies scattered across mayatk, blendertk and extapps; diffing them showed the entire divergence was docstrings, a host module name, an env prefix, a port and a thread label - zero logic. All of that is now constructor data. Instance-owned registries replace the module-global_OPS(so two plugins can be hosted in one process, and tests build a throwaway registry instead of calling aclear()hook), and the core registerssystem.ping/system.list_ops/system.describeitself - those are part of the client contract (RpcClient.list_opsinvokes an op, not a route), so a plugin that forgot one broke a documented client method, and they had already drifted apart. Standard-library only, because an installed plugin payload carries a verbatim copy wherepythontkis not importable;m3trik/scripts/sync_rpc_core.pystages it andtest_sync_rpc_core.pypins both the copies and the stdlib-only rule. (2) The inbound axis: the invariant flow is nowresolve -> preflight -> produce -> deliver -> ingest, withROUND_TRIPbesideSEND_TO/SAVE_ASandScriptRoundTripDelivereras its strategy. Half the fleet already round-trips (RizomUV imports UVs back; the scene-import pair is a whole inbound pipeline) butsend()was documented one-way, so every return leg was bespoke._ingestis a plain hook, not a Strategy - every real inbound leg is irreducibly host-specific, so a strategy object would be indirection with one implementation each. The in-place contract differs fromsave_asin exactly two ways, both now enforced: no staging sibling (input and output are one path), andScriptRunnerjudges the run by the artifact having changed (expect=REWRITTEN) rather than appearing - clearing it first would delete the app's own input, and an app that exits 0 without saving would otherwise hand the caller back its untouched export to re-ingest as though it had been processed. AlsoHandoffBridge.deliverersdefaults toNonerather than a shared mutable{}(backlogged 2026-08-05). Two hazards found in review of the above and fixed with it: a secondary spec (run_spec/round_trip_spec) that omitsmodes=inheritsScriptLaunchSpec's default(SEND_TO,)and would have silently REPLACED the interactive send deliverer --send()would then run the target headlessly and fail on a missing artifact with nothing pointing at the one-word omission, so a mode claimed twice now raises at construction; andround_trip()warns when_ingestis not overridden, since the default identity ingest would run the target and discard its result -- a round trip that looks like it worked.