Skip to content

pythontk v0.9.26

Latest

Choose a tag to compare

@m3trik m3trik released this 19 Aug 21:49
4c19a97
  • 2026-08-19 ΓÇö FileUtils.relativize_output_dir(path, base): the portable spelling of a browsed directory, and the exact inverse of resolve_output_dir. Every optional-output-directory field in the ecosystem reads its text through resolve_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 through resolve_output_dir included); mayatk's lightmap baker drops its private copy.

  • 2026-08-19 ΓÇö UvTransfer.normal_convention classifies 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's dx\. anchor did not happen to catch. It now asks MapRegistry.resolve_type_from_path ΓÇö the same classifier the rest of the texture pipeline uses ΓÇö and reports DirectX only for Normal_DirectX, so _DX / DirectX / NRMLDX / N-dx and 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, so rock_directx_normal.png was DirectX and is now OpenGL ΓÇö the suffix is what names a map, and the untagged Normal type means "convention unknown", where leaving it alone is the cheaper error (flipping a guess inverts a map that may already be right). _DIRECTX_TOKENS is 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 export ptk.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 a TransferTable (per texel and sub-sample: covering triangle + source UV, uint16 fixed 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_normals additionally 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); pad fills the gutter from the table's coverage. merge_layouts folds 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>.png naming, 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 matching np.rot90/flip, box filter on shrunk islands ΓÇö are pinned by test/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 out 1024x512, 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) and describe_size_clamp() (the same thing as the sentence a panel shows). mayatk's Scene Exporter reads the sentinel in a class body, so it declares pythontk>=0.9.25; consumers pinning lower get an AttributeError at import rather than at first use.

  • 2026-08-18 ΓÇö CLI.add_connection_args no 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 --help on 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 via getpass.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_USER and DEFAULT_CRED_TARGET are 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_HOST survives with a neutral value and DEFAULT_PORT replaces the inline 22. The resolver and the getpass fallback live on a new _CLIInternal base per the house encapsulation rule, so CLI's public surface is unchanged apart from the two deleted constants.

  • 2026-08-18 ΓÇö HandoffBridge owns the run scratch: staging a hand-off no longer means hand-rolling a temp directory per bridge (core_utils/app_handoff.py). _make_payload_path already 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 own tempfile join. New _scratch_dir(request, name) is the mkdtemp replacement for that, backed by TempArtifacts under the bridge's existing payload_prefix, with the lifetime chosen by the declarative scoped_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 calling super() (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 returning None ΓÇö leaves the scratch on disk to inspect and lets the sweep reclaim it later. A host that dies mid-run would skip any finally, which is the whole reason this is a swept namespace rather than a try.

    _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 declare scoped_scratch_modes = (ROUND_TRIP,) and a _delivered_paths, and nothing else. That attribute is deliberately the twin of uitk.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_bridge 80 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 _ingest is 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.png ends in _1, not in any registry alias, so resolve_type_from_path returned None ΓÇö 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. New MapRegistry.split_duplicate_token (mirroring split_tile_token) and a retry in resolve_type_from_path that 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, with 8/16/24/32/48/64 excluded as technical tails: im_Height_16 is 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 of resolve_type_from_path, split out) rather than re-entering resolve_type_from_path: base is already a STEM at that point, and os.path.splitext on 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, and resolve_texture_filename appends when it cannot find the suffix at the tail, so a tolerant key=False would turn X_Base_Color_1.png into X_Base_Color_1_Base_Color.png. The strict None yields 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_web is written by the applier from the FINAL values (the embedded PNG, and the scalar that restores the bake range); the per-object fromFBX.userProperties.lightmapInfo markers are written EARLIER by the DCC bake pass, before normalisation exists. Measured on a client hand-off: lightmap_web said OFFICE_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 .exr they 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's GLTFLoader copies them into Object3D.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 surviving map/uv_set/intensity/scaleOffset keys 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 under extras.fromFBX.userProperties, while blendertk's native glTF export writes it as a TOP-LEVEL node extra ΓÇö verified on a real deliverable whose nodes carry extras: {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 naming room binds a GLB node NS:room, because a referenced Maya rack arrives namespaced), so keying the corrections off records[*]["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 says room while the bound node is NS:room. Fed the PUBLISHED dicts (the same objects that went into lightmap_web), since a record's map is the SOURCE .exr basename ΓÇö what a caller wants to know ΓÇö and its intensity is unrounded where the published one is round(., 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 via None == 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_glb updated deliberately ΓÇö it pinned map == 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-wide data_export.lightmap_metadata and mayatk's external .{stem}.scene_data.json remain 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_lightmaps returns 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.glb has 7 nodes with a top-level lightmapInfo and no lightmap_metadata anywhere ΓÇö 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) < 2 cannot 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_texture fills 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 shipped mat_MSAO.png with 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: multi is a MINIMUM measured against what resolved, so it still ships the pair by explicit opt-in, as does force. Behaviour change for mask_map=True callers ΓÇö 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_smoothness now 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 mayatk prepare_maps sites are unaffected (base-color only, and mask_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 since textures (content addresses) and validate (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_glb recomputes every reference's sha256 against the image actually carrying it, checks validate against 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 .glb on a machine with no DCC, and can never damage the asset it inspects. A GLB from another producer is reported (envelope: None), not raised. problems and ok are 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 separate notes list, so the obvious if report["problems"] cannot read a sound asset as defective. 7 failing-first tests. A malformed envelope is now reported rather than raised through: sections and textures were 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 raised AttributeError out of the one method a recipient runs to find that out. Each is a fail() with its own message, deliberately not a silent normalise to {} ΓÇö reporting ok: True on 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 everywhere is 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_roughness repairs 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. unvalidated is 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), while unvalidated reaches the RECIPIENT through verify_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 from MapRegistry ΓÇö 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 inside apply_scene_sidecar's existing session against the materials the section does not name, so a fully-covered export decodes nothing. GlbEdit.alpha_extrema generalised to channel_extrema(img_idx, channel) so both alpha repairs and this probe share ONE decode of an atlas. 8 tests (7 failing-first; the eighth pins GLTF_ORM_CHANNELS against 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.generator is 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: a source naming an application but no version stamped the literal text None into the middle of the string (someApp None + pythontk 0.9.24), where the .strip() could not reach it, and a source carrying only a version was dropped outright by the if app: gate. Both now join whatever is actually present. Only a caller of the public build_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.json beside a preview payload is gone; the GLB's extras is 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_path had 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 to Payload.extras and stops there; the deliverer embeds it in the GLB, where read_scene_sidecar / verify_glb read 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 inside deliver(), so a new step (Draco, per-slot resolution ceilings) meant editing the path every DCC bridge in the ecosystem runs through. It is now EDIT_PASSES (one shared GLB edit session: scene_sidecar ΓåÆ prune_textures ΓåÆ lightmaps) and FILE_PASSES (the closed file: optimize_textures), name ΓåÆ method, the same shape as MeshConvert.SIDECAR_APPLIERS one level down; a pass takes a PreviewPassContext (.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, so context.edit is None there 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 KTX2 toktx preflight 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 under scripts/<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, and on() for 'load'/'frame'/'key'). Two ship in the box ΓÇö turntable (rotation applied to the pivot, so it survives a push) and inspect (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 like texture_format, but None means 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 READS handoff.rendering out of the deliverable's own sidecar (key light, per-material envMapIntensity, environment level, tone-mapping exposure) with its former literals as the fallbacks for older files, so MeshConvert.RENDERING_POLICY is 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 (lightMapIntensity is a sentence) and assigning a string to an intensity renders black with nothing in the file looking wrong. .js added to the wheel's package data and MANIFEST.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 model pivot.rotation.y swung it around the viewer at 1.6 m radius rather than spinning it -- wrong at a desk and sickening in a headset, and viewer.pivot is the group every script is handed); and the viewer page and every script are now placed through the same atomic _sync_file -> _write_asset write the published asset already used, since a browser fetching mid-copyfile gets 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_script now checks that a packaged name resolves to a file that exists, the check the external path= branch always had ΓÇö an install that did not carry its *.js package data otherwise surfaced as a raw shutil failure from inside deliver(), after the active set had already been swapped and after every conversion pass had run; and inspect counts lightmapped materials into a Set keyed on the material, like the materials total beside it, since counting them per mesh visit let one shared lightmapped material report lightmappedMaterials: 46 against materials: 11. +1 test.

  • 2026-08-18 ΓÇö AppSpec gains 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_app hits the Windows App Paths registry and scan_globs walks 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. path memoizes resolve() per spec instance (specs are module-level singletons attached to their bridge class, so that is per-app process-wide without a registry), available is its truthiness, and refresh() 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 through object.__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 in test_bridge.py; pythontk 3259 passed.