object-healer 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """ObjectHealer: repair meshes for 3D printing with headless Blender."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,169 @@
1
+ """Find Blender and run the repair pass (heal.py) in it, one Blender process per file."""
2
+ import json
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ from dataclasses import dataclass
9
+ from importlib import resources
10
+
11
+ from . import gltf
12
+
13
+ # Exactly the formats heal.py imports and exports. Never .blend: a Blender file can carry
14
+ # Python (drivers, registered text blocks) that runs if auto-run is ever on.
15
+ SUPPORTED = (".obj", ".stl", ".ply", ".glb", ".gltf")
16
+ MAC_APP_PATHS = (
17
+ "/Applications/Blender.app/Contents/MacOS/Blender",
18
+ "~/Applications/Blender.app/Contents/MacOS/Blender",
19
+ )
20
+ RESULT_PREFIX = "OBJECT_HEALER_RESULT "
21
+ DEFAULT_TIMEOUT = 300
22
+
23
+ NOT_FOUND = (
24
+ "Blender was not found. Install Blender 4.0 or later from https://www.blender.org/download/ "
25
+ "or set BLENDER to its executable (on a Mac, /Applications/Blender.app/Contents/MacOS/Blender)."
26
+ )
27
+
28
+
29
+ class BlenderNotFound(RuntimeError):
30
+ pass
31
+
32
+
33
+ def _executable(path):
34
+ return os.path.isfile(path) and os.access(path, os.X_OK)
35
+
36
+
37
+ def find_blender(env=None, which=shutil.which, app_paths=MAC_APP_PATHS):
38
+ """$BLENDER, then the usual Blender.app locations, then `blender` on PATH."""
39
+ env = os.environ if env is None else env
40
+ configured = env.get("BLENDER")
41
+ if configured:
42
+ path = os.path.expanduser(configured)
43
+ if _executable(path):
44
+ return path
45
+ raise BlenderNotFound(f"BLENDER is set to {configured!r}, which is not an executable file.")
46
+ for path in app_paths:
47
+ path = os.path.expanduser(path)
48
+ if _executable(path):
49
+ return path
50
+ found = which("blender")
51
+ if found:
52
+ return found
53
+ raise BlenderNotFound(NOT_FOUND)
54
+
55
+
56
+ def heal_script():
57
+ return str(resources.files("object_healer") / "heal.py")
58
+
59
+
60
+ def default_output(path):
61
+ stem, ext = os.path.splitext(path)
62
+ return stem + "_healed" + ext
63
+
64
+
65
+ @dataclass
66
+ class HealResult:
67
+ ok: bool
68
+ input: str
69
+ output: str | None = None
70
+ vertices: int | None = None
71
+ faces: int | None = None
72
+ reason: str | None = None
73
+ floaters_removed: int | None = None
74
+
75
+
76
+ def check_paths(src, dst):
77
+ """Return a reason the pair can't be healed, or None."""
78
+ if not os.path.isfile(src):
79
+ return "no such file"
80
+ if any(os.path.splitext(p)[1].lower() in (".blend", ".blend1") for p in (src, dst)):
81
+ return "Blender files (.blend) are refused: they can carry embedded Python"
82
+ for path, what in ((src, "input"), (dst, "output")):
83
+ ext = os.path.splitext(path)[1].lower()
84
+ if ext not in SUPPORTED:
85
+ return f"unsupported {what} format {ext or '(none)'}; use one of {', '.join(SUPPORTED)}"
86
+ if os.path.realpath(src) == os.path.realpath(dst):
87
+ return "the output would overwrite the input"
88
+ if not os.path.isdir(os.path.dirname(dst)):
89
+ return f"the output folder {os.path.dirname(dst)} does not exist"
90
+ return None
91
+
92
+
93
+ def run_blender(src, dst, blender, timeout=DEFAULT_TIMEOUT, run=subprocess.run, *, cwd):
94
+ """Run heal.py in Blender with factory settings (no user add-ons or startup scripts) and
95
+ auto-run of embedded Python off, from cwd, a folder object-healer created."""
96
+ cmd = [blender, "--background", "--factory-startup", "--disable-autoexec",
97
+ "--python-exit-code", "1", "--python", heal_script(), "--", src, "--output", dst]
98
+ try:
99
+ proc = run(cmd, capture_output=True, text=True, timeout=timeout, cwd=cwd)
100
+ except subprocess.TimeoutExpired:
101
+ return HealResult(False, src, reason=f"Blender took longer than {timeout:g} s")
102
+ except OSError as e:
103
+ return HealResult(False, src, reason=f"could not start Blender: {e}")
104
+ for line in proc.stdout.splitlines():
105
+ if line.startswith(RESULT_PREFIX):
106
+ r = json.loads(line[len(RESULT_PREFIX):])
107
+ return HealResult(r["status"] == "ok", src, r["output"], r["vertices"], r["faces"], r["reason"])
108
+ tail = [l for l in proc.stderr.splitlines() if l.strip()] or \
109
+ [l for l in proc.stdout.splitlines() if l.strip()]
110
+ return HealResult(False, src, reason=f"Blender exited with status {proc.returncode} and no result"
111
+ + (f": {tail[-1].strip()}" if tail else ""))
112
+
113
+
114
+ def strip_in_subprocess(src, dst, timeout=DEFAULT_TIMEOUT, run=subprocess.run, *, cwd):
115
+ """Run floaters.strip_floaters in its own Python (PyMeshLab's Qt 5 must stay out of the
116
+ window's process); return the number of pieces removed or raise RuntimeError.
117
+
118
+ -I keeps the working directory, PYTHONPATH and user site-packages off sys.path, so a
119
+ planted object_healer/ folder can't stand in for the installed package; cwd is a folder
120
+ object-healer created as well.
121
+ """
122
+ try:
123
+ proc = run([sys.executable, "-I", "-m", "object_healer.floaters", src, dst],
124
+ capture_output=True, text=True, timeout=timeout, cwd=cwd)
125
+ except subprocess.TimeoutExpired:
126
+ raise RuntimeError(f"took longer than {timeout:g} s") from None
127
+ for line in reversed(proc.stdout.splitlines()):
128
+ try:
129
+ r = json.loads(line)
130
+ except ValueError:
131
+ continue
132
+ if isinstance(r, dict) and "removed" in r:
133
+ return r["removed"]
134
+ if isinstance(r, dict) and "error" in r:
135
+ raise RuntimeError(r["error"])
136
+ tail = [l for l in proc.stderr.splitlines() if l.strip()]
137
+ raise RuntimeError(f"exited with status {proc.returncode}" + (f": {tail[-1].strip()}" if tail else ""))
138
+
139
+
140
+ def heal_file(src, dst=None, *, blender, strip_floaters=False, timeout=DEFAULT_TIMEOUT,
141
+ run=subprocess.run):
142
+ """Heal one mesh into dst (default <name>_healed.<ext> next to src)."""
143
+ src = os.path.abspath(src)
144
+ dst = os.path.abspath(dst or default_output(src))
145
+ problem = check_paths(src, dst)
146
+ if problem:
147
+ return HealResult(False, src, reason=problem)
148
+
149
+ with tempfile.TemporaryDirectory(prefix="object-healer-") as tmp:
150
+ source = src
151
+ if os.path.splitext(src)[1].lower() in (".gltf", ".glb"):
152
+ # Blender (and PyMeshLab) get a private copy holding only the files the model may
153
+ # reference, so nothing outside its folder is read and nothing can be swapped in.
154
+ try:
155
+ source = gltf.stage(src, os.path.join(tmp, "model"))
156
+ except gltf.Refused as e:
157
+ return HealResult(False, src, reason=str(e))
158
+ removed = None
159
+ if strip_floaters:
160
+ stripped = os.path.join(tmp, "stripped.ply")
161
+ try:
162
+ removed = strip_in_subprocess(source, stripped, timeout, run, cwd=tmp)
163
+ except (RuntimeError, OSError) as e:
164
+ return HealResult(False, src, reason=f"floater removal failed: {e}")
165
+ source = stripped
166
+ result = run_blender(source, dst, blender, timeout, run, cwd=tmp)
167
+ result.input = src
168
+ result.floaters_removed = removed
169
+ return result
object_healer/cli.py ADDED
@@ -0,0 +1,74 @@
1
+ """object-healer: with no command, open the window; `object-healer heal` repairs from the shell.
2
+
3
+ Exit status: 0 when every file healed, 1 when any failed, 2 for a usage or setup error.
4
+ """
5
+ import argparse
6
+ import sys
7
+
8
+ from . import __version__, floaters
9
+ from .blender import (DEFAULT_TIMEOUT, SUPPORTED, BlenderNotFound, HealResult, find_blender,
10
+ heal_file)
11
+
12
+
13
+ def build_parser():
14
+ parser = argparse.ArgumentParser(
15
+ prog="object-healer",
16
+ description="Repair meshes for 3D printing in headless Blender. "
17
+ "With no command, opens the drag-and-drop window.",
18
+ )
19
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
20
+ sub = parser.add_subparsers(dest="command", metavar="COMMAND")
21
+ sub.add_parser("gui", help="open the drag-and-drop window (the default)")
22
+ heal = sub.add_parser(
23
+ "heal", help="heal files from the command line",
24
+ description="Merge duplicate vertices, make normals consistent and fill holes. "
25
+ f"Formats: {' '.join(SUPPORTED)}. Each IN is written to <name>_healed.<ext> "
26
+ "next to it unless -o names the output.",
27
+ )
28
+ heal.add_argument("inputs", nargs="+", metavar="IN", help="mesh file(s) to heal")
29
+ heal.add_argument("-o", "--output", metavar="OUT",
30
+ help="output path for a single IN; its extension picks the format")
31
+ heal.add_argument("--strip-floaters", action="store_true",
32
+ help="first remove pieces smaller than 5%% of the biggest piece "
33
+ "(scan debris); needs object-healer[scan]")
34
+ heal.add_argument("--blender", metavar="PATH",
35
+ help="Blender executable (default: $BLENDER, then Blender.app, then PATH)")
36
+ heal.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT, metavar="SECONDS",
37
+ help=f"limit per file (default {DEFAULT_TIMEOUT})")
38
+ return parser
39
+
40
+
41
+ def main(argv=None):
42
+ parser = build_parser()
43
+ args = parser.parse_args(argv)
44
+ if args.command in (None, "gui"):
45
+ from .gui import run_gui
46
+ return run_gui()
47
+
48
+ if args.output and len(args.inputs) > 1:
49
+ parser.error("-o/--output takes a single IN")
50
+ if args.timeout <= 0:
51
+ parser.error("--timeout must be positive")
52
+ if args.strip_floaters and not floaters.available():
53
+ print(f"object-healer: error: {floaters.MISSING_EXTRA}", file=sys.stderr)
54
+ return 2
55
+ try:
56
+ blender = find_blender({"BLENDER": args.blender} if args.blender else None)
57
+ except BlenderNotFound as e:
58
+ print(f"object-healer: error: {e}", file=sys.stderr)
59
+ return 2
60
+
61
+ failed = 0
62
+ for src in args.inputs:
63
+ try:
64
+ r = heal_file(src, args.output, blender=blender, strip_floaters=args.strip_floaters,
65
+ timeout=args.timeout)
66
+ except Exception as e: # one bad file must not stop the rest of the batch
67
+ r = HealResult(False, src, reason=f"unexpected error: {type(e).__name__}: {e}")
68
+ if r.ok:
69
+ extra = f"; removed {r.floaters_removed} floating piece(s)" if r.floaters_removed else ""
70
+ print(f"healed {src} -> {r.output} ({r.vertices:,} vertices, {r.faces:,} faces{extra})")
71
+ else:
72
+ failed += 1
73
+ print(f"failed {src}: {r.reason}", file=sys.stderr)
74
+ return 1 if failed else 0
@@ -0,0 +1,110 @@
1
+ """Optional pre-pass for raw scans: drop small disconnected pieces (turntable and background
2
+ debris) with PyMeshLab before the Blender pass. Blender's hole filling closes the object but
3
+ keeps every floating piece, so scans that include their surroundings need this first.
4
+
5
+ PyMeshLab bundles its own Qt 5, which must not load into a process that already has PyQt6's
6
+ Qt 6 (the window), so object-healer runs this module in a subprocess:
7
+
8
+ python -m object_healer.floaters SRC DST # prints {"removed": N} or {"error": "..."}
9
+ """
10
+ import importlib.util
11
+ import json
12
+ import sys
13
+
14
+ MISSING_EXTRA = (
15
+ "--strip-floaters needs PyMeshLab, which ships with the scan extra: "
16
+ "uv tool install 'object-healer[scan]' (or pipx install 'object-healer[scan]')"
17
+ )
18
+ DEFAULT_PERCENT = 5.0
19
+
20
+
21
+ class MissingExtra(RuntimeError):
22
+ pass
23
+
24
+
25
+ def available():
26
+ """Whether PyMeshLab is installed, without importing it (see the module docstring)."""
27
+ return importlib.util.find_spec("pymeshlab") is not None
28
+
29
+
30
+ def _components(ms):
31
+ return ms.get_topological_measures()["connected_components_number"]
32
+
33
+
34
+ def largest_piece_diagonal(vertices, faces):
35
+ """Bounding-box diagonal of the biggest piece, where faces sharing a vertex join a piece.
36
+
37
+ Union-find in numpy (hook roots to the smaller root, then compress), so the cost tracks
38
+ the face count rather than the number of pieces.
39
+ """
40
+ import numpy as np
41
+
42
+ parent = np.arange(len(vertices))
43
+ a = np.concatenate([faces[:, 0], faces[:, 1]])
44
+ b = np.concatenate([faces[:, 1], faces[:, 2]])
45
+ while True:
46
+ ra, rb = parent[a], parent[b]
47
+ if (ra == rb).all():
48
+ break
49
+ low = np.minimum(ra, rb)
50
+ np.minimum.at(parent, ra, low)
51
+ np.minimum.at(parent, rb, low)
52
+ while True:
53
+ jumped = parent[parent]
54
+ if (jumped == parent).all():
55
+ break
56
+ parent = jumped
57
+
58
+ used = np.unique(faces)
59
+ labels, points = parent[used], vertices[used]
60
+ order = np.argsort(labels, kind="stable")
61
+ labels, points = labels[order], points[order]
62
+ starts = np.flatnonzero(np.r_[True, labels[1:] != labels[:-1]])
63
+ lo = np.minimum.reduceat(points, starts, axis=0)
64
+ hi = np.maximum.reduceat(points, starts, axis=0)
65
+ return float(np.sqrt(((hi - lo) ** 2).sum(axis=1)).max())
66
+
67
+
68
+ def strip_floaters(src, dst, percent=DEFAULT_PERCENT):
69
+ """Write src to dst without pieces smaller than percent of the biggest piece; return how
70
+ many pieces were removed.
71
+
72
+ The size is each piece's bounding-box diagonal. The threshold is taken from the biggest
73
+ piece, not from the whole mesh: MeshLab's own percentage is of the whole mesh's bounding
74
+ box, so one far-off speck can make it delete the object itself.
75
+ """
76
+ try:
77
+ import pymeshlab
78
+ except ImportError:
79
+ raise MissingExtra(MISSING_EXTRA) from None
80
+
81
+ ms = pymeshlab.MeshSet()
82
+ ms.load_new_mesh(src)
83
+ mesh = ms.current_mesh()
84
+ if mesh.face_number() == 0:
85
+ raise ValueError("the file has no faces (a point cloud?), so there are no pieces to compare")
86
+ before = _components(ms)
87
+ if before > 1:
88
+ threshold = largest_piece_diagonal(mesh.vertex_matrix(), mesh.face_matrix()) * percent / 100
89
+ ms.meshing_remove_connected_component_by_diameter(
90
+ mincomponentdiag=pymeshlab.PureValue(threshold))
91
+ removed = before - _components(ms)
92
+ ms.save_current_mesh(dst)
93
+ return removed
94
+
95
+
96
+ def main(argv):
97
+ if len(argv) != 2:
98
+ print(json.dumps({"error": "usage: python -m object_healer.floaters SRC DST"}))
99
+ return 2
100
+ try:
101
+ removed = strip_floaters(*argv)
102
+ except Exception as e:
103
+ print(json.dumps({"error": " ".join(str(e).split()) or type(e).__name__}))
104
+ return 1
105
+ print(json.dumps({"removed": removed}))
106
+ return 0
107
+
108
+
109
+ if __name__ == "__main__":
110
+ sys.exit(main(sys.argv[1:]))
object_healer/gltf.py ADDED
@@ -0,0 +1,164 @@
1
+ """Stage a glTF or GLB, and only the files it may reference, into a private folder for Blender.
2
+
3
+ A .gltf (and the JSON chunk of a .glb) names its buffers and images by URI, and Blender's
4
+ importer reads whatever they resolve to. A crafted model could otherwise pull a local file's
5
+ bytes into the healed output. So before Blender sees anything:
6
+
7
+ - every buffers[].uri and images[].uri must be a base64 data: URI or a relative path that stays
8
+ inside the model's own folder, judged with Blender's own rules (below), both on the path as
9
+ written and after symlinks are followed;
10
+ - the model and each file it references are copied into a private folder, every referenced file
11
+ read through a descriptor that is checked to still be the in-folder file, and Blender is given
12
+ only the copy, so nothing can be swapped between the check and the import.
13
+
14
+ Blender 5.1 (io_scene_gltf2, io/imp/gltf2_io_gltf.py load_uri) decodes a URI as data only when
15
+ it starts with "data:" and contains ";base64,"; anything else, including "data:/../x", is
16
+ read as a file at join(dirname(model), uri_to_path(uri)), where uri_to_path turns "\\" into "/",
17
+ percent-decodes and normpaths (io/com/path.py). The data test here is stricter than Blender's,
18
+ so whatever passes it Blender also decodes as data.
19
+ """
20
+ import json
21
+ import os
22
+ import re
23
+ import stat
24
+ import struct
25
+ from urllib.parse import unquote, urlsplit
26
+
27
+ GLB_MAGIC = b"glTF"
28
+ GLB_JSON_CHUNK = 0x4E4F534A
29
+ DATA_URI = re.compile(r"data:[^,]*;base64,")
30
+
31
+
32
+ class Refused(Exception):
33
+ pass
34
+
35
+
36
+ def uri_to_path(uri):
37
+ """Blender's io_scene_gltf2 uri_to_path, verbatim in effect."""
38
+ return os.path.normpath(unquote(uri.replace("\\", "/")))
39
+
40
+
41
+ def _json_from(data, glb):
42
+ if glb:
43
+ if len(data) < 20 or data[:4] != GLB_MAGIC:
44
+ raise ValueError("not a GLB file")
45
+ length, kind = struct.unpack_from("<II", data, 12)
46
+ if kind != GLB_JSON_CHUNK or 20 + length > len(data):
47
+ raise ValueError("the GLB has no JSON chunk")
48
+ data = data[20:20 + length]
49
+ doc = json.loads(data.decode("utf-8"))
50
+ if not isinstance(doc, dict):
51
+ raise ValueError("the JSON is not an object")
52
+ return doc
53
+
54
+
55
+ def _uris(doc):
56
+ for key in ("buffers", "images"):
57
+ items = doc.get(key) or []
58
+ if not isinstance(items, list):
59
+ raise ValueError(f"{key} is not a list")
60
+ for i, item in enumerate(items):
61
+ if isinstance(item, dict) and "uri" in item:
62
+ yield f"{key}[{i}].uri", item["uri"]
63
+
64
+
65
+ def _inside(folder, path):
66
+ return os.path.commonpath([folder, path]) == folder
67
+
68
+
69
+ def reference(uri, folder):
70
+ """The path, relative to folder, that Blender would read for uri; None for embedded data.
71
+ Raises Refused naming the problem."""
72
+ if not isinstance(uri, str):
73
+ raise Refused("is not a string")
74
+ if uri == "" or DATA_URI.match(uri):
75
+ return None # Blender skips an empty buffer URI; base64 data is decoded, not read
76
+ if uri.startswith("data:"):
77
+ raise Refused("is a data: URI without ;base64, (Blender would read it as a file path)")
78
+ if urlsplit(uri).scheme:
79
+ raise Refused("uses a URI scheme; only base64 data: URIs and files next to the model are allowed")
80
+ rel = uri_to_path(uri)
81
+ if "\x00" in rel:
82
+ raise Refused("contains a NUL character")
83
+ if os.path.isabs(rel):
84
+ raise Refused("is an absolute path")
85
+ lexical = os.path.normpath(os.path.join(folder, rel))
86
+ real_folder = os.path.realpath(folder)
87
+ if not _inside(folder, lexical) or rel in (os.curdir, os.pardir):
88
+ raise Refused("points outside the model's folder")
89
+ if not _inside(real_folder, os.path.realpath(lexical)):
90
+ raise Refused("points outside the model's folder (through a symlink)")
91
+ return rel
92
+
93
+
94
+ def _copy_inside(folder, rel, dest):
95
+ """Copy folder/rel to dest through one descriptor, checked to be a regular file that is still
96
+ the in-folder file after symlinks are followed."""
97
+ path = os.path.join(folder, rel)
98
+ try:
99
+ fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
100
+ except FileNotFoundError:
101
+ raise Refused("names a file that does not exist") from None
102
+ except OSError as e:
103
+ raise Refused(f"can't be opened ({e.strerror})") from None
104
+ opened = os.fstat(fd)
105
+ if not stat.S_ISREG(opened.st_mode):
106
+ os.close(fd)
107
+ raise Refused("is not a regular file")
108
+ with os.fdopen(fd, "rb") as f:
109
+ real = os.path.realpath(path)
110
+ try:
111
+ now = os.stat(real)
112
+ except OSError:
113
+ now = None
114
+ if (not _inside(os.path.realpath(folder), real) or now is None
115
+ or (now.st_dev, now.st_ino) != (opened.st_dev, opened.st_ino)):
116
+ raise Refused("changed or moved outside the model's folder while it was being read")
117
+ os.makedirs(os.path.dirname(dest), exist_ok=True)
118
+ try:
119
+ out = open(dest, "xb")
120
+ except FileExistsError:
121
+ return # the same file under another case on a case-insensitive disk
122
+ with out:
123
+ while chunk := f.read(1 << 20):
124
+ out.write(chunk)
125
+
126
+
127
+ def stage(src, dest):
128
+ """Copy the model at src and every file it references into the new folder dest, keeping the
129
+ relative layout; return the copy of the model. Raises Refused with a reason for the user."""
130
+ glb = src.lower().endswith(".glb")
131
+ folder = os.path.dirname(os.path.abspath(src))
132
+ with open(src, "rb") as f:
133
+ data = f.read()
134
+ try:
135
+ doc = _json_from(data, glb)
136
+ uris = list(_uris(doc))
137
+ except (ValueError, UnicodeDecodeError) as e:
138
+ raise Refused(f"not a readable glTF file ({e})") from None
139
+
140
+ refs = {}
141
+ for label, uri in uris:
142
+ try:
143
+ rel = reference(uri, folder)
144
+ except Refused as e:
145
+ raise Refused(f"refused: {label} {uri!r} {e}") from None
146
+ except ValueError as e: # a path os functions reject outright
147
+ raise Refused(f"refused: {label} {uri!r} is not a usable path ({e})") from None
148
+ if rel is not None:
149
+ refs.setdefault(rel, (label, uri))
150
+
151
+ os.makedirs(dest)
152
+ staged = os.path.join(dest, os.path.basename(src))
153
+ for rel, (label, uri) in refs.items():
154
+ if os.path.normcase(rel) == os.path.normcase(os.path.basename(src)):
155
+ continue # the model naming itself; the copy written below serves
156
+ try:
157
+ _copy_inside(folder, rel, os.path.join(dest, rel))
158
+ except Refused as e:
159
+ raise Refused(f"refused: {label} {uri!r} {e}") from None
160
+ except ValueError as e:
161
+ raise Refused(f"refused: {label} {uri!r} is not a usable path ({e})") from None
162
+ with open(staged, "wb") as f:
163
+ f.write(data) # the exact bytes that were checked
164
+ return staged