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.
- object_healer/__init__.py +3 -0
- object_healer/__main__.py +3 -0
- object_healer/blender.py +169 -0
- object_healer/cli.py +74 -0
- object_healer/floaters.py +110 -0
- object_healer/gltf.py +164 -0
- object_healer/gui.py +297 -0
- object_healer/heal.py +138 -0
- object_healer/icons/icon-32.png +0 -0
- object_healer/icons/icon-512.png +0 -0
- object_healer-0.1.0.dist-info/METADATA +137 -0
- object_healer-0.1.0.dist-info/RECORD +15 -0
- object_healer-0.1.0.dist-info/WHEEL +4 -0
- object_healer-0.1.0.dist-info/entry_points.txt +2 -0
- object_healer-0.1.0.dist-info/licenses/LICENSE +674 -0
object_healer/blender.py
ADDED
|
@@ -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
|