$ cat node-template.py

3

3D Print Prep

// Prepares a 3D model for printing: cleanup, watertight repair, gap sealing, optional remesh/hollowing/decimation. Outputs a printable STL (mm), a preview and a validation report.

Process
3D
#3d#mesh#3d-print#stl#watertight#repair
template.py
1"""print-prep-3d — prepare a 3D model for printing via headless Blender.23Calls Gais.model.prepare_print (gais-blender service): cleanup ->4make-manifold repair -> Manifold-solver boolean sealing -> optional5remesh/hollow/decimate -> validation. Three outputs: printable STL (mm),6GLB preview, JSON validation report. No os/pathlib/HTTP here7(lint-blocked); files are stream-copied with plain open().8"""910from __future__ import annotations1112import json13import sys14import traceback15from typing import Any1617from gais import Gais1819INPUT_DIR = "/data/input"20OUTPUT_DIR = "/data/output"2122STL_FILENAME = "print_ready.stl"23PREVIEW_FILENAME = "print_preview.glb"2425SUPPORTED_EXTENSIONS = (".glb", ".gltf", ".stl", ".obj")262728def _f(value: Any, default: float) -> float:29    try:30        return float(value)31    except (TypeError, ValueError):32        return default333435def _i(value: Any, default: int) -> int:36    try:37        return int(float(value))38    except (TypeError, ValueError):39        return default404142def _b(value: Any, default: bool) -> bool:43    if isinstance(value, bool):44        return value45    if isinstance(value, str):46        return value.strip().lower() in ("1", "true", "yes", "on")47    if value is None:48        return default49    return bool(value)505152def _input_file_path(inputs: dict[str, Any], key: str) -> str | None:53    """Upstream file ports deliver a bare filename under /data/input."""54    name = inputs.get(key)55    if not name or not isinstance(name, str):56        return None57    base = name.replace("\\", "/").rsplit("/", 1)[-1].strip()58    if not base or base in (".", ".."):59        return None60    return f"{INPUT_DIR}/{base}"616263def _copy_to_output(src_path: str, name: str) -> None:64    """Stream-copy in 1 MiB chunks (large STLs must not be slurped)."""65    with open(src_path, "rb") as src, open(f"{OUTPUT_DIR}/{name}", "wb") as dst:66        while True:67            chunk = src.read(1024 * 1024)68            if not chunk:69                break70            dst.write(chunk)717273def process(inputs: dict[str, Any]) -> dict[str, Any]:74    mesh_path = _input_file_path(inputs, "mesh")75    if mesh_path is None:76        raise ValueError("Required input 'mesh' not provided")77    if not mesh_path.lower().endswith(SUPPORTED_EXTENSIONS):78        raise ValueError(79            f"Unsupported mesh format ({mesh_path.rsplit('/', 1)[-1]}). "80            f"Supported: {', '.join(SUPPORTED_EXTENSIONS)}."81        )8283    remesh_method = str(inputs.get("remesh_method") or "voxel")84    if remesh_method not in ("none", "voxel", "quadriflow"):85        raise ValueError(f"Unknown remesh_method {remesh_method!r}")8687    result = Gais.model.prepare_print(88        mesh_path,89        target_size_mm=_f(inputs.get("target_size_mm"), 100.0),90        wall_thickness_mm=_f(inputs.get("wall_thickness_mm"), 0.0),91        remesh_method=remesh_method,92        voxel_size_mm=_f(inputs.get("voxel_size_mm"), 0.5),93        max_faces=_i(inputs.get("max_faces"), 500000),94        fail_on_issues=_b(inputs.get("fail_on_issues"), False),95        merge_distance_mm=_f(inputs.get("merge_distance_mm"), 0.01),96        target_faces=_i(inputs.get("target_faces"), 5000),97    )9899    _copy_to_output(str(result.file("model_stl")), STL_FILENAME)100    _copy_to_output(str(result.file("preview")), PREVIEW_FILENAME)101102    with open(str(result.file("report")), "r", encoding="utf-8") as fh:103        report = json.load(fh)104105    if not report.get("is_watertight", False):106        print(107            f"[print-prep-3d] warning: result is not watertight "108            f"({report.get('non_manifold_edges')} non-manifold edges)",109            file=sys.stderr,110        )111112    return {113        "model_stl": STL_FILENAME,114        "preview": PREVIEW_FILENAME,115        "report": json.dumps(report),116    }117118119def main() -> None:120    try:121        envelope = json.loads(sys.stdin.read() or "{}")122        if not isinstance(envelope, dict):123            envelope = {}124        inputs = envelope.get("inputs", {}) or {}125        json.dump(process(inputs), sys.stdout)126    except Exception as e:127        print(128            json.dumps({129                "error": str(e),130                "errorType": type(e).__name__,131                "traceback": traceback.format_exc(),132            }),133            file=sys.stderr,134        )135        sys.exit(1)136137138if __name__ == "__main__":139    main()

$ git log --oneline

v1.2.0
HEAD
2026-08-18