-
2026-08-10 — User-facing messages go vendor-neutral (public-repo audit).
Metadata's unreadable-file guidance andFileUtils.write_atomic's docstring stop naming specific sync vendors — the diagnosis ("a cloud-sync client holds / hasn't hydrated the file") is vendor-independent;is_cloud_placeholder's docstring keeps its vendor examples, since enumerating the sync engines it detects is documentation, not messaging. -
2026-08-09 — One mode vocabulary for every bridge, and
round_tripstopped being welded to one artifact shape.SEND_TO/SAVE_AS/ROUND_TRIPare an on-disk contract, not just a Python one — each template'sBRIDGE_MODEStuple names them — yet five bridges (mayatk/blendertk/extapps Marmoset, mayatk/blendertk Substance) spelled their own copy, and spelled itroundtripagainst the canon'sround_trip. A second definition of a file format's keywords is a second dialect: the two were not interchangeable, and nothing said so, so a mode that looked shared was not. All five now import fromscript_template;ScriptTemplate.normalize_modesis the one place legacy spellings fold to the canon, applied bydeclared_modesand by the Substance engine's ownast-based parser (which reads templates directly and would otherwise have dropped a staleroundtripout of its filter and quietly reduced a headless round trip to a one-way send — an unrecognized mode does not raise). Existing templates, including user-authored ones, keep working.Two adjacent fixes fell out of it.
ScriptTemplate.declared_valuesis now the raw<FIELD> = (...)reader anddeclared_modesis that plusnormalize_modes— because templates declare more than modes through the same convention (mayatk's bake template also carriesBRIDGE_OUTPUT_EXT/BRIDGE_OUTPUT/BRIDGE_TIMEOUT), and folding mode spellings in the shared reader would rewrite any other field whose value collided with a legacy one. AndScriptLaunchBridge.list_template_modesfiltered againstspec.modesalone, so any bridge with arun_specrelabelled its blocking templates assend_to— the discovery helpers fall back toallowed[0]for anything outside the list, and the panel then routes a blocking recipe throughsend(), leaving__OUT_FILE__empty and the app to fail minutes in. It now derives from the deliverer registry via the newmodesproperty; insertion order keepssend_toas the lenient fallback.ROUND_TRIPnow means only "the result lands back in the host", leaving the artifact shape to whichever deliverer is registered for it:ScriptRoundTripDelivererfor the target editing the payload in place (RizomUV, judged byREWRITTEN),ScriptRunDelivererfor the target writing a new intermediate (mayatk's Blender lightmap bake returns a manifest, judged byCREATED).round_trip()therefore gates on the deliverer registry rather than onround_trip_spec— a bridge whose round trip produces an artifact declaresmodes=(SAVE_AS, ROUND_TRIP)on its existingrun_spec, since the machinery is identical and only the destination differs — and takes an optionalout, normalized throughresolve_save_pathexactly assave_asdoes. Without this the bake had to advertise itself assave_asand send the artist looking for a deliverable that is deliberately never kept. -
2026-08-08 —
PlateEmitterandColor.kelvin_to_linear_rgb— the two primitives a fixture light needs, owned once. Turning a thin luminaire plate into a rectangular area light is the same arithmetic in every host (thin axis = emission normal, other two = rectangle, stand clear of the housing), sogeo_utils.plate_emitter.PlateEmitter.from_boundsowns it and blendertk'slights_from_geometrynow calls it instead of carrying its own copy — which is how the two would have drifted the moment mayatk grew a twin. It carries both hard-won guards with it: the coplanar rule (a reference point derived from a ceiling grid lies IN that grid's plane, so the sign of the displacement to it is modelling noise — believed only past the plate's own thickness, floored at 1% of its long edge for the zero-thickness plane people model lenses as) and the clearance rule (offset applies on top of half the plate's thickness, or the light sits inside a housing that blocks it).up_axisis the one host difference: Blender resolves an ambiguous plate down about Z, Maya about Y.ImgUtils.kelvin_to_linear_rgbconverts a colour temperature to LINEAR RGB — the value a renderer wants, against the sRGB-encoded one a colour picker shows, which fed to a light reads markedly too pale. Tanner Helland's fit to the Planckian locus, linearised, normalised to peak 1 so temperature sets hue and intensity stays a separate dial. It sits with the other colour-space conversions (srgb_to_linear,convert_rgb_to_hsv) rather than on theColorvalue type, because it CONVERTS into a colour rather than describing one; written pure-stdlib so it still answers when this module's optional numpy/Pillow imports have degraded toNone. -
2026-08-08 — The room was black in its own WebXR preview: three correct behaviours compounding one lost texture. Fixed with a
metallic_roughnesssidecar section, plus web texture optimization (125.5 MB -> 6.4 MB). Bisected with a headless-browser screenshot harness against the live preview server, eliminating in turn theKHR_texture_transformpath, the material clones, the embed dedupe and the lightmap image itself — each structurally correct — until the one number left was the packed ORM: FBX2glTF writes a solid-white metallic/roughness texture when it cannot resolve the source maps (measured: the room's real metallic map means 0.12). glTF reads metallic from blue, so the whole room rendered metallic=1; a pure metal has no diffuse response; a lightmap contributes only to diffuse; and a lightmapped viewer turns its scene lights off. Three correct behaviours, one lost map, zero error messages.set_glb_metallic_roughnessis the third sidecar applier: it packs the ORIGINAL maps throughMapFactory.pack_orm_texture(the registry's one ORM packer — R fills white so the image stays neutral if later wired as occlusion), embeds through the session's dedupe cache, and repairs by material name like its two siblings.The application order was the second half of the bug. The preview deliverer converted (which wires lightmaps, CLONING each material as it stands) and applied the sidecar afterwards — so every repair landed on the base material no primitive references anymore. Nested-dict sharing let base colour leak through to the clones by luck; top-level keys (
emissiveFactor, and the newmetallicRoughnessTexturewould have joined it) did not. The deliverer now converts withlightmaps=Falseand runs oneopen_glbsession ordered sidecar-then-lightmaps, so clones copy fully repaired materials.optimize_glb_texturesis the new web-delivery pass (opt-in; the preview deliverer runs it, plain conversions stay byte-stable): every embedded image is decoded, resized to a longest edge (2048 default; images thelightmap_webmanifest names are exempt — the bake sized those deliberately), and re-encoded WebP (alpha-capable, universally decoded by WebXR-class browsers), with textures gaining the standardEXT_texture_webpbinding and their plainsourcekept as the spec's fallback. The BIN chunk is repacked — image payloads replaced, former data-URI images relocated into it (dropping base64's 33%), every other bufferView copied verbatim with offsets recomputed; a repack forces the writer down the full-rewrite path (GlbEdit.replace_rest), because the in-place fast path would write the new JSON over the OLD BIN. Measured on the production room end to end, with a headless screenshot proving the result renders: 125.5 MB -> 6.37 MB, image payload 106.6 -> 3.7 MB, KHR transforms and all 48 per-instance bindings intact. KTX2/basis (GPU-resident compression, the real headset-memory win) is deliberately not this pass — it needs an external encoder andKHR_texture_basisu; backlogged. -
2026-08-08 — A GLB embed reuses bytes the file already carries: 125.5 MB -> 94.7 MB on a production room.
_embed_image's dedupe cache is keyed by the source PATH, so it could only collapse embeds made during one session. It could not see that the FBX->GLB conversion had already written the same texture into the BIN chunk — and since the scene sidecar exists precisely to re-apply the channels FBX translation drops (base colour, emissive), the normal case was two copies of the same bytes, the second inflated ~33% by base64 in the JSON chunk. Measured on the OFFICE_ENV room: base colour, table diffuse and emissive were each present twice — 23.1 MB of duplicate payload costing 30.8 MB on disk, a quarter of the deliverable, and it grew the JSON chunk to 35 MB against 2.6 MB of actual geometry.GlbEdit.image_by_contentnow indexes the existing images by sha256 of their payload (bufferView-backed anddata:alike, built lazily so a session that embeds nothing never reads past the JSON) andtexture_for_imagepoints a texture at the survivor. Content-addressed, not name-addressed: the two copies arrive by different routes and agree on nothing else — the bufferView image is named by the FBX exporter, the sidecar's by its source path. Verified end to end on the real preview push: 14 images -> 11, JSON 35.06 MB -> 0.1 MB, identicalKHR_texture_transformwiring and 48 per-instance rects. -
2026-08-08 — Per-instance lightmap rects in GLB, via
KHR_texture_transform— plusImgUtils.flip_rect_v. A lightmapped scene with instanced geometry needs each instance to sample its own patch of a shared atlas while the mesh data stays shared. glTF already has the standard way to say that, soapply_glb_lightmapsuses it rather than inventing anextrascontract only our own viewer would understand: a node whose manifest record carries a non-identityscaleOffsetgets a cloned material whoseocclusionTexturecarries aKHR_texture_transform, andextensionsUseddeclares it — so three.js, model-viewer, Babylon and the production WebXR app all render it correctly with no viewer changes at all. When two such nodes share a glTF mesh (FBX2glTF preserves Maya instancing as node-level mesh reuse — probe-measured), the mesh JSON entry is cloned while its accessors and bufferViews are not, so per-instance materials cost no geometry. One atlas still embeds once no matter how many instances bind it, and an identity-rect manifest produces exactly what it did before: no clones, no extension. The encode is now lazy at the bind point rather than eager — an entry whose primitives all fail theTEXCOORD_1/already-claimed guards used to leave an orphan embedded image behind.ImgUtils.flip_rect_vis the shared V-flip: glTF's V axis runs opposite Blender's, so the same rect is[sx, sy, ox, 1-sy-oy]there — scales unchanged, offset mirrored. One helper (involutory, unit-tested as such) rather than the constant inlined at each site; the Unity path stays bottom-left native and never calls it. -
2026-08-08 —
StrUtils.truncate(..., mode="path")— shorten a path by dropping whole components, never half a folder name. The existingmiddlemode cuts by character count, so a path comes back with debris at the seam (O:/Cloud/Projects/jets/..ures/body_DIFF.png) — the elision reads as part of a name and the eye has to work out where the real boundary was. The new mode cuts only at separators, and grows what it keeps in the order that carries meaning: the head opens on the drive/root and its first directory (a bareO:/…locates nothing), the tail then takes the filename and as many of its parents as fit, and leftover budget goes back to the head —O:/Cloud/Projects/…/textures/c130j_body_DIFF.pngat 48. Separator style is preserved (a backslash path stays backslashed) and a leading separator run survives, so a UNC share keeps its//server/. Degenerate shapes fall back tomiddlerather than inventing a boundary: fewer than three components, a filename that alone overruns the budget, or a string with no separator at all — and the marker is never emitted between components that were adjacent (an ellipsis that elides nothing). 11 tests including a sweep asserting the result never exceedslengthat any budget from 8 to 80, and one pinning the docstring example (a wrong example is a wrong contract). Consumed by uitk'sTableWidget.set_column_truncationand the mayatk/blendertk Texture Path Editor's "Truncate Texture Paths" toggle. -
2026-08-08 — GLB deliverables self-feed committed lightmaps:
MeshConvert.apply_glb_lightmaps+ImgUtils.encode_hdr_for_web. The scene-state principle made real for GLB: a host DCC's bake commits to the scene (markers + alightmap_metadatamanifest on thedata_exportcarrier that rides every FBX — the same manifest unitytk already binds Unity-native lightmaps from), and the deliverable feeds its own repair. Two probe-verified facts make it zero-coupling: FBX2glTF v0.13.1 exports the second FBX UV set asTEXCOORD_1(RAW_VERTEX_ATTRIBUTE_UV1in the tagged source), and the--user-propertiesflagfbx_to_glbalready passes transcribes the carrier's string props into node extras with no size limit. So the manifest is already inside every GLB this converter produces, andapply_glb_lightmaps— run unconditionally infbx_to_glb's existing post-conversion edit session (lightmaps=Trueescape hatch; warn-only like the alpha repair) — reads it back out, resolves the EXR basenames via the manifest'sdirlocate hint, encodes once per unique atlas, and bindsocclusionTextureontexCoord 1plus the root-extraslightmap_webmanifest the WebXR viewer already parses. Every consumer offbx_to_glb— the Scene Exporters, the WebXR previews, tentacle's Maya quick export — got lightmapped GLBs with zero edits. Absent manifest = clean no-op; every miss is loud (noTEXCOORD_1in the primitives, an unresolvable map, two objects claiming one material with different maps, a newer manifest schema — refused rather than misread, since wrong binding reads as a bad bake). Webintensityis the encode scalar only: authored intensity is already in the EXR texels.encode_hdr_for_webis the cv2 twin of blendertk's bpy-I/Oencode_for_web(Blender ships no cv2): raw float read — deliberately NOTload_image, which quantizes to 8-bit — nonzero-percentile divide before the clippinglinear_to_srgb, PNG bytes + the recovery scalar. Both test suites pin the same golden constant (0.5 → scalar 0.5) so the implementations cannot drift._embed_image_bytessplits the tail off_embed_imageso in-memory encoders share the session dedupe cache (an atlas shared by six objects embeds once).