-
Notifications
You must be signed in to change notification settings - Fork 239
/
Copy pathopenscad_runner.py
166 lines (145 loc) · 5.99 KB
/
openscad_runner.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
"""
Helpful classes for running OpenScad from Python.
@Copyright Arthur Moore 2024 MIT License
"""
from __future__ import annotations
import json
import subprocess
from dataclasses import dataclass, is_dataclass, asdict
from pathlib import Path
from tempfile import NamedTemporaryFile
from typing import NamedTuple, Optional
class DataClassJSONEncoder(json.JSONEncoder):
'''Allow json serialization'''
def default(self, o):
if is_dataclass(o):
return asdict(o)
# Let the base class default method raise the TypeError
return super().default(o)
class Vec3(NamedTuple):
'''Simple 3d Vector (x, y, z)'''
x: float
y: float
z: float
@dataclass(frozen=True)
class CameraArguments:
"""
Controls the camera position when outputting to png format.
@see `openscad -h`.
Supports fluid interface.
"""
translate: Vec3
rotate: Vec3
distance: float
def with_translation(self, new_translate: Vec3) -> CameraArguments:
return CameraArguments(translate=new_translate, rotate=self.rotate, distance=self.distance)
def with_rotation(self, new_rotate: Vec3) -> CameraArguments:
return CameraArguments(translate=self.translate, rotate=new_rotate, distance=self.distance)
def with_distance(self, new_distance: float) -> CameraArguments:
return CameraArguments(translate=self.translate, rotate=rotate, distance=new_distance)
def as_argument(self) -> str:
return '--camera=' \
f'{",".join(map(str,self.translate))},{",".join(map(str,self.rotate))},{self.distance}'
@dataclass(kw_only=True, frozen=True)
class ParameterFile:
parameterSets: dict[str, dict]
fileFormatVersion: int = 1
@classmethod
def from_json(cls, *pargs, **nargs):
"""
Wrapper for `json.loads`, with some post-processing.
The Customizer saves everything as strings. --Arthur 2024-04-28
"""
nargs["object_pairs_hook"] = cls.object_pairs_hook
file = ParameterFile(**json.loads(*pargs, **nargs))
assert(file.fileFormatVersion == 1)
return file
@classmethod
def object_pairs_hook(self, pairs: list[tuple]):
'''Fixes customizer turning everything into strings'''
output = dict(pairs)
for (key, value) in output.items():
if(type(value) == str):
if(value == "true"):
output[key] = True
continue
if(value == "false"):
output[key] = False
continue
try:
output[key] = float(value)
except ValueError:
pass
return output
def set_variable_argument(var: str, val: str) -> [str, str]:
"""
Allows setting a variable to a particular value.
@warning value **can** be a function, but this is called for every file, so may generate 'undefined' warnings.
"""
return ['-D', f'{var}={str(val)}']
class CameraRotations:
'''Pre-defined useful camera rotations'''
Default = Vec3(0,0,0),
AngledTop = Vec3(45,0,45)
AngledBottom = Vec3(225,0,225)
Top = Vec3(45,0,0)
class OpenScadRunner:
'''Helper to run the openscad binary'''
scad_file_path: Path
openscad_binary_path: Path
image_folder_base: Path
parameters: Optional[dict]
'''If set, a temporary parameter file is created, and used with these variables'''
WINDOWS_DEFAULT_PATH = 'C:\\Program Files\\OpenSCAD\\openscad.exe'
TOP_ANGLE_CAMERA = CameraArguments(Vec3(0,0,0),Vec3(45,0,45),150)
common_arguments = [
#'--hardwarnings', # Does not work when setting variables by using functions
'--enable=predictible-output',
'--imgsize=1280,720',
'--view=axes',
'--projection=ortho',
#"--summary", "all",
#"--summary-file", "-"
] + \
set_variable_argument('$fa', 8) + set_variable_argument('$fs', 0.25)
def __init__(self, file_path: Path):
self.openscad_binary_path = self.WINDOWS_DEFAULT_PATH
self.scad_file_path = file_path
self.image_folder_base = Path('.')
self.camera_arguments = None
self.parameters = None
def create_image(self, args: [str], image_file_name: str) -> subprocess.CompletedProcess:
"""
Run the code and create an image.
@Important The only verification is that no errors occured.
There is no verification if the image was created, or the image contents.
"""
assert(self.scad_file_path.exists())
assert(self.image_folder_base.exists())
image_path = self.image_folder_base.joinpath(image_file_name)
command_arguments = self.common_arguments + \
([self.camera_arguments.as_argument()] if self.camera_arguments != None else []) + \
args + \
["-o", str(image_path), str(self.scad_file_path)]
#print(command_arguments)
if self.parameters != None:
#print(self.parameters)
params = ParameterFile(parameterSets={"python_generated": self.parameters})
with NamedTemporaryFile(prefix="gridfinity-rebuilt-", suffix=".json", mode='wt',delete_on_close=False) as file:
json.dump(params, file, sort_keys=True, indent=2, cls=DataClassJSONEncoder)
file.close()
command_arguments += ["-p", file.name, "-P", "python_generated"]
return self._run(command_arguments)
else:
return self._run(command_arguments)
def _run(self, args: [str]) -> subprocess.CompletedProcess:
"""
Run openscad with the passed in arguments.
"""
output = subprocess.run([self.openscad_binary_path]+args, capture_output=True)
error_strings = output.stderr.decode().strip().splitlines()
if any(line.startswith("ERROR:") for line in error_strings):
# OpenSCAD doesn't set an error return if it errors from bad SCAD code!
output.returncode = 11
output.check_returncode()
return output