⏳ This skill is pending AI review.

Scores will appear once the review pipeline completes.

version unknown

gaussian-splat-to-blender-nodes

@gokayfem⭐ 64 stars

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.

Choose how to use this skill

You do not need every option. Choose the path your AI client supports. The stable page stays the same; versioned files are immutable.

1. Native installer

This listing has no registered native installer command. Use the complete package or source fallback below, depending on what your client supports.

Do not guess an installer command or replace an existing version without reviewing the diff.

2. Complete package recommended

Download the ZIP when available. It includes SKILL.md plus the references, security notes and version metadata.

No complete ProSkills package is published for this listing yet.

3. Prompt-only

Copy the prompt above when the agent can read the stable page or when you want to adopt the workflow without installing a skill.

Need only the instruction file?

Download SKILL.md only if your client requires a single file. The complete ZIP is safer for a full installation because it preserves the references and release context.

No path installs or executes anything by itself. Your agent still needs access to the project files. Before updating, compare the installed version and review the diff.

—/10

// RATINGS

⭐GitHub Stars
⭐⭐ 64 on GitHubGitHub ↗

Growing

🟢ProSkills Score
—
📍

Not yet listed on ClawHub or SkillsMP

// README

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 and shader-skills.

Watch the release video — grey/textured reveals and a matched 360° comparison · Read the skill · Reusable prompts · Hybrid mapping guide

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. 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.

Splat reference → measured frame + cameras → semantic scene parts
               → Geometry Nodes + Shader Nodes → native audit + visual comparison
               → optional calibrated appearance mapping → hybrid review + packed blend
IncludedPurpose
Installable agent skillAcquisition, registration, construction, detail and candid critique
Project-agnostic prompt packFull brief, whole-scene capture, part contracts, detail, critique and delivery
Asset inspectorSHA-256 and limited PLY / SPLAT / KSPLAT metadata
Shared camera toolsValidated coordinates and repeatable world/scan orbit manifests
Native-scene auditRemove the reference in memory; compare mesh and instance signatures
Blender rendererSequential rendering of a shared camera manifest
Still/video comparisonStrict frame pairing, labels and hashed evidence; optional FFmpeg MP4
Synthetic starterSmall editable GN table; no scan download required
Hybrid mapping toolsUV preparation, depth/object visibility, robust projection, material-aware fallback and packed assembly
Hybrid prompt/configPer-project objects, material slots, cameras, source masks and appearance mode
Garden studySeven 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

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

Claude Code plugin

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

Manual / Codex

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 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.

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, starting with the copy-ready project brief. 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 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. Use the part contract, detail pass and review prompt as the project progresses. For a turntable, follow the matched 360° guide. 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 when captured appearance is part of the intended result. Supply your own reconstructed scene and calibrated captures, then fill the configuration template. 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 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

// HOW IT'S BUILT

// FILES

28 files shipped with this skill. Inline text is the catalog copy. The upstream link is the source repository.

  • skills/gaussian-splat-to-blender-nodes/SKILL.md7.6 KB2a6f6518483aupstream ↗
    ---
    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.
    
  • skills/gaussian-splat-to-blender-nodes/scripts/audit_blend.py7.5 KBd7a79ef6eb79upstream ↗
    """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()
    
  • skills/gaussian-splat-to-blender-nodes/scripts/cameras.py4.6 KB88333a66286eupstream ↗
    #!/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()
    
  • skills/gaussian-splat-to-blender-nodes/scripts/compare.py3.7 KBc3214ae4b094upstream ↗
    #!/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()
    
  • skills/gaussian-splat-to-blender-nodes/scripts/inspect_asset.py4.3 KB1f0a45d5f4c2upstream ↗
    #!/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()
    
  • skills/gaussian-splat-to-blender-nodes/scripts/project_textures.py5.3 KB132a6596c8f2upstream ↗
    """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()
    
  • skills/gaussian-splat-to-blender-nodes/scripts/projection_blender.py18 KB1e519894bc06upstream ↗
    """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()
    
  • skills/gaussian-splat-to-blender-nodes/scripts/projection_config.py6.3 KBf2c9fbd3a18eupstream ↗
    """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")
    
  • skills/gaussian-splat-to-blender-nodes/scripts/projection_core.py8.6 KB251833fce3edupstream ↗
    """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
    
  • skills/gaussian-splat-to-blender-nodes/scripts/render_manifest.py3.6 KB0f87a7d79c33upstream ↗
    """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()
    
  • skills/gaussian-splat-to-blender-nodes/references/acquisition-and-cameras.md2.7 KBcc922f9c14cdupstream ↗
    # 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.
    
  • skills/gaussian-splat-to-blender-nodes/references/comparison-videos.md2.1 KB24cc742aeab6upstream ↗
    # 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`.
    
  • skills/gaussian-splat-to-blender-nodes/references/hybrid-projection.md12 KB5ef0bdf649b4upstream ↗
    # 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.
    
  • skills/gaussian-splat-to-blender-nodes/references/procedural-construction.md2.8 KBc8135cb00fe2upstream ↗
    # 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.
    
  • skills/gaussian-splat-to-blender-nodes/references/prompting-workflow.md7.3 KB3a358a223d1dupstream ↗
    # 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.
    
  • skills/gaussian-splat-to-blender-nodes/references/validation-and-critique.md3.4 KBd9f5f64a4e7fupstream ↗
    # 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.
    
  • skills/gaussian-splat-to-blender-nodes/references/whole-scene-capture.md8.1 KB50339ca6163eupstream ↗
    # 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.
    
  • skills/gaussian-splat-to-blender-nodes/agents/openai.yaml346 Bac6da318f136upstream ↗
    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."
    
  • skills/gaussian-splat-to-blender-nodes/templates/projection.json1.2 KB9b3cc8081399upstream ↗
    {
      "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": {}
        }
      }
    }
    
  • skills/gaussian-splat-to-blender-nodes/templates/prompts/capture-and-registration.md5.3 KB4bcade1eb63cupstream ↗
    # 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.
    
  • skills/gaussian-splat-to-blender-nodes/templates/prompts/comparison-and-delivery.md4.1 KB91f0ac4c27afupstream ↗
    # 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.
    
  • skills/gaussian-splat-to-blender-nodes/templates/prompts/detail-pass.md3.7 KBd6b6600cbcf8upstream ↗
    # 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.
    
  • skills/gaussian-splat-to-blender-nodes/templates/prompts/hybrid-projection.md3.8 KBbe55b838bf2cupstream ↗
    # 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.
    
  • skills/gaussian-splat-to-blender-nodes/templates/prompts/part-contract.md3.7 KB22ea9e81c2cfupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • skills/gaussian-splat-to-blender-nodes/templates/prompts/project-brief.md10 KB3c4fd8246455upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • skills/gaussian-splat-to-blender-nodes/templates/prompts/review-and-correction.md4.6 KB1b1d5aa16447upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • skills/gaussian-splat-to-blender-nodes/templates/starter.py5.4 KBf0655f58fdfbupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • README.mdupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

Plain-text copy, no login: /skill-bundles/gokayfem-gaussian-splat-to-blender-nodes-skills-gaussian-splat-to-blender-nodes.md

// REPO STATS

64 stars