# gaussian-splat-to-blender-nodes

Adopt this skill: https://proskills.md/skills/other/gokayfem-gaussian-splat-to-blender-nodes-skills-gaussian-splat-to-blender-nodes

Page: https://proskills.md/skills/other/gokayfem-gaussian-splat-to-blender-nodes-skills-gaussian-splat-to-blender-nodes
This document is the public skill bundle for that page. It contains SKILL.md and every helper file the catalog carries. No login is required.

## File: skills/gaussian-splat-to-blender-nodes/SKILL.md
size: 7751
sha: 2a6f6518483ab16df60102d93ad05b072e1832a3
upstream: https://github.com/gokayfem/gaussian-splat-to-blender-nodes/blob/main/skills/gaussian-splat-to-blender-nodes/SKILL.md

```text
---
name: gaussian-splat-to-blender-nodes
description: Reconstruct Gaussian splat scenes as editable native Blender Geometry Nodes meshes and Shader Node materials. Use for splat-to-procedural reconstruction, matched-camera analysis, semantic scene decomposition, detailed environment recreation, native-scene audits, optional hybrid capture-texture mapping onto reconstructed surfaces, and reference-versus-Blender comparison videos. This is an agent-guided measured workflow, not automatic splat-to-mesh conversion.
license: MIT
---

# World to World Mapping — Gaussian Splat to Blender Nodes

Build an editable scene that explains the reference through geometry, materials and light.
Use the splat as measurement and visual evidence. Deliver native source, parameters and
honest multi-view comparisons. Default to procedural materials. When captured appearance is
requested, use the optional hybrid workflow below and disclose its baked-surface limits.

## Prompting a complete project

For a full-scene task, use the [prompting workflow](references/prompting-workflow.md) and
[master project brief](templates/prompts/project-brief.md). They supply the actual brief,
capture, part-contract, detail, review and delivery prompt structure with project-specific
values left to measure. Read [whole-scene capture](references/whole-scene-capture.md) when
building the reference package: it explains the source-viewer, camera, coverage and close-up
capture procedure. These prompts work with one agent; delegation and recurring review are
optional, separately authorized modes.

## Start from evidence

1. Inspect current artifact versions and preserve user work.
2. Record asset URL, author/dataset, usage terms, byte size and SHA-256. Viewer software
   licensing does not establish dataset rights. Keep scans and decoded arrays local.
3. Identify the actual container and producer. `scripts/inspect_asset.py` fingerprints
   assets and reads limited metadata; it does not decode Gaussians into surfaces.
4. Establish one scan-to-world transform, scale, handedness and camera convention. Save
   position, target, up, vertical FOV and dimensions. Register stable landmarks first.
5. Read [acquisition and cameras](references/acquisition-and-cameras.md).

## Reconstruct coarse to fine

1. List major objects and occlusions. Separate measurements, estimates and invented hidden
   surfaces. Prioritize silhouette and spatial relationships.
2. Create collections: `GS_REFERENCE` (hidden from rendering), `SCENE_PROCEDURAL` (or a
   project-specific equivalent), `CAMERAS`, `LIGHTING`, and `QA`.
3. Use empty mesh hosts with live GN modifiers. Build meaningful primitives, profiles,
   curves, branches and instances. Document fitted control points. Instancing every
   Gaussian as the beauty scene is not procedural reconstruction.
4. Expose semantic controls with units, bounds and descriptions. Seed stochastic placement.
   Organize substantial graphs into documented subgroups and labeled frames.
5. Use Shader Nodes with part-local coordinates and physical feature scales. Splat color
   contains illumination; it is not automatically material albedo.
6. Produce an integrated matched-camera checkpoint before adding expensive detail.
7. Read [procedural construction](references/procedural-construction.md). Build joints,
   thickness, bevels, apertures, branch hierarchy and edge wear before micro-noise.

## Detail without inventing fidelity

More polygons, random noise or instances do not establish similarity. Require visible
meso-detail at the intended distance and fine detail in native-resolution crops. Preserve
improvements in newer versions. Omissions in isolated part tests are not missing integrated
objects.

Inspect the current project's identity-defining features and intended viewing scale. Rough
source edges and tiny seams need not block a ready-for-review result. Prioritize visible
structure, material assignment and multi-view fidelity over numerical residuals.

## Optional hybrid captured appearance

When the requested mode includes captured textures, read
[hybrid projection](references/hybrid-projection.md) and use the
[hybrid prompt](templates/prompts/hybrid-projection.md) with
[projection.json](templates/projection.json). Prepare reconstructed UV mesh copies in
Blender, project registered captures with depth/object/source-mask rejection, then assemble
packed appearance/mix images with original-material fallback. Per-project configuration
supplies all objects, material slots, cameras, world bounds and rejection rules.

Mapped copies are baked evaluated meshes, not live generated GN hosts. Preserve the source
and rebake after geometry/UV/frame changes. Captured radiance is an unlit appearance mode;
irradiance-divided albedo is only an approximation. Check wrong-object appearance, broad
ghosts/stretching, large color mismatch and missing coverage in full matched views. Rank
up to three current material issues; fewer or none is fine. Do not import another scene's
thresholds or restart edge polishing without a materially visible defect.

## Verify what actually exists

1. Build with `--python-exit-code 1`. Fail on missing parts, node warnings and invalid links.
2. Run `scripts/audit_blend.py` in a disposable background process. It removes the scan
   in memory and compares evaluated mesh/instance signatures. It never saves the blend.
   Apply it to live procedural collections; it intentionally rejects baked hybrid hosts.
   Verify hybrid packed images, UV/slot wiring, fallback and complementary renders separately.
3. Render with the reference removed. Inspect clay and beauty views, opposite/side/overview
   cameras and detail crops. Both renderers must use one shared camera manifest.
4. Record exact source/output hashes. An audit of an old blend does not validate a new one.
5. Read [validation and critique](references/validation-and-critique.md). Rank up to three
   materially visible differences with region, correction and verification view. Separate
   actual observations, hypotheses and agent-reported metrics.
6. If pixel-level independence matters, compare renders before/after scan removal too.
   The structural audit does not test shader, driver or compositor side effects.

## Compare stills and motion

`scripts/cameras.py` produces world/scan orbit poses. Render `world` poses with
`scripts/render_manifest.py`; render `scan` poses in the chosen reference viewer. The
reference side is viewer-specific and is not automated here.

`scripts/compare.py` pairs images and optionally encodes an MP4 with FFmpeg. See
[comparison videos](references/comparison-videos.md). Preserve framing and source labels.
Never invent missing reference angles. Serialize expensive renders and use immutable outputs.

## Tools and delivery

Resolve paths relative to this skill directory. Test Blender without a scan using
`templates/starter.py`. Basic helpers use the standard library; comparisons need Pillow,
and hybrid projection needs NumPy, SciPy and Pillow. MP4 encoding requires FFmpeg/libx264.
Blender tools target 5.0+; report the version actually tested.

Deliver the editable blend, deterministic source/parameters, camera manifests, provenance,
audit JSON and comparisons. Report remaining mismatches and unsupported views. Do not
promise perfect reconstruction or declare completion from script success.

The full repository includes an experimental `prompts/garden` builder and pinned nodekit
from `gokayfem/video-to-blender`. A skill-only install contains the portable tools and starter;
clone the repository for Garden.

Do not schedule reviews, contact other agents, publish assets or start API jobs implicitly.
Honor user authorization and stop queued work when asked to stop.
```

## File: skills/gaussian-splat-to-blender-nodes/scripts/audit_blend.py
size: 7655
sha: d7a79ef6eb799c25467189acc22ecdfaee4c064d
upstream: https://github.com/gokayfem/gaussian-splat-to-blender-nodes/blob/main/skills/gaussian-splat-to-blender-nodes/scripts/audit_blend.py

```text
"""Audit native Geometry Nodes objects and remove GS_REFERENCE in memory.

Run in a disposable background Blender process with --python-exit-code 1.
Never saves the loaded .blend. Signatures cover evaluated mesh vertices/topology,
material assignments and instance transforms. This is structural evidence, not a
render-equivalence test or a guarantee that a scene resembles its reference.
"""
import argparse
from array import array
import hashlib
import json
from pathlib import Path
import struct
import sys

import bpy

def mesh_signature(obj):
    me = obj.to_mesh()
    if me is None:
        return None
    try:
        h = hashlib.sha256()
        for items, prop, code, stride in ((me.vertices, "co", "f", 3), (me.loops, "vertex_index", "i", 1),
                                          (me.polygons, "loop_total", "i", 1), (me.polygons, "material_index", "i", 1)):
            a = array(code, [0]) * (len(items)*stride)
            items.foreach_get(prop, a)
            h.update(a.tobytes())
        h.update(json.dumps([m.name if m else None for m in me.materials]).encode())
        return dict(vertices=len(me.vertices), faces=len(me.polygons), sha256=h.hexdigest())
    finally:
        obj.to_mesh_clear()

def snapshot(names):
    bpy.context.view_layer.update()
    dg = bpy.context.evaluated_depsgraph_get()
    objects, instances, cache = {}, [], {}
    for name in sorted(names):
        ob = bpy.data.objects.get(name)
        if ob is None:
            objects[name] = None
            continue
        evaluated = ob.evaluated_get(dg)
        objects[name] = dict(mesh=mesh_signature(evaluated), matrix=[list(row) for row in ob.matrix_world])
    for inst in dg.object_instances:
        if not inst.is_instance or not inst.parent or inst.parent.original.name not in names:
            continue
        ob = inst.object
        key = ob.as_pointer()
        if key not in cache:
            cache[key] = mesh_signature(ob)
        h = hashlib.sha256(json.dumps(cache[key], sort_keys=True).encode())
        h.update(struct.pack("<16d", *(x for row in inst.matrix_world for x in row)))
        instances.append(h.hexdigest())
    return dict(objects=objects, instance_count=len(instances),
                instances_sha256=hashlib.sha256("".join(sorted(instances)).encode()).hexdigest())

def audit(collection, reference):
    coll = bpy.data.collections.get(collection)
    if coll is None or not coll.all_objects:
        raise ValueError(f"Missing/empty procedural collection: {collection}")
    objects = list(coll.all_objects)
    names = {o.name for o in objects}
    failures, warnings, inventory = [], [], []
    for ob in objects:
        mods = [m for m in ob.modifiers if m.type == "NODES" and m.node_group and m.show_render and m.show_viewport]
        if ob.type != "MESH" or not mods:
            failures.append(f"{ob.name}: expected mesh object with enabled Geometry Nodes")
        if ob.type == "MESH" and len(ob.data.vertices):
            failures.append(f"{ob.name}: base mesh is nonempty; inspect whether geometry was baked/imported")
        inventory.append(dict(name=ob.name, base_vertices=len(ob.data.vertices) if ob.type == "MESH" else None,
                              node_groups=[m.node_group.name for m in mods]))
        for m in mods:
            for w in getattr(m, "node_warnings", []):
                failures.append(f"{ob.name}: {w.message}")
    refs = bpy.data.collections.get(reference)
    if refs and not refs.hide_render:
        failures.append(f"{reference} collection is not hidden from rendering")
    reference_names = {ob.name for ob in refs.all_objects} if refs else set()
    if names & reference_names:
        raise ValueError("Reference and procedural collections share objects; refusing destructive test")
    groups = [g for g in bpy.data.node_groups if g.bl_idname == "GeometryNodeTree"]
    for g in groups:
        for link in g.links:
            if not link.is_valid:
                failures.append(f"{g.name}: invalid node link")
        for n in g.nodes:
            if n.bl_idname in ("GeometryNodeObjectInfo", "GeometryNodeCollectionInfo"):
                for socket in n.inputs:
                    value = getattr(socket, "default_value", None)
                    if isinstance(value, bpy.types.Object) and value.name in reference_names:
                        warnings.append(f"{g.name}/{n.name}: references scan object {value.name}")
                    if isinstance(value, bpy.types.Collection) and value.name == reference:
                        warnings.append(f"{g.name}/{n.name}: references scan collection")
    materials = [m for m in bpy.data.materials if m.users]
    for m in materials:
        if not m.node_tree:
            failures.append(f"{m.name}: no Shader Nodes")
    image_nodes = sorted({f"{g.name}/{n.name}" for g in [*bpy.data.node_groups, *(m.node_tree for m in materials if m.node_tree)]
                          for n in g.nodes if n.bl_idname in ("ShaderNodeTexImage", "ShaderNodeTexEnvironment")})
    if image_nodes:
        warnings.append("Image textures need provenance/manual review; they are not inherently scan-free")
    before = snapshot(names)
    if refs:
        for ob in list(refs.all_objects):
            bpy.data.objects.remove(ob, do_unlink=True)
        bpy.data.collections.remove(refs, do_unlink=True)
        # Blender can retain the previous evaluated GN result after an ID is deleted.
        # Invalidate graph/object caches explicitly before the independence comparison.
        for group in bpy.data.node_groups:
            group.update_tag()
        for ob in bpy.data.objects:
            ob.update_tag(refresh={"DATA"})
    after = snapshot(names)
    if before != after:
        failures.append("Evaluated geometry/instances changed after removing the reference")
    mesh_vertices = sum((v.get("mesh") or {}).get("vertices", 0) for v in after["objects"].values() if v)
    if mesh_vertices == 0 and after["instance_count"] == 0:
        failures.append("Procedural collection evaluates to no meshes or instances")
    return dict(blender_version=bpy.app.version_string, collection=collection, reference=reference,
                reference_objects_removed=len(reference_names), inventory=inventory,
                geometry_node_groups=len(groups), shader_materials=len(materials), image_nodes=image_nodes,
                before=before, after=after, failures=failures, warnings=warnings,
                passed=not failures, limits="Does not compare rendered pixels, materials before/after, animation, or prove visual fidelity")

def main():
    p = argparse.ArgumentParser(description=__doc__)
    p.add_argument("--collection", default="GARDEN_PROCEDURAL")
    p.add_argument("--reference", default="GS_REFERENCE")
    p.add_argument("--out", required=True, type=Path)
    a = p.parse_args(sys.argv[sys.argv.index("--")+1:] if "--" in sys.argv else [])
    r = audit(a.collection, a.reference)
    source = Path(bpy.data.filepath)
    if source.is_file():
        h = hashlib.sha256()
        with source.open("rb") as f:
            for chunk in iter(lambda: f.read(1024*1024), b""):
                h.update(chunk)
        r["blend"] = dict(filename=source.name, sha256=h.hexdigest(), mtime_ns=source.stat().st_mtime_ns)
    a.out.parent.mkdir(parents=True, exist_ok=True)
    a.out.write_text(json.dumps(r, indent=2)+"\n")
    print(json.dumps({k: r[k] for k in ("passed", "failures", "warnings", "geometry_node_groups", "shader_materials")}))
    if not r["passed"]:
        raise RuntimeError("Native scene audit failed; see JSON report")

if __name__ == "__main__":
    main()
```

## File: skills/gaussian-splat-to-blender-nodes/scripts/cameras.py
size: 4752
sha: 88333a66286ef9d5c448a0e839aae777a2c1fc4a
upstream: https://github.com/gokayfem/gaussian-splat-to-blender-nodes/blob/main/skills/gaussian-splat-to-blender-nodes/scripts/cameras.py

```text
#!/usr/bin/env python3
"""Create one orbit manifest for a Z-up Blender scene and its scan-space reference.

World points = scale * R * (scan points - origin). No third-party dependencies.
"""
import argparse
import json
import math
from pathlib import Path

def dot(a, b):
    return sum(x * y for x, y in zip(a, b))

def cross(a, b):
    return [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]]

def unit(v):
    n = math.sqrt(dot(v, v))
    if not math.isfinite(n) or n < 1e-12:
        raise ValueError("Degenerate camera direction")
    return [x/n for x in v]

def vector(v):
    if len(v) != 3 or not all(math.isfinite(float(x)) for x in v):
        raise ValueError("Expected three finite coordinates")
    return [float(x) for x in v]

def validate_frame(f):
    r = f["R_scan_to_world"]
    vector(f["origin_scan"])
    s = f["scale_m_per_unit"]
    if not math.isfinite(s) or s <= 0 or len(r) != 3:
        raise ValueError("Frame requires positive scale and a 3x3 proper rotation")
    for row in r:
        vector(row)
    for i in range(3):
        for j in range(3):
            if abs(dot(r[i], r[j]) - (i == j)) > 1e-5:
                raise ValueError("Rotation is not orthonormal")
    if abs(dot(r[0], cross(r[1], r[2])) - 1) > 1e-5:
        raise ValueError("Rotation reflects handedness; determinant must be +1")
    return f

def transform(v, frame, to_scan=False, direction=False):
    validate_frame(frame)
    v = vector(v)
    r, o, s = (frame[k] for k in ("R_scan_to_world", "origin_scan", "scale_m_per_unit"))
    if to_scan:
        v = v if direction else [x/s for x in v]
        out = [sum(r[j][i]*v[j] for j in range(3)) for i in range(3)]
        return out if direction else [out[i]+o[i] for i in range(3)]
    v = v if direction else [v[i]-o[i] for i in range(3)]
    out = [dot(row, v) for row in r]
    return out if direction else [s*x for x in out]

def validate_camera(c):
    p, t, u = (vector(c[k]) for k in ("position", "look_at", "up"))
    z = unit([p[i]-t[i] for i in range(3)])
    unit(cross(u, z))
    if not 0 < c["fov_y_deg"] < 179:
        raise ValueError("Vertical FOV must be in (0, 179) degrees")
    if any(type(c[k]) is not int or c[k] <= 0 for k in ("width", "height")):
        raise ValueError("Image dimensions must be positive integers")
    return c

def camera_to_scan(c, frame):
    validate_camera(c)
    return dict(c, position=transform(c["position"], frame, True),
                look_at=transform(c["look_at"], frame, True),
                up=unit(transform(c["up"], frame, True, True)))

def orbit(target, radius, elevation, frames, width, height, fov, start=0):
    target = vector(target)
    if not math.isfinite(radius) or radius <= 0 or frames < 2 or not -89 < elevation < 89:
        raise ValueError("Use radius > 0, at least 2 frames and elevation between -89 and 89")
    if not math.isfinite(start):
        raise ValueError("Start azimuth must be finite")
    e = math.radians(elevation)
    cameras = {}
    for i in range(frames):
        a = math.radians(start + 360*i/frames)
        d = [math.cos(e)*math.cos(a), math.cos(e)*math.sin(a), math.sin(e)]
        cameras[f"{i:06d}"] = validate_camera(dict(
            position=[target[j]+radius*d[j] for j in range(3)], look_at=target,
            up=[0, 0, 1], fov_y_deg=fov, width=width, height=height))
    return cameras

def main():
    p = argparse.ArgumentParser(description=__doc__)
    p.add_argument("--out", required=True, type=Path)
    p.add_argument("--frame", type=Path, help="Optional scan-to-world similarity transform JSON")
    p.add_argument("--target", type=float, nargs=3, default=[0, 0, 0.7])
    p.add_argument("--radius", type=float, default=3)
    p.add_argument("--elevation", type=float, default=30)
    p.add_argument("--start", type=float, default=0)
    p.add_argument("--frames", type=int, default=240)
    p.add_argument("--fps", type=int, default=24)
    p.add_argument("--width", type=int, default=640)
    p.add_argument("--height", type=int, default=640)
    p.add_argument("--fov", type=float, default=50)
    a = p.parse_args()
    if a.fps <= 0:
        p.error("fps must be positive")
    world = orbit(a.target, a.radius, a.elevation, a.frames, a.width, a.height, a.fov, a.start)
    result = dict(schema_version=1, fps=a.fps, loop=True, world=world)
    if a.frame:
        f = validate_frame(json.loads(a.frame.read_text()))
        result.update(frame=f, scan={k: camera_to_scan(c, f) for k, c in world.items()})
    a.out.parent.mkdir(parents=True, exist_ok=True)
    a.out.write_text(json.dumps(result, indent=2) + "\n")
    print(f"Wrote {len(world)} poses to {a.out}; closing pose is not duplicated")

if __name__ == "__main__":
    main()
```

## File: skills/gaussian-splat-to-blender-nodes/scripts/compare.py
size: 3787
sha: c3214ae4b09456172babf5d7741f9980c181207a
upstream: https://github.com/gokayfem/gaussian-splat-to-blender-nodes/blob/main/skills/gaussian-splat-to-blender-nodes/scripts/compare.py

```text
#!/usr/bin/env python3
"""Compare equal-sized, identically named PNG pairs without changing their framing.

Use directories for a numbered sequence and --video to encode with FFmpeg.
No independent crops, auto-alignment, frame interpolation or fabricated missing views.
"""
import argparse
import json
import subprocess
from pathlib import Path
from PIL import Image, ImageDraw
from inspect_asset import sha256

def pairs(left, right):
    left, right = Path(left), Path(right)
    if left.is_file() and right.is_file():
        return [(left, right)]
    if not left.is_dir() or not right.is_dir():
        raise ValueError("Provide two image files or two frame directories")
    a = {p.name: p for p in left.glob("*.png")}
    b = {p.name: p for p in right.glob("*.png")}
    if not a or a.keys() != b.keys():
        raise ValueError(f"Frame sets differ or are empty: left-only={sorted(a.keys()-b.keys())[:5]}, right-only={sorted(b.keys()-a.keys())[:5]}")
    return [(a[k], b[k]) for k in sorted(a)]

def compose(left, right, labels=("Gaussian splat reference", "Native Blender nodes")):
    with Image.open(left) as a, Image.open(right) as b:
        if a.size != b.size:
            raise ValueError(f"Mismatched image sizes: {a.size} vs {b.size}")
        w, h = a.size
        out = Image.new("RGB", (w*2, h+32), (19, 23, 27))
        out.paste(a.convert("RGB"), (0, 32))
        out.paste(b.convert("RGB"), (w, 32))
    d = ImageDraw.Draw(out)
    d.text((10, 9), labels[0], fill="white")
    d.text((w+10, 9), labels[1], fill="white")
    return out

def main():
    p = argparse.ArgumentParser(description=__doc__)
    p.add_argument("left", type=Path)
    p.add_argument("right", type=Path)
    p.add_argument("--out", required=True, type=Path, help="New directory for comparisons and evidence.json")
    p.add_argument("--video", action="store_true", help="Write comparison.mp4 using FFmpeg/libx264")
    p.add_argument("--fps", type=int, default=24)
    p.add_argument("--labels", nargs=2, default=["Gaussian splat reference", "Native Blender nodes"])
    a = p.parse_args()
    if a.fps <= 0:
        p.error("fps must be positive")
    pp = pairs(a.left, a.right)
    # Validate every pair before writing, so a mixed-size sequence cannot encode silently.
    sizes = set()
    for l, r in pp:
        with Image.open(l) as il, Image.open(r) as ir:
            if il.size != ir.size:
                p.error(f"Pair size mismatch: {l.name}")
            sizes.add(il.size)
    if len(sizes) != 1:
        p.error("Image dimensions vary across the sequence")
    a.out.mkdir(parents=True, exist_ok=False)
    evidence = []
    for i, (l, r) in enumerate(pp):
        dest = a.out / f"{i:06d}.png"
        compose(l, r, a.labels).save(dest)
        evidence.append(dict(frame=i, source_name=l.name,
                             left_sha256=sha256(l), right_sha256=sha256(r), comparison_sha256=sha256(dest)))
    record = dict(fps=a.fps, frames=len(pp), labels=a.labels, source_dimensions=list(next(iter(sizes))),
                  camera_match="Must be verified against the shared camera manifest; equal image size is not proof", images=evidence)
    if a.video:
        dest = a.out / "comparison.mp4"
        subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-n", "-framerate", str(a.fps),
                        "-i", str(a.out/"%06d.png"), "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2",
                        "-c:v", "libx264", "-crf", "18", "-pix_fmt", "yuv420p", "-movflags", "+faststart", str(dest)], check=True)
        record["video"] = dict(filename=dest.name, sha256=sha256(dest))
    (a.out/"evidence.json").write_text(json.dumps(record, indent=2)+"\n")
    print(f"Wrote {len(pp)} paired frames to {a.out}")

if __name__ == "__main__":
    main()
```

## File: skills/gaussian-splat-to-blender-nodes/scripts/inspect_asset.py
size: 4383
sha: 1f0a45d5f4c27ca64687fd07109a6e3bb6207062
upstream: https://github.com/gokayfem/gaussian-splat-to-blender-nodes/blob/main/skills/gaussian-splat-to-blender-nodes/scripts/inspect_asset.py

```text
#!/usr/bin/env python3
"""Fingerprint a local asset and inspect common splat container metadata.

This is a header/record-layout inspector, NOT a semantic decoder or a mesh converter.
PLY body values and KSPLAT bucket/payload integrity are not validated.
"""
import argparse
import hashlib
import json
import struct
from pathlib import Path

def sha256(path):
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(1024*1024), b""):
            h.update(chunk)
    return h.hexdigest()

def inspect(path):
    path = Path(path)
    size = path.stat().st_size
    result = dict(filename=path.name, bytes=size, sha256=sha256(path))
    suffix = path.suffix.lower()
    if suffix == ".ply":
        with path.open("rb") as f:
            header = bytearray()
            while len(header) < 1024*1024:
                line = f.readline(4096)
                if not line:
                    raise ValueError("PLY header has no end_header")
                header.extend(line)
                if line.strip() == b"end_header":
                    break
            else:
                raise ValueError("PLY header exceeds 1 MiB")
        lines = header.decode("ascii").splitlines()
        if lines[0] != "ply":
            raise ValueError("Invalid PLY magic")
        elements, current, fmt = [], None, None
        for line in lines[1:]:
            p = line.split()
            if not p:
                continue
            if p[0] == "format":
                if len(p) != 3 or p[1] not in ("ascii", "binary_little_endian", "binary_big_endian") or p[2] != "1.0":
                    raise ValueError("Unsupported PLY format")
                fmt = p[1]
            elif p[0] == "element":
                if len(p) != 3 or int(p[2]) < 0:
                    raise ValueError("Invalid PLY element count")
                current = dict(name=p[1], count=int(p[2]), properties=[])
                elements.append(current)
            elif p[0] == "property":
                if current is None:
                    raise ValueError("PLY property without element")
                current["properties"].append(" ".join(p[1:]))
        if fmt is None or not elements:
            raise ValueError("Incomplete PLY header")
        props = {p.split()[-1] for e in elements if e["name"] == "vertex" for p in e["properties"]}
        result.update(format="ply", encoding=fmt, header_bytes=len(header), elements=elements,
                      gaussian_fields_present={k: k in props for k in ("opacity", "scale_0", "rot_0", "f_dc_0")},
                      validation="Header only; field conventions and body require the producer's decoder")
    elif suffix == ".splat":
        if size == 0 or size % 32:
            raise ValueError("Expected nonempty 32-byte records for the common .splat layout")
        result.update(format="splat", records=size//32, record_bytes=32,
                      validation="Size only; .splat has no magic/version marker. Confirm producer/layout")
    elif suffix == ".ksplat":
        with path.open("rb") as f:
            h = f.read(4096)
        if len(h) < 4096 or h[:2] != b"\x00\x01":
            raise ValueError("Only GaussianSplats3D KSPLAT v0.1 headers are supported")
        max_sections, sections, max_splats, splats = struct.unpack_from("<IIII", h, 4)
        level = struct.unpack_from("<H", h, 20)[0]
        if not 0 < sections <= max_sections or splats > max_splats or level not in (0, 1, 2):
            raise ValueError("Inconsistent KSPLAT header")
        if size < 4096 + 1024*max_sections:
            raise ValueError("Truncated KSPLAT section headers")
        result.update(format="ksplat", version="0.1", sections=sections, splats=splats,
                      compression_level=level, validation="Main header only; section records and payload not decoded")
    else:
        result.update(format="unknown", validation="Fingerprint only; use the producer's decoder")
    return result

def main():
    p = argparse.ArgumentParser(description=__doc__)
    p.add_argument("asset", type=Path)
    p.add_argument("--out", type=Path)
    a = p.parse_args()
    output = json.dumps(inspect(a.asset), indent=2) + "\n"
    if a.out:
        a.out.parent.mkdir(parents=True, exist_ok=True)
        a.out.write_text(output)
    print(output, end="")

if __name__ == "__main__":
    main()
```

## File: skills/gaussian-splat-to-blender-nodes/scripts/project_textures.py
size: 5406
sha: 132a6596c8f2830c6efd01c18137d352e1962fc1
upstream: https://github.com/gokayfem/gaussian-splat-to-blender-nodes/blob/main/skills/gaussian-splat-to-blender-nodes/scripts/project_textures.py

```text
"""Project calibrated captures onto prepared UV surfaces. Run with ordinary Python.

python project_textures.py --config mapping.json --prepared outputs/prepared --out outputs/mapped
"""
import argparse
import json
from pathlib import Path

import numpy as np
from PIL import Image

from projection_core import finish_texture, project_surface
from projection_config import load_config, sha256

def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--config", required=True, type=Path)
    parser.add_argument("--prepared", required=True, type=Path)
    parser.add_argument("--out", required=True, type=Path)
    parser.add_argument("--chunk-size", type=int, default=65536)
    args = parser.parse_args()
    if args.chunk_size <= 0:
        parser.error("chunk-size must be positive")
    cfg, root = load_config(args.config)
    prep = json.loads((args.prepared/"prepared.json").read_text())
    if prep["config_sha256"] != sha256(args.config):
        raise ValueError("Config changed since preparation; create a new prepared checkpoint")
    for relative, digest in prep["files"].items():
        if sha256(args.prepared/relative) != digest:
            raise ValueError(f"Prepared artifact changed: {relative}")
    captures, fingerprints = {}, {}
    for name, view in cfg["views"].items():
        camera = view["camera"]
        shape = (camera["height"], camera["width"])
        path = root/view["image"]
        with Image.open(path) as image:
            rgb = np.asarray(image.convert("RGB"))
        if rgb.shape[:2] != shape:
            raise ValueError(f"Capture dimensions do not match camera: {name}")
        fingerprints[name] = dict(image_sha256=sha256(path), source_valid_masks={})
        captures[name] = dict(camera=camera, rgb=rgb,
                              depth=np.load(args.prepared/f"depth/{name}.npy", mmap_mode="r"),
                              ids=np.load(args.prepared/f"depth/{name}_id.npy", mmap_mode="r"))
        if any(captures[name][key].shape != shape for key in ("depth", "ids")):
            raise ValueError(f"Visibility-map shape mismatch: {name}")
    args.out.mkdir(parents=True, exist_ok=False)
    report = dict(schema_version=1, config_sha256=sha256(args.config),
                  prepared_sha256=sha256(args.prepared/"prepared.json"),
                  source_blend_sha256=prep["source_blend_sha256"], captures=fingerprints, targets={})
    for target in cfg["targets"]:
        name = target["name"]
        fields = {k: np.load(args.prepared/f"{name}_{k}.npy", mmap_mode="r")
                  for k in ("position", "normal", "valid", "material")}
        valid = fields["valid"]
        if valid.dtype != bool or fields["material"].shape != valid.shape or not valid.any() or fields["position"].shape != (*valid.shape, 3) or fields["normal"].shape != (*valid.shape, 3):
            raise ValueError(f"Invalid prepared surface arrays: {name}")
        if not np.isfinite(fields["position"][valid]).all() or not np.isfinite(fields["normal"][valid]).all():
            raise ValueError(f"Non-finite prepared geometry: {name}")
        views = []
        for key, view in cfg["views"].items():
            item = dict(captures[key])
            mask = view.get("source_valid_masks", {}).get(name)
            if mask:
                path = root/mask
                with Image.open(path) as image:
                    allowed = np.asarray(image.convert("L")) >= 128
                if allowed.shape != item["depth"].shape:
                    raise ValueError(f"Source-mask dimensions do not match camera: {key}/{name}")
                item["allowed"] = allowed
                fingerprints[key]["source_valid_masks"][name] = sha256(path)
            views.append(item)
        color, state = project_surface(fields["position"], fields["normal"], valid,
                                       fields["material"], views, target, args.chunk_size)
        irr = np.load(args.prepared/f"{name}_irradiance.npy") if target.get("appearance") == "estimated_albedo" else None
        rgb, mix, padded = finish_texture(color, state, fields["material"], target, irr)
        Image.fromarray(np.rint(rgb*255).astype(np.uint8)).save(args.out/f"{name}_appearance.png")
        Image.fromarray(np.rint(mix*255).astype(np.uint8)).save(args.out/f"{name}_mix.png")
        Image.fromarray(padded).save(args.out/f"{name}_coverage.png")
        np.save(args.out/f"{name}_coverage.npy", padded)
        denominator = int(valid.sum())
        report["targets"][name] = dict(appearance=target.get("appearance", "captured_radiance"),
            surface_texels=denominator, direct=float(np.count_nonzero(state == 1)/denominator),
            rejected_fallback=float(np.count_nonzero(state == 2)/denominator),
            unseen_fallback=float(np.count_nonzero(state == 3)/denominator),
            padding_texels=int(np.count_nonzero(padded == 4)))
        print(name, json.dumps(report["targets"][name]), flush=True)
    report["files"] = {p.name: sha256(p) for p in sorted(args.out.iterdir()) if p.is_file()}
    report["coverage_legend"] = {"0": "outside selected UV surface", "1": "direct capture",
        "2": "rejected source; procedural fallback", "3": "unseen; procedural fallback", "4": "UV padding"}
    (args.out/"projection.json").write_text(json.dumps(report, indent=2)+"\n")

if __name__ == "__main__":
    main()
```

## File: skills/gaussian-splat-to-blender-nodes/scripts/projection_blender.py
size: 18420
sha: 1e519894bc066b3bf11254587df5b05fa8d9e629
upstream: https://github.com/gokayfem/gaussian-splat-to-blender-nodes/blob/main/skills/gaussian-splat-to-blender-nodes/scripts/projection_blender.py

```text
"""Prepare or assemble project-agnostic hybrid texture mapping in a disposable Blender process.

blender -b scene.blend --python-exit-code 1 --python projection_blender.py -- prepare --config mapping.json --out prepared
blender -b scene.blend --python-exit-code 1 --python projection_blender.py -- assemble --config mapping.json --prepared prepared --textures mapped --out hybrid.blend

Source geometry is evaluated from reconstructed objects, never from a reference splat.
Assembly saves baked copies and retains hidden source graphs; later GN edits require rebaking.
"""
import argparse
import json
import math
from pathlib import Path
import sys

import bpy
import numpy as np
from mathutils import Matrix, Vector

sys.path.insert(0, str(Path(__file__).resolve().parent))
from projection_config import load_config, sha256

def material(name):
    mat = bpy.data.materials.new(name)
    mat.use_nodes = True
    mat.node_tree.nodes.clear()
    out = mat.node_tree.nodes.new("ShaderNodeOutputMaterial")
    return mat, mat.node_tree, out

def emission(name, value=None):
    mat, nt, out = material(name)
    shader = nt.nodes.new("ShaderNodeEmission")
    nt.links.new(shader.outputs[0], out.inputs["Surface"])
    if value is not None:
        shader.inputs["Color"].default_value = (*value, 1)
    return mat, nt, shader

def disable_reference(cfg):
    for name in cfg.get("reference_collections", ["GS_REFERENCE"]):
        col = bpy.data.collections.get(name)
        if col:
            col.hide_render = True
            col.hide_viewport = True

def render_setup(device, samples):
    sc = bpy.context.scene
    sc.render.engine = "CYCLES"
    sc.cycles.device, sc.cycles.samples = device, samples
    sc.render.resolution_percentage = 100
    sc.render.pixel_aspect_x = sc.render.pixel_aspect_y = 1
    sc.render.use_border = False
    sc.render.film_transparent = True
    sc.render.use_compositing = False
    sc.render.use_sequencer = False
    if hasattr(sc.render, "use_motion_blur"):
        sc.render.use_motion_blur = False
    return sc

def evaluated_copy(source, name):
    if source.type != "MESH":
        raise ValueError(f"Target must be a mesh host (including GN): {source.name}")
    # Realize GN instances before requesting the evaluated mesh component.
    ng = bpy.data.node_groups.new("Mapping Realize", "GeometryNodeTree")
    ng.interface.new_socket("Geometry", in_out="INPUT", socket_type="NodeSocketGeometry")
    ng.interface.new_socket("Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
    gi, go = ng.nodes.new("NodeGroupInput"), ng.nodes.new("NodeGroupOutput")
    realize = ng.nodes.new("GeometryNodeRealizeInstances")
    ng.links.new(gi.outputs[0], realize.inputs["Geometry"])
    ng.links.new(realize.outputs[0], go.inputs[0])
    mod = source.modifiers.new("Mapping temporary realize", "NODES"); mod.node_group = ng
    try:
        bpy.context.view_layer.update()
        mesh = bpy.data.meshes.new_from_object(source.evaluated_get(bpy.context.evaluated_depsgraph_get()))
    finally:
        source.modifiers.remove(mod)
        bpy.data.node_groups.remove(ng)
    if not mesh.polygons:
        raise ValueError(f"Target evaluates to no mesh faces: {source.name}")
    obj = bpy.data.objects.new(name, mesh)
    bpy.context.scene.collection.objects.link(obj)
    obj.matrix_world = source.matrix_world.copy()
    return obj

def select(obj):
    for other in bpy.context.view_layer.objects:
        other.select_set(False)
    obj.hide_set(False); obj.hide_render = False
    obj.select_set(True); bpy.context.view_layer.objects.active = obj

def bake(obj, mats, size, kind, samples):
    originals = list(obj.data.materials)
    indices = np.array([p.material_index for p in obj.data.polygons], np.int32)
    obj.data.materials.clear()
    for mat in mats:
        obj.data.materials.append(mat)
    obj.data.polygons.foreach_set("material_index", indices)
    image = bpy.data.images.new(f"MAP_{obj.name}_{kind}", size, size, float_buffer=True, alpha=True)
    image.colorspace_settings.name = "Non-Color"
    nodes = []
    for mat in set(mats):
        tex = mat.node_tree.nodes.new("ShaderNodeTexImage"); tex.image = image
        mat.node_tree.nodes.active = tex; nodes.append((mat, tex))
    select(obj)
    sc = bpy.context.scene
    sc.cycles.samples = samples if kind == "irradiance" else 1
    sc.render.bake.margin = 0
    if kind == "irradiance":
        sc.render.bake.use_pass_direct = True
        sc.render.bake.use_pass_indirect = True
        sc.render.bake.use_pass_color = False
    try:
        bpy.ops.object.bake(type="DIFFUSE" if kind == "irradiance" else "EMIT")
        array = np.array(image.pixels[:], np.float32).reshape(size, size, 4)[::-1, :, :3].copy()
    finally:
        for mat, tex in nodes:
            mat.node_tree.nodes.remove(tex)
        bpy.data.images.remove(image)
        obj.data.materials.clear()
        for mat in originals:
            obj.data.materials.append(mat)
        obj.data.polygons.foreach_set("material_index", indices)
    return array

def camera_object():
    obj = bpy.data.objects.new("MAP_camera", bpy.data.cameras.new("MAP_camera"))
    bpy.context.scene.collection.objects.link(obj)
    obj.data.sensor_fit = "VERTICAL"
    obj.data.clip_start, obj.data.clip_end = 0.001, 10000
    return obj

def set_camera(obj, c):
    pos, target, up = (Vector(c[k]) for k in ("position", "look_at", "up"))
    z = (pos-target).normalized(); x = up.cross(z).normalized(); y = z.cross(x)
    obj.matrix_world = Matrix(((x.x, y.x, z.x, pos.x), (x.y, y.y, z.y, pos.y),
                               (x.z, y.z, z.z, pos.z), (0, 0, 0, 1)))
    obj.data.angle_y = math.radians(c["fov_y_deg"])
    sc = bpy.context.scene; sc.camera = obj
    sc.render.resolution_x, sc.render.resolution_y = c["width"], c["height"]

def visibility(cfg, out):
    sc = bpy.context.scene
    sc.cycles.samples = 1; sc.cycles.use_denoising = False
    sc.cycles.pixel_filter_type = "BOX"; sc.cycles.filter_width = 0.01
    sc.world = None
    sc.render.image_settings.file_format = "OPEN_EXR"; sc.render.image_settings.color_depth = "32"
    ids = {t["object"]: t["object_id"] for t in cfg["targets"]}
    for obj in sc.objects:
        obj.pass_index = ids.get(obj.name, 32767)
    depth, nt, shader = emission("MAP_distance")
    geo = nt.nodes.new("ShaderNodeNewGeometry")
    origin = nt.nodes.new("ShaderNodeCombineXYZ")
    dist = nt.nodes.new("ShaderNodeVectorMath"); dist.operation = "DISTANCE"
    nt.links.new(geo.outputs["Position"], dist.inputs[0]); nt.links.new(origin.outputs[0], dist.inputs[1])
    nt.links.new(dist.outputs["Value"], shader.inputs["Color"])
    ident, it, ish = emission("MAP_object_ID")
    info = it.nodes.new("ShaderNodeObjectInfo")
    it.links.new(info.outputs["Object Index"], ish.inputs["Color"])
    cam = camera_object()
    (out/"depth").mkdir()
    for name, view in cfg["views"].items():
        c = view["camera"]; set_camera(cam, c)
        for i, value in enumerate(c["position"]):
            origin.inputs[i].default_value = value
        for suffix, mat in (("", depth), ("_id", ident)):
            bpy.context.view_layer.material_override = mat
            path = out/"depth"/f"{name}{suffix}.exr"
            sc.render.filepath = str(path)
            bpy.ops.render.render(write_still=True)
            image = bpy.data.images.load(str(path), check_existing=False)
            pixels = np.array(image.pixels[:], np.float32).reshape(c["height"], c["width"], 4)[::-1]
            values = np.where(pixels[..., 3] > 0.5, pixels[..., 0], 0 if suffix else np.inf)
            values = np.rint(values).astype(np.uint16) if suffix else values.astype(np.float32)
            np.save(out/"depth"/f"{name}{suffix}.npy", values)
            bpy.data.images.remove(image); path.unlink()
        print("VISIBILITY", name, flush=True)
    bpy.context.view_layer.material_override = None

def prepare(args, cfg):
    args.out.mkdir(parents=True, exist_ok=False)
    sc = render_setup(args.device, args.samples)
    disable_reference(cfg)
    report = dict(schema_version=1, config_sha256=sha256(args.config),
                  source_blend_sha256=sha256(bpy.data.filepath), blender_version=bpy.app.version_string, targets={})
    copies = []
    for target in cfg["targets"]:
        source = bpy.data.objects[target["object"]]
        if source.hide_render or not source.visible_get():
            raise ValueError(f"Target is not visible in the current view layer: {source.name}")
        name = target["name"]
        obj = evaluated_copy(source, "MAP_"+name)
        copies.append(obj)
        source.hide_render = True
        select(obj)
        fallback_uv = next((uv.name for uv in obj.data.uv_layers if uv.active_render), None)
        if target.get("uv_mode", "unwrap") == "unwrap":
            obj.data.uv_layers.new(name="MappingUV")
            obj.data.uv_layers.active_index = len(obj.data.uv_layers)-1
            bpy.ops.object.mode_set(mode="EDIT"); bpy.ops.mesh.select_all(action="SELECT")
            bpy.ops.uv.smart_project(angle_limit=math.radians(60), island_margin=0.003, scale_to_bounds=True)
            bpy.ops.object.mode_set(mode="OBJECT")
        elif target["uv_mode"] != "existing" or not obj.data.uv_layers.active:
            raise ValueError("uv_mode must be unwrap or existing with a valid nonoverlapping UV map")
        mapping_uv = obj.data.uv_layers.active.name
        obj.data.uv_layers.active.active_render = True
        if not obj.data.materials:
            fallback = bpy.data.materials.new("MAP neutral fallback"); fallback.use_nodes = True
            obj.data.materials.append(fallback)
        for i, mat in enumerate(obj.data.materials):
            if mat is None:
                # GN can retain an unused empty slot before its assigned materials.
                # Keep the slot index; removing it would remap face identities.
                mat = bpy.data.materials.new(f"MAP neutral slot {i}"); mat.use_nodes = True
                obj.data.materials[i] = mat
            if not mat.use_nodes:
                raise ValueError(f"Target needs node materials in every slot: {name}, slot {i}")
        count = len(obj.data.materials); size = target.get("texture_size", 1024)
        selected = target.get("material_slots", list(range(count)))
        if not selected or any(type(i) is not int or i < 0 or i >= count for i in selected):
            raise ValueError(f"Invalid material_slots: {name}")
        for field, socket in (("position", "Position"), ("normal", "True Normal")):
            mat, nt, shader = emission("MAP_"+field)
            geo = nt.nodes.new("ShaderNodeNewGeometry")
            nt.links.new(geo.outputs[socket], shader.inputs["Color"])
            array = bake(obj, [mat]*count, size, field, args.samples)
            np.save(args.out/f"{name}_{field}.npy", array)
        mats = [emission("MAP_slot_"+str(i), (i+1,)*3)[0] for i in range(count)]
        indices = np.rint(bake(obj, mats, size, "material", args.samples)[..., 0]).astype(np.int32)-1
        normals = np.load(args.out/f"{name}_normal.npy")
        valid = (np.linalg.norm(normals, axis=2) > 0.5) & np.isin(indices, selected)
        if not valid.any():
            raise ValueError(f"No selected UV texels: {name}")
        np.save(args.out/f"{name}_material.npy", indices)
        np.save(args.out/f"{name}_valid.npy", valid)
        if target.get("appearance") == "estimated_albedo":
            white, nt, output = material("MAP_white_diffuse")
            diffuse = nt.nodes.new("ShaderNodeBsdfDiffuse")
            diffuse.inputs["Color"].default_value = (1, 1, 1, 1)
            nt.links.new(diffuse.outputs[0], output.inputs["Surface"])
            np.save(args.out/f"{name}_irradiance.npy", bake(obj, [white]*count, size, "irradiance", args.samples))
        report["targets"][name] = dict(object=obj.name, source_object=source.name,
            materials=[m.name for m in obj.data.materials], selected_slots=selected,
            mapping_uv=mapping_uv, fallback_uv=fallback_uv,
            polygons=len(obj.data.polygons), surface_texels=int(valid.sum()))
        obj.hide_render = True; source.hide_render = False
    bpy.data.libraries.write(str(args.out/"targets.blend"), set(copies), fake_user=True, path_remap="ABSOLUTE")
    visibility(cfg, args.out)
    report["files"] = {str(p.relative_to(args.out)): sha256(p) for p in sorted(args.out.rglob("*")) if p.is_file()}
    (args.out/"prepared.json").write_text(json.dumps(report, indent=2)+"\n")

def assemble(args, cfg):
    prep = json.loads((args.prepared/"prepared.json").read_text())
    mapped = json.loads((args.textures/"projection.json").read_text())
    if args.out.exists() or args.out.resolve() == Path(bpy.data.filepath).resolve():
        raise ValueError("Output must be a new .blend path; preserve the source")
    if prep["source_blend_sha256"] != sha256(bpy.data.filepath):
        raise ValueError("Source blend changed since preparation; rebake the new geometry")
    if sha256(args.config) != prep["config_sha256"] or mapped["prepared_sha256"] != sha256(args.prepared/"prepared.json"):
        raise ValueError("Config/prepared/mapped checkpoint mismatch")
    for directory, manifest in ((args.prepared, prep), (args.textures, mapped)):
        for name, digest in manifest["files"].items():
            if sha256(directory/name) != digest:
                raise ValueError(f"Artifact changed: {name}")
    if mapped["config_sha256"] != prep["config_sha256"]:
        raise ValueError("Projection config mismatch")
    wanted = [prep["targets"][t["name"]]["object"] for t in cfg["targets"]]
    if any(name in bpy.data.objects for name in wanted):
        raise ValueError("Source already contains mapped targets; use the original frozen source")
    with bpy.data.libraries.load(str(args.prepared/"targets.blend")) as (src, dst):
        dst.objects = wanted
    disable_reference(cfg)
    collection = bpy.data.collections.new("MAPPED_SURFACES")
    bpy.context.scene.collection.children.link(collection)
    for target, obj in zip(cfg["targets"], dst.objects):
        name = target["name"]
        collection.objects.link(obj); obj.hide_render = False; obj.hide_viewport = False
        source = bpy.data.objects[target["object"]]
        source.hide_render = True; source.hide_viewport = True
        original_uv = prep["targets"][name]["fallback_uv"]
        if original_uv:
            obj.data.uv_layers[original_uv].active_render = True
        rgb = bpy.data.images.load(str(args.textures/f"{name}_appearance.png"), check_existing=False)
        rgb.colorspace_settings.name = "sRGB"; rgb.pack()
        mix = bpy.data.images.load(str(args.textures/f"{name}_mix.png"), check_existing=False)
        mix.colorspace_settings.name = "Non-Color"; mix.pack()
        for slot in prep["targets"][name]["selected_slots"]:
            mat = obj.data.materials[slot].copy(); mat.name += " [mapped]"
            obj.data.materials[slot] = mat; nt = mat.node_tree
            output = next(n for n in nt.nodes if n.type == "OUTPUT_MATERIAL" and n.is_active_output)
            if not output.inputs["Surface"].is_linked:
                raise ValueError(f"No fallback surface shader: {name}/{slot}")
            fallback = output.inputs["Surface"].links[0].from_socket
            tex = nt.nodes.new("ShaderNodeTexImage"); tex.image = rgb; tex.extension = "EXTEND"
            mask = nt.nodes.new("ShaderNodeTexImage"); mask.image = mix; mask.extension = "EXTEND"
            uv = nt.nodes.new("ShaderNodeUVMap"); uv.uv_map = prep["targets"][name]["mapping_uv"]
            nt.links.new(uv.outputs[0], tex.inputs["Vector"]); nt.links.new(uv.outputs[0], mask.inputs["Vector"])
            settings = target.get("material_overrides", {}).get(str(slot), {})
            scale = nt.nodes.new("ShaderNodeVectorMath"); scale.operation = "SCALE"
            scale.inputs["Scale"].default_value = settings.get("color_scale", 1.0)
            nt.links.new(tex.outputs["Color"], scale.inputs[0])
            if target.get("appearance", "captured_radiance") == "captured_radiance":
                shader = nt.nodes.new("ShaderNodeEmission")
                nt.links.new(scale.outputs[0], shader.inputs["Color"])
            else:
                shader = nt.nodes.new("ShaderNodeBsdfPrincipled")
                nt.links.new(scale.outputs[0], shader.inputs["Base Color"])
                shader.inputs["Roughness"].default_value = settings.get("roughness", 0.5)
                shader.inputs["Specular IOR Level"].default_value = settings.get("specular", 0.5)
            blend = nt.nodes.new("ShaderNodeMixShader")
            nt.links.new(mask.outputs["Color"], blend.inputs[0])
            nt.links.new(fallback, blend.inputs[1]); nt.links.new(shader.outputs[0], blend.inputs[2])
            nt.links.new(blend.outputs[0], output.inputs["Surface"])
        obj["mapping_source_object"] = source.name
        obj["mapping_editability"] = "Baked evaluated mesh; edits to source GN require rebaking"
    bpy.context.scene["mapping_manifest_sha256"] = sha256(args.textures/"projection.json")
    args.out.parent.mkdir(parents=True, exist_ok=True)
    bpy.ops.wm.save_as_mainfile(filepath=str(args.out.resolve()), compress=True)
    print("SAVED_HYBRID", args.out, flush=True)

def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("action", choices=("prepare", "assemble"))
    parser.add_argument("--config", required=True, type=Path)
    parser.add_argument("--out", required=True, type=Path)
    parser.add_argument("--prepared", type=Path)
    parser.add_argument("--textures", type=Path)
    parser.add_argument("--device", choices=("CPU", "GPU"), default="CPU")
    parser.add_argument("--samples", type=int, default=32)
    args = parser.parse_args(sys.argv[sys.argv.index("--")+1:] if "--" in sys.argv else [])
    if not bpy.data.filepath or not Path(bpy.data.filepath).is_file():
        parser.error("Open a saved reconstructed scene first")
    if args.samples < 1:
        parser.error("samples must be positive")
    cfg, _ = load_config(args.config)
    if args.action == "prepare":
        prepare(args, cfg)
    elif args.prepared and args.textures:
        assemble(args, cfg)
    else:
        parser.error("assemble requires --prepared and --textures")

if __name__ == "__main__":
    main()
```

## File: skills/gaussian-splat-to-blender-nodes/scripts/projection_config.py
size: 6488
sha: f2c9fbd3a18e9a67d2d769008122cd7cc3d0e3ab
upstream: https://github.com/gokayfem/gaussian-splat-to-blender-nodes/blob/main/skills/gaussian-splat-to-blender-nodes/scripts/projection_config.py

```text
"""Shared projection configuration and file fingerprints (standard library only)."""
import hashlib
import json
import math
from pathlib import Path
import re
from cameras import validate_camera

def sha256(path):
    with Path(path).open("rb") as stream:
        digest = hashlib.sha256()
        for block in iter(lambda: stream.read(1024 * 1024), b""):
            digest.update(block)
    return digest.hexdigest()

def load_config(path):
    path = Path(path).resolve()
    cfg = json.loads(path.read_text())
    if cfg.get("schema_version") != 1 or not cfg.get("targets") or not cfg.get("views"):
        raise ValueError("schema_version=1, nonempty targets and views are required")
    names, objects, ids = set(), set(), set()
    for target in cfg["targets"]:
        name, obj, ident = target["name"], target["object"], target["object_id"]
        if not re.fullmatch(r"[A-Za-z0-9_-]+", name) or name in names or obj in objects:
            raise ValueError("Targets need unique safe names and unique source objects")
        if type(ident) is not int or not 1 <= ident <= 32766 or ident in ids:
            raise ValueError("Target object IDs must be unique integers in 1..32766")
        names.add(name); objects.add(obj); ids.add(ident)
        size = target.get("texture_size", 1024)
        if type(size) is not int or not 8 <= size <= 8192:
            raise ValueError("texture_size must be an integer in 8..8192")
        mode = target.get("appearance", "captured_radiance")
        if mode not in ("captured_radiance", "estimated_albedo"):
            raise ValueError("appearance must be captured_radiance or estimated_albedo")
        options(target)
        for rule in target.get("reject_rules", []):
            validate_rule(rule)
        if target.get("uv_mode", "unwrap") not in ("unwrap", "existing"):
            raise ValueError("uv_mode must be unwrap or existing")
        slots = target.get("material_slots")
        if slots is not None and (not slots or any(type(s) is not int or s < 0 for s in slots) or len(set(slots)) != len(slots)):
            raise ValueError("material_slots must be unique nonnegative integer indices")
        for slot, values in target.get("material_overrides", {}).items():
            if not slot.isdigit() or (slots is not None and int(slot) not in slots):
                raise ValueError("Material overrides must name selected, zero-based material slots")
            for key, value in values.items():
                if key not in ("robust_q", "color_scale", "roughness", "specular"):
                    raise ValueError(f"Unknown material setting: {key}")
                if not math.isfinite(value) or value < 0 or (key in ("roughness", "specular") and value > 1):
                    raise ValueError(f"Invalid material setting: {key}")
    for name, view in cfg["views"].items():
        if not re.fullmatch(r"[A-Za-z0-9_-]+", name):
            raise ValueError("View names must be safe file stems")
        validate_camera(view["camera"])
        if min(view["camera"][k] for k in ("width", "height")) < 3:
            raise ValueError("Projection captures must be at least 3x3 pixels")
        if not view.get("image"):
            raise ValueError("Every view needs a capture image path")
        if set(view.get("source_valid_masks", {})) - names:
            raise ValueError("source_valid_masks must name configured targets")
    return cfg, path.parent

def options(target):
    p = dict(mode="weighted", power=12.0, min_cos=0.3, vertical_min_cos=0.3,
             depth_absolute=0.006, depth_relative=0.004, depth_erode=1,
             border=9, resolution_cap=0.0, edge_feather=0.0, robust_q=0.3,
             mix_feather=3.0, padding=8, irradiance_floor=0.35)
    unknown = set(target.get("projection", {})) - p.keys()
    if unknown:
        raise ValueError(f"Unknown projection options: {sorted(unknown)}")
    p.update(target.get("projection", {}))
    if p["mode"] not in ("weighted", "robust"):
        raise ValueError("Projection mode must be weighted or robust")
    for key in ("min_cos", "vertical_min_cos", "edge_feather"):
        if not math.isfinite(p[key]) or not 0 <= p[key] <= 1:
            raise ValueError(f"{key} must be in [0,1]")
    for key in ("depth_absolute", "depth_relative", "resolution_cap", "robust_q", "mix_feather"):
        if not math.isfinite(p[key]) or p[key] < 0:
            raise ValueError(f"{key} must be finite and nonnegative")
    if not 0 < p["power"] <= 32 or not 0 < p["irradiance_floor"] <= 1:
        raise ValueError("power must be in (0,32], irradiance_floor in (0,1]")
    for key in ("depth_erode", "border", "padding"):
        if type(p[key]) is not int or not 0 <= p[key] <= 255:
            raise ValueError(f"{key} must be an integer in 0..255")
    return p

def validate_rule(rule):
    """Reject malformed local heuristics before starting any Blender bakes."""
    allowed = {"bounds_min", "bounds_max", "rgb_min", "rgb_max", "channel_ratios", "min_sum"}
    if set(rule) - allowed:
        raise ValueError("Unknown rejection rule field")
    for key in ("bounds_min", "bounds_max", "rgb_min", "rgb_max"):
        if key not in rule and key.startswith("rgb"):
            continue
        value = rule.get(key, [])
        if len(value) != 3 or not all(math.isfinite(v) for v in value):
            raise ValueError(f"{key} must contain three finite numbers")
        if key.startswith("rgb") and not all(0 <= v <= 1 for v in value):
            raise ValueError(f"{key} must be in [0,1]")
    if any(lo > hi for lo, hi in zip(rule["bounds_min"], rule["bounds_max"])):
        raise ValueError("Rejection bounds must be ordered")
    for ratio in rule.get("channel_ratios", []):
        if len(ratio) != 3 or any(c not in ("r", "g", "b") for c in ratio[:2]):
            raise ValueError("Channel ratios require two channel names and a ratio")
        if not math.isfinite(ratio[2]) or ratio[2] <= 0:
            raise ValueError("Channel ratios must be finite and positive")
    if "min_sum" in rule:
        value = rule["min_sum"]
        channels = value.get("channels", [])
        if not channels or len(set(channels)) != len(channels) or any(c not in ("r", "g", "b") for c in channels):
            raise ValueError("min_sum requires unique RGB channels")
        if not math.isfinite(value["value"]) or not 0 <= value["value"] <= len(channels):
            raise ValueError("min_sum threshold is outside the channel sum range")
```

## File: skills/gaussian-splat-to-blender-nodes/scripts/projection_core.py
size: 8761
sha: 251833fce3ed72409012a42d2bbe7f3e93356166
upstream: https://github.com/gokayfem/gaussian-splat-to-blender-nodes/blob/main/skills/gaussian-splat-to-blender-nodes/scripts/projection_core.py

```text
"""Project-independent capture projection, derived from the Garden c11b/c22s/c26b study.

No Blender dependency. Coordinates and tolerances are in the configured world frame.
Captured radiance is not clean albedo; source masks complement reconstructed depth.
"""
import math

import numpy as np
from scipy.ndimage import distance_transform_edt, gaussian_filter, maximum_filter, minimum_filter

from projection_config import options

def srgb_to_linear(c):
    return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)

def linear_to_srgb(c):
    c = np.clip(c, 0, 1)
    return np.where(c <= 0.0031308, c * 12.92, 1.055 * c ** (1 / 2.4) - 0.055)

def project(camera, points):
    pos, look, up = (np.asarray(camera[k], float) for k in ("position", "look_at", "up"))
    z = pos - look; z /= np.linalg.norm(z)
    x = np.cross(up, z); x /= np.linalg.norm(x)
    y = np.cross(z, x)
    d = points - pos
    distance = np.linalg.norm(d, axis=1)
    forward = -(d @ z)
    f = 0.5 * camera["height"] / math.tan(math.radians(camera["fov_y_deg"]) / 2)
    safe = np.where(np.abs(forward) > 1e-12, forward, 1e-12)
    u = camera["width"] / 2 + f * (d @ x) / safe
    v = camera["height"] / 2 - f * (d @ y) / safe
    return d, distance, forward, f, u, v

def bilinear(image, u, v):
    h, w = image.shape[:2]
    u = np.clip(u - 0.5, 0, w - 1.001); v = np.clip(v - 0.5, 0, h - 1.001)
    x, y = u.astype(int), v.astype(int)
    a, b = (u - x)[:, None], (v - y)[:, None]
    return (image[y, x] * (1-a) * (1-b) + image[y, x+1] * a * (1-b)
            + image[y+1, x] * (1-a) * b + image[y+1, x+1] * a * b)

def reject_samples(points, srgb, rules):
    """Explicit, optional world-region/color rules; never infer semantic labels from hue."""
    rejected = np.zeros(len(points), bool)
    channels = {"r": 0, "g": 1, "b": 2}
    for rule in rules:
        # A bounded region is mandatory: a scene-specific fix must not filter an entire world.
        low, high = np.asarray(rule["bounds_min"], float), np.asarray(rule["bounds_max"], float)
        if low.shape != (3,) or high.shape != (3,) or not np.isfinite([low, high]).all() or (low > high).any():
            raise ValueError("Rejection rules need finite, ordered 3D bounds")
        hit = ((points >= low) & (points <= high)).all(1)
        for left, right, ratio in rule.get("channel_ratios", []):
            if not math.isfinite(ratio) or ratio <= 0:
                raise ValueError("Channel ratios must be finite and positive")
            hit &= srgb[:, channels[left]] > ratio * srgb[:, channels[right]]
        if "rgb_min" in rule:
            hit &= (srgb >= np.asarray(rule["rgb_min"])).all(1)
        if "rgb_max" in rule:
            hit &= (srgb <= np.asarray(rule["rgb_max"])).all(1)
        if "min_sum" in rule:
            hit &= srgb[:, [channels[c] for c in rule["min_sum"]["channels"]]].sum(1) > rule["min_sum"]["value"]
        rejected |= hit
    return rejected

def prepare_view(view, target, p):
    depth, ids = view["depth"], view["ids"]
    depth = minimum_filter(depth, size=max(p["depth_erode"], 1))
    foreign = maximum_filter(ids != target["object_id"], size=max(p["border"], 1))
    allowed = view.get("allowed")
    if allowed is not None:
        # Bilinear samples may touch four pixels; don't interpolate across a rejected pixel.
        allowed = minimum_filter(allowed, size=3)
    return dict(view, depth=depth, foreign=foreign, allowed=allowed,
                linear_rgb=srgb_to_linear(view["rgb"].astype(np.float32)/255))

def sample_view(points, normals, view, target, p):
    c = view["camera"]
    d, distance, forward, f, u, v = project(c, points)
    cosine = -(normals*d).sum(1) / np.maximum(distance, 1e-12)
    x, y = np.clip(u.astype(int), 0, c["width"]-1), np.clip(v.astype(int), 0, c["height"]-1)
    threshold = np.where(np.abs(normals[:, 2]) < 0.4, p["vertical_min_cos"], p["min_cos"])
    valid = (forward > 0) & (u >= 1) & (u < c["width"]-1) & (v >= 1) & (v < c["height"]-1)
    valid &= cosine > threshold
    valid &= distance <= view["depth"][y, x] + p["depth_absolute"] + p["depth_relative"]*distance
    valid &= ~view["foreign"][y, x]
    observed_candidate = valid.copy()
    if view.get("allowed") is not None:
        valid &= view["allowed"][y, x]
    color = bilinear(view["linear_rgb"], u, v)
    valid &= ~reject_samples(points, linear_to_srgb(color), target.get("reject_rules", []))
    resolution = f / np.maximum(distance, 1e-12)
    if p["resolution_cap"]:
        resolution = np.minimum(resolution, p["resolution_cap"])
    weight = ((np.clip(cosine, 0, 1)**4)*resolution**2)**(p["power"]/2)
    if p["edge_feather"]:
        edge = np.minimum(np.minimum(u, c["width"]-u)/(c["width"]/2),
                          np.minimum(v, c["height"]-v)/(c["height"]/2))
        t = np.clip(edge/p["edge_feather"], 0, 1)
        weight *= t*t*(3-2*t)
    return np.where(valid, weight, 0), color, observed_candidate

def blend_samples(weights, colors, mode, quantile):
    """c11b robust diffuse consensus: suppress bright outliers without selecting dark ghosts."""
    if mode == "robust":
        lum = colors @ np.array([0.2126, 0.7152, 0.0722])
        total = weights.sum(0)
        mu = (weights*lum).sum(0)/np.maximum(total, 1e-300)
        sd = np.sqrt(np.maximum((weights*lum*lum).sum(0)/np.maximum(total, 1e-300)-mu*mu, 0)) + 0.02
        dark = lum < mu - np.where(quantile > 1, 3.0, 2.2)*sd
        weights = np.where(dark, 0, weights*np.exp(-3*np.maximum(lum-(mu-quantile*sd), 0)/sd))
    total = weights.sum(0)
    color = (weights[..., None]*colors).sum(0)/np.maximum(total, 1e-300)[:, None]
    return color, total > 0

def project_surface(position, normal, valid, material_ids, views, target, chunk_size=65536):
    p = options(target)
    index = np.flatnonzero(valid)
    output = np.zeros((*valid.shape, 3), np.float32)
    state = np.zeros(valid.shape, np.uint8)
    prepared = [prepare_view(v, target, p) for v in views]
    for start in range(0, len(index), chunk_size):
        ix = index[start:start+chunk_size]
        points, normals = position.reshape(-1, 3)[ix], normal.reshape(-1, 3)[ix].astype(float)
        normals /= np.maximum(np.linalg.norm(normals, axis=1, keepdims=True), 1e-12)
        sampled = [sample_view(points, normals, v, target, p) for v in prepared]
        weights = np.array([s[0] for s in sampled])
        colors = np.array([s[1] for s in sampled])
        q = np.full(len(ix), p["robust_q"])
        for slot, override in target.get("material_overrides", {}).items():
            q[material_ids.ravel()[ix] == int(slot)] = override.get("robust_q", p["robust_q"])
        color, covered = blend_samples(weights, colors, p["mode"], q)
        candidate = np.array([s[2] for s in sampled]).any(0)
        output.reshape(-1, 3)[ix] = color
        # 2 = observed candidate rejected to procedural fallback; 3 = unseen fallback.
        state.ravel()[ix] = np.where(covered, 1, np.where(candidate, 2, 3))
    return output, state

def finish_texture(color, state, material_ids, target, irradiance=None):
    """Feather only toward procedural fallback; never fill unseen faces with another object's color."""
    p = options(target)
    direct = state == 1
    if target.get("appearance", "captured_radiance") == "estimated_albedo":
        if irradiance is None or irradiance.shape != color.shape or not np.isfinite(irradiance).all():
            raise ValueError("estimated_albedo needs a finite, same-size linear irradiance bake")
        e = irradiance.mean(2)
        lit = direct & (e > 0.05)
        if not lit.any():
            raise ValueError("No lit direct texels for an irradiance estimate")
        floor = p["irradiance_floor"]*float(np.median(e[lit]))
        color = np.clip(color/np.maximum(e, floor)[..., None], 0, 0.9)
    mix = np.zeros(state.shape, np.float32)
    # Material-aware filtering preserves slate/wood and other real face-slot boundaries.
    for slot in np.unique(material_ids[state > 0]):
        surface = (material_ids == slot) & (state > 0)
        d = direct & surface
        if p["mix_feather"]:
            f = gaussian_filter(d.astype(float), p["mix_feather"])/np.maximum(
                gaussian_filter(surface.astype(float), p["mix_feather"]), 1e-6)
            mix[d] = np.clip(2*f[d]-0.5, 0, 1)
        else:
            mix[d] = 1
    rgb = linear_to_srgb(color)
    if direct.any() and p["padding"]:
        distance, nearest = distance_transform_edt(~direct, return_indices=True)
        padding = (state == 0) & (distance <= p["padding"])
        rgb[padding] = rgb[tuple(nearest[:, padding])]
        mix[padding] = mix[tuple(nearest[:, padding])]
        state = state.copy(); state[padding] = 4
    return rgb, mix, state
```

## File: skills/gaussian-splat-to-blender-nodes/scripts/render_manifest.py
size: 3691
sha: 0f87a7d79c3303b93279d5e6f4199576c19c698b
upstream: https://github.com/gokayfem/gaussian-splat-to-blender-nodes/blob/main/skills/gaussian-splat-to-blender-nodes/scripts/render_manifest.py

```text
"""Render a shared camera manifest in Blender. Removes GS_REFERENCE in memory.

One process renders sequentially. No scan renderer is bundled: export the manifest's
scan cameras in the chosen reference viewer using identical dimensions and FOV.
Does not save or overwrite the source .blend or change its lighting/color management.
"""
import argparse
import hashlib
import json
from pathlib import Path
import sys
import bpy
from mathutils import Matrix, Vector

sys.path.insert(0, str(Path(__file__).resolve().parent))
from cameras import validate_camera

def main():
    p = argparse.ArgumentParser(description=__doc__)
    p.add_argument("--manifest", required=True, type=Path)
    p.add_argument("--out", required=True, type=Path)
    p.add_argument("--reference", default="GS_REFERENCE")
    p.add_argument("--views", help="Comma-separated names; default is every world camera")
    p.add_argument("--samples", type=int, default=32)
    p.add_argument("--engine", choices=["CYCLES", "BLENDER_EEVEE"], default="BLENDER_EEVEE")
    a = p.parse_args(sys.argv[sys.argv.index("--")+1:] if "--" in sys.argv else [])
    data = json.loads(a.manifest.read_text())
    cameras = data["world"]
    chosen = a.views.split(",") if a.views else list(cameras)
    if a.samples <= 0 or not chosen:
        p.error("Positive samples and at least one camera required")
    for name in chosen:
        if not name or Path(name).name != name or name in (".", "..") or "\\" in name:
            p.error("Camera names must be safe file stems")
        validate_camera(cameras[name])
    a.out.mkdir(parents=True, exist_ok=False)
    ref = bpy.data.collections.get(a.reference)
    if ref:
        for ob in list(ref.all_objects):
            bpy.data.objects.remove(ob, do_unlink=True)
        bpy.data.collections.remove(ref, do_unlink=True)
    sc = bpy.context.scene
    sc.render.engine = a.engine
    if a.engine == "CYCLES":
        sc.cycles.device, sc.cycles.samples, sc.cycles.use_denoising = "CPU", a.samples, True
    else:
        sc.eevee.taa_render_samples = a.samples
    cam = bpy.data.objects.new("QA_manifest_camera", bpy.data.cameras.new("QA_manifest_camera"))
    sc.collection.objects.link(cam)
    sc.camera = cam
    cam.data.sensor_fit = "VERTICAL"
    cam.data.clip_start, cam.data.clip_end = 0.01, 1000
    sc.render.resolution_percentage = 100
    sc.render.pixel_aspect_x = sc.render.pixel_aspect_y = 1
    sc.render.use_border = False
    sc.render.image_settings.file_format = "PNG"
    sc.render.image_settings.color_mode = "RGB"
    record = dict(manifest_sha256=hashlib.sha256(a.manifest.read_bytes()).hexdigest(),
                  engine=a.engine, samples=a.samples, view_transform=sc.view_settings.view_transform, views=[])
    import math
    for name in chosen:
        c = cameras[name]
        pos, target, up = (Vector(c[k]) for k in ("position", "look_at", "up"))
        z = (pos-target).normalized()
        x = up.cross(z).normalized()
        y = z.cross(x)
        cam.matrix_world = Matrix(((x.x, y.x, z.x, pos.x), (x.y, y.y, z.y, pos.y),
                                   (x.z, y.z, z.z, pos.z), (0, 0, 0, 1)))
        cam.data.angle_y = math.radians(c["fov_y_deg"])
        sc.render.resolution_x, sc.render.resolution_y = c["width"], c["height"]
        path = a.out / f"{name}.png"
        sc.render.filepath = str(path.resolve())
        bpy.ops.render.render(write_still=True)
        record["views"].append(dict(name=name, camera=c, sha256=hashlib.sha256(path.read_bytes()).hexdigest()))
        (a.out/"render.json").write_text(json.dumps(record, indent=2)+"\n")
        print(f"RENDERED {name}", flush=True)

if __name__ == "__main__":
    main()
```

## File: skills/gaussian-splat-to-blender-nodes/references/acquisition-and-cameras.md
size: 2796
sha: cc922f9c14cdead710e7d29e76ad214ed788bbdf
upstream: https://github.com/gokayfem/gaussian-splat-to-blender-nodes/blob/main/skills/gaussian-splat-to-blender-nodes/references/acquisition-and-cameras.md

````text
# Acquisition and camera registration

For the complete source-viewer, coverage and capture sequence, read
[whole-scene capture](whole-scene-capture.md) and use the
[capture prompt](../templates/prompts/capture-and-registration.md).

Record scene/author, asset and viewer URLs, retrieval date, bytes, SHA-256, producer/version,
usage terms and conversions. Keep scans, decoded per-splat arrays and private session data
local. Do not infer dataset rights from an MIT viewer.

## Formats

Use the producer's decoder and confirm field semantics. Common 3DGS training PLY files
contain log-scales, opacity logits, quaternions and spherical harmonics; PLY is only a
container and other exporters differ. Do not exponentiate or sigmoid already-linear data.
A common 32-byte `.splat` layout contains position, scale, RGBA and quantized rotation
without higher-order SH. KSPLAT uses versioned compressed sections; SPZ is different.

The inspector enumerates PLY headers, checks common `.splat` record size, and reads
KSPLAT v0.1 main-header metadata. It does not validate PLY bodies/KSPLAT payloads, decode
positions or infer semantic labels. Unknown formats receive a fingerprint only.

Primary implementations: [GaussianSplats3D](https://github.com/mkkellogg/GaussianSplats3D)
and [3D Gaussian Splatting](https://github.com/graphdeco-inria/gaussian-splatting).

## One frame, two renderers

With column vectors, proper rotation R, scan origin o and positive metric scale s:

```text
world_point = s * R * (scan_point - o)
scan_point = transpose(R) * world_point / s + o
world_direction = R * scan_direction
```

Directions/up receive no translation or metric scale. Check orthonormal rows and determinant
+1. A reflection is not a rotation. Example `frame.json`:

```json
{
  "scale_m_per_unit": 1.0,
  "origin_scan": [0, 0, 0],
  "R_scan_to_world": [[1, 0, 0], [0, 1, 0], [0, 0, 1]],
  "assumption": "Scale measured from a known physical dimension"
}
```

World is right-handed, metres and Z-up. Blender cameras look along local -Z with +Y up.
For position p, target t and up u, use z=normalize(p-t), x=normalize(cross(u,z)),
y=cross(z,x) as matrix columns. Reject collinear up/view vectors.

Record **vertical** FOV, dimensions and square pixel aspect. Account for browser viewport
bounds: a cropped screenshot is not necessarily the whole render viewport. Register three
dispersed landmarks, then verify another landmark and an opposite view. Check projected
pixels before changing geometry. Lock the camera while correcting a part.

Scale may be an assumption: Garden uses a table height of 0.74 m. Label estimates and
inferred hidden surfaces. Splat floaters, transparent backsides and unseen regions can be
capture artifacts. A camera manifest proves repeatability, not ground-truth calibration.
````

## File: skills/gaussian-splat-to-blender-nodes/references/comparison-videos.md
size: 2139
sha: 24cc742aeab6a8d4d2850134f1a5eaa4a01ab6fe
upstream: https://github.com/gokayfem/gaussian-splat-to-blender-nodes/blob/main/skills/gaussian-splat-to-blender-nodes/references/comparison-videos.md

````text
# Matched 360-degree comparison

Run from the repository root; substitute the installed Blender executable if needed.

```sh
S=skills/gaussian-splat-to-blender-nodes
python3 "$S/scripts/cameras.py" --frame prompts/garden/data/frame.json \
  --target 0 0 0.7 --radius 3 --elevation 30 --frames 240 --fps 24 \
  --width 640 --height 640 --out outputs/orbit.json

blender -b --factory-startup outputs/garden.blend --python-exit-code 1 \
  --python "$S/scripts/render_manifest.py" -- \
  --manifest outputs/orbit.json --out outputs/orbit-native --samples 32
```

This produces 240 poses at fixed distance/elevation. The closing pose is not duplicated.
The manifest's FPS defines duration; the Blender scene remains at its current frame.
This is a static-scene camera orbit, not animation evaluation.

Render the manifest's **scan** cameras in the reference viewer with exact position, target,
up, vertical FOV, dimensions and six-digit PNG filenames. Record quality, SH degree,
background and exposure. Disable auto-rotation and let the viewer settle. A universal
reference renderer/browser driver is not included.

Do not reframe either side, interpolate missing frames or approximate the reference orbit
by eye. With limited scan coverage, select and label a supported arc instead of claiming 360°.

```sh
python3 -m pip install -r requirements.txt
python3 "$S/scripts/compare.py" outputs/orbit-reference outputs/orbit-native \
  --out outputs/comparison --video --fps 24
```

Requires FFmpeg with libx264 on PATH. Output: H.264/yuv420p, CRF 18, faststart, no audio.
Pairing rejects missing names, inconsistent dimensions and existing output directories.
Labels occupy a separate 32-pixel strip. The encoder pads at most one pixel at the right
or bottom for even dimensions. It does not resize or auto-align the scene images.

`evidence.json` records input/output hashes, frame count and FPS. Equal image sizes do not
prove camera matching: verify manifests and landmark projections. Inspect quarter turns
and the seam for clipping, missing frames, labels and changing occlusion.

For a still pair, give two image files and omit `--video`.
````

## File: skills/gaussian-splat-to-blender-nodes/references/hybrid-projection.md
size: 12080
sha: 5ef0bdf649b4f09cacb2f780d56d7a99a2372ac9
upstream: https://github.com/gokayfem/gaussian-splat-to-blender-nodes/blob/main/skills/gaussian-splat-to-blender-nodes/references/hybrid-projection.md

````text
# Optional hybrid appearance mapping

Use this mode when the user wants captured appearance on reconstructed surfaces. The
procedural scene remains the geometric source. Selected surfaces become **baked evaluated
meshes with packed textures**, while untouched parts retain their original node graphs.
Keep the source blend and label that distinction. This is not automatic splat meshing,
material recovery or a texture that follows later Geometry Nodes edits.

## Contents

- [Inputs and configuration](#inputs-and-configuration)
- [Run the pipeline](#run-the-pipeline)
- [Visibility and fallback](#visibility-and-fallback)
- [Appearance and material slots](#appearance-and-material-slots)
- [Review and delivery](#review-and-delivery)

## Inputs and configuration

Start from [the hybrid prompt](../templates/prompts/hybrid-projection.md) and copy
[projection.json](../templates/projection.json) into the project. Its object name, cameras,
paths and distances are **illustrative values to replace**, not fitted defaults for a scene.
No Garden object list, coordinates, semantic labels, hue filters or texture files are built
into the tools.

Supply a saved reconstructed `.blend` and calibrated, opaque RGB sRGB PNG/JPEG captures from the
chosen reference renderer. Composite transparent captures deliberately before use; the
projector ignores alpha. Images must have the declared dimensions, with no lens distortion,
crop, camera shift or UI overlay. Cameras use world-space position/look_at/up, vertical FOV
in degrees, square pixels, and a top-left image origin. Register geometry in the same world
frame. The tools do not estimate poses or run a splat viewer. See
[whole-scene capture](whole-scene-capture.md).

Configure:

| Field | Meaning |
|---|---|
| `targets[].name` | Unique safe output stem |
| `object` / `object_id` | Exact reconstructed Blender object name / unique integer 1–32766 |
| `texture_size` | Square atlas size, 8–8192; start small and measure memory/time |
| `material_slots` | Optional selected **zero-based evaluated face material slots**; omitted means all |
| `uv_mode` | `unwrap` for a new smart unwrap; `existing` for an already valid nonoverlapping active UV map |
| `appearance` | `captured_radiance` (unlit) or `estimated_albedo` (approximate lit surface) |
| `views` | Named calibrated images and cameras; paths relative to the config file |
| `source_valid_masks` | Per-view mapping from target name to grayscale source mask; white allows that surface |
| `reference_collections` | Reference collections hidden during preparation/assembly; default `GS_REFERENCE` |

Targets are mesh objects, including generating GN mesh hosts. Instances are realized on a
copy; points/curves that do not evaluate to faces are not supported. Object transforms and
actual material-slot identities are preserved. A missing slot gets a neutral fallback without
renumbering faces. Existing non-node materials must be converted before preparation. Unwrapping adds a new
mapping UV layer; existing layers and the original render UV selection are preserved for
fallback shaders. Explicit mapping nodes use the new layer. Original fallback image
dependencies are resolved to their source paths; only new appearance/mix images are packed
automatically. Keep external fallback assets available or package them with the deliverable.

Tune distances to the project's units. `depth_absolute` is world-space tolerance;
`depth_relative` is dimensionless. `border` and `depth_erode` are pixel filter widths,
`mix_feather` is an atlas-pixel Gaussian sigma, and `padding` is atlas pixels. Optional
`resolution_cap` limits pixels per world unit and `edge_feather` fades camera-edge weights.
`min_cos` rejects grazing views; `vertical_min_cos` applies where `abs(normal.z) < 0.4`
in a Z-up Blender world. Leave these equal for orientation-independent weighting.
All these controls belong to the configuration, not scene-specific code.

## Run the pipeline

Use Python 3.10+ with `numpy`, `scipy` and `Pillow` (the repository's requirements include
them), and Blender 5.0+ with its bundled NumPy. Tested versions are recorded in the repository
validation notes. Run sequentially in disposable background processes; do not run scripts
against an unsaved interactive scene.

```sh
S=skills/gaussian-splat-to-blender-nodes

# Freeze geometry, bake world positions/normals/material IDs and scene visibility.
blender -b --factory-startup project/source.blend --python-exit-code 1 \
  --python "$S/scripts/projection_blender.py" -- prepare \
  --config project/mapping.json --out project/outputs/prepared-v01 --samples 32

# Project calibrated captures, reject contaminated samples, write textures + coverage.
python3 "$S/scripts/project_textures.py" \
  --config project/mapping.json --prepared project/outputs/prepared-v01 \
  --out project/outputs/mapped-v01

# Assemble packed appearance/mix images on copies with the original shaders as fallback.
blender -b --factory-startup project/source.blend --python-exit-code 1 \
  --python "$S/scripts/projection_blender.py" -- assemble \
  --config project/mapping.json --prepared project/outputs/prepared-v01 \
  --textures project/outputs/mapped-v01 --out project/outputs/hybrid-v01.blend
```

Each output must be new. Preparation fingerprints the config and source blend; projection
checks all prepared arrays and records capture/mask hashes; assembly verifies prepared and
mapped artifacts. Changing the config, geometry, materials, transforms or lighting means a
new prepared checkpoint. Unchanged captures may be reused. Reprojecting changed captures
requires a new mapped directory. Reports describe the files actually used, not a mutable
“latest” alias. Changing the source after assembly does not update the mapped copies.

CPU is the default. `prepare --device GPU` requires the user's existing Cycles device setup;
the tool does not alter device preferences. `--chunk-size` on the projector controls the
texel batch size, not the full cached capture/atlas memory. Visibility needs two renders per
view; each target needs three UV bakes, plus irradiance for estimated albedo. Pilot with a
small atlas and a few views before scaling.

## Visibility and fallback

Projection combines facing-angle/resolution weighting, reconstructed scene distance and
object-ID rejection, and conservative object boundaries. Only the first opaque reconstructed
surface is modeled by the visibility pass. Transparent/transmissive surfaces, volumes,
animation, view-dependent splat geometry and overlapping UVs need a separate validated
workflow; they are not solved here.

**Reconstructed depth cannot detect an occluder that exists only in the source capture.**
For example, if an unmodeled prop covers a surface in the scan, its pixels may be assigned
to that surface. Add per-target source masks, a better aligned reconstruction, or another
clean view. Masks use the same dimensions/camera as the capture. The allowed region is
shrunk to avoid bilinear sampling through rejected pixels.

When masks are impractical, `reject_rules` can explicitly reject samples in a bounded world
region. A rule requires `bounds_min` and `bounds_max` vectors, and optionally `rgb_min`,
`rgb_max`, `channel_ratios` (triples such as `["g", "r", 1.5]`) and
`min_sum` (`{"channels": ["g", "b"], "value": 0.5}`). Color values use sRGB 0–1.
Conditions within one rule are ANDed; multiple rules are ORed. An empty condition set rejects
the entire bounded region. These are **user-configured heuristics**, not segmentation.
Do not copy a study's hue/coordinate thresholds to another scene. Verify any such rejection
from a second view so legitimate surface color is not erased.

No acceptable sample means original-material fallback. The projector never copies nearby
captured color into unseen faces. Filtering uses real material IDs; padding affects only
pixels outside the selected UV surface. Keep adequate island spacing for the chosen padding
and texture filtering. Inspect mapping at normal size before chasing subpixel UV artifacts.

Coverage PNGs contain raw class values (0–4), so they look nearly black in an ordinary image
viewer; display them with discrete colors when diagnosing a visible defect. NumPy copies and
the legend are also saved:

| Value | Interpretation |
|---|---|
| 0 | Outside the selected UV surface |
| 1 | Direct accepted capture; edge texels may blend toward fallback |
| 2 | Geometrically visible candidate rejected by a mask/rule; fallback |
| 3 | No acceptable geometric visibility/facing sample; fallback |
| 4 | Padding outside the selected surface |

Percentages use selected valid UV texels, not scene area, image pixels or fidelity. The
`*_mix.png` image is the actual shader blend weight; class 1 alone does not imply 100% mix.

## Appearance and material slots

`weighted` averages in linear light. `robust` adds a diffuse-consensus heuristic that reduces
bright view outliers and rejects sufficiently dark outliers. It can still blur details or
choose the wrong view. `robust_q` controls that heuristic; it is not a measured quantile or
an image-quality score. Preserve actual face/material separation instead of inferring it
from “horizontal” or “vertical” normals.

`material_overrides` is keyed by actual slot index as a string. Supported values are
`robust_q`, `color_scale`, and, for estimated albedo, `roughness` and `specular`. Inspect
`prepared.json` to see the evaluated slot names. Configure any needed overrides, then
prepare a fresh checkpoint. Unselected slots retain their source materials.

- **Captured radiance:** emission avoids applying scene light a second time. Captured
  shadows/highlights remain fixed, and the mapped object can contribute emitted light in
  Cycles. This is an appearance display mode, not a relightable material.
- **Estimated albedo:** a white diffuse irradiance bake under the source scene's lighting
  divides out approximate illumination with a floor to limit shadow amplification. The
  captured image may include specular response, baked shadows and view dependence that this
  approximation cannot remove. It feeds a Principled shader and can still show double lighting.

Neither mode recovers physical albedo. Compare unlit/lit diagnostics when a broad brightness
mismatch appears; do not solve it by blindly increasing texture gains.

## Review and delivery

First inspect matched reference, procedural/clay and hybrid hero plus opposite/oblique views
at normal viewing size. Rank up to three material defects: wrong-object appearance on another
surface, broad ghosts/stretching, large color/material errors or materially missing coverage.
Use atlas/mask crops only to explain those defects. Separate geometry/camera mismatch from
projection errors, and plausible cast shadows from false textures. Rough source boundaries
and tiny seams alone need not block a scoped ready-for-review result.

Reopen the saved hybrid and verify packed images, intended UV map/slot wiring, hidden source
copies and fallback coverage. Audit live procedural collections separately:
`audit_blend.py` intentionally rejects baked hosts and is **not** a hybrid acceptance test.
The synthetic [integration procedure](../../../tests/README.md) tests the mapping pipeline
in a full repository checkout; a skill-only install does not include repository tests.
Remove reference data in a disposable copy and render representative views to establish
independence from the original scan; packed appearance remains intentionally derived from it.

Deliver exact source/config/texture/blend/report paths and hashes, matched views and the
editability contract. Record source permissions, unobserved surfaces and lighting limits.
A full 360° comparison must use the same poses and labels on all sides; do not synthesize
missing reference evidence. Package once major scoped mismatches are addressed instead of
running an unbounded edge-polish loop. The generalized implementation derives from the
c11b/c22s/c26b study, but it does not reproduce that fitted scene without its local inputs.
````

## File: skills/gaussian-splat-to-blender-nodes/references/procedural-construction.md
size: 2900
sha: c8135cb00fe21fc1fd926cb952db4b0e941d0ee2
upstream: https://github.com/gokayfem/gaussian-splat-to-blender-nodes/blob/main/skills/gaussian-splat-to-blender-nodes/references/procedural-construction.md

```text
# Procedural reconstruction patterns

| Subject | Construction | Verification |
|---|---|---|
| Round table | Radial plank wedges, separate slate disc, frame, braces and feet | Top, low side, rim crop |
| Irregular vase | Radius profile, azimuth deformation, thick rim and inner wall | Side silhouette and mouth |
| Dried arrangement | Stem hierarchy, broad fans, curled leaves and seedhead clusters | Hero outline, side and close-up |
| Tree | Measured branch paths, taper, continuous forks, bark displacement | Backlit outline, fork crop |
| Shrubs | Coarse occupancy controls, branches and oriented leaf families | Gaps, overlap and depth from two sides |
| Paving | Measured slab layout, bevels/chips, recessed joints and constrained litter | Overview and grazing close-up |
| Lawn/beds | Uneven density, blade clumps, bare patches and soil | Ground-level edge and overhead patches |
| Pots/structures | Profiles, lips, soil surfaces, supports, stakes and contents | Paired views with correct occlusion |

## Spatial frequencies

**Macro:** placement, silhouette, supports and canopy envelope.
**Meso:** joinery, plank gaps, vase mouth, leaves, forks, slab edges and soil clumps.
**Micro:** grain pores, scratches, roughness breakup and small bark fissures.

Use geometry when a feature changes silhouette, parallax or shadows. Shading can carry
small surface response. Micro-noise cannot compensate for absent meso-geometry. Fit
structure with curves/parameters, then use seeded randomness inside bounded distributions.

Do not make all foliage spheres or identical flat leaves. Use species-like profiles,
curl/thickness where visible, branch-scale clustering and coherent orientation. Preserve
negative spaces; indiscriminate density often makes matching worse.

## Materials and lighting

Splat appearance includes captured illumination and view dependence. Sampling it as albedo
then lighting it again can double shadows. Keep camera, color management and exposure fixed
while testing one variable at a time. Convert sRGB picks to linear shader values.

Use local coordinates for grain, bark and slate. Match grain direction and joint dirt before
adding noise. Tune roughness independently of color. Check wet highlights, metallic response,
black cavities and leaf translucency. Use neutral clay to isolate geometry/light errors.
Match key direction, sky fill and contact shadows before applying a global tint.

## Editability

Keep semantic JSON parameters, deterministic seeds, named groups and documented controls.
A sparse fitted occupancy field is useful; the full scan as a hidden geometry source is not
an independent reconstruction. Document required helper data.

Keep instances until needed. Zero evaluated mesh vertices on an instancer does not mean
empty output: inspect dependency-graph instances. A baked mesh with a pass-through GN
modifier is not a native procedural builder.
```

## File: skills/gaussian-splat-to-blender-nodes/references/prompting-workflow.md
size: 7424
sha: 3a358a223d1d929fa93326cbea4dc73adf0d1045
upstream: https://github.com/gokayfem/gaussian-splat-to-blender-nodes/blob/main/skills/gaussian-splat-to-blender-nodes/references/prompting-workflow.md

````text
# Prompt structure and handoffs

## Contents

- [Use the templates](#use-the-templates)
- [The actual structure behind the study](#the-actual-structure-behind-the-study)
- [Project inputs and artifacts](#project-inputs-and-artifacts)
- [Stage exits and scope](#stage-exits-and-scope)

## Use the templates

Start with [project-brief.md](../templates/prompts/project-brief.md). It contains the full
assignment, resource context, representation rules, sequence, review contract and delivery
bar. Send the narrower prompts only when their stage applies:

| Prompt | When to use it |
|---|---|
| [Capture and registration](../templates/prompts/capture-and-registration.md) | Source discovery or reference capture/calibration is incomplete |
| [Part contract](../templates/prompts/part-contract.md) | A component needs focused measurement/building under the shared scene contract |
| [Detail pass](../templates/prompts/detail-pass.md) | Complete composition exists but construction/material detail remains weak |
| [Review and correction](../templates/prompts/review-and-correction.md) | Fresh matched renders are ready for critique |
| [Hybrid appearance mapping](../templates/prompts/hybrid-projection.md) | Captured appearance is requested on selected reconstructed surfaces |
| [Comparison and delivery](../templates/prompts/comparison-and-delivery.md) | A frozen version needs final evidence or a comparison video |

Replace `{{...}}` placeholders with project values. Blank measurements mean **measure and
record uncertainty**, not fabricate values or stop at a questionnaire. Keep previous human
choices; an already-approved reference does not need another approval cycle. If the desired
scene has not been selected, collect candidates with actual previews and resolve that choice
before building the wrong subject.

## The actual structure behind the study

The original project used a short project instruction file pointing to a longer brief.
The brief defined the entire environment, source identity, known tool/API differences,
representation rules, measurement tasks, build sequence and live review outputs. A separate
part contract froze coordinates/cameras and assigned module ownership. A later detail brief
responded to inspected images, preserving earlier fixes while specifying construction and
surface corrections. Review messages named current artifacts and concrete verification views.

Reference capture was its own stage: a high-quality source was selected; the live viewer's
camera and asset were identified; a local viewer rendered the untouched asset from explicit
cameras; geometry analysis established a world frame and per-part parameters; reference
contact sheets and close-ups were supplied to the builder. See
[whole-scene capture](whole-scene-capture.md) for the method.

The final orbit used a frozen native checkpoint and one shared camera path for both renderers.
This prevents a long export from silently mixing versions. Several lessons are retained:
integrate all major parts early, judge detail at native resolution, preserve improvements,
serialize heavy renders and distinguish a working graph from a faithful image.

The generic templates preserve these contracts without carrying over the study's object
list, metric assumptions, exact view count, fixed iteration count, machine limits or blanket
recurring-review permission. They are suitable for environments, rooms, streets, machinery,
landscapes and object assemblies.

## Project inputs and artifacts

Fill the master brief with the project root/name/prefix, reference source, selected view,
whole-scene scope, must-keep elements, allowed omissions, Blender environment, output target
and resource budget. Scope is spatial and semantic: define the surrounding context as well
as the focal subject, and inventory small objects that materially establish identity.

Suggested **generated project** layout (these are output conventions, not files bundled for
every scene):

```text
project/
  PROJECT_BRIEF.md                filled master prompt
  docs/REFERENCE.md              inventory, source identity, coverage, uncertainty
  docs/COORDINATES.md            frame, units, cameras, display conventions
  docs/PART_CONTRACT.md          shared interfaces and ownership
  docs/DETAIL_PASS.md            current region-specific targets
  docs/review-log.md             timestamped evidence and corrections
  assets/source.json            provenance; original binary remains local
  data/frame.json                validated scan-to-world transform
  data/cameras.json              {world: {...}, scan: {...}} from one definition
  data/capture-manifest.json     image paths/hashes, camera IDs, viewer settings
  data/scene-inventory.json      parts, relations, coverage and confidence
  data/params/<part>.json        measurements and fitted controls
  scene/parts/<part>.py          project builders
  build.py                       integrated deterministic entry point
  outputs/reference/<view>.png   actual reference-render pixels
  outputs/derived/               local analysis, maps and decoder products
  outputs/checkpoints/<version>/ immutable blend, graph/audit and source identity
  outputs/review/<version>/      full comparisons, crops and status
  outputs/review/latest_status.json
```

Use project-relative artifact paths in prompts and reports. Include file hashes, camera IDs,
version and mtime in review evidence; avoid user-specific filesystem paths or session logs
in reusable/public prompts. Keep decoded attributes row-aligned with positions if analysis
uses them. A frame change invalidates derived positions, part fits and affected reference
captures; update those as one versioned change rather than letting parts drift independently.

`GS_REFERENCE` is the default reference collection used by the tools. Use
`SCENE_PROCEDURAL` as a generic native collection, or choose a project-specific name and
pass it explicitly to `audit_blend.py --collection ...`. The shipped Garden example's default
name is not a requirement for other projects. The build and capture adapters described by
these prompts must be implemented for the chosen project; the toolkit supplies only the
generic helpers documented in the README.

## Stage exits and scope

| Stage | Continue when | If evidence is insufficient |
|---|---|---|
| Source | Identity and selected scene are established | Resolve source/scope, not a procedural guess |
| Capture | Landmarks align; useful views cover the inventory | Add targeted views or record a coverage gap |
| Blockout | All major masses exist in one native scene | Integrate missing parts before polishing |
| Geometry/look | Same cameras show corrected structure/response | Change the highest-impact cause and verify again |
| Detail | Actual close-ups show the claimed feature | Correct form/scale; do not upscale a small preview |
| Delivery | Exact saved version has audit and visual evidence | Deliver as incomplete with explicit limits |

These are evidence checks, not mandatory human approval gates. Work through stages already
authorized by the user. Do not interpret “perfect replication” as a promise of an exact
inverse solution or an unbounded budget. Agree practical scope when necessary and report
uncertainty honestly. Optional part delegation and recurring external critique require their
own authorization; a single agent can perform every stage with the same contracts.
````

## File: skills/gaussian-splat-to-blender-nodes/references/validation-and-critique.md
size: 3457
sha: d9f5f64a4e7f0321528fcf713450a81fb8e271fc
upstream: https://github.com/gokayfem/gaussian-splat-to-blender-nodes/blob/main/skills/gaussian-splat-to-blender-nodes/references/validation-and-critique.md

```text
# Validation and candid critique

## Evidence ladder

1. **Build:** exit status, parts, warnings and valid links.
2. **Native structure:** empty hosts, generating graphs, Shader Nodes and documented helpers.
3. **Independence:** delete GS_REFERENCE in memory and compare evaluated output; compare
   before/after beauty renders when render equivalence is required.
4. **Registration:** shared poses, FOV, dimensions and matching landmark projections.
5. **Fidelity:** whole-scene pairs, opposite/side views and native-resolution detail crops.
6. **Repeatability:** rebuild the source commit with identical parameters/seeds.

The audit checks the named procedural collection at the current frame. It hashes evaluated
mesh coordinates/topology, material assignments, instance geometry and transforms before
and after deleting reference objects. It checks empty hosts, enabled GN modifiers, warnings,
invalid links, material node trees and reference render hiding. Texture nodes and explicit
scan dependencies are reported for manual inspection. It never saves the blend.

It does not prove visual similarity or test shader/driver/compositor effects, animation,
external dependencies, all non-mesh geometry or pixel equality. Run it in a disposable
background process, never in the user's unsaved interactive scene.

For hybrid delivery, apply the native audit only to untouched live-GN collections. Verify
mapped baked copies separately: packed images, exact UV/material slots, original-material
fallback, source/config hashes and complementary rendered views. See
[hybrid projection](hybrid-projection.md). Captured textures are intentional in that mode;
unlit radiance and approximate albedo must be labeled, not treated as clean relightable data.

## Review record

Record UTC time, source commit, blend hash/mtime, render hashes, camera hash, engine,
samples, dimensions, color management and reference identity. State integrated/isolated,
clay/beauty scope. Keep outputs immutable.

Example:

> Observed: the hero's upper-right canopy is one opaque mass; the reference has three
> openings and visible branches. Adjust the clump envelope and measured gaps before
> increasing leaf density. Verify hero and right views plus the same foliage crop.
> Hypothesis: excessive sky fill may also flatten depth; test after geometry.

Rank up to three materially visible current mismatches; fewer or none is fine. Give region, evidence, correction and
verification view. Start with framing/silhouettes, then construction/occlusion, material/light
and detail. Preserve improvements; never repeat superseded defects. Avoid unsupported
similarity scores and generic praise. Rough source edges and minor seams alone do not
justify an unbounded polish loop; prioritize normal-size multi-view appearance.

No new render means no new visual finding. Record the blocker without inventing critique
or repeating requests already acknowledged. An agent's description is not image evidence.

## Completion and scope

Deliver editable source/blend, provenance, cameras, native audit, multi-view comparisons
and remaining limits. A beautiful generic scene is not close replication. Weakly captured
backside views expose coverage limits, not correctness.

Serialize expensive renders. Schedule recurring critique or message another agent only
with explicit authorization and correct session identity. Honor stops; never restart a
stopped reconstruction merely to write a report.
```

## File: skills/gaussian-splat-to-blender-nodes/references/whole-scene-capture.md
size: 8342
sha: 50339ca6163eeb9464c8c2894ce81a63a28715e8
upstream: https://github.com/gokayfem/gaussian-splat-to-blender-nodes/blob/main/skills/gaussian-splat-to-blender-nodes/references/whole-scene-capture.md

````text
# How the whole scene was captured for reconstruction

This describes **reference-view capture from an existing splat**, not photographic acquisition
or splat training. The study used the original asset as a stable measurement source and
captured several views of it. A hero screenshot alone did not describe the whole environment.

## 1. Select the scene and preserve its identity

The source-selection pass inspected real high-quality previews and distinguished a complex
environment from isolated assets. Once the user selected the scene, its viewer, model URL,
quality setting and approved screenshot became the reference contract. The exact downloaded
binary was preserved with byte size/hash, container version and available SH metadata.

For another project, use its already-selected asset if one exists. Otherwise compare scenes
on scope, coverage, visible geometry and usable source quality. More splats alone is not a
quality ranking. Verify a download is complete and actually a model, not an error document.
Keep the source unchanged; derived conversions get separate paths and provenance.

## 2. Reproduce the reference renderer before modeling

The study inspected the deployed viewer's asset and initial camera configuration, then
rendered the untouched asset in a local GaussianSplats3D viewer. Its adapter used explicit
position/target/up/FOV, a controlled canvas size and direct canvas PNG export. It disabled
built-in camera controls and progressive loading for that capture path. Both the deployed
and local reference images were inspected for alignment before trusting the local renderer.

Generalize the **contract**, not that viewer's API or version:

| Capture setting | What to record/control |
|---|---|
| Asset | Source hash, producer/container and conversion identity |
| Viewer | Renderer/library version, quality/LOD and SH degree |
| Camera | Position, target or rotation, up, vertical FOV, lens shift if present, clipping |
| Canvas | Pixel width/height, pixel ratio, aspect, viewport crop and principal point |
| Appearance | Background/alpha, exposure, output color space and tone mapping |
| Readiness | Asset loading complete, camera fixed, renderer/sort updated and output settled |
| Image | Camera ID, native dimensions, timestamp, file path and SHA-256 |

The original adapter repeatedly updated/rendered after each camera change before exporting
the canvas. That settling loop was a pragmatic implementation choice, not a universal timing
guarantee. A new adapter should use available load/sort completion signals, bounded waiting
and actual image checks. Record a timeout as a failed capture; do not accept a loading frame.

Capture the rendering canvas where the tool permits it, or use its supported screenshot
surface and account for the exact viewport. Do not include browser chrome, resize one side
independently, or mistake device pixels for CSS pixels. A browser UI that cannot expose exact
poses provides approximate registration; label that limitation rather than claiming a match.

Adapter pseudocode (a specification, **not** bundled executable functions):

```text
load_unchanged_asset_and_wait()
for view in shared_manifest.scan:
    set_canvas_dimensions_and_pixel_ratio(view)
    set_camera_position_target_up_and_vertical_fov(view)
    update_camera_matrices_and_projection()
    wait_for_loading_and_sorting_then_render_until_settled(timeout)
    export_reference_png(view.id)
    verify_dimensions_and_record_hash_settings_and_readiness(view.id)
```

## 3. Establish the shared world frame

The study decoded positions and attributes with the source format's conventions, filtered
candidate measurements by opacity, fitted a support plane, identified a stable central form,
and defined an origin/axes/scale. Top-down color and height maps and screen projections helped
locate surrounding objects. These were **analysis aids**, not replacement beauty renders.

Choose appropriate evidence for a new scene: floors/walls, surveyed anchors, a measured
dimension or known camera calibration. Plane fitting and robust shape fits can help; a lowest
point layer need not be a floor, and opacity thresholds are scene/format dependent. Check
outliers and uncertainty. State when metric scale is assumed rather than measured.

Store one proper scan-to-world transform and derive both renderers' cameras from it. See
[the coordinate conventions](acquisition-and-cameras.md). Keep all parts in that same frame.
When decoding, preserve attribute alignment, scale/opacity encodings and quaternion order;
a point projection is only a layout diagnostic and does not reproduce SH splat rendering.

## 4. Capture coverage, not just attractive angles

The initial study set contained a hero, opposite and side views, an elevated overview, a low
view and a focal detail. Later, matched close-ups targeted newly identified problems. Use
the following roles as a starting coverage plan, not a fixed required number of cameras:

| View role | What it reveals |
|---|---|
| Selected/hero | Composition, projected landmarks and the main silhouette |
| Opposite + lateral | Hidden supports, depth, overlap and missing surrounding context |
| Elevated/overview | Footprints, spacing, boundaries and repeated-layout structure |
| Low/grazing | Contact, thickness, joints, support geometry and shadow depth |
| Region close-ups | Meso/fine construction and material response at useful pixel scale |
| Wider context | Background planes, peripheral objects and scene extent |

Build an inventory across all views: foreground, middle ground, background, surfaces,
structural supports, repeated populations, secondary props and transitions. Record each
part's identifying features, coordinates/bounds, adjacency/occlusion and best verification
views. Include small context-defining objects; a screen-area cutoff must not silently drop
them. Mark regions as observed, partially observed or inferred.

Create a labeled contact sheet and add views where the inventory has gaps. “Whole scene”
means the requested environment and its visible relationships; a full orbit cannot recover
uncaptured surfaces or turn blurry background splats into known geometry.

## 5. Match the camera before critiquing reconstruction

Compare the local reference hero against the selected source view. Check several dispersed
landmarks, their distances/occlusions, crop and aspect; verify another view too. Preserve any
measured residual and its method. In the study this was a reference-versus-reference check,
**not** proof that the native reconstruction matched.

Lock the calibrated poses. Use exactly those world-space poses for Blender, with the same
projection and pixel dimensions. Record display transforms instead of assuming a particular
one is correct for every viewer. No independent zoom/crop or camera-specific object hiding
to make a comparison look closer. Fix material, light and geometry as separate variables.

## 6. Capture additional detail and the final orbit

For close-ups the study moved toward an observed region along a useful existing line of
sight and stored the resulting camera, target, FOV and resolution. Both renderers then used
that camera. This preserves familiar coverage better than freely orbiting into unseen sides,
but it still requires checking for newly exposed holes. Render close-ups at actual evaluation
resolution; enlarging a preview cannot reveal fine detail. Use identical pixel rectangles
for reference/previous/current crops and retain the uncropped frames.

For the comparison video, freeze a source checkpoint and share one orbit manifest between
reference and native renderers. Inspect cardinal views and coverage before rendering the
sequence. Record any start/end holds separately from unique camera poses. Keep frames paired
by camera ID, remove the scan from native beauty, serialize heavy rendering and inspect the
encoded output. See [the video procedure](comparison-videos.md).

The public toolkit provides fingerprinting, camera math, a native renderer, auditing and
pairing/encoding. It does **not** bundle the study's local viewer, automatic scene segmentation,
format decoder or a universal screenshot driver. The
[capture prompt](../templates/prompts/capture-and-registration.md) tells an agent what to
implement and prove with the tools available for a new project.
````

## File: skills/gaussian-splat-to-blender-nodes/agents/openai.yaml
size: 346
sha: ac6da318f136fa430e917cf2b1dcd00ee6136629
upstream: https://github.com/gokayfem/gaussian-splat-to-blender-nodes/blob/main/skills/gaussian-splat-to-blender-nodes/agents/openai.yaml

```text
interface:
  display_name: "World to World Mapping"
  short_description: "Reconstruct splats with Blender nodes and texture mapping"
  default_prompt: "Use $gaussian-splat-to-blender-nodes to reconstruct my Gaussian splat reference as native Blender geometry and materials, starting with camera registration and an honest multi-view checkpoint."
```

## File: skills/gaussian-splat-to-blender-nodes/templates/projection.json
size: 1206
sha: 9b3cc80813996fc72d4196db96302d878a7f8ee4
upstream: https://github.com/gokayfem/gaussian-splat-to-blender-nodes/blob/main/skills/gaussian-splat-to-blender-nodes/templates/projection.json

```text
{
  "schema_version": 1,
  "reference_collections": ["GS_REFERENCE"],
  "targets": [
    {
      "name": "primary_surface",
      "object": "REPLACE_WITH_RECONSTRUCTED_OBJECT",
      "object_id": 1,
      "texture_size": 1024,
      "uv_mode": "unwrap",
      "appearance": "captured_radiance",
      "projection": {
        "mode": "robust",
        "power": 12,
        "min_cos": 0.3,
        "vertical_min_cos": 0.3,
        "depth_absolute": 0.006,
        "depth_relative": 0.004,
        "border": 9,
        "mix_feather": 3,
        "padding": 8
      },
      "material_overrides": {},
      "reject_rules": []
    }
  ],
  "views": {
    "hero": {
      "image": "captures/hero.png",
      "camera": {
        "position": [3, -4, 2],
        "look_at": [0, 0, 0.5],
        "up": [0, 0, 1],
        "fov_y_deg": 50,
        "width": 1280,
        "height": 720
      },
      "source_valid_masks": {}
    },
    "opposite": {
      "image": "captures/opposite.png",
      "camera": {
        "position": [-3, 4, 2],
        "look_at": [0, 0, 0.5],
        "up": [0, 0, 1],
        "fov_y_deg": 50,
        "width": 1280,
        "height": 720
      },
      "source_valid_masks": {}
    }
  }
}
```

## File: skills/gaussian-splat-to-blender-nodes/templates/prompts/capture-and-registration.md
size: 5394
sha: 4bcade1eb63cb949db6a7142d979bba9dd5b31f9
upstream: https://github.com/gokayfem/gaussian-splat-to-blender-nodes/blob/main/skills/gaussian-splat-to-blender-nodes/templates/prompts/capture-and-registration.md

```text
# Prompt: capture and measure the whole reference scene

Use after the project brief when source evidence or calibration is incomplete. This is
capture **from an existing splat**, not instructions to photograph or train a new dataset.

---

Prepare the reference package for `{{PROJECT_NAME}}` in `{{PROJECT_ROOT}}` from
`{{REFERENCE_SOURCE}}`, preserving the selected view `{{APPROVED_VIEW}}` and scope
`{{WHOLE_SCENE_SCOPE}}`. Read the project's brief and the skill's whole-scene capture guide.

## Source identity and selection

If a source is already selected, use it. Otherwise find high-quality scenes that actually
cover the requested environment; compare real previews, source availability and capture
coverage. Do not substitute isolated objects for a whole-scene request. Resolve the choice
with the user when it is not established.

Identify the actual model used by the selected viewer through supported inspection tools.
Acquire it where authorized, preserve an unchanged master and record URL/provenance, usage
terms, bytes/hash, producer, quality, compression and available SH metadata. A screenshot,
an incomplete download and an HTML error document are not usable 3D inputs.

## Reference-render adapter

Use an existing compatible viewer or implement a small project-local capture adapter if
needed. Pin and record its version. The public toolkit does not provide a universal viewer
or format decoder; do not claim otherwise. Prefer the real asset's original rendering
semantics over a center-point preview for final reference comparisons.

Discover and record camera position/rotation or target, up, vertical FOV, aspect, viewport,
principal point/lens shift, clipping, pixel ratio, quality/SH and color/tone-mapping settings.
Disable automatic camera motion for deterministic captures. Load the actual complete asset,
apply a saved pose, update projection/matrices, wait for rendering/sorting to settle within
a bounded timeout, then save the actual scene pixels. Inspect them for loading placeholders,
holes and cropping. Report unsupported controls honestly.

Export canvas PNGs when supported, or capture the supported rendering surface with exact
viewport bounds. Do not include browser chrome or independently resize reference images.
Record image dimensions, camera ID, settings, timestamp and hash in a capture manifest.

## World frame and measurements

Decode only through a format-correct implementation. Check coordinate conventions, linear
versus log scale, opacity versus logit, quaternion order and RGB/SH semantics. Keep original
attributes aligned with positions. Validate decoded structure and projected landmarks.

Fit a stable frame from appropriate scene anchors, planes or known dimensions. Reject
floaters/outliers with documented filters; do not copy another scene's numerical thresholds.
Store origin, proper rotation, positive scale, units and assumptions in `data/frame.json`.
If absolute scale is unknowable, label the chosen scale explicitly.

Where useful, create orthographic color/height/density maps and region-specific projections
for measuring footprints, levels, spacing and extents. These are diagnostic maps, not
reference beauty renders or final generated meshes. Record map bounds and pixel-to-world
conversion so measurements can be reproduced.

## View coverage and registration

Create one canonical camera definition with both world and scan poses. Match the selected
hero first; compare the local reference render against the selected source screenshot using
dispersed landmarks and exact crop/aspect. This is reference-versus-reference calibration,
not evidence of reconstruction quality. Verify a second view before locking the frame.

Build a useful coverage set: hero, opposite/lateral, elevated overview, low/contact views,
wider context and focal details. Choose counts and positions based on the scene's actual
visibility. Inspect every view; add targeted captures for important gaps and mark uncaptured
or degraded regions. A 360° orbit does not guarantee valid source coverage.

Inventory all major and identity-defining components, including supports, openings,
peripheral objects, repeated populations and transitions. Record subparts, bounds/proportions,
adjacency/occlusion, observed materials, distributions and confidence. Cross-check measurements
between views; flag disagreement instead of averaging incompatible observations away.

For close-ups, target a measured region along an already useful viewing direction when
possible, store the new pose/FOV/resolution, and render it on both sides. Verify that the
closer view still has reliable source coverage. Crops use the same pixel rectangle in both
images; enlarging a preview is not a new detail capture.

## Hand off evidence

Write source provenance, `docs/REFERENCE.md`, `docs/COORDINATES.md`, `data/frame.json`,
`data/cameras.json`, `data/capture-manifest.json`, `data/scene-inventory.json`, the actual
reference PNGs and a labeled contact sheet. Include uncertainty, source coverage limits,
calibration residuals with their method, and adapter/decoder commands that actually ran.

Keep the approved source immutable. Freeze the frame/cameras for builders and state which
outputs must be regenerated if calibration changes. Then continue the authorized integrated
blockout; do not remain indefinitely in infrastructure or claim capture is reconstruction.
```

## File: skills/gaussian-splat-to-blender-nodes/templates/prompts/comparison-and-delivery.md
size: 4247
sha: 91f0ac4c27afa5b19d799c7bdab729a50c551d00
upstream: https://github.com/gokayfem/gaussian-splat-to-blender-nodes/blob/main/skills/gaussian-splat-to-blender-nodes/templates/prompts/comparison-and-delivery.md

```text
# Prompt: frozen-checkpoint comparison and handoff

Use after the desired checkpoint is chosen. A comparison video can document an unfinished
scene; exporting it is not acceptance of the reconstruction.

---

Prepare the agreed deliverables for `{{PROJECT_NAME}}` from `{{CHECKPOINT}}`.
Requested media: `{{MATCHED_STILLS_DETAILS_AND_OPTIONAL_ORBIT_SPEC}}`.
Reference asset/camera package: `{{REFERENCE_PACKAGE}}`.
Destination: `{{LOCAL_OUTPUT_DIRECTORY}}`. Publishing/uploading is separate unless explicitly
included in the user's request.

Freeze the source/parameters, cameras and exact saved blend in an immutable checkpoint.
Record hashes, source identity, Blender version and render/color settings. Do not read a live
file that another build may overwrite midway through export. Keep review and media outputs
versioned so later corrections cannot silently replace the compared scene.

Audit that exact blend in a disposable process with the actual procedural collection name.
Check live GN generation, Shader Nodes, warnings/links and removal of `GS_REFERENCE`.
Preserve legitimate sparse QA helpers that are not the scan. If required, render before/after
scan removal too; a structural audit alone does not test every shader/compositor dependency.

For a hybrid checkpoint, audit live procedural collections separately and follow the hybrid
prompt for baked mapped surfaces. Reopen and verify packed images, UV/material wiring,
fallback and source/config hashes. Record which surfaces require rebaking after edits.

## Matched stills and optional orbit

Render the agreed canonical views and detail crops from the frozen version. Pair them with
the same camera IDs, dimensions and projection in the reference renderer. Retain full frames,
label sources and report remaining mismatches. For a hybrid release, include reference,
clay/grey and textured variants from the same poses. Grey-to-textured reveals may mix those
registered variants; label each representation and keep the full 360° comparison readable.

If an orbit is requested, define one camera manifest: target, radius/path, height/elevation,
start azimuth, vertical FOV, image dimensions, unique pose count and FPS. A full 360° orbit
has no duplicated closing pose; any start/end holds are additional encoded frames and must
be documented. Do not change canonical review cameras to manufacture the export path.

Test several cardinal/important poses before the expensive run. Check reference coverage,
clipping, framing, occlusions and lighting at those angles. If a full orbit exposes unreliable
source regions, state the limitation; do not synthesize missing reference images or quietly
replace the requested comparison with a supported-looking carousel.

Render the same manifest on both sides, with the source removed from native beauty. Use the
project's shared render queue/lock; coordinate a temporary hold on other expensive project
renders when authorized. Never terminate unrelated jobs or delete their locks. Save frames
with stable matching IDs. Validate counts, dimensions and correspondence before encoding.

Use the bundled comparison tool or a documented equivalent. No independent crops/resizing,
interpolation to conceal missing poses, source swaps or camera-specific hiding. Label
reference/native and the checkpoint. Record output hash, codec, dimensions, FPS, unique poses,
encoded frames, duration and holds. Decode-check the movie and visually inspect cardinal
views, the seam and detail regions. Do not treat a successful encoder exit as visual QA.

## Final handoff

Provide source/build commands, parameters/presets, native blend, camera/frame/provenance,
actual validation results, control documentation, node screenshots and agreed media.
Reopen the saved deliverable; verify representative controls affect geometry/materials and
report any supported-range or resource limits.

Distinguish verified structural properties, inspected visual differences and untested claims.
Name remaining mismatches and uncertain/unseen surfaces. Avoid invented fidelity percentages
or “perfect replication.” Preserve the last working artifacts and honor any user stop;
completion of this export does not authorize restarting reconstruction or recurring review.
```

## File: skills/gaussian-splat-to-blender-nodes/templates/prompts/detail-pass.md
size: 3758
sha: d6b6600cbcf840367360bc1d5148c87abdfd10a1
upstream: https://github.com/gokayfem/gaussian-splat-to-blender-nodes/blob/main/skills/gaussian-splat-to-blender-nodes/templates/prompts/detail-pass.md

```text
# Prompt: more construction detail and closer resemblance

Use after an integrated checkpoint exists. Fill targets from actual current images, not
from a generic list of decorative effects. Keep object names and mechanisms scene-specific.

---

Continue `{{PROJECT_NAME}}` from `{{CURRENT_CHECKPOINT}}` using the locked frame and cameras.
The current target needs substantially more **evidenced construction and surface detail**.
Read the project brief, current status and latest review first. Preserve improvements in
`{{ALREADY_IMPROVED_REGIONS}}`; do not repeat critique against superseded versions.

Inspect `{{CURRENT_MATCHED_IMAGES}}` against `{{REFERENCE_IMAGES}}` and
`{{PREVIOUS_MATCHED_IMAGES}}`. Confirm exact artifact versions and scope. If the latest parts
are not integrated, obtain an integrated checkpoint before claiming whole-scene improvement.

## Prioritize visible causes

Fill this table from what is actually visible; do not invent a diagnosis:

| Priority / part / image region | Observed difference | Construction or shading correction | Verification views |
|---|---|---|---|
| `{{TARGET_1}}` | `{{EVIDENCE_1}}` | `{{CHANGE_1}}` | `{{VIEWS_1}}` |
| `{{TARGET_2}}` | `{{EVIDENCE_2}}` | `{{CHANGE_2}}` | `{{VIEWS_2}}` |
| `{{TARGET_3}}` | `{{EVIDENCE_3}}` | `{{CHANGE_3}}` | `{{VIEWS_3}}` |

First correct spatial arrangement, envelope, negative spaces and contact. More density or
randomness cannot repair the wrong silhouette. Preserve measured regular patterns where
they exist; “organic” does not mean randomly perturb everything.

For each important part, inspect three scales:

- **Macro:** shape, dimensions, support, alignment, population envelope and occlusion.
- **Meso:** constituent pieces, joints, openings, thickness, folds, branch hierarchy,
  seams, edge wear, deposits and attachment/contact. Choose mechanisms relevant to this scene.
- **Micro:** correctly scaled grain, pores, scratches, pits, directional fissures and
  roughness/transmission variation, only where supported by the reference.

Use geometry where a feature affects outline, shadow or parallax. Repeated structures need
meaningful families, bounded variation and coherent placement rather than identical stand-ins
or uniform noise. Match material response as well as average color; do not globally darken
a surface to hide wrong sheen or double-light appearance sampled from the scan.

Inspect one coherent lighting setup across all views. Use a clay pass to distinguish form
and lighting from material errors. Preserve display/exposure conventions while comparing.
Treat regional color statistics as diagnostics, not percentages of fidelity.

## Proof for this pass

Render each relevant close-up at its real evaluation resolution, with a matched reference
pose and field of view. Save labeled **reference / previous / current** crops using identical
pixel rectangles, alongside uncropped context views. An upscaled small preview does not
prove fine detail. Change sampling/resolution deliberately and record it so noise differences
are not confused with improved geometry.

Keep all principal forms native GN and all claimed procedural materials editable Shader
Nodes. Allocate resources to visible structure, keep instances where possible, and serialize
expensive renders. Keep the last working checkpoint; a successful build that silently skipped
a failed part is not progress.

For each target, report **observed mismatch → implemented change → what the new image proves
→ remaining difference**. Verify the change in a second useful view, preserve improvements,
and integrate all current parts. End this pass with an honest ranked list of remaining errors,
not a declaration of completion based on detail counts or a prettier image.
```

## File: skills/gaussian-splat-to-blender-nodes/templates/prompts/hybrid-projection.md
size: 3914
sha: be55b838bf2c40af193ddf48c0aea9146e0e3992
upstream: https://github.com/gokayfem/gaussian-splat-to-blender-nodes/blob/main/skills/gaussian-splat-to-blender-nodes/templates/prompts/hybrid-projection.md

```text
# Prompt: project-independent hybrid appearance mapping

Use after a reconstructed scene and registered reference captures exist. Fill the project
inputs; retain decisions already established. This prompt authorizes only the work requested
by the user and does not schedule reviews or contact other sessions.

---

Use **gaussian-splat-to-blender-nodes** and read its hybrid-projection reference.

- Project: `{{PROJECT_ROOT_AND_NAME}}`
- Frozen reconstructed blend/source: `{{SOURCE_CHECKPOINT}}`
- Reference captures, provenance and calibrated cameras: `{{CAPTURE_PACKAGE}}`
- Selected reconstructed objects/materials: `{{TARGET_SCOPE_OR_INSPECT_AND_IDENTIFY}}`
- Appearance mode: `{{CAPTURED_RADIANCE_OR_ESTIMATED_ALBEDO}}`
- Intended viewing distance, output views and resource budget: `{{REVIEW_TARGET}}`

Add captured appearance to the selected reconstructed surfaces. Preserve the original
procedural source. Derive object names, material slots, coordinates, source masks, tolerances
and camera poses from this project. Do not copy another scene's object list, hue filters,
world-space rejection boxes, lighting gains or fitted camera path. The shipped projection
JSON is a configuration template, not a calibrated preset.

1. Inspect current source/config/report hashes and capture dimensions. Confirm one world
   frame, vertical FOV and identical source/reconstruction cameras. Inspect hero and
   opposite/oblique images before fitting any texture. Identify source-only occluders.
2. Configure evaluated mesh targets and actual face material slots. Keep meaningful object
   boundaries and the source Shader Node materials as fallback. Set source-valid masks for
   wrong-object pixels; use scoped color/region rejection only with visual evidence.
3. Run a small serialized prepare → project → assemble pilot with the supplied scripts.
   Use fresh versioned outputs. Do not launch competing expensive renders. Inspect actual
   appearance, mix and coverage artifacts; successful scripts and high coverage are not
   evidence of visual fidelity.
4. Compare matched reference/procedural/hybrid full frames at normal viewing size. Rank only
   material current mismatches, up to three: wrong-object projection, broad ghosts/stretches,
   large material/color errors and materially missing coverage. Preserve improvements.
   Separate geometry and camera errors from projection defects. Use crops/masks only when
   they explain a visible problem. Accept rough scan edges and minor seams unless they
   create conspicuous wrong-object appearance; do not optimize residuals for their own sake.
5. Correct the highest-impact cause and verify the same complementary views. Unseen or
   rejected samples must fall back to the source material, never inherit a neighbor's
   captured texture. Check material boundaries using actual face slots, not normal direction.
6. Reopen the packed hybrid file and verify UV/material wiring, source-copy visibility and
   fallback behavior. Audit untouched live GN collections separately. Report the selected
   surfaces as baked evaluated meshes: later geometry/UV/frame changes require rebaking.

Captured radiance includes shadows/highlights and is not clean albedo. Label the unlit mode
and any emitted-light effect. Treat irradiance-divided albedo as an approximation; inspect
lit views for baked lighting and double lighting. Do not claim universal relightability.

When no major defect remains in this scope, package the accepted result as ready for review
with exact artifact paths/hashes, source/coverage limitations and remaining visible
mismatches. Reuse current evidence and avoid duplicate renders. If a release comparison is
requested, include clearly labeled reference, clay/grey and hybrid views on one shared full
360° path; label unsupported coverage honestly. No claim of perfect replication from one
view, a coverage percentage or a passing script.
```

## File: skills/gaussian-splat-to-blender-nodes/templates/prompts/part-contract.md
size: 3792
sha: 22ea9e81c2cfe8a66539e7b86f8c94b5f34dd8a5
upstream: https://github.com/gokayfem/gaussian-splat-to-blender-nodes/blob/main/skills/gaussian-splat-to-blender-nodes/templates/prompts/part-contract.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/gokayfem/gaussian-splat-to-blender-nodes/blob/main/skills/gaussian-splat-to-blender-nodes/templates/prompts/part-contract.md

## File: skills/gaussian-splat-to-blender-nodes/templates/prompts/project-brief.md
size: 10631
sha: 3c4fd82464553861cd60c165c2606be0b489894d
upstream: https://github.com/gokayfem/gaussian-splat-to-blender-nodes/blob/main/skills/gaussian-splat-to-blender-nodes/templates/prompts/project-brief.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/gokayfem/gaussian-splat-to-blender-nodes/blob/main/skills/gaussian-splat-to-blender-nodes/templates/prompts/project-brief.md

## File: skills/gaussian-splat-to-blender-nodes/templates/prompts/review-and-correction.md
size: 4689
sha: 1b1d5aa1644706461e839e76c20699231bd0bbba
upstream: https://github.com/gokayfem/gaussian-splat-to-blender-nodes/blob/main/skills/gaussian-splat-to-blender-nodes/templates/prompts/review-and-correction.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/gokayfem/gaussian-splat-to-blender-nodes/blob/main/skills/gaussian-splat-to-blender-nodes/templates/prompts/review-and-correction.md

## File: skills/gaussian-splat-to-blender-nodes/templates/starter.py
size: 5537
sha: f0655f58fdfb8c06d0b3f1f27c4107e52d5d0248
upstream: https://github.com/gokayfem/gaussian-splat-to-blender-nodes/blob/main/skills/gaussian-splat-to-blender-nodes/templates/starter.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/gokayfem/gaussian-splat-to-blender-nodes/blob/main/skills/gaussian-splat-to-blender-nodes/templates/starter.py

## File: README.md
upstream: https://github.com/gokayfem/gaussian-splat-to-blender-nodes/blob/main/README.md

````text
# World to World Mapping

**Gaussian splat to Blender nodes — reconstruct geometry, then optionally map captured appearance.**

A project-independent agent skill and toolkit for measured reconstruction with native
**Geometry Nodes, Shader Nodes and calibrated multi-view texture projection**. Built from
the Garden study, in the spirit of [video-to-blender](https://github.com/gokayfem/video-to-blender)
and [shader-skills](https://github.com/gokayfem/shader-skills).

<p align="center">
  <img src="gallery/world-to-world-mapping.jpg" width="800" alt="World to World Mapping release: grey and textured Blender scene comparison">
</p>

[Watch the release video — grey/textured reveals and a matched 360° comparison](https://v3b.fal.media/files/b/0aafb407/k_VXzVOBiFnE1RwnpVGxF_world_to_world_mapping-gaussian_splat_to_blender_nodes.mp4)
· [Read the skill](skills/gaussian-splat-to-blender-nodes/SKILL.md)
· [Reusable prompts](prompts/README.md)
· [Hybrid mapping guide](skills/gaussian-splat-to-blender-nodes/references/hybrid-projection.md)

*The video shows the accepted Garden c26b hybrid experiment. Its mapped table and paving
are baked evaluated meshes with packed appearance textures; other reconstructed parts retain
node-based construction. The source scan and fitted hybrid assets are not bundled. See
[media provenance and limitations](gallery/README.md). The installation name remains
`gaussian-splat-to-blender-nodes`.*

## What it does

This is an **agent-guided reconstruction workflow**, not a one-click splat-to-mesh converter.
The splat provides observations; the output describes objects through meaningful geometry,
parameters and materials that you can edit in Blender.

```text
Splat reference → measured frame + cameras → semantic scene parts
               → Geometry Nodes + Shader Nodes → native audit + visual comparison
               → optional calibrated appearance mapping → hybrid review + packed blend
```

| Included | Purpose |
|---|---|
| Installable agent skill | Acquisition, registration, construction, detail and candid critique |
| Project-agnostic prompt pack | Full brief, whole-scene capture, part contracts, detail, critique and delivery |
| Asset inspector | SHA-256 and limited PLY / SPLAT / KSPLAT metadata |
| Shared camera tools | Validated coordinates and repeatable world/scan orbit manifests |
| Native-scene audit | Remove the reference in memory; compare mesh and instance signatures |
| Blender renderer | Sequential rendering of a shared camera manifest |
| Still/video comparison | Strict frame pairing, labels and hashed evidence; optional FFmpeg MP4 |
| Synthetic starter | Small editable GN table; no scan download required |
| Hybrid mapping tools | UV preparation, depth/object visibility, robust projection, material-aware fallback and packed assembly |
| Hybrid prompt/config | Per-project objects, material slots, cameras, source masks and appearance mode |
| Garden study | Seven procedural parts, parameters and vendored node-building helpers |

No automatic semantic segmentation, universal splat decoder or reference renderer is bundled.
An audit pass proves specific structural checks; it does not prove visual resemblance.

## Install

### Agent Skills

```sh
npx skills add gokayfem/gaussian-splat-to-blender-nodes
```

### Claude Code plugin

```text
/plugin marketplace add gokayfem/gaussian-splat-to-blender-nodes
/plugin install gaussian-splat-to-blender-nodes@gaussian-splat-to-blender-nodes
```

### Manual / Codex

```sh
git clone https://github.com/gokayfem/gaussian-splat-to-blender-nodes.git
cd gaussian-splat-to-blender-nodes
mkdir -p ~/.codex/skills
cp -R skills/gaussian-splat-to-blender-nodes ~/.codex/skills/
```

For Claude project-local use, copy the skill into `.claude/skills/`. See
[adapters](adapters/README.md) for other agents. A skill-only install includes tools and the
starter; clone the full repository for Garden.

## Try it without a scan

Requires **Python 3.10+** and **Blender 5.0+**. Local integration verification used Blender
5.0.1; validate other versions before relying on them. Substitute your Blender executable
for `blender` if it is not on PATH.

```sh
S=skills/gaussian-splat-to-blender-nodes

blender -b --factory-startup --python-exit-code 1 \
  --python "$S/templates/starter.py" -- --out outputs/starter.blend

blender -b --factory-startup outputs/starter.blend --python-exit-code 1 \
  --python "$S/scripts/audit_blend.py" -- --out outputs/starter-audit.json

python3 "$S/scripts/cameras.py" --frames 4 --radius 3 --target 0 0 0.5 \
  --width 640 --height 480 --out outputs/starter-cameras.json

blender -b --factory-startup outputs/starter.blend --python-exit-code 1 \
  --python "$S/scripts/render_manifest.py" -- \
  --manifest outputs/starter-cameras.json --out outputs/starter-views
```

Width and Depth controls change the tabletop and supports. The starter is a pipeline test,
not a reconstructed asset. The audit deletes its hidden synthetic reference point in memory
and leaves the saved file unchanged.

## Reconstruct your scene

Use the [full prompting guide](prompts/README.md), starting with the
[copy-ready project brief](skills/gaussian-splat-to-blender-nodes/templates/prompts/project-brief.md).
It generalizes the actual structure used for the study: source selection and whole-scene
capture, shared measurements/cameras, scoped part builders, integrated blockout, detail
correction and final evidence. It works for other scene types and with a single agent.

The [capture guide](skills/gaussian-splat-to-blender-nodes/references/whole-scene-capture.md)
explains how repeatable reference views came from the original splat: viewer/asset identity,
exact camera and canvas settings, settled captures, shared world coordinates, coverage views,
analysis maps and matched close-ups. This is reference capture, not splat training.

For a shorter starting message, give your agent an authorized source, intended views and a
quality target:

> Use gaussian-splat-to-blender-nodes. Reconstruct this scene as editable native Blender
> geometry and materials. First fingerprint the asset, establish a measured coordinate
> frame, match the reference camera and identify the major objects. Build an integrated
> coarse checkpoint before fine detail. Review side, opposite and detail views; be candid
> about mismatches. Deliver source, parameters, blend and scan-removal evidence.

Start with the [skill workflow](skills/gaussian-splat-to-blender-nodes/SKILL.md).
Use the [part contract](skills/gaussian-splat-to-blender-nodes/templates/prompts/part-contract.md),
[detail pass](skills/gaussian-splat-to-blender-nodes/templates/prompts/detail-pass.md) and
[review prompt](skills/gaussian-splat-to-blender-nodes/templates/prompts/review-and-correction.md)
as the project progresses.
For a turntable, follow the [matched 360° guide](skills/gaussian-splat-to-blender-nodes/references/comparison-videos.md).
Reference frames must come from a compatible viewer using the same camera manifest;
the Blender renderer handles the native side.

## Map captured appearance onto your reconstruction

Use the [hybrid mapping prompt](skills/gaussian-splat-to-blender-nodes/templates/prompts/hybrid-projection.md)
when captured appearance is part of the intended result. Supply your own reconstructed
scene and calibrated captures, then fill the
[configuration template](skills/gaussian-splat-to-blender-nodes/templates/projection.json).
Objects, materials, cameras, visibility masks and rejection rules are configuration inputs;
the implementation contains no required Garden objects or fitted scene coordinates.

The [step-by-step guide](skills/gaussian-splat-to-blender-nodes/references/hybrid-projection.md)
covers the three commands: **prepare → project → assemble**. Install `requirements.txt` for
the NumPy/SciPy/Pillow projector. Blender prepares evaluated UV surfaces and visibility maps;
ordinary Python maps the captures; Blender packs the results onto selected mesh copies.
Unse
````
