@volter/blender-engine 0.1.134 → 0.1.135

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.
package/browser/rna.ts CHANGED
@@ -528,22 +528,29 @@ export interface BlenderUvLayout {
528
528
  }
529
529
 
530
530
  /**
531
- * ONE TRACK of {@link BlenderActionClip}: a bone's LOCAL `position`,
532
- * `quaternion` or `scale` sampled at every integer frame.
533
- *
534
- * `constant` says the door found every sample equal to the first and shipped
535
- * two keys instead of N — which is most of a rig, most of the time.
531
+ * ONE F-CURVE of {@link BlenderActionClip}, whole: one component (`index`) of one of a bone's
532
+ * channels (`location`, `rotation_quaternion` as w,x,y,z, `rotation_euler`, `rotation_axis_angle`
533
+ * as angle,x,y,z, `scale`), with everything `fcurve_eval_keyframes` reads to evaluate it.
536
534
  */
537
- export interface BlenderClipTrack {
535
+ export interface BlenderClipTrack extends BlenderClipCurve {
538
536
  readonly bone: string;
539
- readonly property: 'position' | 'quaternion' | 'scale';
540
- readonly stride: number;
541
- readonly count: number;
542
- readonly constant: boolean;
543
- /** Float32 seconds, `count` of them, zero at `clipStart`. */
544
- readonly timeBase64: string;
545
- /** Float32, `count * stride`. */
546
- readonly valueBase64: string;
537
+ readonly property: 'location' | 'rotation_quaternion' | 'rotation_euler' | 'rotation_axis_angle' | 'scale';
538
+ readonly index: number;
539
+ }
540
+
541
+ /** An F-Curve's body: everything Blender evaluates it from, wherever it lives. */
542
+ export interface BlenderClipCurve {
543
+ /** `FCurve.extrapolation`: `CONSTANT` or `LINEAR`. */
544
+ readonly extrapolation: string;
545
+ /** Per key, how the segment after it interpolates: 0 constant, 1 linear, 2 Bezier. */
546
+ readonly interpolation: readonly number[];
547
+ /** Float32, six per key: `co`, `handle_left`, `handle_right` (frame, value each). */
548
+ readonly keysBase64: string;
549
+ /** A Cycles modifier: [mode before, cycles before, mode after, cycles after]; mode 0 none,
550
+ * 1 repeat, 2 repeat with offset, 3 mirror. */
551
+ readonly cycles?: readonly [number, number, number, number];
552
+ /** What this curve does in Blender that is not played (an easing type, another modifier). */
553
+ readonly unsupported?: readonly string[];
547
554
  }
548
555
 
549
556
  /** One column of the Timeline's summary row: a frame at which SOMETHING is
@@ -616,9 +623,18 @@ export interface BlenderActionClip {
616
623
  readonly tracks: readonly BlenderClipTrack[];
617
624
  readonly clipStart?: number;
618
625
  readonly clipEnd?: number;
626
+ /** The range Blender places the action over NLA strips in: its keys, null on a side a Cycles
627
+ * modifier repeats into. */
628
+ readonly keysStart?: number | null;
629
+ readonly keysEnd?: number | null;
630
+ /** Blender repeats it past its range (Cycles modifiers on its curves, or `use_cyclic`). */
631
+ readonly cyclic?: boolean;
619
632
  readonly duration?: number;
620
633
  readonly sampled?: number;
621
634
  /** Bones the action names that the armature does not have. */
622
635
  readonly unplayedBones?: readonly string[];
636
+ /** What the action keys besides bone channels (the object's own transform, a property),
637
+ * which plays only in Blender. */
638
+ readonly unsupported?: readonly string[];
623
639
  readonly reason?: string;
624
640
  }
@@ -1332,10 +1332,116 @@ def _armature_bones(obj, locked_groups):
1332
1332
  # (`overlay_armature.cc:2059-2084`); it shades both the solid and
1333
1333
  # the wire toward `bone_locked_weight`.
1334
1334
  "lockedWeight": bone.name in locked_groups,
1335
+ # WHAT THE PRESENTER EVALUATES THE POSE FROM, the same inputs `BKE_pose_where_is` takes:
1336
+ # the rest matrix (`Bone.matrix_local`, armature space), the channels' own values (what
1337
+ # a channel no action animates keeps), and the bone's constraints.
1338
+ "rest": _matrix_rows(bone.matrix_local),
1339
+ "channels": _bone_channels(pchan),
1340
+ **_bone_inherit(bone),
1341
+ **_bone_constraints(pchan),
1335
1342
  })
1336
1343
  return bones
1337
1344
 
1338
1345
 
1346
+ def _bone_channels(pchan):
1347
+ """A pose bone's channel VALUES as Blender stores them (`PoseBone.location`,
1348
+ `rotation_*`, `scale`), every rotation property and the mode that chooses among them, so the
1349
+ presenter blends them exactly as the NLA does, per channel, before building the matrix."""
1350
+ return {
1351
+ "mode": pchan.rotation_mode,
1352
+ "location": [float(v) for v in pchan.location],
1353
+ "rotation_quaternion": [float(v) for v in pchan.rotation_quaternion],
1354
+ "rotation_euler": [float(v) for v in pchan.rotation_euler],
1355
+ "rotation_axis_angle": [float(v) for v in pchan.rotation_axis_angle],
1356
+ "scale": [float(v) for v in pchan.scale],
1357
+ }
1358
+
1359
+
1360
+ def _bone_inherit(bone):
1361
+ """The parenting flags, only when one is not Blender's default (the presenter composes the
1362
+ default chain `parent @ rest @ basis`, and names a bone that asks for another)."""
1363
+ flags = []
1364
+ if not bone.use_inherit_rotation:
1365
+ flags.append("use_inherit_rotation=False")
1366
+ if bone.inherit_scale != "FULL":
1367
+ flags.append("inherit_scale=%s" % bone.inherit_scale)
1368
+ if not bone.use_local_location:
1369
+ flags.append("use_local_location=False")
1370
+ if getattr(bone, "use_relative_parent", False):
1371
+ flags.append("use_relative_parent=True")
1372
+ return {"inherit": flags} if flags else {}
1373
+
1374
+
1375
+ def _bone_constraints(pchan):
1376
+ """A pose bone's constraint stack, in order. Every constraint is named with its type; the ones
1377
+ the presenter evaluates (Damped Track) carry what it needs."""
1378
+ stack = []
1379
+ for con in pchan.constraints:
1380
+ entry = {"name": con.name, "type": con.type, "enabled": bool(con.enabled),
1381
+ "influence": float(con.influence)}
1382
+ target = getattr(con, "target", None)
1383
+ if target is not None:
1384
+ entry["target"] = target.name
1385
+ subtarget = getattr(con, "subtarget", "")
1386
+ if subtarget:
1387
+ entry["subtarget"] = subtarget
1388
+ if con.type == "DAMPED_TRACK":
1389
+ entry["trackAxis"] = con.track_axis
1390
+ entry["headTail"] = float(con.head_tail)
1391
+ stack.append(entry)
1392
+ return {"constraints": stack} if stack else {}
1393
+
1394
+
1395
+ def _strip_influence_curve(strip):
1396
+ """A strip's keyed influence (`NlaStrip.fcurves`, data path `influence`), whole, so the
1397
+ presenter evaluates it at every frame as `nlastrip_evaluate_controls` does."""
1398
+ if not strip.use_animated_influence:
1399
+ return {}
1400
+ for fcurve in strip.fcurves:
1401
+ if fcurve.data_path == "influence":
1402
+ return {"influenceCurve": _curve_body(fcurve)}
1403
+ return {}
1404
+
1405
+
1406
+ def _armature_animation(obj):
1407
+ """THE ARMATURE'S ANIMATION STACK as Blender evaluates it (`BKE_animsys_evaluate_animdata`):
1408
+ its NLA tracks bottom to top, then the active action over them, each with its influence and
1409
+ blend type. Strip timing is Blender's own (`nlastrip_get_frame`), so it is sent whole."""
1410
+ adt = obj.animation_data
1411
+ if adt is None:
1412
+ return {}
1413
+ tracks = []
1414
+ for track in adt.nla_tracks:
1415
+ strips = []
1416
+ for strip in track.strips:
1417
+ strips.append({
1418
+ "name": strip.name, "type": strip.type,
1419
+ "action": strip.action.name if strip.action is not None else None,
1420
+ "start": float(strip.frame_start), "end": float(strip.frame_end),
1421
+ "actionStart": float(strip.action_frame_start), "actionEnd": float(strip.action_frame_end),
1422
+ "scale": float(strip.scale), "repeat": float(strip.repeat),
1423
+ "influence": float(strip.influence), "animatedInfluence": bool(strip.use_animated_influence),
1424
+ **_strip_influence_curve(strip),
1425
+ "animatedTime": bool(strip.use_animated_time),
1426
+ "blendIn": float(strip.blend_in), "blendOut": float(strip.blend_out),
1427
+ "blendType": strip.blend_type, "extrapolation": strip.extrapolation,
1428
+ "reversed": bool(strip.use_reverse), "mute": bool(strip.mute),
1429
+ })
1430
+ tracks.append({"name": track.name, "mute": bool(track.mute), "solo": bool(track.is_solo),
1431
+ "strips": strips})
1432
+ drivers = sorted({match.group(1) for match in (_BONE_PATH.match(fc.data_path) for fc in adt.drivers)
1433
+ if match is not None})
1434
+ return {"animation": {
1435
+ "useNla": bool(adt.use_nla),
1436
+ "influence": float(adt.action_influence),
1437
+ "blendType": adt.action_blend_type,
1438
+ "extrapolation": adt.action_extrapolation,
1439
+ "tweak": bool(adt.use_tweak_mode),
1440
+ "tracks": tracks,
1441
+ **({"drivers": drivers} if drivers else {}),
1442
+ }}
1443
+
1444
+
1339
1445
  def _locked_weight_groups(view_layer):
1340
1446
  """The vertex groups `BONE_DRAW_LOCKED_WEIGHT` is read from.
1341
1447
 
@@ -1378,6 +1484,7 @@ def _armatures(view_layer):
1378
1484
  "action": (obj.animation_data.action.name
1379
1485
  if obj.animation_data is not None and obj.animation_data.action is not None
1380
1486
  else None),
1487
+ **_armature_animation(obj),
1381
1488
  }
1382
1489
  return armatures
1383
1490
 
@@ -1651,6 +1758,9 @@ class Session:
1651
1758
  # THE SCENE'S CLOCK beside the frame number: what a Timeline draws and a mixer maps time by.
1652
1759
  frame["clock"] = {"start": int(scene.frame_start), "end": int(scene.frame_end),
1653
1760
  "fps": float(scene.render.fps) / float(scene.render.fps_base or 1.0)}
1761
+ # ACTIONS NOTHING KEEPS: no user and no fake user, so the next save drops them (Blender's own
1762
+ # rule). A game that plays one is told before it vanishes.
1763
+ frame["unkeptActions"] = sorted(a.name for a in bpy.data.actions if a.users == 0 and not a.use_fake_user)
1654
1764
  frame["weights"] = _weights(scene, view_layer, frame, self._known)
1655
1765
  warnings = list(frame.get("warnings", ()))
1656
1766
  warnings.extend(_WARNINGS)
@@ -3853,6 +3963,13 @@ def rna_context(object_name=None, collection_path=None):
3853
3963
  # list longer than _OUTLINER_PAGE answers its first page and says how many more
3854
3964
  # there are, so a scene of 20,000 objects cannot turn one read into a
3855
3965
  # 20,000-row payload.
3966
+ #
3967
+ # AND THE READ COSTS WHAT IT SHOWS, not what the scene holds: an object's row
3968
+ # and its expansion are built only once the page has kept its place
3969
+ # (`rna_outliner`). The ids do not move for it. An id's ordinal is the number of
3970
+ # rows that claimed the same address before it in the WHOLE tree's order, kept
3971
+ # rows or not, and `_OutlinerScope` answers that count for a kept object's rows
3972
+ # from what every earlier object would have claimed.
3856
3973
 
3857
3974
  _OUTLINER_PAGE = 64
3858
3975
 
@@ -4191,6 +4308,60 @@ def _outliner_object(obj, view_layer, seen, chosen, active):
4191
4308
  return row
4192
4309
 
4193
4310
 
4311
+ def _outliner_preorder(row, out):
4312
+ """A built row and everything under it, IN THE ORDER THEY CLAIMED THEIR IDS:
4313
+ every builder above makes a row before its children, and its children in
4314
+ list order, so the claim order is the tree's pre-order."""
4315
+ out.append(row)
4316
+ for child in row["children"]:
4317
+ _outliner_preorder(child, out)
4318
+ return out
4319
+
4320
+
4321
+ class _OutlinerScope:
4322
+ """THE COUNTS ONE KEPT OBJECT'S ROWS CLAIM AGAINST, in `_outliner_unique`'s
4323
+ own `seen` shape (`get`, item assignment).
4324
+
4325
+ Every address an object's expansion claims is one of three kinds, and two
4326
+ of them never need the rows that came before to be built:
4327
+
4328
+ - under the OBJECT's own address (`.animation_data`, `.pose`, its pose
4329
+ bones, `.constraints`, `.modifiers`, `.particle_systems`,
4330
+ `.vertex_groups`): only that object's expansions claim it, so the
4331
+ count before is how many times the object was expanded before;
4332
+ - under its DATA's or the data's shape keys' address (the data row,
4333
+ key blocks, bones, bone collections): only expansions of that data
4334
+ claim it -- bones only outside Pose Mode -- so the count before is
4335
+ how many times the data was expanded before;
4336
+ - anything else (the object row itself, materials, a modifier's linked
4337
+ object or node group, an instanced collection) is shared with any
4338
+ row in the tree, and its count is the tree's own, in `seen`."""
4339
+
4340
+ def __init__(self, seen, scopes):
4341
+ self.seen = seen
4342
+ # (prefix, exact address, count before), most specific first.
4343
+ self.scopes = scopes
4344
+ self.local = {}
4345
+
4346
+ def _before(self, path):
4347
+ if path is not None:
4348
+ for prefix, address, before in self.scopes:
4349
+ if path == address or path.startswith(prefix):
4350
+ return before
4351
+ return None
4352
+
4353
+ def get(self, path, default=0):
4354
+ if path in self.local:
4355
+ return self.local[path]
4356
+ before = self._before(path)
4357
+ return self.seen.get(path, default) if before is None else before
4358
+
4359
+ def __setitem__(self, path, count):
4360
+ self.local[path] = count
4361
+ if self._before(path) is None:
4362
+ self.seen[path] = count
4363
+
4364
+
4194
4365
  # ---- the node editor's tree ------------------------------------------------
4195
4366
  #
4196
4367
  # THE ONE READ A NODE VIEW NEEDS. The generic `rna_view` door answers every one
@@ -4897,8 +5068,12 @@ def rna_action_clip(object_name=None, bake=True, action_name=None):
4897
5068
  adt = arm_obj.animation_data if arm_obj is not None else None
4898
5069
  assigned = adt.action if adt is not None else None
4899
5070
  if arm_obj is None or (assigned is None and not action_name):
4900
- header["reason"] = ("Nothing here carries an action: the Timeline draws the scene range "
4901
- "and an empty summary row, which is Blender's own empty state.")
5071
+ # NLA STRIPS PLAY WITHOUT AN ACTIVE ACTION, and the Timeline's summary row draws only the
5072
+ # active action's keys, as Blender's does; so the empty state is said only when nothing plays.
5073
+ strips = adt is not None and any(len(track.strips) for track in adt.nla_tracks)
5074
+ if not strips:
5075
+ header["reason"] = ("Nothing here carries an action: the Timeline draws the scene range "
5076
+ "and an empty summary row, which is Blender's own empty state.")
4902
5077
  return header
4903
5078
  action = assigned
4904
5079
  slot = getattr(adt, "action_slot", None) if adt is not None else None
@@ -4937,16 +5112,28 @@ def rna_action_clip(object_name=None, bake=True, action_name=None):
4937
5112
  if not bake:
4938
5113
  return header
4939
5114
  frames = sorted(columns)
4940
- first = int(math.floor(frames[0]))
4941
- last = int(math.ceil(frames[-1]))
5115
+ # THE ACTION'S OWN RANGE (`Action.frame_range`: its keys, or its manual range) with its keys:
5116
+ # what a strip or the action line plays, sampled through `FCurve.evaluate`, which applies the
5117
+ # curves' extrapolation and modifiers (a Cycles modifier repeats them) as Blender does.
5118
+ span = getattr(action, "frame_range", None)
5119
+ first = int(math.floor(min(frames[0], span[0] if span is not None else frames[0])))
5120
+ last = int(math.ceil(max(frames[-1], span[1] if span is not None else frames[-1])))
4942
5121
  if last <= first:
4943
5122
  last = first + 1
4944
5123
  header["clipStart"] = first
4945
5124
  header["clipEnd"] = last
5125
+ # THE RANGE BLENDER PLACES THE ACTIVE ACTION OVER over NLA strips (`calc_action_range` with
5126
+ # modifiers): its keys, opened to either side a Cycles modifier repeats into (null: unbounded).
5127
+ # A manual frame range does not enter it. Measured: a cycling action is not held at its end.
5128
+ header["keysStart"], header["keysEnd"] = _action_key_range(action, first, last)
4946
5129
  channels = {}
5130
+ others = set()
4947
5131
  for fcurve in curves:
4948
5132
  match = _BONE_PATH.match(fcurve.data_path)
4949
5133
  if match is None:
5134
+ # NOT A BONE'S CHANNEL: the armature object's own transform (root motion), a custom
5135
+ # property, a bone channel this door does not read. Named, so it is never dropped quietly.
5136
+ others.add(fcurve.data_path)
4950
5137
  continue
4951
5138
  bone, prop = match.group(1), match.group(2)
4952
5139
  channels.setdefault((bone, prop), {})[int(fcurve.array_index)] = fcurve
@@ -4955,61 +5142,19 @@ def rna_action_clip(object_name=None, bake=True, action_name=None):
4955
5142
  "door does not play (object transform, shape keys, a material). The "
4956
5143
  "Timeline still draws its keys." % (action.name,))
4957
5144
  return header
4958
- rest_relative = {}
4959
- for pchan in _pose_bones_parents_first(arm_obj):
4960
- local = pchan.bone.matrix_local
4961
- parent = pchan.parent
4962
- rest_relative[pchan.name] = (parent.bone.matrix_local.inverted_safe() @ local
4963
- if parent is not None else local.copy())
4964
- times = [(frame - first) / fps for frame in range(first, last + 1)]
4965
5145
  tracks = []
4966
5146
  unplayed = []
4967
- for bone in sorted({name for name, _ in channels}):
4968
- pchan = arm_obj.pose.bones.get(bone)
4969
- if pchan is None:
5147
+ for (bone, prop) in sorted(channels):
5148
+ if arm_obj.pose.bones.get(bone) is None:
4970
5149
  unplayed.append(bone)
4971
5150
  continue
4972
- rest = rest_relative[bone]
4973
- rotation_mode = pchan.rotation_mode
4974
- positions, quaternions, scales = [], [], []
4975
- previous = None
4976
- for frame in range(first, last + 1):
4977
- location = _sample(channels.get((bone, "location")), pchan.location, frame)
4978
- scale = _sample(channels.get((bone, "scale")), pchan.scale, frame, default=1.0)
4979
- if rotation_mode == "QUATERNION":
4980
- quat = mathutils.Quaternion(
4981
- _sample(channels.get((bone, "rotation_quaternion")),
4982
- pchan.rotation_quaternion, frame, size=4, default=None,
4983
- identity=(1.0, 0.0, 0.0, 0.0)))
4984
- elif rotation_mode == "AXIS_ANGLE":
4985
- raw = _sample(channels.get((bone, "rotation_axis_angle")),
4986
- pchan.rotation_axis_angle, frame, size=4, default=None,
4987
- identity=(0.0, 0.0, 1.0, 0.0))
4988
- quat = mathutils.Quaternion(raw[1:4], raw[0])
4989
- else:
4990
- quat = mathutils.Euler(
4991
- _sample(channels.get((bone, "rotation_euler")), pchan.rotation_euler, frame),
4992
- rotation_mode).to_quaternion()
4993
- basis = mathutils.Matrix.LocRotScale(mathutils.Vector(location), quat,
4994
- mathutils.Vector(scale))
4995
- position, rotation, scaling = (rest @ basis).decompose()
4996
- # QUATERNION CONTINUITY, and it is not cosmetic: three's
4997
- # `QuaternionLinearInterpolant` takes the shorter arc between
4998
- # NEIGHBOURING samples, so one sign flip in the middle of a bake
4999
- # spins the bone the long way round for a frame.
5000
- if previous is not None and rotation.dot(previous) < 0.0:
5001
- rotation.negate()
5002
- previous = rotation.copy()
5003
- positions.extend((position.x, position.y, position.z))
5004
- quaternions.extend((rotation.x, rotation.y, rotation.z, rotation.w))
5005
- scales.extend((scaling.x, scaling.y, scaling.z))
5006
- tracks.extend(_track(bone, "position", times, positions, 3))
5007
- tracks.extend(_track(bone, "quaternion", times, quaternions, 4))
5008
- tracks.extend(_track(bone, "scale", times, scales, 3))
5151
+ for index, fcurve in sorted(channels[(bone, prop)].items()):
5152
+ tracks.append(_curve(bone, prop, index, fcurve))
5153
+ if others:
5154
+ header["unsupported"] = ["curves on %s" % ", ".join(sorted(others))]
5009
5155
  header.update({
5010
5156
  "tracks": tracks,
5011
5157
  "duration": (last - first) / fps,
5012
- "sampled": len(times),
5013
5158
  "unplayedBones": unplayed,
5014
5159
  })
5015
5160
  return header
@@ -5022,52 +5167,94 @@ _KEY_TYPE_RANK = {"JITTER": 1, "GENERATED": 2, "MOVING_HOLD": 3, "BREAKDOWN": 4,
5022
5167
  "KEYFRAME": 5, "EXTREME": 6}
5023
5168
 
5024
5169
 
5025
- def _sample(group, current, frame, size=3, default=0.0, identity=None):
5026
- """One channel's value at `frame`: each component's own F-Curve where there
5027
- is one, and the pose channel's CURRENT value where there is not -- which is
5028
- what Blender does too (an unkeyed component simply keeps its value)."""
5029
- fallback = list(identity) if identity is not None else [
5030
- (current[i] if current is not None and i < len(current) else default) for i in range(size)]
5031
- if identity is not None and current is not None and group is not None:
5032
- fallback = [float(v) for v in current]
5033
- if group is None:
5034
- return fallback
5035
- out = list(fallback)
5036
- for index, fcurve in group.items():
5037
- if 0 <= index < len(out):
5038
- out[index] = float(fcurve.evaluate(frame))
5039
- return out
5040
-
5170
+ _INTERPOLATION = {"CONSTANT": 0, "LINEAR": 1, "BEZIER": 2}
5171
+ _CYCLE_MODE = {"NONE": 0, "REPEAT": 1, "REPEAT_OFFSET": 2, "MIRROR": 3}
5041
5172
 
5042
- def _track(bone, prop, times, values, stride):
5043
- """One three.js keyframe track, or two keys when nothing moves.
5044
-
5045
- The arrays cross as base64 Float32 (`timeBase64`, `valueBase64`), the shape
5046
- every large payload in this file takes."""
5047
- count = len(times)
5048
- constant = True
5049
- for i in range(1, count):
5050
- for c in range(stride):
5051
- if abs(values[i * stride + c] - values[c]) > 1e-6:
5052
- constant = False
5053
- break
5054
- if not constant:
5055
- break
5056
- if constant:
5057
- if count == 0:
5058
- return []
5059
- times = [times[0], times[-1]]
5060
- values = values[0:stride] * 2
5061
- return [{
5062
- "bone": bone,
5063
- "property": prop,
5064
- "stride": stride,
5065
- "count": len(times),
5066
- "constant": constant,
5067
- "timeBase64": base64.b64encode(array.array("f", times).tobytes()).decode("ascii"),
5068
- "valueBase64": base64.b64encode(array.array("f", values).tobytes()).decode("ascii"),
5069
- }]
5070
5173
 
5174
+ def _action_key_range(action, first, last):
5175
+ """`Action::get_frame_range_of_keys(true)` (`action.cc`, `get_frame_range_of_fcurves`): every
5176
+ F-Curve of the WHOLE action (every layer, strip and slot) widens the range by its keys, and only
5177
+ its LAST modifier counts: Limits widens it by its set X limits, Cycles opens each side its mode
5178
+ repeats into, any other type opens both sides. Mute is not read. None is an open side."""
5179
+ low, high = None, None
5180
+ opened_low = opened_high = False
5181
+ curves = list(getattr(action, "fcurves", None) or ())
5182
+ for layer in getattr(action, "layers", ()):
5183
+ for strip in getattr(layer, "strips", ()):
5184
+ for bag in (getattr(strip, "channelbags", None) or ()):
5185
+ curves.extend(bag.fcurves)
5186
+ # A curve's keys and its last modifier count independently: a keyless curve carrying a
5187
+ # Generator or Noise still opens the range, as in Blender.
5188
+ limits = []
5189
+ for fcurve in curves:
5190
+ points = fcurve.keyframe_points
5191
+ if len(points):
5192
+ frames = [float(key.co[0]) for key in points]
5193
+ low = min(frames) if low is None else min(low, min(frames))
5194
+ high = max(frames) if high is None else max(high, max(frames))
5195
+ if not len(fcurve.modifiers):
5196
+ continue
5197
+ modifier = fcurve.modifiers[-1]
5198
+ if modifier.type == "LIMITS":
5199
+ limits.append(modifier)
5200
+ elif modifier.type == "CYCLES":
5201
+ opened_low = opened_low or modifier.mode_before != "NONE"
5202
+ opened_high = opened_high or modifier.mode_after != "NONE"
5203
+ else:
5204
+ opened_low = opened_high = True
5205
+ if low is None:
5206
+ low, high = first, last
5207
+ for modifier in limits:
5208
+ if modifier.use_min_x:
5209
+ low = min(low, float(modifier.min_x))
5210
+ if modifier.use_max_x:
5211
+ high = max(high, float(modifier.max_x))
5212
+ return (None if opened_low else low), (None if opened_high else high)
5213
+
5214
+
5215
+ def _curve(bone, prop, index, fcurve):
5216
+ """One bone channel's F-Curve: its address and its body (`_curve_body`)."""
5217
+ return {"bone": bone, "property": prop, "index": index, **_curve_body(fcurve)}
5218
+
5219
+
5220
+ def _curve_body(fcurve):
5221
+ """ONE F-CURVE, whole: its keys and handles, how each segment interpolates, its extrapolation
5222
+ and its Cycles modifier, which is everything `fcurve_eval_keyframes` and `fcm_cycles_time` read
5223
+ to evaluate it. The presenter evaluates it as they do, at any frame, fractional or not.
5224
+
5225
+ The keys cross as base64 Float32, six per key: `co`, `handle_left`, `handle_right`
5226
+ (frame, value each). A segment whose interpolation is one of the easing types (Back, Bounce,
5227
+ Elastic, Sine, ...) and any modifier other than Cycles are named rather than played."""
5228
+ keys = array.array("f")
5229
+ interpolation = []
5230
+ unsupported = []
5231
+ for key in fcurve.keyframe_points:
5232
+ keys.extend((key.co[0], key.co[1], key.handle_left[0], key.handle_left[1],
5233
+ key.handle_right[0], key.handle_right[1]))
5234
+ code = _INTERPOLATION.get(key.interpolation)
5235
+ if code is None:
5236
+ unsupported.append("%s interpolation" % key.interpolation.lower())
5237
+ code = 1
5238
+ interpolation.append(code)
5239
+ cycles = None
5240
+ for modifier in fcurve.modifiers:
5241
+ if modifier.mute:
5242
+ continue
5243
+ if modifier.type == "CYCLES" and not modifier.use_restricted_range and not modifier.use_influence:
5244
+ cycles = [_CYCLE_MODE.get(modifier.mode_before, 0), int(modifier.cycles_before),
5245
+ _CYCLE_MODE.get(modifier.mode_after, 0), int(modifier.cycles_after)]
5246
+ else:
5247
+ unsupported.append("%s modifier" % modifier.type.lower())
5248
+ out = {
5249
+ "extrapolation": fcurve.extrapolation,
5250
+ "interpolation": interpolation,
5251
+ "keysBase64": base64.b64encode(keys.tobytes()).decode("ascii"),
5252
+ }
5253
+ if cycles is not None:
5254
+ out["cycles"] = cycles
5255
+ if unsupported:
5256
+ out["unsupported"] = sorted(set(unsupported))
5257
+ return out
5071
5258
 
5072
5259
  def rna_outliner(selected=None):
5073
5260
  """BLENDER'S VIEW LAYER TREE for the session's scene.
@@ -5092,10 +5279,16 @@ def rna_outliner(selected=None):
5092
5279
  engine_active = view_layer.objects.active
5093
5280
  active = engine_active.name if engine_active is not None else None
5094
5281
  seen = {}
5095
- # Every object row, with the row it sits under and the COLLECTION row it
5282
+ # Every object's PLACE, with the row it sits under and the COLLECTION row it
5096
5283
  # belongs to -- what `ObjectsChildrenBuilder`'s map holds, and what the
5097
- # parent walk below moves.
5284
+ # parent walk below moves. A place is not a row yet: `{"place": obj,
5285
+ # "name", "children"}`, where `children` are the child objects the fold
5286
+ # hangs under it. The page decides which places become rows.
5098
5287
  placements = {}
5288
+ # Every place, in the order the whole tree's rows claim their ids: each
5289
+ # collection's row, its child collections, then its objects, depth first.
5290
+ occurrences = []
5291
+ collections = []
5099
5292
 
5100
5293
  def place(obj, row, collection_row):
5101
5294
  placements.setdefault(obj.name, []).append([row, collection_row, collection_row])
@@ -5122,10 +5315,12 @@ def rna_outliner(selected=None):
5122
5315
  # Fold parenting before paging: a parent may follow its children in
5123
5316
  # collection order, and must not disappear behind the flat cap.
5124
5317
  for obj in collection.objects:
5125
- object_row = _outliner_object(obj, view_layer, seen, chosen, active)
5126
- place(obj, object_row, row)
5127
- children.append(object_row)
5318
+ spot = {"place": obj, "name": obj.name, "children": []}
5319
+ occurrences.append(spot)
5320
+ place(obj, spot, row)
5321
+ children.append(spot)
5128
5322
  row["children"] = children
5323
+ collections.append(row)
5129
5324
  return row
5130
5325
 
5131
5326
  scene_collection = _outliner_row(
@@ -5138,10 +5333,12 @@ def rna_outliner(selected=None):
5138
5333
  child.collection.name))
5139
5334
  for child in root.children]
5140
5335
  for obj in root.collection.objects:
5141
- object_row = _outliner_object(obj, view_layer, seen, chosen, active)
5142
- place(obj, object_row, scene_collection)
5143
- children.append(object_row)
5336
+ spot = {"place": obj, "name": obj.name, "children": []}
5337
+ occurrences.append(spot)
5338
+ place(obj, spot, scene_collection)
5339
+ children.append(spot)
5144
5340
  scene_collection["children"] = children
5341
+ collections.append(scene_collection)
5145
5342
 
5146
5343
  # `ObjectsChildrenBuilder::make_object_parent_hierarchy_collections`, parents
5147
5344
  # before children: a child object is MOVED out of its collection's row and
@@ -5159,8 +5356,15 @@ def rna_outliner(selected=None):
5159
5356
  placed.add(obj.name)
5160
5357
  ordered.append(obj)
5161
5358
 
5162
- for name in list(placements):
5163
- order(bpy.data.objects[name])
5359
+ # The placed object itself, not `bpy.data.objects[name]`: a lookup by name is
5360
+ # a walk of the whole ID list, and one per object made this loop quadratic
5361
+ # (43 ms of a 1,490-object read).
5362
+ for entries in list(placements.values()):
5363
+ order(entries[0][0]["place"])
5364
+ # A move takes the place out of its collection's list; the lists are
5365
+ # filtered once below rather than searched once per move (`list.remove`).
5366
+ moved = set()
5367
+ duplicates = []
5164
5368
  for obj in ordered:
5165
5369
  if obj.parent is None:
5166
5370
  continue
@@ -5169,24 +5373,25 @@ def rna_outliner(selected=None):
5169
5373
  if not parents or not mine:
5170
5374
  continue
5171
5375
  for parent_row, _parent_owner, parent_collection in parents:
5172
- moved = False
5376
+ moved_here = False
5173
5377
  for entry in mine:
5174
5378
  row, owner, _collection = entry
5175
5379
  if owner is not parent_collection:
5176
5380
  continue
5177
- owner["children"].remove(row)
5381
+ moved.add(id(row))
5178
5382
  parent_row["children"].append(row)
5179
5383
  entry[1] = parent_row
5180
- moved = True
5384
+ moved_here = True
5181
5385
  break
5182
- if moved:
5386
+ if moved_here:
5183
5387
  continue
5184
- duplicate = _outliner_row(
5185
- _rna_address(obj), obj.name, "TSE_SOME_ID", _outliner_object_icon(obj), seen,
5186
- struct="Object", objectType=obj.type, object=obj.name,
5187
- selected=obj.name in chosen, active=obj.name == active, notInCollection=True)
5388
+ duplicate = {"place": obj, "name": obj.name, "children": [], "duplicate": True}
5389
+ duplicates.append(duplicate)
5188
5390
  parent_row["children"].append(duplicate)
5189
5391
  mine.append([duplicate, parent_row, parent_collection])
5392
+ if moved:
5393
+ for row in collections:
5394
+ row["children"] = [child for child in row["children"] if id(child) not in moved]
5190
5395
 
5191
5396
  # THE SELECTION KEEPS ITS ROWS PAST THE PAGE. A row is how the editor
5192
5397
  # addresses an object -- the viewport's outline and gizmo, the Properties
@@ -5196,18 +5401,20 @@ def rna_outliner(selected=None):
5196
5401
  # page of the selection, so selecting everything cannot turn one read back
5197
5402
  # into the whole tree the page exists to keep out (Canyon Comet, 1,493
5198
5403
  # objects selected: 1.77 MB a read uncapped, 142 KB capped).
5404
+ # The caller's choice comes before the rest of the engine's selection: a click
5405
+ # onto a selection already larger than a page keeps the object just clicked.
5199
5406
  wanted = [active] if active is not None else []
5200
- wanted += [o.name for o in view_layer.objects if o.select_get(view_layer=view_layer)]
5201
5407
  wanted += sorted(chosen)
5408
+ wanted += [o.name for o in view_layer.objects if o.select_get(view_layer=view_layer)]
5202
5409
  kept = set()
5203
5410
  for name in wanted:
5204
- if len(kept) > _OUTLINER_PAGE:
5411
+ if len(kept) >= _OUTLINER_PAGE:
5205
5412
  break
5206
5413
  kept.add(name)
5207
5414
  holding = set()
5208
5415
 
5209
5416
  def hold(row):
5210
- inside = row.get("struct") == "Object" and row.get("object") in kept
5417
+ inside = "place" in row and row["name"] in kept
5211
5418
  for child in row.get("children", []):
5212
5419
  inside = hold(child) or inside
5213
5420
  if inside:
@@ -5216,13 +5423,36 @@ def rna_outliner(selected=None):
5216
5423
 
5217
5424
  hold(scene_collection)
5218
5425
 
5426
+ # A PLACE THE PAGE KEEPS BECOMES ITS ROW: the object's own row and the
5427
+ # expansion `TreeElementIDObject::expand` hangs under it, then the child
5428
+ # objects the fold put there, in that order. Its ids are claimed below, in
5429
+ # the whole tree's order; `claims` is the expansion's own claim order.
5430
+ scratch = {}
5431
+
5432
+ def kept_row(spot):
5433
+ if "place" not in spot:
5434
+ return spot
5435
+ obj = spot["place"]
5436
+ if spot.get("duplicate"):
5437
+ row = _outliner_row(
5438
+ _rna_address(obj), obj.name, "TSE_SOME_ID", _outliner_object_icon(obj), scratch,
5439
+ struct="Object", objectType=obj.type, object=obj.name,
5440
+ selected=obj.name in chosen, active=obj.name == active, notInCollection=True)
5441
+ spot["claims"] = [row]
5442
+ else:
5443
+ row = _outliner_object(obj, view_layer, scratch, chosen, active)
5444
+ spot["claims"] = _outliner_preorder(row, [])
5445
+ row["children"] = row["children"] + spot["children"]
5446
+ spot["row"] = row
5447
+ return row
5448
+
5219
5449
  # Cap the hierarchy's visible child lists, not the collection's flat input.
5220
5450
  # Every parent participates in folding even when its child list needs a page.
5221
5451
  def page_tree(row):
5222
5452
  members = row.get("children", [])
5223
5453
  children, more = _outliner_page(members)
5224
5454
  beyond = [child for child in members[len(children):] if id(child) in holding]
5225
- children = children + beyond
5455
+ children = [kept_row(child) for child in children + beyond]
5226
5456
  row["children"] = children
5227
5457
  if more - len(beyond):
5228
5458
  row["more"] = more - len(beyond)
@@ -5231,6 +5461,90 @@ def rna_outliner(selected=None):
5231
5461
 
5232
5462
  page_tree(scene_collection)
5233
5463
 
5464
+ # THE IDS, CLAIMED IN THE WHOLE TREE'S ORDER as if every object had been
5465
+ # built: a kept object's rows claim through `_OutlinerScope`; an object the
5466
+ # page left behind adds only what it would have claimed that a later row
5467
+ # can share -- its own address, its materials, its modifiers' linked
5468
+ # datablocks, its instanced collection -- and one more expansion of itself
5469
+ # and of its data. Data with no address of its own claims its rows the
5470
+ # long way, every one in the shared count.
5471
+ addresses = {}
5472
+
5473
+ def address(datablock):
5474
+ key = datablock.as_pointer()
5475
+ if key not in addresses:
5476
+ addresses[key] = _rna_address(datablock)
5477
+ return addresses[key]
5478
+
5479
+ def bump(path):
5480
+ seen[path] = seen.get(path, 0) + 1
5481
+
5482
+ expanded = {}
5483
+ data_expanded = {}
5484
+ bones_expanded = {}
5485
+ for spot in occurrences:
5486
+ obj = spot["place"]
5487
+ object_key = obj.as_pointer()
5488
+ object_path = address(obj)
5489
+ data = obj.data
5490
+ exact = object_path is None
5491
+ scopes = [] if exact else [(object_path + ".", None, expanded.get(object_key, 0))]
5492
+ data_key = bones = None
5493
+ if data is not None:
5494
+ data_key = data.as_pointer()
5495
+ data_path = address(data)
5496
+ keys = getattr(data, "shape_keys", None)
5497
+ keys_path = address(keys) if keys is not None else None
5498
+ exact = exact or data_path is None or (keys is not None and keys_path is None)
5499
+ bones = isinstance(data, bpy.types.Armature) and obj.mode != "POSE"
5500
+ if not exact:
5501
+ before = data_expanded.get(data_key, 0)
5502
+ scopes += [(data_path + ".bones[", None, bones_expanded.get(data_key, 0)),
5503
+ (data_path + ".", data_path, before)]
5504
+ if keys_path is not None:
5505
+ scopes.append((keys_path + ".", keys_path, before))
5506
+ if exact:
5507
+ scopes = []
5508
+ row = spot.get("row")
5509
+ if row is not None or exact:
5510
+ # Built rows, or the long way for data the scopes cannot name.
5511
+ scope = _OutlinerScope(seen, scopes)
5512
+ if row is None:
5513
+ _outliner_object(obj, view_layer, scope, chosen, active)
5514
+ else:
5515
+ for claimed in spot["claims"]:
5516
+ claimed["id"] = _outliner_unique(claimed["path"], scope)
5517
+ else:
5518
+ bump(object_path)
5519
+ if data is not None:
5520
+ for material in getattr(data, "materials", ()) or ():
5521
+ if material is not None:
5522
+ bump(address(material))
5523
+ for slot in obj.material_slots:
5524
+ if slot.material is not None:
5525
+ bump(address(slot.material))
5526
+ for md in obj.modifiers:
5527
+ for member in ("object", "node_group"):
5528
+ linked = getattr(md, member, None)
5529
+ if linked is None:
5530
+ continue
5531
+ linked_path = address(linked)
5532
+ if linked_path:
5533
+ bump(linked_path)
5534
+ break
5535
+ if obj.instance_collection is not None and obj.instance_type == "COLLECTION":
5536
+ bump(address(obj.instance_collection))
5537
+ expanded[object_key] = expanded.get(object_key, 0) + 1
5538
+ if data_key is not None:
5539
+ data_expanded[data_key] = data_expanded.get(data_key, 0) + 1
5540
+ if bones:
5541
+ bones_expanded[data_key] = bones_expanded.get(data_key, 0) + 1
5542
+ # The fold's duplicates claim after every collection's objects, as they are made.
5543
+ for spot in duplicates:
5544
+ claimed = _outliner_unique(address(spot["place"]), seen)
5545
+ if "row" in spot:
5546
+ spot["row"]["id"] = claimed
5547
+
5234
5548
  return {
5235
5549
  "scene": scene_path,
5236
5550
  "viewLayer": view_layer_path,
@@ -33,6 +33,7 @@ import { LineSegmentsGeometry } from 'three/addons/lines/LineSegmentsGeometry.js
33
33
  import { z } from 'zod';
34
34
 
35
35
  const scalar = z.number().finite();
36
+ const matrixSchema = z.array(z.tuple([scalar, scalar, scalar, scalar])).length(4);
36
37
 
37
38
  /** ONE BONE, as `session.py`'s `_armature_bones` answers it. */
38
39
  export const boneSchema = z
@@ -49,9 +50,76 @@ export const boneSchema = z
49
50
  select: z.boolean(),
50
51
  active: z.boolean(),
51
52
  lockedWeight: z.boolean(),
53
+ /** `Bone.matrix_local`, the rest matrix in the armature's space. */
54
+ rest: matrixSchema.optional(),
55
+ /** The channels' own values: what a channel no action animates keeps. */
56
+ channels: z.object({
57
+ mode: z.string(),
58
+ location: z.array(scalar).length(3),
59
+ rotation_quaternion: z.array(scalar).length(4),
60
+ rotation_euler: z.array(scalar).length(3),
61
+ rotation_axis_angle: z.array(scalar).length(4),
62
+ scale: z.array(scalar).length(3),
63
+ }).strict().optional(),
64
+ /** Parenting flags that are not Blender's default, by name. */
65
+ inherit: z.array(z.string()).optional(),
66
+ /** The bone's constraint stack, in order. */
67
+ constraints: z.array(z.object({
68
+ name: z.string(),
69
+ type: z.string(),
70
+ enabled: z.boolean(),
71
+ influence: scalar,
72
+ target: z.string().optional(),
73
+ subtarget: z.string().optional(),
74
+ trackAxis: z.string().optional(),
75
+ headTail: scalar.optional(),
76
+ }).strict()).optional(),
52
77
  })
53
78
  .strict();
54
79
 
80
+ /** One NLA strip, as `session.py`'s `_armature_animation` answers it. */
81
+ export const nlaStripSchema = z.object({
82
+ name: z.string(),
83
+ type: z.string(),
84
+ action: z.string().nullable(),
85
+ start: scalar,
86
+ end: scalar,
87
+ actionStart: scalar,
88
+ actionEnd: scalar,
89
+ scale: scalar,
90
+ repeat: scalar,
91
+ influence: scalar,
92
+ animatedInfluence: z.boolean(),
93
+ /** The keyed influence's F-Curve, when `animatedInfluence`. */
94
+ influenceCurve: z.object({
95
+ extrapolation: z.string(),
96
+ interpolation: z.array(z.number()),
97
+ keysBase64: z.string(),
98
+ cycles: z.tuple([z.number(), z.number(), z.number(), z.number()]).optional(),
99
+ unsupported: z.array(z.string()).optional(),
100
+ }).strict().optional(),
101
+ animatedTime: z.boolean(),
102
+ blendIn: scalar,
103
+ blendOut: scalar,
104
+ blendType: z.string(),
105
+ extrapolation: z.string(),
106
+ reversed: z.boolean(),
107
+ mute: z.boolean(),
108
+ }).strict();
109
+
110
+ /** An armature's animation stack: NLA tracks bottom to top, then the active action over them. */
111
+ export const armatureAnimationSchema = z.object({
112
+ useNla: z.boolean(),
113
+ influence: scalar,
114
+ blendType: z.string(),
115
+ extrapolation: z.string(),
116
+ /** The NLA is in tweak mode (`use_tweak_mode`), which the presenter does not play. */
117
+ tweak: z.boolean().optional(),
118
+ tracks: z.array(z.object({ name: z.string(), mute: z.boolean(), solo: z.boolean(), strips: z.array(nlaStripSchema) }).strict()),
119
+ /** Bones whose channels a driver sets. */
120
+ drivers: z.array(z.string()).optional(),
121
+ }).strict();
122
+
55
123
  export const armatureSchema = z
56
124
  .object({
57
125
  object: z.string(),
@@ -63,10 +131,14 @@ export const armatureSchema = z
63
131
  bones: z.array(boneSchema),
64
132
  /** `animation_data.action`, the action it plays (`session.py` `_armatures`). */
65
133
  action: z.string().nullable().optional(),
134
+ animation: armatureAnimationSchema.optional(),
66
135
  })
67
136
  .strict();
68
137
 
69
138
  export type BlenderArmature = z.infer<typeof armatureSchema>;
139
+ export type BlenderArmatureBone = z.infer<typeof boneSchema>;
140
+ export type BlenderArmatureAnimation = z.infer<typeof armatureAnimationSchema>;
141
+ export type BlenderNlaStrip = z.infer<typeof nlaStripSchema>;
70
142
  type Bone = z.infer<typeof boneSchema>;
71
143
 
72
144
  /**
@@ -193,6 +193,8 @@ export class RuntimeSkeletons {
193
193
  return world.invert();
194
194
  });
195
195
  mesh.updateWorldMatrix(true, false);
196
+ // A re-export rebinds the same mesh: its old skeleton's bone texture goes with it.
197
+ (skinned.skeleton as THREE.Skeleton | undefined)?.dispose();
196
198
  skinned.bind(new THREE.Skeleton(bones, inverses), mesh.matrixWorld.clone());
197
199
  return skinned;
198
200
  }
@@ -7,7 +7,7 @@ import * as THREE from 'three';
7
7
  import { z } from 'zod';
8
8
  import { onPresenterChange, presenterChanged } from './blender-presenter-change';
9
9
  import { bytesFromBase64 } from './blender-base64';
10
- import { ArmatureOverlay, armatureSchema } from './blender-runtime-armature';
10
+ import { ArmatureOverlay, armatureSchema, type BlenderArmature } from './blender-runtime-armature';
11
11
  import { type FrameSkin, RuntimeSkeletons, skinGeometry } from './blender-runtime-skeleton';
12
12
  import { UNKNOWN_GEOMETRY, UNKNOWN_IMAGE } from './blender-runtime-frame';
13
13
  import {
@@ -607,6 +607,8 @@ export const frameSchema = z
607
607
  actions: z.record(z.string(), z.number().int().nonnegative()).default({}),
608
608
  /** The scene's frame range and rate (`session.py`), beside `frame`. */
609
609
  clock: z.object({ start: z.number().int(), end: z.number().int(), fps: z.number().finite().positive() }).strict().optional(),
610
+ /** Actions with no user and no fake user, which the next save drops. */
611
+ unkeptActions: z.array(z.string()).default([]),
610
612
  /** The active object's active vertex group, per vertex
611
613
  * (`blender-runtime-weights.ts`); null when nothing is painted. */
612
614
  weights: weightsSchema.nullable().default(null),
@@ -1126,18 +1128,23 @@ export class BlenderRuntimeView {
1126
1128
  animationFacts(): {
1127
1129
  frame: number;
1128
1130
  clock: { start: number; end: number; fps: number } | null;
1131
+ /** Actions the next save drops: no user and no fake user. */
1132
+ unkept: readonly string[];
1129
1133
  active: string | null;
1130
1134
  actions: Readonly<Record<string, number>>;
1131
- armatures: Readonly<Record<string, { action: string | null }>>;
1135
+ /** Each armature as the frame describes it: bones (rest, channels, constraints, Blender's own
1136
+ * pose), its action and its NLA stack. */
1137
+ armatures: Readonly<Record<string, BlenderArmature>>;
1132
1138
  skinArmature(object: string): string | null;
1133
1139
  } {
1134
1140
  const frame = this.frame;
1135
1141
  return {
1136
1142
  frame: frame?.frame ?? 0,
1137
1143
  clock: frame?.clock ?? null,
1144
+ unkept: frame?.unkeptActions ?? [],
1138
1145
  active: frame?.active ?? null,
1139
1146
  actions: frame?.actions ?? {},
1140
- armatures: Object.fromEntries(Object.entries(frame?.armatures ?? {}).map(([name, armature]) => [name, { action: armature.action ?? null }])),
1147
+ armatures: frame?.armatures ?? {},
1141
1148
  skinArmature: (name) => {
1142
1149
  const object = this.objectForBlenderName(name) as THREE.Mesh | null;
1143
1150
  const skin = object?.geometry?.userData['blenderSkin'] as { armature: string } | undefined;
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@volter/blender-engine",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "GPL-3.0-or-later",
5
- "version": "0.1.134",
5
+ "version": "0.1.135",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
package/wasm/BUNDLE.json CHANGED
@@ -45,6 +45,6 @@
45
45
  "note": "Emscripten 6.0.5, Release -O3 objects and -O2 link, retained public build recipe and dependency licenses. Scoped render-evaluated export preserves render-only geometry and render-specific modifier results without replacing the viewport export state. Prior geometry identity and memory fixes remain. Full-scene Three versus Cycles rendering parity remains incomplete.",
46
46
  "callTrampoline": "CPython 3.13.13 is compiled with PY_CALL_TRAMPOLINE for Blender CPython slot arity casts. This applies to CPython dispatch, not arbitrary indirect calls.",
47
47
  "engineIds": "The session preserves the compiled native CYCLES engine. VOLTER_THREE selects the explicit raster presenter; BLENDER_EEVEE and BLENDER_WORKBENCH remain presentation engines.",
48
- "exportDoor": "The source-owned bpy_web_export.cc exports Principled physical inputs, normal images, eight named UV layers and evaluated corner normals, and takes graph_materials/graph_images/graph_generated (a graph-drawn shader mix reduced to the one Principled BSDF it reaches, graph images at the door revisions with every UDIM tile, orco for deformed meshes whose graph reads Generated coordinates). A frame can defer its changed meshes and pictures (defer) for the caller to pull one at a time (export_mesh, export_image), each call freeing the previous one's columns and a picture it decoded; an undo step keeps the revisions when Blender kept the depsgraph (session_undo); Blender's CPU subdivision descriptor is released once a mesh is read; the memory door reports malloc in-use and free. Datablock revisions are keyed by session_uid. writefile.cc writes one copy of the document per save in the browser build. CPU evaluators adopt stencil tables without cloning; browser subdivision descriptors are released immediately after CPU tessellation/deformation. Native evaluated corner triangles accompany corner normals. Consumed local conversion stencils are released before later UV channels are constructed. Caller-thread file-read, evaluated-object and subdivision checkpoints provide backpressure for resumable startup. Shared geometry revisions advance once per frame. Existing synchronous export remains available. Optional instance_sources exports stable evaluated collection and particle source meshes, including excluded library meshes and converted curves. Frame-local instance_geometry tokens verify Python placement metadata against the exported geometry and are consumed before presentation. Geometry resource identity preserves implicit input storage only for unmodified geometry and separates evaluated modifier/deformation outputs. Object and mesh resource keys have independent namespaces. Scoped begin_render_export/end_render_export reads the render engine depsgraph and isolates viewport revision, undo, deferred resource and image watcher state. Geometry resource identity preserves implicit input storage only for unmodified geometry and separates evaluated modifier/deformation outputs. Object and mesh resource keys have independent namespaces. A frame ships each armature-deformed mesh's skin with its geometry (write_skin: four joints and weights per drawn vertex by the armature's deform bones, keyed by skin_digest so it is re-sent only when the binding changes) and each armature's assigned action (actions).",
48
+ "exportDoor": "The source-owned bpy_web_export.cc exports Principled physical inputs, normal images, eight named UV layers and evaluated corner normals, and takes graph_materials/graph_images/graph_generated (a graph-drawn shader mix reduced to the one Principled BSDF it reaches, graph images at the door revisions with every UDIM tile, orco for deformed meshes whose graph reads Generated coordinates). A frame can defer its changed meshes and pictures (defer) for the caller to pull one at a time (export_mesh, export_image), each call freeing the previous one's columns and a picture it decoded; an undo step keeps the revisions when Blender kept the depsgraph (session_undo); Blender's CPU subdivision descriptor is released once a mesh is read; the memory door reports malloc in-use and free. Datablock revisions are keyed by session_uid. writefile.cc writes one copy of the document per save in the browser build. CPU evaluators adopt stencil tables without cloning; browser subdivision descriptors are released immediately after CPU tessellation/deformation. Native evaluated corner triangles accompany corner normals. Consumed local conversion stencils are released before later UV channels are constructed. Caller-thread file-read, evaluated-object and subdivision checkpoints provide backpressure for resumable startup. Shared geometry revisions advance once per frame. Existing synchronous export remains available. Optional instance_sources exports stable evaluated collection and particle source meshes, including excluded library meshes and converted curves. Frame-local instance_geometry tokens verify Python placement metadata against the exported geometry and are consumed before presentation. Geometry resource identity preserves implicit input storage only for unmodified geometry and separates evaluated modifier/deformation outputs. Object and mesh resource keys have independent namespaces. Scoped begin_render_export/end_render_export reads the render engine depsgraph and isolates viewport revision, undo, deferred resource and image watcher state. Geometry resource identity preserves implicit input storage only for unmodified geometry and separates evaluated modifier/deformation outputs. Object and mesh resource keys have independent namespaces. A frame ships each armature-deformed mesh's skin with its geometry (write_skin: four joints and weights per drawn vertex by the armature's deform bones, keyed by skin_digest so it is re-sent only when the binding changes), every action in the file with its revision (actions), and each armature's assigned action.",
49
49
  "preloadedPayload": "releasePreloadedFileData releases the JS preload buffer after session ready. The measured working payload exclusions are retained."
50
50
  }