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/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