fbxkit 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.
- fbxkit/__about__.py +7 -0
- fbxkit/__init__.py +210 -0
- fbxkit/__main__.py +8 -0
- fbxkit/builtin_ops.py +561 -0
- fbxkit/cli.py +429 -0
- fbxkit/document.py +448 -0
- fbxkit/errors.py +110 -0
- fbxkit/io.py +260 -0
- fbxkit/ops.py +231 -0
- fbxkit/params.py +239 -0
- fbxkit/patch.py +226 -0
- fbxkit/schema.py +316 -0
- fbxkit/selectors.py +337 -0
- fbxkit/serialize.py +143 -0
- fbxkit/session/__init__.py +51 -0
- fbxkit/session/base.py +233 -0
- fbxkit/session/fake.py +391 -0
- fbxkit/session/fbx.py +913 -0
- fbxkit/session/index.py +208 -0
- fbxkit/session/protocol.py +170 -0
- fbxkit-0.1.0.dist-info/METADATA +861 -0
- fbxkit-0.1.0.dist-info/RECORD +25 -0
- fbxkit-0.1.0.dist-info/WHEEL +4 -0
- fbxkit-0.1.0.dist-info/entry_points.txt +2 -0
- fbxkit-0.1.0.dist-info/licenses/LICENSE +21 -0
fbxkit/builtin_ops.py
ADDED
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
"""The operations that ship with fbxkit: the 增 / 删 / 改 of the title.
|
|
2
|
+
|
|
3
|
+
Every one of them is written against :class:`~fbxkit.session.protocol.SessionProtocol`
|
|
4
|
+
and nothing else, so all of them are exercised by the test suite with no SDK
|
|
5
|
+
installed. None of them import ``fbx``.
|
|
6
|
+
|
|
7
|
+
Three rules they all follow, and it is worth stating them once here rather than
|
|
8
|
+
in each docstring:
|
|
9
|
+
|
|
10
|
+
1. **Dry run computes the same answer.** An operation works out its changes
|
|
11
|
+
first and commits them second, so ``dry_run`` skips only the commit. A dry
|
|
12
|
+
run that disagrees with the real run is worse than not having one.
|
|
13
|
+
2. **Setting a value to what it already holds is a noop, not a change.** The
|
|
14
|
+
report then says what actually happened rather than what was requested,
|
|
15
|
+
which is the difference between "47 nodes changed" and "47 nodes matched,
|
|
16
|
+
3 changed".
|
|
17
|
+
3. **Deepest node first.** :func:`~fbxkit.ops.select_targets` sorts that way, so
|
|
18
|
+
editing a parent never invalidates a path the same batch is about to use.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Any, List, Optional
|
|
25
|
+
|
|
26
|
+
from .errors import OperationError, ParamError
|
|
27
|
+
from .ops import Operation, TargetedParams, register, select_targets
|
|
28
|
+
from .schema import Change
|
|
29
|
+
from .selectors import leaf_name, parent_path
|
|
30
|
+
|
|
31
|
+
__all__ = [] # everything here is reached through the registry
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# --------------------------------------------------------------------------
|
|
35
|
+
# 增 -- create
|
|
36
|
+
# --------------------------------------------------------------------------
|
|
37
|
+
@dataclass
|
|
38
|
+
class CreateNodeParams:
|
|
39
|
+
name: str
|
|
40
|
+
parent: str = ""
|
|
41
|
+
type: str = "null"
|
|
42
|
+
exist_ok: bool = False
|
|
43
|
+
|
|
44
|
+
PARAM_HELP = {
|
|
45
|
+
"name": "name for the new node",
|
|
46
|
+
"parent": "path of the parent; empty means the scene root",
|
|
47
|
+
"type": "null, mesh, skeleton, camera, light",
|
|
48
|
+
"exist_ok": "return the existing node instead of failing when the path is taken",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@register
|
|
53
|
+
class CreateNodeOp(Operation):
|
|
54
|
+
"""Add a node.
|
|
55
|
+
|
|
56
|
+
``parent`` is an exact path, not a selector: creating "a child of every
|
|
57
|
+
mesh" is a different and much rarer intent than creating one node, and
|
|
58
|
+
conflating them makes the common case dangerous.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
name = "create_node"
|
|
62
|
+
summary = "Create a node under an exact parent path"
|
|
63
|
+
params_class = CreateNodeParams
|
|
64
|
+
|
|
65
|
+
def run(self, session, params, ctx):
|
|
66
|
+
parent = params.parent
|
|
67
|
+
if parent and session.node_ref(parent) is None:
|
|
68
|
+
raise OperationError(
|
|
69
|
+
f"cannot create {params.name!r}: no parent node at {parent!r}"
|
|
70
|
+
)
|
|
71
|
+
wanted = f"{parent}|{params.name}"
|
|
72
|
+
if session.node_ref(wanted) is not None:
|
|
73
|
+
if params.exist_ok:
|
|
74
|
+
return []
|
|
75
|
+
raise OperationError(
|
|
76
|
+
f"a node already exists at {wanted!r}; FBX permits duplicate sibling "
|
|
77
|
+
"names, so this would create a second node reachable only as "
|
|
78
|
+
f"{wanted}#1 -- pass exist_ok=true if that is what you want"
|
|
79
|
+
)
|
|
80
|
+
if ctx.dry_run:
|
|
81
|
+
return [Change(kind="create", target=wanted, detail=params.type,
|
|
82
|
+
before=None, after={"name": params.name, "type": params.type})]
|
|
83
|
+
created = session.create_node(parent, params.name, params.type)
|
|
84
|
+
return [Change(kind="create", target=created, detail=params.type,
|
|
85
|
+
before=None, after={"name": params.name, "type": params.type})]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# --------------------------------------------------------------------------
|
|
89
|
+
# 删 -- delete
|
|
90
|
+
# --------------------------------------------------------------------------
|
|
91
|
+
@dataclass
|
|
92
|
+
class DeleteNodeParams(TargetedParams):
|
|
93
|
+
recursive: bool = True
|
|
94
|
+
|
|
95
|
+
PARAM_HELP = dict(
|
|
96
|
+
TargetedParams.PARAM_HELP,
|
|
97
|
+
recursive=("delete descendants too (default true); false leaves them "
|
|
98
|
+
"orphaned in the file, which is almost never what you want"),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@register
|
|
103
|
+
class DeleteNodeOp(Operation):
|
|
104
|
+
"""Remove nodes, and by default everything under them.
|
|
105
|
+
|
|
106
|
+
``recursive=False`` exists because the SDK offers it, not because it is a
|
|
107
|
+
good idea. Measured: a non-recursive destroy removes the node from the graph
|
|
108
|
+
but leaves its children alive in the scene, unparented and invisible to a
|
|
109
|
+
DAG walk -- and the exporter then writes them into the file as free-floating
|
|
110
|
+
objects. The operation reports that as a diagnostic rather than letting it
|
|
111
|
+
pass silently.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
name = "delete_node"
|
|
115
|
+
summary = "Delete nodes matching a selector"
|
|
116
|
+
params_class = DeleteNodeParams
|
|
117
|
+
|
|
118
|
+
def run(self, session, params, ctx):
|
|
119
|
+
targets = select_targets(session, params.target, params.required)
|
|
120
|
+
# Shallowest first here, against the usual deepest-first order. Deletion
|
|
121
|
+
# is the one operation where acting on the ancestor *subsumes* the work
|
|
122
|
+
# on its descendants, so taking the parent first turns "|root|rig|**"
|
|
123
|
+
# into one reported change covering the subtree rather than one line per
|
|
124
|
+
# node. Every other operation wants deepest-first, which is why the
|
|
125
|
+
# default is that way round.
|
|
126
|
+
targets = sorted(targets, key=lambda ref: (ref.path.count("|"), ref.path))
|
|
127
|
+
changes = []
|
|
128
|
+
already_gone = set()
|
|
129
|
+
for ref in targets:
|
|
130
|
+
if ref.path in already_gone:
|
|
131
|
+
continue
|
|
132
|
+
info = session.node_info(ref.path)
|
|
133
|
+
doomed = [ref.path]
|
|
134
|
+
if params.recursive:
|
|
135
|
+
doomed = [n.path for n in session.iter_nodes()
|
|
136
|
+
if n.path == ref.path or n.path.startswith(ref.path + "|")]
|
|
137
|
+
if not params.recursive and info.child_count:
|
|
138
|
+
ctx.note(
|
|
139
|
+
f"{ref.path} kept {info.child_count} child node(s) alive but "
|
|
140
|
+
"unparented; they stay in "
|
|
141
|
+
"the file as free-floating objects"
|
|
142
|
+
)
|
|
143
|
+
if not ctx.dry_run:
|
|
144
|
+
doomed = session.delete_node(ref.path, recursive=params.recursive)
|
|
145
|
+
already_gone.update(doomed)
|
|
146
|
+
changes.append(
|
|
147
|
+
Change(kind="delete", target=ref.path,
|
|
148
|
+
detail=f"{len(doomed)} node(s)",
|
|
149
|
+
before={"type": ref.type, "removed": sorted(doomed)}, after=None)
|
|
150
|
+
)
|
|
151
|
+
return changes
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# --------------------------------------------------------------------------
|
|
155
|
+
# 改 -- rename
|
|
156
|
+
# --------------------------------------------------------------------------
|
|
157
|
+
@dataclass
|
|
158
|
+
class RenameParams(TargetedParams):
|
|
159
|
+
name: Optional[str] = None
|
|
160
|
+
pattern: Optional[str] = None
|
|
161
|
+
replacement: str = ""
|
|
162
|
+
|
|
163
|
+
PARAM_HELP = dict(
|
|
164
|
+
TargetedParams.PARAM_HELP,
|
|
165
|
+
name="the literal new name; only valid when the selector matches one node",
|
|
166
|
+
pattern="regular expression applied to each matched node's name",
|
|
167
|
+
replacement="what to substitute for pattern (supports \\1 backreferences)",
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@register
|
|
172
|
+
class RenameOp(Operation):
|
|
173
|
+
"""Rename nodes, literally or by regular expression.
|
|
174
|
+
|
|
175
|
+
Two modes because the two intents are genuinely different. ``name`` renames
|
|
176
|
+
one node and refuses to run against several -- giving twenty nodes the same
|
|
177
|
+
literal name is legal in FBX and produces twenty nodes nothing can address
|
|
178
|
+
individually, so it is treated as a mistake rather than performed. Batch
|
|
179
|
+
work uses ``pattern``/``replacement``, which gives each node a different
|
|
180
|
+
result by construction::
|
|
181
|
+
|
|
182
|
+
{"op": "rename", "target": "**[type=mesh]", "pattern": "^SM_", "replacement": ""}
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
name = "rename"
|
|
186
|
+
summary = "Rename nodes, literally or by regex substitution"
|
|
187
|
+
params_class = RenameParams
|
|
188
|
+
|
|
189
|
+
def run(self, session, params, ctx):
|
|
190
|
+
if (params.name is None) == (params.pattern is None):
|
|
191
|
+
raise ParamError(
|
|
192
|
+
"rename takes either 'name' (one node) or 'pattern' plus "
|
|
193
|
+
"'replacement' (many), not both and not neither"
|
|
194
|
+
)
|
|
195
|
+
targets = select_targets(session, params.target, params.required)
|
|
196
|
+
if params.name is not None and len(targets) > 1:
|
|
197
|
+
raise OperationError(
|
|
198
|
+
f"selector {params.target!r} matched {len(targets)} nodes and "
|
|
199
|
+
f"'name' would give them all the "
|
|
200
|
+
f"identical name {params.name!r}; use pattern/replacement for a batch "
|
|
201
|
+
"rename"
|
|
202
|
+
)
|
|
203
|
+
try:
|
|
204
|
+
compiled = re.compile(params.pattern) if params.pattern else None
|
|
205
|
+
except re.error as exc:
|
|
206
|
+
raise ParamError(f"rename: bad pattern {params.pattern!r}: {exc}") from exc
|
|
207
|
+
|
|
208
|
+
changes = []
|
|
209
|
+
for ref in targets:
|
|
210
|
+
if compiled is not None:
|
|
211
|
+
new_name = compiled.sub(params.replacement, ref.name)
|
|
212
|
+
else:
|
|
213
|
+
new_name = params.name
|
|
214
|
+
if new_name == ref.name:
|
|
215
|
+
continue
|
|
216
|
+
if not new_name:
|
|
217
|
+
raise OperationError(
|
|
218
|
+
f"renaming {ref.path!r} would leave it with an empty name, which is "
|
|
219
|
+
"legal in FBX but makes the node unaddressable by "
|
|
220
|
+
"path"
|
|
221
|
+
)
|
|
222
|
+
if not ctx.dry_run:
|
|
223
|
+
session.rename_node(ref.path, new_name)
|
|
224
|
+
changes.append(
|
|
225
|
+
Change(kind="update", target=ref.path, detail="name",
|
|
226
|
+
before=ref.name, after=new_name)
|
|
227
|
+
)
|
|
228
|
+
return changes
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# --------------------------------------------------------------------------
|
|
232
|
+
# 改 -- reparent
|
|
233
|
+
# --------------------------------------------------------------------------
|
|
234
|
+
@dataclass
|
|
235
|
+
class ReparentParams(TargetedParams):
|
|
236
|
+
parent: str = ""
|
|
237
|
+
|
|
238
|
+
PARAM_HELP = dict(
|
|
239
|
+
TargetedParams.PARAM_HELP,
|
|
240
|
+
parent="exact path of the new parent; empty means the scene root",
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@register
|
|
245
|
+
class ReparentOp(Operation):
|
|
246
|
+
"""Move nodes under a different parent."""
|
|
247
|
+
|
|
248
|
+
name = "reparent"
|
|
249
|
+
summary = "Move nodes under a new parent"
|
|
250
|
+
params_class = ReparentParams
|
|
251
|
+
|
|
252
|
+
def run(self, session, params, ctx):
|
|
253
|
+
if params.parent and session.node_ref(params.parent) is None:
|
|
254
|
+
raise OperationError(f"no parent node at {params.parent!r}")
|
|
255
|
+
targets = select_targets(session, params.target, params.required)
|
|
256
|
+
changes = []
|
|
257
|
+
for ref in targets:
|
|
258
|
+
if ref.parent_path == params.parent:
|
|
259
|
+
continue
|
|
260
|
+
if params.parent == ref.path or params.parent.startswith(ref.path + "|"):
|
|
261
|
+
raise OperationError(
|
|
262
|
+
f"cannot reparent {ref.path!r} under {params.parent!r}: "
|
|
263
|
+
"that would make the node its "
|
|
264
|
+
"own ancestor"
|
|
265
|
+
)
|
|
266
|
+
if not ctx.dry_run:
|
|
267
|
+
session.reparent_node(ref.path, params.parent)
|
|
268
|
+
changes.append(
|
|
269
|
+
Change(kind="update", target=ref.path, detail="parent",
|
|
270
|
+
before=ref.parent_path, after=params.parent)
|
|
271
|
+
)
|
|
272
|
+
return changes
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# --------------------------------------------------------------------------
|
|
276
|
+
# 改 -- properties
|
|
277
|
+
# --------------------------------------------------------------------------
|
|
278
|
+
@dataclass
|
|
279
|
+
class SetPropertyParams(TargetedParams):
|
|
280
|
+
name: str = ""
|
|
281
|
+
value: Any = None
|
|
282
|
+
|
|
283
|
+
PARAM_HELP = dict(
|
|
284
|
+
TargetedParams.PARAM_HELP,
|
|
285
|
+
name="property name, e.g. 'Lcl Translation' or 'Visibility'",
|
|
286
|
+
value="the new value; a list for vector properties",
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@register
|
|
291
|
+
class SetPropertyOp(Operation):
|
|
292
|
+
"""Set an existing property on every matched node."""
|
|
293
|
+
|
|
294
|
+
name = "set_property"
|
|
295
|
+
summary = "Set a property on matching nodes"
|
|
296
|
+
params_class = SetPropertyParams
|
|
297
|
+
|
|
298
|
+
def run(self, session, params, ctx):
|
|
299
|
+
if not params.name:
|
|
300
|
+
raise ParamError("set_property requires a non-empty 'name'")
|
|
301
|
+
targets = select_targets(session, params.target, params.required)
|
|
302
|
+
changes = []
|
|
303
|
+
for ref in targets:
|
|
304
|
+
old = session.get_property(ref.path, params.name)
|
|
305
|
+
if _same(old, params.value):
|
|
306
|
+
continue
|
|
307
|
+
if not ctx.dry_run:
|
|
308
|
+
session.set_property(ref.path, params.name, params.value)
|
|
309
|
+
changes.append(
|
|
310
|
+
Change(kind="update", target=ref.path, detail=params.name,
|
|
311
|
+
before=old, after=params.value)
|
|
312
|
+
)
|
|
313
|
+
return changes
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@dataclass
|
|
317
|
+
class AddPropertyParams(TargetedParams):
|
|
318
|
+
name: str = ""
|
|
319
|
+
type: str = "String"
|
|
320
|
+
value: Any = None
|
|
321
|
+
|
|
322
|
+
PARAM_HELP = dict(
|
|
323
|
+
TargetedParams.PARAM_HELP,
|
|
324
|
+
name="name for the new user-defined property",
|
|
325
|
+
type="String, Bool, Integer, Double or Double3",
|
|
326
|
+
value="its initial value",
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
@register
|
|
331
|
+
class AddPropertyOp(Operation):
|
|
332
|
+
"""Attach a user-defined property -- the usual way pipeline metadata rides along."""
|
|
333
|
+
|
|
334
|
+
name = "add_property"
|
|
335
|
+
summary = "Add a user-defined property to matching nodes"
|
|
336
|
+
params_class = AddPropertyParams
|
|
337
|
+
|
|
338
|
+
def run(self, session, params, ctx):
|
|
339
|
+
if not params.name:
|
|
340
|
+
raise ParamError("add_property requires a non-empty 'name'")
|
|
341
|
+
targets = select_targets(session, params.target, params.required)
|
|
342
|
+
changes = []
|
|
343
|
+
for ref in targets:
|
|
344
|
+
if not ctx.dry_run:
|
|
345
|
+
session.create_property(ref.path, params.name, params.type, params.value)
|
|
346
|
+
changes.append(
|
|
347
|
+
Change(kind="create", target=ref.path, detail=params.name,
|
|
348
|
+
before=None, after=params.value)
|
|
349
|
+
)
|
|
350
|
+
return changes
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
@dataclass
|
|
354
|
+
class RemovePropertyParams(TargetedParams):
|
|
355
|
+
name: str = ""
|
|
356
|
+
|
|
357
|
+
PARAM_HELP = dict(TargetedParams.PARAM_HELP,
|
|
358
|
+
name="name of the user-defined property to remove")
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@register
|
|
362
|
+
class RemovePropertyOp(Operation):
|
|
363
|
+
"""Remove a user-defined property. Built-ins are refused, not silently skipped."""
|
|
364
|
+
|
|
365
|
+
name = "remove_property"
|
|
366
|
+
summary = "Remove a user-defined property from matching nodes"
|
|
367
|
+
params_class = RemovePropertyParams
|
|
368
|
+
|
|
369
|
+
def run(self, session, params, ctx):
|
|
370
|
+
if not params.name:
|
|
371
|
+
raise ParamError("remove_property requires a non-empty 'name'")
|
|
372
|
+
targets = select_targets(session, params.target, params.required)
|
|
373
|
+
changes = []
|
|
374
|
+
for ref in targets:
|
|
375
|
+
existing = [p for p in session.properties(ref.path, user_only=True)
|
|
376
|
+
if p.name == params.name]
|
|
377
|
+
if not existing:
|
|
378
|
+
# A property that is not there is nothing to remove -- a noop.
|
|
379
|
+
# A *built-in* one that is there is a different thing entirely:
|
|
380
|
+
# the request can never succeed, so it is a caller mistake and
|
|
381
|
+
# gets said out loud rather than silently counted as done.
|
|
382
|
+
builtin = [p for p in session.properties(ref.path)
|
|
383
|
+
if p.name == params.name]
|
|
384
|
+
if builtin:
|
|
385
|
+
raise OperationError(
|
|
386
|
+
f"{params.name!r} on {ref.path!r} is a built-in "
|
|
387
|
+
"property and cannot be "
|
|
388
|
+
"removed; only user-defined properties can"
|
|
389
|
+
)
|
|
390
|
+
continue
|
|
391
|
+
if not ctx.dry_run:
|
|
392
|
+
old = session.remove_property(ref.path, params.name)
|
|
393
|
+
else:
|
|
394
|
+
old = existing[0].value
|
|
395
|
+
changes.append(
|
|
396
|
+
Change(kind="delete", target=ref.path, detail=params.name,
|
|
397
|
+
before=old, after=None)
|
|
398
|
+
)
|
|
399
|
+
return changes
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
# --------------------------------------------------------------------------
|
|
403
|
+
# 改 -- transform convenience
|
|
404
|
+
# --------------------------------------------------------------------------
|
|
405
|
+
@dataclass
|
|
406
|
+
class SetTransformParams(TargetedParams):
|
|
407
|
+
translation: Optional[List[float]] = None
|
|
408
|
+
rotation: Optional[List[float]] = None
|
|
409
|
+
scaling: Optional[List[float]] = None
|
|
410
|
+
|
|
411
|
+
PARAM_HELP = dict(
|
|
412
|
+
TargetedParams.PARAM_HELP,
|
|
413
|
+
translation="[x, y, z] local translation",
|
|
414
|
+
rotation="[x, y, z] local rotation in degrees",
|
|
415
|
+
scaling="[x, y, z] local scaling",
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
@register
|
|
420
|
+
class SetTransformOp(Operation):
|
|
421
|
+
"""Set local transform components, skipping the ones left as null.
|
|
422
|
+
|
|
423
|
+
Sugar over three ``set_property`` calls, but the sugar is the point: the FBX
|
|
424
|
+
property names are ``Lcl Translation`` / ``Lcl Rotation`` / ``Lcl Scaling``,
|
|
425
|
+
with the space and the abbreviation, and getting one wrong is a silent
|
|
426
|
+
no-match rather than an error at the call site.
|
|
427
|
+
"""
|
|
428
|
+
|
|
429
|
+
name = "set_transform"
|
|
430
|
+
summary = "Set local translation / rotation / scaling"
|
|
431
|
+
params_class = SetTransformParams
|
|
432
|
+
|
|
433
|
+
_FIELDS = (("translation", "Lcl Translation"),
|
|
434
|
+
("rotation", "Lcl Rotation"),
|
|
435
|
+
("scaling", "Lcl Scaling"))
|
|
436
|
+
|
|
437
|
+
def run(self, session, params, ctx):
|
|
438
|
+
wanted = [(prop, getattr(params, attr))
|
|
439
|
+
for attr, prop in self._FIELDS if getattr(params, attr) is not None]
|
|
440
|
+
if not wanted:
|
|
441
|
+
raise ParamError(
|
|
442
|
+
"set_transform needs at least one of translation, rotation, scaling"
|
|
443
|
+
)
|
|
444
|
+
for prop, value in wanted:
|
|
445
|
+
if len(value) != 3:
|
|
446
|
+
raise ParamError(
|
|
447
|
+
f"{prop} takes 3 numbers, got {len(value)}"
|
|
448
|
+
)
|
|
449
|
+
targets = select_targets(session, params.target, params.required)
|
|
450
|
+
changes = []
|
|
451
|
+
for ref in targets:
|
|
452
|
+
for prop, value in wanted:
|
|
453
|
+
old = session.get_property(ref.path, prop)
|
|
454
|
+
if _same(old, value):
|
|
455
|
+
continue
|
|
456
|
+
if not ctx.dry_run:
|
|
457
|
+
session.set_property(ref.path, prop, value)
|
|
458
|
+
changes.append(
|
|
459
|
+
Change(kind="update", target=ref.path, detail=prop,
|
|
460
|
+
before=old, after=list(value))
|
|
461
|
+
)
|
|
462
|
+
return changes
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
# --------------------------------------------------------------------------
|
|
466
|
+
# 改 -- textures
|
|
467
|
+
# --------------------------------------------------------------------------
|
|
468
|
+
@dataclass
|
|
469
|
+
class RetargetTexturesParams:
|
|
470
|
+
pattern: str = ""
|
|
471
|
+
replacement: str = ""
|
|
472
|
+
prefix: Optional[str] = None
|
|
473
|
+
only_missing: bool = False
|
|
474
|
+
required: bool = True
|
|
475
|
+
|
|
476
|
+
PARAM_HELP = {
|
|
477
|
+
"pattern": "regular expression matched against each texture's path",
|
|
478
|
+
"replacement": "what to substitute (supports \\1 backreferences)",
|
|
479
|
+
"prefix": "alternatively, rebase every texture onto this directory",
|
|
480
|
+
"only_missing": "touch only textures whose file does not currently exist",
|
|
481
|
+
"required": "fail when no texture matches (default true)",
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
@register
|
|
486
|
+
class RetargetTexturesOp(Operation):
|
|
487
|
+
"""Rewrite texture paths -- the single most common thing wrong with a delivered FBX.
|
|
488
|
+
|
|
489
|
+
A texture path that resolved on the artist's machine and nowhere else is
|
|
490
|
+
invisible until something looks, so this operation reports, for each texture
|
|
491
|
+
it rewrites, whether the new path actually exists on disk. A batch that
|
|
492
|
+
repoints two hundred textures at a directory that is empty is a batch that
|
|
493
|
+
should say so.
|
|
494
|
+
"""
|
|
495
|
+
|
|
496
|
+
name = "retarget_textures"
|
|
497
|
+
summary = "Rewrite texture file paths by regex or by rebasing onto a directory"
|
|
498
|
+
params_class = RetargetTexturesParams
|
|
499
|
+
|
|
500
|
+
def run(self, session, params, ctx):
|
|
501
|
+
if bool(params.pattern) == bool(params.prefix):
|
|
502
|
+
raise ParamError(
|
|
503
|
+
"retarget_textures takes either 'pattern' (with 'replacement') or "
|
|
504
|
+
"'prefix', not both and not neither"
|
|
505
|
+
)
|
|
506
|
+
try:
|
|
507
|
+
compiled = re.compile(params.pattern) if params.pattern else None
|
|
508
|
+
except re.error as exc:
|
|
509
|
+
raise ParamError(
|
|
510
|
+
f"retarget_textures: bad pattern {params.pattern!r}: {exc}"
|
|
511
|
+
) from exc
|
|
512
|
+
|
|
513
|
+
textures = session.textures()
|
|
514
|
+
if params.only_missing:
|
|
515
|
+
textures = [t for t in textures if not t.exists]
|
|
516
|
+
if not textures and params.required:
|
|
517
|
+
raise OperationError(
|
|
518
|
+
"no {}textures in this scene".format("missing " if params.only_missing else "")
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
changes = []
|
|
522
|
+
for tex in textures:
|
|
523
|
+
if compiled is not None:
|
|
524
|
+
new_path = compiled.sub(params.replacement, tex.filename)
|
|
525
|
+
else:
|
|
526
|
+
new_path = os.path.join(params.prefix, os.path.basename(tex.filename))
|
|
527
|
+
new_path = new_path.replace("\\", "/")
|
|
528
|
+
if new_path == tex.filename:
|
|
529
|
+
continue
|
|
530
|
+
if not ctx.dry_run:
|
|
531
|
+
session.set_texture_filename(tex.name, new_path)
|
|
532
|
+
if not os.path.isfile(new_path):
|
|
533
|
+
ctx.note(
|
|
534
|
+
f"texture {tex.name!r} now points at {new_path!r}, which "
|
|
535
|
+
"does not exist on this "
|
|
536
|
+
"machine"
|
|
537
|
+
)
|
|
538
|
+
changes.append(
|
|
539
|
+
Change(kind="update", target=tex.name, detail="filename",
|
|
540
|
+
before=tex.filename, after=new_path)
|
|
541
|
+
)
|
|
542
|
+
return changes
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _same(old, new):
|
|
546
|
+
# type: (Any, Any) -> bool
|
|
547
|
+
"""Would writing ``new`` over ``old`` change anything?
|
|
548
|
+
|
|
549
|
+
Compares numerically where both sides are numbers, so a JSON patch carrying
|
|
550
|
+
``1`` does not read as a change against a stored ``1.0`` and inflate every
|
|
551
|
+
report with edits that did nothing.
|
|
552
|
+
"""
|
|
553
|
+
if isinstance(old, (list, tuple)) and isinstance(new, (list, tuple)):
|
|
554
|
+
if len(old) != len(new):
|
|
555
|
+
return False
|
|
556
|
+
return all(_same(a, b) for a, b in zip(old, new))
|
|
557
|
+
if isinstance(old, bool) or isinstance(new, bool):
|
|
558
|
+
return bool(old) == bool(new)
|
|
559
|
+
if isinstance(old, (int, float)) and isinstance(new, (int, float)):
|
|
560
|
+
return float(old) == float(new)
|
|
561
|
+
return old == new
|