-
2026-08-19 ΓÇö
FileUtils.relativize_output_dir(path, base): the portable spelling of a browsed directory, and the exact inverse ofresolve_output_dir. Every optional-output-directory field in the ecosystem reads its text throughresolve_output_dir(blank = base, a relative entry = a subdirectory of it, a full path wins); the browse dialog beside it can only hand back an absolute path, so each panel was re-deriving the way back by hand. One rule now: under base becomes the relative subdirectory, base itself becomes""(which the field reads as its default), and anything outside comes back verbatim ΓÇö that is what the user picked,"../.."is not portable either, and re-spelling the separators of a value the dialog just wrote would show an edit nobody made.test_file.py+4 (round-trip throughresolve_output_dirincluded); mayatk's lightmap baker drops its private copy. -
2026-08-19 ΓÇö
UvTransfer.normal_conventionclassifies through the shared map registry instead of a local token regex (geo_utils/uv_transfer.py). Handedness was sniffed with(directx|_dx\b|_dx_|dx\.)over the filename, which is a subset of what the ecosystem actually emits and silently flips a normal map's green channel wherever it misses. Measured misses:rock_NormalDX_1.png(a duplicate marker pushes the tag off the end) read as OpenGL, as did every tile-suffixed spelling the pattern'sdx\.anchor did not happen to catch. It now asksMapRegistry.resolve_type_from_pathΓÇö the same classifier the rest of the texture pipeline uses ΓÇö and reports DirectX only forNormal_DirectX, so_DX/DirectX/NRMLDX/N-dxand their OpenGL twins all resolve across every delimiter the registry accepts, with UDIM and duplicate tokens stripped first. One case narrows: the old scan fired on a DirectX tag ANYWHERE in the name, sorock_directx_normal.pngwas DirectX and is now OpenGL ΓÇö the suffix is what names a map, and the untaggedNormaltype means "convention unknown", where leaving it alone is the cheaper error (flipping a guess inverts a map that may already be right)._DIRECTX_TOKENSis deleted rather than deprecated: it was a private class constant with no importer in the monorepo.test/test_uv_transfer.py+5 (35). -
2026-08-19 ΓÇö
UvTransfer: texture transfer between two UV layouts of the same triangles, as a numpy texel remap (geo_utils/uv_transfer.py; root exportptk.UvTransfer/ptk.TransferTable). A UV re-layout — repacking an atlas, consolidating several materials' maps into one, moving textures from UV set A to B — is a remap, not a bake: every target triangle corresponds to a known source triangle, so each texel maps barycentrically to exactly one source UV. Ray-cast bakers get this wrong by construction wherever a mesh touches itself (measured on a turret/wire assembly: the wire lying on the nut is what the nut's rays hit first, and no cage value can fix a contact region), and a renderer is pure overhead for it.build(src_tris, dst_tris, size)rasterizes the target triangles once into aTransferTable(per texel and sub-sample: covering triangle + source UV,uint16fixed point — 8 bytes × passes × size²);transfer(table, sources)remaps one image or{source_id: image | constant}for a multi-material consolidation, accumulating every covered sub-sample so shared triangle edges never seam and island edges are anti-aliased, not darkened;transfer_normalsadditionally re-expresses tangent-space XY in the target island's frame (per-triangle polar decomposition of the source→target UV map, reflection-aware,convention="opengl"|"directx"because a rotation mixes X and Y);padfills the gutter from the table's coverage.merge_layoutsfolds per-material jobs that share a UV set into one job when their islands are disjoint (the unit of a transfer is a layout, not a material);transfer_materials(jobs, ...)is the DCC-agnostic orchestration both host adapters call — sizing, table, per-channel remap, constants for sources with no map, padding,<material>_<Channel>.pngnaming, 8/16-bit IO. Source coverage masks pre-fill a source's gutter before sampling so hard-edged maps cannot fringe. Rasterization is batched by triangle bbox size in float32 (200k triangles at 4k, supersample 2: ~6 s); sampling is dense per pass (one bilinear gather per corner, masked adds) rather than gather/scatter by index. Conventions — V-up UVs, V-flipped images, +0.5 texel centres, identity exact to the UV quantization, rotate/mirror matchingnp.rot90/flip, box filter on shrunk islands — are pinned bytest/test_uv_transfer.py(27 tests). -
2026-08-18 ΓÇö a power-of-two snap now resizes a map instead of reshaping it, and the size clamp is a named, describable rule (
core_utils/engines/textures/map_optimizer.py). The POT rule snapped each axis independently, which is what a naive "force POT" does and is wrong for any non-square map:1024x768ΓÇö already a power of two on its long edge and already inside every ceiling ΓÇö came out1024x512, because 768 floors to 512 on its own. A POT rule states how BIG a texture may be, not what SHAPE it is, so only the long edge is snapped now and the short edge is derived from the source ratio. Anything whose long edge was already POT is left alone entirely.This is an output-SHAPE change, so it is worth stating plainly: a non-square map run through a POT budget comes out with a different height than it did on 0.9.24. The old dimensions were the defect ΓÇö a destroyed aspect ratio, under the ceiling the tooltip advertises, and reported by nothing.
_ASPECT_DRIFT_TOLERANCE(0.01) keeps the warning for the case that still genuinely reshapes: the derived short edge is an integer, so an extreme ratio can hit the min-1 floor, and only that is worth a line in the log rather than every sub-pixel remainder.New public surface for callers that need to state the rule rather than re-implement it:
MapOptimizer.SIZE_CLAMP_TEMPLATE(the sentinel meaning "take the ceiling from the active template"),resolve_size_clamp()(the resolved clamp as data) anddescribe_size_clamp()(the same thing as the sentence a panel shows). mayatk's Scene Exporter reads the sentinel in a class body, so it declarespythontk>=0.9.25; consumers pinning lower get anAttributeErrorat import rather than at first use. -
2026-08-18 ΓÇö
CLI.add_connection_argsno longer ships a machine identity; the defaults resolve at CALL time (core_utils/cli.py). The module carried a hard-coded workspace host, an account name and a Windows Credential Manager target as public constants (DEFAULT_HOST/DEFAULT_USER/DEFAULT_CRED_TARGET), which this package then published to PyPI: a deployment's machine identity is not library data, and a--helpon a downloaded wheel printed one person's account and credential-store entry. Each argument now resolves as explicit argument → environment variable (PYTHONTK_SSH_HOST/PYTHONTK_SSH_USER/PYTHONTK_SSH_CRED_TARGET) → neutral fallback (localhost, the current OS user viagetpass.getuser(), and — for--cred-target— the resolved host, since a credential entry named after the machine it opens is the only fallback that is right by construction rather than by coincidence).DEFAULT_USERandDEFAULT_CRED_TARGETare removed outright rather than kept as deprecated aliases — an alias would re-introduce the identity into the published module, which is the entire point of the change; nothing in the ecosystem imported either name (checked across*.py/*.ps1/*.psm1), and comfyui, the one consumer that targeted a fixed machine, now passes its host and user in from[tool.comfyui](comfy_manager.connection_defaults()).DEFAULT_HOSTsurvives with a neutral value andDEFAULT_PORTreplaces the inline22. The resolver and thegetpassfallback live on a new_CLIInternalbase per the house encapsulation rule, soCLI's public surface is unchanged apart from the two deleted constants. -
2026-08-18 ΓÇö
HandoffBridgeowns the run scratch: staging a hand-off no longer means hand-rolling a temp directory per bridge (core_utils/app_handoff.py)._make_payload_pathalready gave a bridge a managed temp file; a bridge that stages a whole set of artifacts (an FBX plus manifests plus a rendered script) had nowhere to put them and each grew its owntempfilejoin. New_scratch_dir(request, name)is themkdtempreplacement for that, backed byTempArtifactsunder the bridge's existingpayload_prefix, with the lifetime chosen by the declarativescoped_scratch_modes(read by an overridable_scratch_policy(request), for a rule the mode alone cannot express):"detached"(the default, and the only sound answer for a hand-off the target app reads AFTER we return ΓÇö no completion signal, so nothing may delete) keeps ONE fixed-name folder per kind, self-overwriting, reclaimed by the age-gated sweep."scoped"is for a run that CONSUMES what it stages ΓÇö a blocking round trip whose real output is relocated somewhere durable ΓÇö and takes a unique ROOT per run with the readable names inside it, so two hosts running at once cannot share or delete one directory and a root kept after a failure still says what the run staged. The store is memoized on the request, so every directory a run opens shares one lifetime and one cleanup.The removal lives in
_run, the invariant skeleton ΓÇö deliberately NOT in_ingest. That hook is routinely overridden, legitimately without callingsuper()(mayatk's Blender bridge is exactly such a return leg), and an invariant a subclass can silently skip is not an invariant. It fires only once the run completes end to end, so a failure anywhere above ΓÇö including a return leg reporting one by returningNoneΓÇö leaves the scratch on disk to inspect and lets the sweep reclaim it later. A host that dies mid-run would skip anyfinally, which is the whole reason this is a swept namespace rather than atry._delivered_paths(result)is the guard that makes"scoped"safe: any delivered path INSIDE the scratch keeps the whole scratch, because a bridge whose durable destination can fall back to the run's own output directory would otherwise delete the very files it just produced. Default empty ΓÇö correct for every detached bridge and for a scoped run that always relocates. First consumers are mayatk's and blendertk's Marmoset bake roundtrips, which each carried a verbatim ~85-line copy of this logic before it moved here; both now declarescoped_scratch_modes = (ROUND_TRIP,)and a_delivered_paths, and nothing else. That attribute is deliberately the twin ofuitk.BridgeSlotsBase.TRANSIENT_OUTPUT_MODES-- the two halves of one decision (the panel stops handing the run a durable Output Dir; the bridge stages and removes its own) named the same way on purpose. Verified:test_bridge80 green (9 new ΓÇö detached reuses one folder and never deletes; a scoped run shares one root and removes it; the output-inside guard; a failed run keeps it; discard is idempotent; the default policy; and the skeleton discarding even when_ingestis overridden or reports failure). -
2026-08-18 ΓÇö A duplicate marker appended after the map-type token no longer makes a texture classify as nothing (
core_utils/engines/textures/map_registry.py).rock_Base_Color_1.pngends in_1, not in any registry alias, soresolve_type_from_pathreturnedNone— and every consumer of the taxonomy reads that as "not a texture map". Measured on a production project: a whole staged generation of maps (<mat>_Base_Color_1.png,_Roughness_1,_Metallic_2, …) was invisible to classification, which is how a packed source map reaches a transfer bake un-unpacked and how a shader build silently leaves slots empty. NewMapRegistry.split_duplicate_token(mirroringsplit_tile_token) and a retry inresolve_type_from_paththat uses it.The retry is gated on a miss, so it can add matches but never change one the registry already had — a name that classifies today classifies identically. The duplicate token is stripped before the tile token, because the marker is appended last (
rock_Normal.1001_1). Bounded to 1ΓÇô2 digits, with8/16/24/32/48/64excluded as technical tails:im_Height_16is a 16-bit height map, not the sixteenth copy of one, and collapsing it onto its 8-bit sibling would hand a network builder two Height maps for one material. The resolution tags (_512,_1024,_2048) need no exclusion ΓÇö they are already past the two-digit bound, which is why the pattern stops there. No registry alias contains a digit at all, so stripping one can never destroy a legitimate match.The retry matches through a new private
_match_alias(the alias-matching half ofresolve_type_from_path, split out) rather than re-enteringresolve_type_from_path:baseis already a STEM at that point, andos.path.splitexton a stem eats any dotted tail (rock.v2_Base_Color->rock), so a recursive retry silently lost every dotted version / LOD name ΓÇö while the same name without a marker resolved fine. Splitting it out also keeps the reduced stem out of the resolve cache, which is keyed by the name as given.MapFactory.resolve_map_type(key=False)deliberately stays strict: its answer is spliced back into a filename, andresolve_texture_filenameappends when it cannot find the suffix at the tail, so a tolerantkey=Falsewould turnX_Base_Color_1.pngintoX_Base_Color_1_Base_Color.png. The strictNoneyields the empty suffix that leaves the name untouched. Both the parameter doc and a test say so, because the two forms disagreeing looks like an oversight until you know why. Verified: pythontk 3324 green (9 new). -
2026-08-18 ΓÇö the per-node lightmap markers now agree with the authoritative carrier instead of contradicting it (
file_utils/mesh_convert/_mesh_convert.py::_reconcile_node_markers). A delivered GLB carries the same lightmap facts in several places.extras.lightmap_webis written by the applier from the FINAL values (the embedded PNG, and the scalar that restores the bake range); the per-objectfromFBX.userProperties.lightmapInfomarkers are written EARLIER by the DCC bake pass, before normalisation exists. Measured on a client hand-off: lightmap_web saidOFFICE_ENV_LightMap.png@ 13.65625 while all 46 per-node markers still said.exr@ 1.0 ΓÇö a consumer trusting one rendered the bake ~13.7x too dark, and those are the copies a reader finds FIRST, since they sit next to the mesh. The.exrthey name ships nowhere; the only real copy is the embedded atlas. Corrected rather than stripped, which the backlog entry had leaned toward ΓÇö but NOT for the reason first written here: the applier locates its EXRs from the MANIFEST's hint (dirs = [manifest.get("dir"), *search_dirs]), not from these markers, whose only in-repo reader is this walk itself. The real ground is the consumer contract: node extras are surfaced generically to any recipient ΓÇö three.js'sGLTFLoadercopies them intoObject3D.userData, Blender's importer into custom properties ΓÇö and this file documents the deliverable as "fully self-describing" and "readable by any glTF tool". So the survivingmap/uv_set/intensity/scaleOffsetkeys ARE read, by consumers we do not control; the defect was that they were stale, not that they exist. Both on-disk shapes are handled: FBX2glTF nests the marker underextras.fromFBX.userProperties, while blendertk's native glTF export writes it as a TOP-LEVEL node extra ΓÇö verified on a real deliverable whose nodes carryextras: {currentUVSet, lightmapInfo}at the same stale.exr@ 1.0. Walking only the nested shape skipped every Blender-authored GLB silently, and this is public API the preview server points at whatever GLB it is handed. So the rename:_strip_locate_hints->_reconcile_node_markers, because the walk that already unwraps, mutates and re-serialises every marker to drop the authoring-path hint now also corrects the values it deliberately keeps. One pass, no second walk, and no second copy of the wrapped-JSON unwrap dance. Keyed by the RESOLVED NODE, not by the manifest entry ΓÇö node lookup here is deliberately namespace-tolerant (a manifest namingroombinds a GLB nodeNS:room, because a referenced Maya rack arrives namespaced), so keying the corrections offrecords[*]["object"](the manifest name) missed on exactly the scenes that tolerance exists for, and missed SILENTLY ΓÇö the markers simply kept their stale values, indistinguishable from no correction at all. Proven: on a namespaced fixture the record saysroomwhile the bound node isNS:room. Fed the PUBLISHED dicts (the same objects that went intolightmap_web), since a record'smapis the SOURCE.exrbasename ΓÇö what a caller wants to know ΓÇö and itsintensityis unrounded where the published one isround(., 6); taking the published pair is what makes the copies identical rather than merely close. A marker on a node this run did not bind is left exactly as found, and a nameless node is skipped rather than colliding with every other nameless node viaNone == None. 3 failing-first tests (one on a NAMESPACED node, one on the top-level marker shape; with an inertness guard, since the assertions would pass vacuously if the encode ever produced 1.0);test_authoring_locate_hints_do_not_ship_in_the_glbupdated deliberately ΓÇö it pinnedmap== the.exr, a filename that resolves nowhere in the deliverable, and now pins the embedded PNG while still proving the payload survives the scrub. Still open (see the backlog entry): the scene-widedata_export.lightmap_metadataand mayatk's external.{stem}.scene_data.jsonremain pre-normalisation; the data_export node is untouched here because no record keys to it. The manifest probe learned the same two shapes (_lightmap_manifest), because the marker walk is unreachable without it:apply_glb_lightmapsreturns early on a GLB whose manifest it cannot find, so a natively exported file ΓÇö manifest and markers both top-level ΓÇö was still a total no-op with the two-shape marker walk in place. What this does NOT reach, stated because the first draft of this entry implied otherwise: a deliverable carrying markers and no manifest at all. blendertk's native glTF export writes exactly that today ΓÇöOFFICE_ENV_both.glbhas 7 nodes with a top-levellightmapInfoand nolightmap_metadataanywhere ΓÇö so those files still ship.exr@ 1.0 and the repair belongs in the producer (backlog item (c)); there is nothing authoritative here to correct them against, and guessing is worse than leaving them. That no-op is now pinned rather than left to the early return. test_mesh_convert 150 passed. -
2026-08-18 ΓÇö the Mask Map gates on the SMOOTHNESS channel instead of counting resolved channels (
core_utils/engines/textures/map_factory/handlers.py::MaskMapHandler).len(resolved) < 2cannot express the rule it was standing in for: the count is 2 for all three 2-of-3 combinations and exactly ONE of them is unsafe. Smoothness is the only channel here whose absent fill is not neutral ΓÇöpack_msao_texturefills it WHITE, i.e. every surface mirror-smooth ΓÇö while an absent AO fills white (unoccluded) and an absent metallic fills black (dielectric). So a metallic+AO set with no smoothness/roughness/glossiness shippedmat_MSAO.pngwith the smoothness channel at extrema (255,255) under the DEFAULT rule, and it is undetectable on review because a flat smoothness channel is what a legitimate mirror material looks like. Now named the way ORM/MRAO have always named their non-neutral channels, so all three packings state the same principle rather than two stating it and one approximating it with a count. The lone-channel rule is kept alongside it (a single resolved channel is still one map wearing a packed name), and the rule vocabulary is untouched:multiis a MINIMUM measured against what resolved, so it still ships the pair by explicit opt-in, as doesforce. Behaviour change formask_map=Truecallers ΓÇö a set that packed silently now skips unless the caller asks for Pack Anyway. 1 failing-first test covering all three pairs (the unsafe one skips; the two benign ones must still pack, so the fix cannot over-tighten);test_mask_map_alpha_defaults_white_without_smoothnessnow opts in explicitly ΓÇö its real subject is metallic data leaking into the alpha, which stays pinned. 473 texture/map tests pass; extapps converter 103 pass; the two mayatkprepare_mapssites are unaffected (base-color only, andmask_map: False). Closes the 2026-08-12 backlog entry. -
2026-08-18 ΓÇö
MeshConvert.verify_glb: the check a RECIPIENT runs, and the first thing in the ecosystem that reads the envelope's own claims back (file_utils/mesh_convert/_mesh_convert.py). Everything a delivered GLB promises has been checkable from the file alone sincetextures(content addresses) andvalidate(the counts the envelope claims for itself) landed ΓÇö but nothing read either back, so a truncated envelope, a section that matched nothing, or a payload swapped after its digest was stamped all arrived indistinguishable from a good delivery.verify_glbrecomputes every reference's sha256 against the image actually carrying it, checksvalidateagainst what the envelope now holds, repeats the per-section apply outcomes the artifact records (a reader must not be assumed to have read the log of a run on someone else's machine), reports the schema version against this reader's (NEWER is the case worth saying out loud ΓÇö this reader would skip what it does not understand and report a clean bill), and folds in the ORM check below. Read-only and side-file-free by design: it can be pointed at a.glbon a machine with no DCC, and can never damage the asset it inspects. A GLB from another producer is reported (envelope: None), not raised.problemsandokare kept strictly in step -- an observation that does NOT fail a deliverable (an ORM binding the envelope never described is legitimate, just unverified) rides a separatenoteslist, so the obviousif report["problems"]cannot read a sound asset as defective. 7 failing-first tests. A malformed envelope is now reported rather than raised through:sectionsandtextureswere isinstance-guarded where the counts are built and then dereferenced unguarded (declared.get(...),refs.items()), so a hand-edited or future-schema envelope whose block is a list raisedAttributeErrorout of the one method a recipient runs to find that out. Each is afail()with its own message, deliberately not a silent normalise to{}ΓÇö reportingok: Trueon an envelope this reader could not parse is the exact false green the method exists to end. +2 tests. -
2026-08-18 ΓÇö
MeshConvert.suspect_orm_materials: two findings on the delivered ORM binding ΓÇö the destructive one and the unverified one (file_utils/mesh_convert/_mesh_convert.py).metallic=1 everywhereis the measured production failure: FBX2glTF white-fills a grayscale ("L"-mode) PBR source, glTF reads metallic from the ORM's blue channel, so the packing renders metallic=1 ΓÇö no diffuse response, and pure black under a lightmap, which contributes to diffuse alone.set_glb_metallic_roughnessrepairs the materials the sidecar names, which is why the measured room looks right, and is exactly why a material the section OMITS shipped black with nothing said.unvalidatedis the half a whiteness test is blind to: a material carrying an ORM binding the envelope never described, so nothing checked its channel semantics ΓÇö which is how a mask map packed for another engine (Unity's is R=Metallic, G=Occlusion, B=Detail) reaches a GLB, is read channel for channel as ORM, and looks like perfectly ordinary image data the whole way. Reported only when the caller says what WAS described, since only they know. The two findings are deliberately routed differently: the destructive one is the export-time highlighted warning (an artist can act on it), whileunvalidatedreaches the RECIPIENT throughverify_glbΓÇö coverage information is not a defect, and an export warning nobody can act on is how the actionable ones stop being read. The glTF channel layout is held here as the spec's own constant (GLTF_ORM_CHANNELS) rather than read fromMapRegistryΓÇö the registry describes the map types this pipeline authors, and taking a spec fact from a mutable taxonomy would let an edit there silently change what is checked ΓÇö with a test pinning the two together so the taxonomy cannot drift from the spec unnoticed either. Runs insideapply_scene_sidecar's existing session against the materials the section does not name, so a fully-covered export decodes nothing.GlbEdit.alpha_extremageneralised tochannel_extrema(img_idx, channel)so both alpha repairs and this probe share ONE decode of an atlas. 8 tests (7 failing-first; the eighth pinsGLTF_ORM_CHANNELSagainst the registry's ORM layout so the spec and the taxonomy cannot part company silently). Closes the detection half of the 2026-08-10 backlog entry. -
2026-08-18 ΓÇö
asset.generatoris stamped with the authoring app and this package (file_utils/mesh_convert/_mesh_convert.py::_stamp_asset_generator). The one provenance field glTF itself defines, which every viewer and inspector already displays ΓÇö so it reaches a recipient who opens the deliverable in a tool that is not ours and reads nothing else we write. Nothing set it before: FBX2glTF's default string shipped unchanged. The converter's own claim is KEPT and ours appended (FBX2glTF via maya 2025 + pythontk 0.9.24) ΓÇö it really did produce the geometry, and replacing that claim would lose the fact that matters most when a mesh arrives wrong. Deliberately coarse: the app and version the envelope already names, plus this package's. No host, no user, no paths ΓÇö a generator string travels to whoever gets the file. Idempotent, so a GLB re-opened and re-applied refreshes the stamp instead of stacking one per pass -- including the case where the only prior claim IS a stamp of ours, which has no separator to split on. 6 failing-first tests ΓÇö two of them on the source dict's edges: asourcenaming an application but no version stamped the literal textNoneinto the middle of the string (someApp None + pythontk 0.9.24), where the.strip()could not reach it, and asourcecarrying only a version was dropped outright by theif app:gate. Both now join whatever is actually present. Only a caller of the publicbuild_scene_sidecar(source=...)can reach either ΓÇö every in-repo producer supplies both ΓÇö which is precisely the caller whose file ships to someone else. -
2026-08-18 ΓÇö the
.scene.jsonbeside a preview payload is gone; the GLB'sextrasis the envelope's only carrier (net_utils/preview_server.py::_attach_sidecar). A second copy of the same envelope, written on every push and read back by nothing ΓÇöscene_sidecar_pathhad no consumer anywhere in the ecosystem. An unread copy is one free to disagree, which is the same failure mode the lightmap metadata's four carriers already demonstrate. The bridge now attaches the envelope toPayload.extrasand stops there; the deliverer embeds it in the GLB, whereread_scene_sidecar/verify_glbread it from. The frozen top-level key set is still pinned, and what the ARTIFACT carries is pinned where it is written (test_mesh_convert's_assert_embeds) rather than through a file copy.mayatk/docs/data_nodes.md's carrier table updated to match. -
2026-08-18 ΓÇö Two extension seams for the WebXR preview: an ordered post-conversion pass registry on
PreviewDeliverer, and viewer scripts the page imports instead of being edited (net_utils/preview_server.py,net_utils/preview_viewer.html,net_utils/preview_scripts/). Everything between conversion and publish was one ~150-line procedure insidedeliver(), so a new step (Draco, per-slot resolution ceilings) meant editing the path every DCC bridge in the ecosystem runs through. It is nowEDIT_PASSES(one shared GLB edit session:scene_sidecar→prune_textures→lightmaps) andFILE_PASSES(the closed file:optimize_textures),name → method, the same shape asMeshConvert.SIDECAR_APPLIERSone level down; a pass takes aPreviewPassContext(.glb .edit .payload .request .texture_format .results .logger) and subclasses extend by overriding the dict. The split is real rather than stylistic — a file pass repacks the BIN chunk, which is exactly what an open edit session cannot have happening underneath it, socontext.editisNonethere and a stale handle fails loudly. Behaviour change: the guard is now per pass rather than per chain, so a sidecar failure no longer takes the lightmap wiring down with it (previously the model arrived unlit with nothing naming the pass that broke); the KTX2toktxpreflight moved ahead of the passes, so a KTX2 push missing the encoder raises before paying for a session it would abandon. On the page side,PreviewServer.SCRIPTS/add_script(name, path=)/set_scripts()publish ES modules underscripts/<name>.js, the manifest names the active set, and the viewer imports each once and calls its default export with a viewer API (THREE,scene,renderer,camera,controls,pivot,model,bounds,policy,setStatus,addButton, andon()for'load'/'frame'/'key'). Two ship in the box —turntable(rotation applied to the pivot, so it survives a push) andinspect(draw calls, materials and decoded texture MB read off the renderer, the numbers a GLB's size does not tell you).bridge.push(scripts=[...])is request-scoped liketexture_format, butNonemeans leave the server's set alone rather than use the default — the server outlives every push, so a script registered once must not be dropped by a push that says nothing about scripts;[]still clears. A script that throws is logged and contained: an optional module must never make a good preview look broken on a device where the console is invisible. The viewer now READShandoff.renderingout of the deliverable's own sidecar (key light, per-materialenvMapIntensity, environment level, tone-mapping exposure) with its former literals as the fallbacks for older files, soMeshConvert.RENDERING_POLICYis the source rather than a second spelling the regex tripwire had to hold together — only finite numbers are taken, since the published policy is partly prose by design (lightMapIntensityis a sentence) and assigning a string to an intensity renders black with nothing in the file looking wrong..jsadded to the wheel's package data andMANIFEST.in(which was also missing.html, i.e. the viewer page itself). Two fixes the seams forced out into the open:layout()now parks the fit-distance offset on the pivot instead of folding it into the model's own position (the composed world transform is identical, but with the offset on the modelpivot.rotation.yswung it around the viewer at 1.6 m radius rather than spinning it -- wrong at a desk and sickening in a headset, andviewer.pivotis the group every script is handed); and the viewer page and every script are now placed through the same atomic_sync_file->_write_assetwrite the published asset already used, since a browser fetching mid-copyfilegets a truncated file -- terminal for a script, because the page claims its URL before awaiting the import and never retries. 24 tests added, 4 rewritten from literal-pinning to contract-pinning; the per-pass guard and the pass order are mutation-checked. Full suite 3288 passed / 0 failed. Two follow-on corrections in the same delta:_resolve_scriptnow checks that a packaged name resolves to a file that exists, the check the externalpath=branch always had — an install that did not carry its*.jspackage data otherwise surfaced as a rawshutilfailure from insidedeliver(), after the active set had already been swapped and after every conversion pass had run; andinspectcounts lightmapped materials into aSetkeyed on the material, like thematerialstotal beside it, since counting them per mesh visit let one shared lightmapped material reportlightmappedMaterials: 46againstmaterials: 11. +1 test. -
2026-08-18 ΓÇö
AppSpecgains cached availability:path/available/refresh(core_utils/app_handoff.py).resolve()is the "look right now" call a launch path wants, and it is not cheap ΓÇöfind_apphits the WindowsApp Pathsregistry andscan_globswalks both Program Files roots, measured at 12-155ms per app on this box (a MISS is the expensive case: it exhausts every stage before returning None). Anything that asks REPEATEDLY could not use it: a panel's availability gate re-runs on every*_init, so an uncached probe would re-glob Program Files each time a panel is shown.pathmemoizesresolve()per spec instance (specs are module-level singletons attached to their bridge class, so that is per-app process-wide without a registry),availableis its truthiness, andrefresh()discards the memo for the one case the cache is wrong about ΓÇö the user installed the app mid-session. A MISS is cached too, deliberately: "not installed" is the answer that would otherwise cost the most to re-derive. The memo is written throughobject.__setattr__and is NOT a dataclass field, so the frozen contract holds: a spec that has probed still hashes and compares equal to one that has not (pinned by a test).resolve()is unchanged, so every existing caller keeps its fresh look. 6 failing-first tests intest_bridge.py; pythontk 3259 passed.