dazpy 2.6.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.
- dazpy/LICENSE +21 -0
- dazpy/__init__.py +158 -0
- dazpy/_animation.py +402 -0
- dazpy/_batch.py +107 -0
- dazpy/_bone.py +87 -0
- dazpy/_camera.py +188 -0
- dazpy/_client.py +452 -0
- dazpy/_element.py +97 -0
- dazpy/_geometry.py +547 -0
- dazpy/_interaction.py +2003 -0
- dazpy/_light.py +87 -0
- dazpy/_material.py +105 -0
- dazpy/_modifier.py +40 -0
- dazpy/_morph.py +43 -0
- dazpy/_node.py +556 -0
- dazpy/_polling.py +59 -0
- dazpy/_pose.py +343 -0
- dazpy/_property.py +101 -0
- dazpy/_render.py +185 -0
- dazpy/_render_api.py +337 -0
- dazpy/_result.py +24 -0
- dazpy/_scene.py +658 -0
- dazpy/_script_builder.py +64 -0
- dazpy/_skeleton.py +683 -0
- dazpy/_timeline.py +65 -0
- dazpy/_undo.py +40 -0
- dazpy/_viewport.py +190 -0
- dazpy/exceptions.py +77 -0
- dazpy/math3.py +611 -0
- dazpy/py.typed +0 -0
- dazpy-2.6.0.dist-info/METADATA +2647 -0
- dazpy-2.6.0.dist-info/RECORD +35 -0
- dazpy-2.6.0.dist-info/WHEEL +5 -0
- dazpy-2.6.0.dist-info/licenses/dazpy/LICENSE +21 -0
- dazpy-2.6.0.dist-info/top_level.txt +1 -0
dazpy/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Blue Moon Foundry
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
dazpy/__init__.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""dazpy — Python SDK for the DAZ Studio Script Server.
|
|
2
|
+
|
|
3
|
+
Connect to a running DAZ Studio instance, execute DazScript code, and
|
|
4
|
+
manipulate the scene through a type-safe Python API.
|
|
5
|
+
|
|
6
|
+
Typical usage::
|
|
7
|
+
|
|
8
|
+
from dazpy import DazClient, DazScene
|
|
9
|
+
|
|
10
|
+
client = DazClient() # connects to 127.0.0.1:18811
|
|
11
|
+
scene = DazScene(client)
|
|
12
|
+
figure = scene.find_skeleton_by_label("Genesis 9")
|
|
13
|
+
figure.find_bone("r_forearm").set_local_rotation(0, 0, 45)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
__version__ = "2.6.0"
|
|
17
|
+
|
|
18
|
+
from ._client import DazClient
|
|
19
|
+
from ._scene import DazScene
|
|
20
|
+
from ._node import DazNode, NodeIdentifier
|
|
21
|
+
from ._skeleton import DazSkeleton
|
|
22
|
+
from ._bone import DazBone
|
|
23
|
+
from ._camera import DazCamera
|
|
24
|
+
from ._light import DazLight
|
|
25
|
+
from ._material import DazMaterial
|
|
26
|
+
from ._modifier import DazModifier
|
|
27
|
+
from ._morph import DazMorph
|
|
28
|
+
from ._geometry import DazGeometry
|
|
29
|
+
from ._render import DazRenderSettings
|
|
30
|
+
from ._viewport import DazViewport
|
|
31
|
+
from ._timeline import DazTimeline
|
|
32
|
+
from ._property import DazProperty
|
|
33
|
+
from ._element import DazElement
|
|
34
|
+
from ._batch import Batch, BatchFuture
|
|
35
|
+
from ._interaction import (
|
|
36
|
+
AxisLimit,
|
|
37
|
+
BalanceTarget,
|
|
38
|
+
BoneChain,
|
|
39
|
+
BoneProfile,
|
|
40
|
+
AnchorTarget,
|
|
41
|
+
ContactTarget,
|
|
42
|
+
FigureRigProfile,
|
|
43
|
+
InteractionAnchor,
|
|
44
|
+
InteractionPlan,
|
|
45
|
+
InteractionRecipe,
|
|
46
|
+
InteractionPosePatch,
|
|
47
|
+
LimbAlignmentResult,
|
|
48
|
+
PreparedInteractionRecipe,
|
|
49
|
+
PreparedInteractionResult,
|
|
50
|
+
FootTarget,
|
|
51
|
+
LookAtTarget,
|
|
52
|
+
HandTarget,
|
|
53
|
+
PoseTarget,
|
|
54
|
+
ResolvedInteractionTarget,
|
|
55
|
+
SolveOptions,
|
|
56
|
+
ValidationIssue,
|
|
57
|
+
build_rig_profile,
|
|
58
|
+
build_rig_profiles_from_snapshot,
|
|
59
|
+
build_fight_recipe,
|
|
60
|
+
build_kiss_recipe,
|
|
61
|
+
build_sit_recipe,
|
|
62
|
+
build_touch_recipe,
|
|
63
|
+
align_foot_target,
|
|
64
|
+
align_hand_target,
|
|
65
|
+
default_axis_limits_for_bone,
|
|
66
|
+
align_single_limb_target,
|
|
67
|
+
apply_interaction_recipe_to_scene,
|
|
68
|
+
prepare_interaction_recipe,
|
|
69
|
+
resolve_interaction_target,
|
|
70
|
+
)
|
|
71
|
+
from ._undo import UndoGroup
|
|
72
|
+
from ._pose import DazPose
|
|
73
|
+
from ._animation import DazAnimation
|
|
74
|
+
from .math3 import Vec3, Quat, BoundingBox
|
|
75
|
+
from ._result import ExecutionResult
|
|
76
|
+
from ._polling import execute_long
|
|
77
|
+
from ._render_api import (
|
|
78
|
+
FigureMorphs,
|
|
79
|
+
RenderVariant,
|
|
80
|
+
RenderBase,
|
|
81
|
+
RenderResult,
|
|
82
|
+
render,
|
|
83
|
+
render_variants,
|
|
84
|
+
)
|
|
85
|
+
from .exceptions import RenderError
|
|
86
|
+
from . import exceptions
|
|
87
|
+
|
|
88
|
+
__all__ = [
|
|
89
|
+
"DazClient",
|
|
90
|
+
"DazScene",
|
|
91
|
+
"DazNode",
|
|
92
|
+
"NodeIdentifier",
|
|
93
|
+
"DazSkeleton",
|
|
94
|
+
"DazBone",
|
|
95
|
+
"DazCamera",
|
|
96
|
+
"DazLight",
|
|
97
|
+
"DazMaterial",
|
|
98
|
+
"DazModifier",
|
|
99
|
+
"DazMorph",
|
|
100
|
+
"DazGeometry",
|
|
101
|
+
"DazRenderSettings",
|
|
102
|
+
"DazViewport",
|
|
103
|
+
"DazTimeline",
|
|
104
|
+
"DazProperty",
|
|
105
|
+
"DazElement",
|
|
106
|
+
"Batch",
|
|
107
|
+
"BatchFuture",
|
|
108
|
+
"AxisLimit",
|
|
109
|
+
"BalanceTarget",
|
|
110
|
+
"BoneChain",
|
|
111
|
+
"BoneProfile",
|
|
112
|
+
"AnchorTarget",
|
|
113
|
+
"ContactTarget",
|
|
114
|
+
"FigureRigProfile",
|
|
115
|
+
"InteractionAnchor",
|
|
116
|
+
"InteractionPlan",
|
|
117
|
+
"InteractionRecipe",
|
|
118
|
+
"InteractionPosePatch",
|
|
119
|
+
"LimbAlignmentResult",
|
|
120
|
+
"PreparedInteractionRecipe",
|
|
121
|
+
"PreparedInteractionResult",
|
|
122
|
+
"FootTarget",
|
|
123
|
+
"LookAtTarget",
|
|
124
|
+
"HandTarget",
|
|
125
|
+
"PoseTarget",
|
|
126
|
+
"ResolvedInteractionTarget",
|
|
127
|
+
"SolveOptions",
|
|
128
|
+
"ValidationIssue",
|
|
129
|
+
"build_rig_profile",
|
|
130
|
+
"build_rig_profiles_from_snapshot",
|
|
131
|
+
"build_fight_recipe",
|
|
132
|
+
"build_kiss_recipe",
|
|
133
|
+
"build_sit_recipe",
|
|
134
|
+
"build_touch_recipe",
|
|
135
|
+
"align_foot_target",
|
|
136
|
+
"align_hand_target",
|
|
137
|
+
"default_axis_limits_for_bone",
|
|
138
|
+
"align_single_limb_target",
|
|
139
|
+
"apply_interaction_recipe_to_scene",
|
|
140
|
+
"prepare_interaction_recipe",
|
|
141
|
+
"resolve_interaction_target",
|
|
142
|
+
"UndoGroup",
|
|
143
|
+
"DazPose",
|
|
144
|
+
"DazAnimation",
|
|
145
|
+
"Vec3",
|
|
146
|
+
"Quat",
|
|
147
|
+
"BoundingBox",
|
|
148
|
+
"ExecutionResult",
|
|
149
|
+
"execute_long",
|
|
150
|
+
"FigureMorphs",
|
|
151
|
+
"RenderVariant",
|
|
152
|
+
"RenderBase",
|
|
153
|
+
"RenderResult",
|
|
154
|
+
"RenderError",
|
|
155
|
+
"render",
|
|
156
|
+
"render_variants",
|
|
157
|
+
"exceptions",
|
|
158
|
+
]
|
dazpy/_animation.py
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from ._script_builder import ScriptBuilder
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from ._skeleton import DazSkeleton
|
|
11
|
+
from ._pose import DazPose
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DazAnimation:
|
|
15
|
+
"""A captured animation clip: bone rotations (and optionally morphs) for every
|
|
16
|
+
frame in the play range.
|
|
17
|
+
|
|
18
|
+
Created with :meth:`capture`; saved/loaded with :meth:`save` / :meth:`load`.
|
|
19
|
+
|
|
20
|
+
Bones are stored as a parallel-list encoding — a single ordered ``bones``
|
|
21
|
+
list of names, and per-frame ``rotations`` arrays aligned to that index —
|
|
22
|
+
so payload size grows with bones × frames rather than with a full dict per
|
|
23
|
+
frame.
|
|
24
|
+
|
|
25
|
+
Typical workflow::
|
|
26
|
+
|
|
27
|
+
from dazpy import DazScene, DazAnimation
|
|
28
|
+
|
|
29
|
+
scene = DazScene()
|
|
30
|
+
figure = scene.find_skeleton_by_label("Genesis 9")
|
|
31
|
+
|
|
32
|
+
anim = DazAnimation.capture(figure, include_morphs=True)
|
|
33
|
+
anim.save("walk.json")
|
|
34
|
+
|
|
35
|
+
JSON schema (same as the ``animation_frame_dump.py`` output)::
|
|
36
|
+
|
|
37
|
+
{
|
|
38
|
+
"figure": "Genesis 9",
|
|
39
|
+
"frame_range": {"start": 0, "end": 90},
|
|
40
|
+
"bones": ["hip", "rForeArm", ...],
|
|
41
|
+
"frames": [
|
|
42
|
+
{"frame": 0, "rotations": [[x,y,z], ...], "morphs": {"PHMSmile": 0.5}},
|
|
43
|
+
...
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
figure: The label of the figure.
|
|
49
|
+
frame_range: ``{"start": int, "end": int}`` in frames.
|
|
50
|
+
bones: Ordered list of bone names matching ``rotations`` columns.
|
|
51
|
+
frames: Per-frame data; each entry has ``"frame"``, ``"rotations"``,
|
|
52
|
+
and ``"morphs"`` keys.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
figure: str,
|
|
58
|
+
frame_range: dict,
|
|
59
|
+
bones: list[str],
|
|
60
|
+
frames: list[dict],
|
|
61
|
+
) -> None:
|
|
62
|
+
self.figure = figure
|
|
63
|
+
self.frame_range = frame_range
|
|
64
|
+
self.bones = bones
|
|
65
|
+
self.frames = frames
|
|
66
|
+
|
|
67
|
+
# ── construction ──────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def capture(cls, skeleton: "DazSkeleton", include_morphs: bool = False) -> "DazAnimation":
|
|
71
|
+
"""Capture the full animation of *skeleton* in a single HTTP call.
|
|
72
|
+
|
|
73
|
+
Scrubs every frame in the scene's play range server-side (no Python
|
|
74
|
+
round-trip per frame). The timeline is restored to its original frame
|
|
75
|
+
before the call returns.
|
|
76
|
+
|
|
77
|
+
When *include_morphs* is ``True``, the script first identifies which
|
|
78
|
+
geometry morphs and node-level properties actually vary across the
|
|
79
|
+
timeline (channels that are keyed but static are excluded), then
|
|
80
|
+
records their values per frame.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
skeleton: The figure to capture.
|
|
84
|
+
include_morphs: Also capture animated morph and property channels.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
A new :class:`DazAnimation`.
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
~dazpy.exceptions.NodeNotFoundError: If the skeleton is not found.
|
|
91
|
+
"""
|
|
92
|
+
lookup = ScriptBuilder.skeleton_lookup(skeleton._identifier)
|
|
93
|
+
include_morphs_js = "true" if include_morphs else "false"
|
|
94
|
+
|
|
95
|
+
script = f"""(function(){{
|
|
96
|
+
{lookup}
|
|
97
|
+
if (!_skel) return null;
|
|
98
|
+
|
|
99
|
+
var _origFrame = Scene.getFrame();
|
|
100
|
+
|
|
101
|
+
var allBones = _skel.getAllBones();
|
|
102
|
+
var boneNames = [];
|
|
103
|
+
for (var i = 0; i < allBones.length; i++) boneNames.push(allBones[i].getName());
|
|
104
|
+
|
|
105
|
+
// Detect channels whose values actually vary across the timeline.
|
|
106
|
+
// Channels that are keyed but hold a constant value are excluded —
|
|
107
|
+
// they add payload without contributing animation data.
|
|
108
|
+
function isVaryingMorph(ch) {{
|
|
109
|
+
var n = ch.getNumKeys();
|
|
110
|
+
if (n < 2) return false;
|
|
111
|
+
var first = ch.getKeyValue(0);
|
|
112
|
+
for (var k = 1; k < n; k++) {{
|
|
113
|
+
if (Math.abs(ch.getKeyValue(k) - first) > 0.0001) return true;
|
|
114
|
+
}}
|
|
115
|
+
return false;
|
|
116
|
+
}}
|
|
117
|
+
|
|
118
|
+
var animatedChannels = [];
|
|
119
|
+
if ({include_morphs_js}) {{
|
|
120
|
+
var obj = _skel.getObject();
|
|
121
|
+
if (obj) {{
|
|
122
|
+
for (var i = 0; i < obj.getNumModifiers(); i++) {{
|
|
123
|
+
var m = obj.getModifier(i);
|
|
124
|
+
if (m.className() === "DzMorph" && isVaryingMorph(m.getValueChannel()))
|
|
125
|
+
animatedChannels.push([m.getName(), m.getValueChannel()]);
|
|
126
|
+
}}
|
|
127
|
+
}}
|
|
128
|
+
for (var i = 0; i < _skel.getNumProperties(); i++) {{
|
|
129
|
+
var p = _skel.getProperty(i);
|
|
130
|
+
if (p && p.getNumKeys && isVaryingMorph(p))
|
|
131
|
+
animatedChannels.push([p.getName(), p]);
|
|
132
|
+
}}
|
|
133
|
+
}}
|
|
134
|
+
|
|
135
|
+
var step = Scene.getTimeStep();
|
|
136
|
+
var range = Scene.getPlayRange();
|
|
137
|
+
var start = Math.round(range.start / step);
|
|
138
|
+
var end = Math.round(range.end / step);
|
|
139
|
+
|
|
140
|
+
var frames = [];
|
|
141
|
+
for (var f = start; f <= end; f++) {{
|
|
142
|
+
Scene.setFrame(f);
|
|
143
|
+
|
|
144
|
+
var rotations = [];
|
|
145
|
+
for (var i = 0; i < allBones.length; i++) {{
|
|
146
|
+
var b = allBones[i];
|
|
147
|
+
rotations.push([
|
|
148
|
+
b.getXRotControl().getValue(),
|
|
149
|
+
b.getYRotControl().getValue(),
|
|
150
|
+
b.getZRotControl().getValue()
|
|
151
|
+
]);
|
|
152
|
+
}}
|
|
153
|
+
|
|
154
|
+
var morphs = {{}};
|
|
155
|
+
for (var i = 0; i < animatedChannels.length; i++) {{
|
|
156
|
+
var v = animatedChannels[i][1].getValue();
|
|
157
|
+
if (Math.abs(v) > 0.0001) morphs[animatedChannels[i][0]] = v;
|
|
158
|
+
}}
|
|
159
|
+
|
|
160
|
+
frames.push({{frame: f, rotations: rotations, morphs: morphs}});
|
|
161
|
+
}}
|
|
162
|
+
|
|
163
|
+
Scene.setFrame(_origFrame);
|
|
164
|
+
|
|
165
|
+
return {{
|
|
166
|
+
figure: _skel.getLabel(),
|
|
167
|
+
frame_range: {{start: start, end: end}},
|
|
168
|
+
bones: boneNames,
|
|
169
|
+
frames: frames
|
|
170
|
+
}};
|
|
171
|
+
}})()"""
|
|
172
|
+
|
|
173
|
+
result = skeleton._client.execute(script).value
|
|
174
|
+
if result is None:
|
|
175
|
+
from .exceptions import NodeNotFoundError
|
|
176
|
+
raise NodeNotFoundError(f"Skeleton not found: {skeleton._identifier.value!r}")
|
|
177
|
+
|
|
178
|
+
return cls(
|
|
179
|
+
figure=result["figure"],
|
|
180
|
+
frame_range=result["frame_range"],
|
|
181
|
+
bones=result["bones"],
|
|
182
|
+
frames=result["frames"],
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
@classmethod
|
|
186
|
+
def load(cls, path: str | Path) -> "DazAnimation":
|
|
187
|
+
"""Load an animation from a JSON file.
|
|
188
|
+
|
|
189
|
+
Accepts files produced by :meth:`save` and by the original
|
|
190
|
+
``animation_frame_dump.py`` example script.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
path: Path to the JSON file.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
A new :class:`DazAnimation`.
|
|
197
|
+
"""
|
|
198
|
+
with open(path, encoding="utf-8") as f:
|
|
199
|
+
data = json.load(f)
|
|
200
|
+
return cls(
|
|
201
|
+
figure=data.get("figure", ""),
|
|
202
|
+
frame_range=data.get("frame_range", {"start": 0, "end": 0}),
|
|
203
|
+
bones=data.get("bones", []),
|
|
204
|
+
frames=data.get("frames", []),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# ── serialisation ─────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
def save(self, path: str | Path) -> None:
|
|
210
|
+
"""Write this animation to a JSON file.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
path: Destination path. Parent directories must exist.
|
|
214
|
+
"""
|
|
215
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
216
|
+
json.dump(self.to_dict(), f, indent=2)
|
|
217
|
+
|
|
218
|
+
def to_dict(self) -> dict:
|
|
219
|
+
"""Return the animation as a plain dict (same schema as the JSON file)."""
|
|
220
|
+
return {
|
|
221
|
+
"figure": self.figure,
|
|
222
|
+
"frame_range": self.frame_range,
|
|
223
|
+
"bones": self.bones,
|
|
224
|
+
"frames": self.frames,
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
# ── clip operations ───────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
def clip(self, start: int, end: int) -> "DazAnimation":
|
|
230
|
+
"""Return a new animation containing only frames in [start, end].
|
|
231
|
+
|
|
232
|
+
*start* and *end* are scene frame numbers (the ``"frame"`` value stored
|
|
233
|
+
in each frame, not Python list indices). Both endpoints are inclusive.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
start: First scene frame to include.
|
|
237
|
+
end: Last scene frame to include.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
A new :class:`DazAnimation` with the same bones but a subset of frames.
|
|
241
|
+
"""
|
|
242
|
+
frames = [f for f in self.frames if start <= f["frame"] <= end]
|
|
243
|
+
new_start = frames[0]["frame"] if frames else start
|
|
244
|
+
new_end = frames[-1]["frame"] if frames else end
|
|
245
|
+
return DazAnimation(
|
|
246
|
+
figure=self.figure,
|
|
247
|
+
frame_range={"start": new_start, "end": new_end},
|
|
248
|
+
bones=self.bones,
|
|
249
|
+
frames=frames,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def blend(self, other: "DazAnimation", t: float) -> "DazAnimation":
|
|
253
|
+
"""Blend this animation with *other* frame by frame.
|
|
254
|
+
|
|
255
|
+
Each frame's bone rotations and morph values are linearly interpolated
|
|
256
|
+
between *self* (``t=0``) and *other* (``t=1``). This is a pure-Python
|
|
257
|
+
operation — no HTTP round-trip.
|
|
258
|
+
|
|
259
|
+
If the two clips have different frame counts, the result is truncated to
|
|
260
|
+
the shorter clip. Frame numbers are taken from *self*.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
other: The target animation.
|
|
264
|
+
t: Blend factor — 0.0 = self, 1.0 = other.
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
A new :class:`DazAnimation` at the interpolated position.
|
|
268
|
+
|
|
269
|
+
Raises:
|
|
270
|
+
ValueError: If *self* and *other* have different bone lists.
|
|
271
|
+
"""
|
|
272
|
+
if self.bones != other.bones:
|
|
273
|
+
raise ValueError(
|
|
274
|
+
f"Cannot blend animations with different bone lists "
|
|
275
|
+
f"({len(self.bones)} vs {len(other.bones)} bones)"
|
|
276
|
+
)
|
|
277
|
+
s = 1.0 - t
|
|
278
|
+
frames = []
|
|
279
|
+
for fa, fb in zip(self.frames, other.frames):
|
|
280
|
+
rotations = [
|
|
281
|
+
[fa["rotations"][i][j] * s + fb["rotations"][i][j] * t for j in range(3)]
|
|
282
|
+
for i in range(len(self.bones))
|
|
283
|
+
]
|
|
284
|
+
all_keys = set(fa["morphs"]) | set(fb["morphs"])
|
|
285
|
+
morphs = {}
|
|
286
|
+
for k in all_keys:
|
|
287
|
+
v = fa["morphs"].get(k, 0.0) * s + fb["morphs"].get(k, 0.0) * t
|
|
288
|
+
if abs(v) > 1e-9:
|
|
289
|
+
morphs[k] = v
|
|
290
|
+
frames.append({"frame": fa["frame"], "rotations": rotations, "morphs": morphs})
|
|
291
|
+
new_end = frames[-1]["frame"] if frames else self.frame_range["start"]
|
|
292
|
+
return DazAnimation(
|
|
293
|
+
figure=self.figure,
|
|
294
|
+
frame_range={"start": self.frame_range["start"], "end": new_end},
|
|
295
|
+
bones=self.bones,
|
|
296
|
+
frames=frames,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
def as_pose(self, frame_index: int = 0) -> "DazPose":
|
|
300
|
+
"""Extract a single frame as a :class:`~dazpy.DazPose`.
|
|
301
|
+
|
|
302
|
+
Bone rotations are stored sparsely — only bones with at least one
|
|
303
|
+
non-zero component are included. Morph values are copied as-is.
|
|
304
|
+
|
|
305
|
+
This is a pure-Python operation — no HTTP round-trip.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
frame_index: 0-based index into :attr:`frames`.
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
A new :class:`~dazpy.DazPose`.
|
|
312
|
+
"""
|
|
313
|
+
from ._pose import DazPose
|
|
314
|
+
frame = self.frames[frame_index]
|
|
315
|
+
bones = {
|
|
316
|
+
name: list(rot)
|
|
317
|
+
for name, rot in zip(self.bones, frame["rotations"])
|
|
318
|
+
if any(abs(v) > 1e-9 for v in rot)
|
|
319
|
+
}
|
|
320
|
+
return DazPose(
|
|
321
|
+
figure=self.figure,
|
|
322
|
+
bones=bones,
|
|
323
|
+
morphs=dict(frame["morphs"]),
|
|
324
|
+
props={},
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
def apply(self, skeleton: "DazSkeleton", frame_index: int = 0) -> None:
|
|
328
|
+
"""Apply a single frame of this animation to *skeleton*.
|
|
329
|
+
|
|
330
|
+
Equivalent to ``anim.as_pose(frame_index).apply(skeleton)`` — one HTTP
|
|
331
|
+
call that sets only the channels present in the frame.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
skeleton: The figure to pose.
|
|
335
|
+
frame_index: 0-based index into :attr:`frames`. Defaults to 0.
|
|
336
|
+
"""
|
|
337
|
+
self.as_pose(frame_index).apply(skeleton)
|
|
338
|
+
|
|
339
|
+
def append(self, other: "DazAnimation") -> "DazAnimation":
|
|
340
|
+
"""Concatenate *other* immediately after this animation.
|
|
341
|
+
|
|
342
|
+
The other clip's frame numbers are shifted so it starts on the frame
|
|
343
|
+
following this animation's last frame. The figure label and bone list
|
|
344
|
+
are taken from *self*.
|
|
345
|
+
|
|
346
|
+
This is a pure-Python operation — no HTTP round-trip.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
other: The animation to append.
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
A new :class:`DazAnimation` containing all frames from both clips.
|
|
353
|
+
|
|
354
|
+
Raises:
|
|
355
|
+
ValueError: If *self* and *other* have different bone lists.
|
|
356
|
+
"""
|
|
357
|
+
if self.bones != other.bones:
|
|
358
|
+
raise ValueError(
|
|
359
|
+
f"Cannot append animations with different bone lists "
|
|
360
|
+
f"({len(self.bones)} vs {len(other.bones)} bones)"
|
|
361
|
+
)
|
|
362
|
+
if not self.frames:
|
|
363
|
+
return DazAnimation(self.figure, dict(other.frame_range), list(other.bones), list(other.frames))
|
|
364
|
+
offset = self.frame_range["end"] + 1 - other.frame_range["start"]
|
|
365
|
+
other_frames = [
|
|
366
|
+
{"frame": f["frame"] + offset, "rotations": f["rotations"], "morphs": f["morphs"]}
|
|
367
|
+
for f in other.frames
|
|
368
|
+
]
|
|
369
|
+
frames = list(self.frames) + other_frames
|
|
370
|
+
return DazAnimation(
|
|
371
|
+
figure=self.figure,
|
|
372
|
+
frame_range={"start": self.frame_range["start"], "end": frames[-1]["frame"]},
|
|
373
|
+
bones=self.bones,
|
|
374
|
+
frames=frames,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
# ── convenience ───────────────────────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
@property
|
|
380
|
+
def frame_count(self) -> int:
|
|
381
|
+
"""Number of captured frames."""
|
|
382
|
+
return len(self.frames)
|
|
383
|
+
|
|
384
|
+
@property
|
|
385
|
+
def bone_count(self) -> int:
|
|
386
|
+
"""Number of bones in the skeleton at capture time."""
|
|
387
|
+
return len(self.bones)
|
|
388
|
+
|
|
389
|
+
def __len__(self) -> int:
|
|
390
|
+
return len(self.frames)
|
|
391
|
+
|
|
392
|
+
def __getitem__(self, index: int) -> dict:
|
|
393
|
+
"""Return the frame dict at *index* (0-based Python index)."""
|
|
394
|
+
return self.frames[index]
|
|
395
|
+
|
|
396
|
+
def __repr__(self) -> str:
|
|
397
|
+
fr = self.frame_range
|
|
398
|
+
return (
|
|
399
|
+
f"DazAnimation(figure={self.figure!r}, "
|
|
400
|
+
f"frames={self.frame_count}, bones={self.bone_count}, "
|
|
401
|
+
f"range={fr.get('start')}–{fr.get('end')})"
|
|
402
|
+
)
|
dazpy/_batch.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ._client import DazClient
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BatchFuture:
|
|
7
|
+
"""Placeholder for a single result within a :class:`Batch` execution.
|
|
8
|
+
|
|
9
|
+
Created by :meth:`Batch.add`; the :attr:`value` property blocks until the
|
|
10
|
+
batch has been executed.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, key: str):
|
|
14
|
+
self._key = key
|
|
15
|
+
self._resolved = False
|
|
16
|
+
self._value = None
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def value(self) -> object:
|
|
20
|
+
"""The result value.
|
|
21
|
+
|
|
22
|
+
Raises:
|
|
23
|
+
RuntimeError: If :meth:`Batch.execute` has not been called yet.
|
|
24
|
+
"""
|
|
25
|
+
if not self._resolved:
|
|
26
|
+
raise RuntimeError("Batch has not been executed yet")
|
|
27
|
+
return self._value
|
|
28
|
+
|
|
29
|
+
def _resolve(self, value: object) -> None:
|
|
30
|
+
self._value = value
|
|
31
|
+
self._resolved = True
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Batch:
|
|
35
|
+
"""Collect multiple DazScript operations and execute them in a single HTTP round-trip.
|
|
36
|
+
|
|
37
|
+
Usage as a context manager (recommended)::
|
|
38
|
+
|
|
39
|
+
with Batch(client) as b:
|
|
40
|
+
pos_future = b.add(["var pos = Scene.findNode('Figure').getWSPos();",
|
|
41
|
+
"var pos = [pos.x, pos.y, pos.z];"])
|
|
42
|
+
name_future = b.add(["var name = Scene.findNode('Figure').getName();"])
|
|
43
|
+
# Both futures resolved after the `with` block
|
|
44
|
+
print(pos_future.value, name_future.value)
|
|
45
|
+
|
|
46
|
+
Or manually::
|
|
47
|
+
|
|
48
|
+
b = Batch(client)
|
|
49
|
+
f = b.add(["var x = 42;"])
|
|
50
|
+
b.execute()
|
|
51
|
+
print(f.value)
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
client: The :class:`~dazpy.DazClient` to use.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, client: DazClient):
|
|
58
|
+
self._client = client
|
|
59
|
+
self._ops: list[tuple[str, list[str], BatchFuture]] = []
|
|
60
|
+
self._counter = 0
|
|
61
|
+
|
|
62
|
+
def add(self, lines: list[str]) -> BatchFuture:
|
|
63
|
+
"""Queue a list of DazScript lines to be included in the batch.
|
|
64
|
+
|
|
65
|
+
The last line in *lines* should assign the desired result to a variable
|
|
66
|
+
named after the key that will be referenced internally.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
lines: DazScript source lines (no ``return`` needed).
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
A :class:`BatchFuture` that resolves after :meth:`execute`.
|
|
73
|
+
"""
|
|
74
|
+
key = f"_r{self._counter}"
|
|
75
|
+
self._counter += 1
|
|
76
|
+
future = BatchFuture(key)
|
|
77
|
+
self._ops.append((key, lines, future))
|
|
78
|
+
return future
|
|
79
|
+
|
|
80
|
+
def _build_script(self) -> str:
|
|
81
|
+
body_lines = []
|
|
82
|
+
return_parts = []
|
|
83
|
+
for key, lines, _ in self._ops:
|
|
84
|
+
body_lines.extend(lines)
|
|
85
|
+
return_parts.append(f'"{key}": {key}')
|
|
86
|
+
return_obj = "{" + ", ".join(return_parts) + "}"
|
|
87
|
+
body_lines.append(f"return {return_obj};")
|
|
88
|
+
body = "\n".join(body_lines)
|
|
89
|
+
return f"(function(){{\n{body}\n}})()"
|
|
90
|
+
|
|
91
|
+
def execute(self) -> None:
|
|
92
|
+
"""Execute all queued operations in a single HTTP request and resolve all futures."""
|
|
93
|
+
if not self._ops:
|
|
94
|
+
return
|
|
95
|
+
script = self._build_script()
|
|
96
|
+
result = self._client.execute(script)
|
|
97
|
+
data = result.value or {}
|
|
98
|
+
for key, _, future in self._ops:
|
|
99
|
+
future._resolve(data.get(key))
|
|
100
|
+
|
|
101
|
+
def __enter__(self) -> "Batch":
|
|
102
|
+
return self
|
|
103
|
+
|
|
104
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
|
|
105
|
+
if exc_type is None:
|
|
106
|
+
self.execute()
|
|
107
|
+
return False
|