mirror of
https://github.com/blender/blender
synced 2026-09-29 04:37:17 +03:00
Anim: Pose Slide operators for objects
This implements the pose slide operators for objects. It extends the `AnimTransformable` API to support objects, which makes most of the pose slide code work right away. The auto keying code had to be adjusted because it used the "Whole Character" keying set which doesn't work for objects. Discussed in an animation & rigging module meeting [1] Part of #156579 [1] https://devtalk.blender.org/t/2026-05-28-animation-rigging-module-meeting/45331#p-162762-ongoing-work-5 Pull Request: https://projects.blender.org/blender/blender/pulls/158906
This commit is contained in:
parent
02cb21e4f0
commit
d00c0d81e0
9 changed files with 206 additions and 60 deletions
|
|
@ -2880,6 +2880,10 @@ class VIEW3D_MT_object_animation(Menu):
|
|||
def draw(self, _context):
|
||||
layout = self.layout
|
||||
|
||||
layout.menu("VIEW3D_MT_pose_slide")
|
||||
|
||||
layout.separator()
|
||||
|
||||
layout.operator("anim.keyframe_insert", text="Insert Keyframe")
|
||||
layout.operator("anim.keyframe_insert_menu", text="Insert Keyframe with Keying Set...").always_prompt = True
|
||||
layout.operator("anim.keyframe_delete_v3d", text="Delete Keyframes...")
|
||||
|
|
@ -4225,10 +4229,6 @@ class VIEW3D_MT_pose(Menu):
|
|||
layout.separator()
|
||||
|
||||
layout.menu("VIEW3D_MT_object_animation")
|
||||
|
||||
layout.separator()
|
||||
|
||||
layout.menu("VIEW3D_MT_pose_slide")
|
||||
layout.menu("VIEW3D_MT_pose_propagate")
|
||||
|
||||
layout.separator()
|
||||
|
|
|
|||
|
|
@ -248,6 +248,18 @@ AnimTransformable::AnimTransformable(Object &owner_id, bPoseChannel &pchan)
|
|||
rna_path_from_id_ = animrig::get_pose_bone_rna_path(pchan);
|
||||
}
|
||||
|
||||
AnimTransformable::AnimTransformable(Object &obj)
|
||||
: type_(AnimTransformable::Type::OBJECT),
|
||||
owner_id_(&obj.id),
|
||||
data_(&obj),
|
||||
location_({obj.loc, 3}),
|
||||
rotation_mode_(reinterpret_cast<eRotationModes *>(&obj.rotmode)),
|
||||
scale_({obj.scale, 3})
|
||||
{
|
||||
build_rotations_array(rotations_, obj.rot, obj.quat, obj.rotAxis, &obj.rotAngle);
|
||||
rna_path_from_id_ = "";
|
||||
}
|
||||
|
||||
template<> bPoseChannel *AnimTransformable::data<bPoseChannel *>() const
|
||||
{
|
||||
BLI_assert(type_ == Type::POSE_BONE);
|
||||
|
|
|
|||
|
|
@ -212,8 +212,7 @@ void slide_subjects_reset(ListBaseT<SlideSubject> *slide_subjects);
|
|||
/** Perform auto-key-framing after changes were made + confirmed. */
|
||||
void slide_subjects_autokey(bContext *C,
|
||||
Scene *scene,
|
||||
const ListBaseT<SlideSubject> *slide_subjects,
|
||||
float cframe);
|
||||
const ListBaseT<SlideSubject> *slide_subjects);
|
||||
|
||||
/** \} */
|
||||
|
||||
|
|
|
|||
|
|
@ -75,6 +75,29 @@
|
|||
|
||||
namespace blender {
|
||||
|
||||
static bool pose_slide_poll(bContext *C)
|
||||
{
|
||||
Object *obact = CTX_data_active_object(C);
|
||||
if (!obact) {
|
||||
return false;
|
||||
}
|
||||
const eContextObjectMode mode = CTX_data_mode_enum(C);
|
||||
if (mode == CTX_MODE_OBJECT) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!(obact->mode & OB_MODE_EDIT)) {
|
||||
Object *obpose = BKE_object_pose_armature_get(obact);
|
||||
if (obpose != nullptr) {
|
||||
if ((obact == obpose) || (obact->mode & OB_MODE_ALL_WEIGHT_PAINT)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/* **************************************************** */
|
||||
/* A) Push & Relax, Breakdowner */
|
||||
|
||||
|
|
@ -685,7 +708,7 @@ static void pose_slide_apply(bContext *C, tPoseSlideOp *pso)
|
|||
static void pose_slide_autoKeyframe(bContext *C, tPoseSlideOp *pso)
|
||||
{
|
||||
/* Wrapper around the generic call. */
|
||||
slide_subjects_autokey(C, pso->scene, &pso->slide_subjects, float(pso->current_frame));
|
||||
slide_subjects_autokey(C, pso->scene, &pso->slide_subjects);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1247,7 +1270,7 @@ void POSE_OT_push(wmOperatorType *ot)
|
|||
ot->invoke = pose_slide_push_invoke;
|
||||
ot->modal = pose_slide_modal;
|
||||
ot->cancel = pose_slide_cancel;
|
||||
ot->poll = ED_operator_posemode;
|
||||
ot->poll = pose_slide_poll;
|
||||
|
||||
/* flags */
|
||||
ot->flag = OPTYPE_REGISTER | OPTYPE_UNDO | OPTYPE_BLOCKING | OPTYPE_GRAB_CURSOR_X;
|
||||
|
|
@ -1304,7 +1327,7 @@ void POSE_OT_relax(wmOperatorType *ot)
|
|||
ot->invoke = pose_slide_relax_invoke;
|
||||
ot->modal = pose_slide_modal;
|
||||
ot->cancel = pose_slide_cancel;
|
||||
ot->poll = ED_operator_posemode;
|
||||
ot->poll = pose_slide_poll;
|
||||
|
||||
/* flags */
|
||||
ot->flag = OPTYPE_REGISTER | OPTYPE_UNDO | OPTYPE_BLOCKING | OPTYPE_GRAB_CURSOR_X;
|
||||
|
|
@ -1366,7 +1389,7 @@ void POSE_OT_blend_with_rest(wmOperatorType *ot)
|
|||
ot->invoke = pose_slide_blend_rest_invoke;
|
||||
ot->modal = pose_slide_modal;
|
||||
ot->cancel = pose_slide_cancel;
|
||||
ot->poll = ED_operator_posemode;
|
||||
ot->poll = pose_slide_poll;
|
||||
|
||||
/* flags */
|
||||
ot->flag = OPTYPE_REGISTER | OPTYPE_UNDO | OPTYPE_BLOCKING | OPTYPE_GRAB_CURSOR_X;
|
||||
|
|
@ -1425,7 +1448,7 @@ void POSE_OT_breakdown(wmOperatorType *ot)
|
|||
ot->invoke = pose_slide_breakdown_invoke;
|
||||
ot->modal = pose_slide_modal;
|
||||
ot->cancel = pose_slide_cancel;
|
||||
ot->poll = ED_operator_posemode;
|
||||
ot->poll = pose_slide_poll;
|
||||
|
||||
/* flags */
|
||||
ot->flag = OPTYPE_REGISTER | OPTYPE_UNDO | OPTYPE_BLOCKING | OPTYPE_GRAB_CURSOR_X;
|
||||
|
|
@ -1477,7 +1500,7 @@ void POSE_OT_blend_to_neighbors(wmOperatorType *ot)
|
|||
ot->invoke = pose_slide_blend_to_neighbors_invoke;
|
||||
ot->modal = pose_slide_modal;
|
||||
ot->cancel = pose_slide_cancel;
|
||||
ot->poll = ED_operator_posemode;
|
||||
ot->poll = pose_slide_poll;
|
||||
|
||||
/* Flags. */
|
||||
ot->flag = OPTYPE_REGISTER | OPTYPE_UNDO | OPTYPE_BLOCKING | OPTYPE_GRAB_CURSOR_X;
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@
|
|||
#include "ED_anim_transformable.hh"
|
||||
#include "ED_armature.hh"
|
||||
#include "ED_keyframing.hh"
|
||||
#include "ED_object.hh"
|
||||
|
||||
#include "ANIM_action.hh"
|
||||
#include "ANIM_action_iterators.hh"
|
||||
|
|
@ -174,6 +175,44 @@ static void store_property_snapshot(PointerRNA &ptr,
|
|||
snapshots.append({prop, std::move(property_values)});
|
||||
}
|
||||
|
||||
static void store_starting_transform(SlideSubject &slide_subject,
|
||||
ed::AnimTransformable &transformable)
|
||||
{
|
||||
slide_subject.old_loc = transformable.get_property(
|
||||
ed::AnimTransformable::PropertyType::LOCATION);
|
||||
slide_subject.old_rot = transformable.get_rotation();
|
||||
slide_subject.old_scale = transformable.get_property(ed::AnimTransformable::PropertyType::SCALE);
|
||||
}
|
||||
|
||||
/**
|
||||
* `id_properties` and `system_properties` can be a nullptr and are skipped if they are.
|
||||
*/
|
||||
static void store_id_properties(SlideSubject &slide_subject,
|
||||
PointerRNA &ptr,
|
||||
IDProperty *id_properties,
|
||||
IDProperty *system_properties)
|
||||
{
|
||||
if (id_properties) {
|
||||
for (const IDProperty &id_prop : id_properties->data.group) {
|
||||
if (ELEM(id_prop.type, IDP_STRING, IDP_ID, IDP_IDPARRAY)) {
|
||||
continue;
|
||||
}
|
||||
char name_escaped[MAX_IDPROP_NAME * 2];
|
||||
BLI_str_escape(name_escaped, id_prop.name, sizeof(name_escaped));
|
||||
std::string property_name_with_brackets = fmt::format("[\"{}\"]", name_escaped);
|
||||
store_property_snapshot(ptr, property_name_with_brackets, slide_subject.properties);
|
||||
}
|
||||
}
|
||||
if (system_properties) {
|
||||
for (const IDProperty &id_prop : system_properties->data.group) {
|
||||
if (ELEM(id_prop.type, IDP_STRING, IDP_ID, IDP_IDPARRAY)) {
|
||||
continue;
|
||||
}
|
||||
store_property_snapshot(ptr, id_prop.name, slide_subject.system_properties);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* helper for slide_subjects_get() -> get the relevant F-Curves per PoseChannel */
|
||||
static void pchan_to_slide_subject(ListBaseT<SlideSubject> &slide_subjects,
|
||||
Object &ob,
|
||||
|
|
@ -199,11 +238,7 @@ static void pchan_to_slide_subject(ListBaseT<SlideSubject> &slide_subjects,
|
|||
/* Set pchan's transform flags. */
|
||||
slide_subject->transform_flag = transFlags;
|
||||
|
||||
slide_subject->old_loc = transformable->get_property(
|
||||
ed::AnimTransformable::PropertyType::LOCATION);
|
||||
slide_subject->old_rot = transformable->get_rotation();
|
||||
slide_subject->old_scale = transformable->get_property(
|
||||
ed::AnimTransformable::PropertyType::SCALE);
|
||||
store_starting_transform(*slide_subject, *transformable);
|
||||
|
||||
slide_subject->ptr = bone_ptr;
|
||||
|
||||
|
|
@ -222,25 +257,7 @@ static void pchan_to_slide_subject(ListBaseT<SlideSubject> &slide_subjects,
|
|||
|
||||
/* Make copy of custom properties. */
|
||||
if (transFlags & ACT_TRANS_PROP) {
|
||||
if (pchan.prop) {
|
||||
for (const IDProperty &id_prop : pchan.prop->data.group) {
|
||||
if (ELEM(id_prop.type, IDP_STRING, IDP_ID, IDP_IDPARRAY)) {
|
||||
continue;
|
||||
}
|
||||
char name_escaped[MAX_IDPROP_NAME * 2];
|
||||
BLI_str_escape(name_escaped, id_prop.name, sizeof(name_escaped));
|
||||
std::string property_name_with_brackets = fmt::format("[\"{}\"]", name_escaped);
|
||||
store_property_snapshot(bone_ptr, property_name_with_brackets, slide_subject->properties);
|
||||
}
|
||||
}
|
||||
if (pchan.system_properties) {
|
||||
for (const IDProperty &id_prop : pchan.system_properties->data.group) {
|
||||
if (ELEM(id_prop.type, IDP_STRING, IDP_ID, IDP_IDPARRAY)) {
|
||||
continue;
|
||||
}
|
||||
store_property_snapshot(bone_ptr, id_prop.name, slide_subject->system_properties);
|
||||
}
|
||||
}
|
||||
store_id_properties(*slide_subject, bone_ptr, pchan.prop, pchan.system_properties);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -307,6 +324,38 @@ static void get_pose_bones_for_slide(bContext *C, ListBaseT<SlideSubject> &slide
|
|||
}
|
||||
}
|
||||
|
||||
static void get_objects_for_slide(bContext *C, ListBaseT<SlideSubject> &slider_data)
|
||||
{
|
||||
CTX_DATA_BEGIN (C, Object *, ob, selected_objects) {
|
||||
PointerRNA object_ptr = RNA_pointer_create_discrete(&ob->id, RNA_Object, ob);
|
||||
|
||||
Vector<FCurve *> curves;
|
||||
const eAction_TransformFlags transFlags = get_item_transform_flags_and_fcurves(
|
||||
ob->id, object_ptr, curves);
|
||||
|
||||
if (!transFlags) {
|
||||
continue;
|
||||
}
|
||||
|
||||
SlideSubject *slide_subject = MEM_new<SlideSubject>("TransformableFCurveLink");
|
||||
BLI_addtail(&slider_data, slide_subject);
|
||||
slide_subject->fcurves = curves;
|
||||
|
||||
ed::AnimTransformable *transformable = MEM_new<ed::AnimTransformable>("transformable_object",
|
||||
*ob);
|
||||
slide_subject->transformable = transformable;
|
||||
slide_subject->transform_flag = transFlags;
|
||||
|
||||
store_starting_transform(*slide_subject, *transformable);
|
||||
slide_subject->ptr = object_ptr;
|
||||
|
||||
if (transFlags & ACT_TRANS_PROP) {
|
||||
store_id_properties(*slide_subject, object_ptr, ob->id.properties, ob->id.system_properties);
|
||||
}
|
||||
}
|
||||
CTX_DATA_END;
|
||||
}
|
||||
|
||||
void slide_subjects_get(bContext *C, ListBaseT<SlideSubject> *r_transformable_list)
|
||||
{
|
||||
BLI_assert(r_transformable_list != nullptr);
|
||||
|
|
@ -315,6 +364,9 @@ void slide_subjects_get(bContext *C, ListBaseT<SlideSubject> *r_transformable_li
|
|||
case CTX_MODE_POSE:
|
||||
get_pose_bones_for_slide(C, *r_transformable_list);
|
||||
break;
|
||||
case CTX_MODE_OBJECT:
|
||||
get_objects_for_slide(C, *r_transformable_list);
|
||||
break;
|
||||
|
||||
default:
|
||||
/* Not implemented. */
|
||||
|
|
@ -347,6 +399,7 @@ void slide_subjects_free(ListBaseT<SlideSubject> *slide_subjects)
|
|||
void slide_subjects_refresh(bContext *C, ID *id)
|
||||
{
|
||||
DEG_id_tag_update(id, ID_RECALC_GEOMETRY);
|
||||
DEG_id_tag_update(id, ID_RECALC_TRANSFORM);
|
||||
switch (GS(id->name)) {
|
||||
case ID_OB:
|
||||
WM_event_add_notifier(C, NC_OBJECT | ND_POSE, id_cast<Object *>(id));
|
||||
|
|
@ -395,39 +448,85 @@ void slide_subjects_reset(ListBaseT<SlideSubject> *slide_subjects)
|
|||
|
||||
void slide_subjects_autokey(bContext *C,
|
||||
Scene *scene,
|
||||
const ListBaseT<SlideSubject> *slide_subjects,
|
||||
const float cframe)
|
||||
const ListBaseT<SlideSubject> *slide_subjects)
|
||||
{
|
||||
/* Insert keyframes as necessary if auto-key-framing.
|
||||
* TODO: don't use a keyingset here. Just use the keyframing code directly. */
|
||||
KeyingSet *ks = animrig::get_keyingset_for_autokeying(scene, ANIM_KS_WHOLE_CHARACTER_ID);
|
||||
Vector<PointerRNA> sources;
|
||||
bool anything_to_key = false;
|
||||
for (SlideSubject &slide_subject : *slide_subjects) {
|
||||
if (!animrig::autokeyframe_cfra_can_key(scene, slide_subject.ptr.owner_id)) {
|
||||
continue;
|
||||
}
|
||||
anything_to_key = true;
|
||||
break;
|
||||
}
|
||||
/* If there is nothing to key, return before deselecting any keys. */
|
||||
if (!anything_to_key) {
|
||||
return;
|
||||
}
|
||||
|
||||
ANIM_deselect_keys_in_animation_editors(C);
|
||||
|
||||
/* Insert keyframes as necessary if auto-key-framing. */
|
||||
for (SlideSubject &slide_subject : *slide_subjects) {
|
||||
PointerRNA &ptr = slide_subject.ptr;
|
||||
if (!animrig::autokeyframe_cfra_can_key(scene, slide_subject.ptr.owner_id)) {
|
||||
continue;
|
||||
}
|
||||
animrig::relative_keyingset_add_source(sources, ptr.owner_id, ptr.type, ptr.data);
|
||||
|
||||
Vector<RNAPath> paths;
|
||||
/* The transform flags tell us which properties have keys. Properties without keys cannot pose
|
||||
* slide, so should not be auto keyed. */
|
||||
if (slide_subject.transform_flag & ACT_TRANS_LOC) {
|
||||
paths.append({"location"});
|
||||
}
|
||||
if (slide_subject.transform_flag & ACT_TRANS_ROT) {
|
||||
paths.append(
|
||||
{animrig::get_rotation_mode_path(slide_subject.transformable->get_rotation_mode())});
|
||||
}
|
||||
if (slide_subject.transform_flag & ACT_TRANS_SCALE) {
|
||||
paths.append({"scale"});
|
||||
}
|
||||
|
||||
/* No need to check the transform_flag here, because those vectors are only filled if the flag
|
||||
* was set in the first place.*/
|
||||
for (const PropertySnapshot &snapshot : slide_subject.additional_properties) {
|
||||
paths.append({RNA_property_identifier(snapshot.property)});
|
||||
}
|
||||
for (const PropertySnapshot &snapshot : slide_subject.properties) {
|
||||
char name_escaped[MAX_IDPROP_NAME * 2];
|
||||
BLI_str_escape(
|
||||
name_escaped, RNA_property_identifier(snapshot.property), sizeof(name_escaped));
|
||||
paths.append({fmt::format("[\"{}\"]", name_escaped)});
|
||||
}
|
||||
for (const PropertySnapshot &snapshot : slide_subject.system_properties) {
|
||||
paths.append({RNA_property_identifier(snapshot.property)});
|
||||
}
|
||||
|
||||
switch (slide_subject.transformable->type()) {
|
||||
case ed::AnimTransformable::Type::POSE_BONE:
|
||||
animrig::autokeyframe_pose_channel(C,
|
||||
scene,
|
||||
id_cast<Object *>(ptr.owner_id),
|
||||
static_cast<bPoseChannel *>(ptr.data),
|
||||
paths,
|
||||
false);
|
||||
break;
|
||||
case ed::AnimTransformable::Type::OBJECT:
|
||||
animrig::autokeyframe_object(C, scene, id_cast<Object *>(ptr.owner_id), paths);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* insert keyframes for all relevant bones in one go */
|
||||
animrig::apply_keyingset(C, &sources, ks, animrig::ModifyKeyMode::INSERT, cframe);
|
||||
|
||||
Vector<Object *> objects;
|
||||
for (SlideSubject &slide_subject : *slide_subjects) {
|
||||
ID *owner_id = slide_subject.transformable->owner_id();
|
||||
if (GS(owner_id->name) != ID_OB) {
|
||||
continue;
|
||||
}
|
||||
Object *ob = id_cast<Object *>(owner_id);
|
||||
if (!ob->pose) {
|
||||
continue;
|
||||
}
|
||||
if (ob->pose->avs.path_bakeflag & MOTIONPATH_BAKE_HAS_PATHS) {
|
||||
/* TODO(sergey): Should ensure we can use more narrow update range here. */
|
||||
ED_pose_recalculate_paths(C, scene, ob, ANIMVIZ_CALC_RANGE_FULL);
|
||||
}
|
||||
objects.append(id_cast<Object *>(owner_id));
|
||||
}
|
||||
/* This includes all motion paths for bones. Could be more fine grained in the future to avoid
|
||||
* needless updates to data that was not changed. */
|
||||
ed::object::motion_paths_recalc(C, scene, ANIMVIZ_CALC_RANGE_CHANGED, objects);
|
||||
}
|
||||
|
||||
/* *********************************************** */
|
||||
|
|
|
|||
|
|
@ -104,7 +104,8 @@ class AnimTransformable {
|
|||
/* There has to be a constructor for every struct supported. */
|
||||
/* Constructor for pose bones. */
|
||||
AnimTransformable(Object &owner_id, bPoseChannel &pchan);
|
||||
/* TODO (christoph): Add object support. */
|
||||
/* Constructor for Objects. */
|
||||
explicit AnimTransformable(Object &object);
|
||||
|
||||
Type type() const
|
||||
{
|
||||
|
|
|
|||
|
|
@ -383,6 +383,14 @@ void single_obdata_user_make(Main *bmain, Scene *scene, Object *ob);
|
|||
*/
|
||||
void motion_paths_clear(bContext *C, bool only_selected);
|
||||
|
||||
/**
|
||||
* Recalculate the motion paths on the given objects. This includes bones when recalculating
|
||||
* armature objects.
|
||||
*/
|
||||
void motion_paths_recalc(bContext *C,
|
||||
Scene *scene,
|
||||
const eAnimvizCalcRange range,
|
||||
const Span<Object *> objects);
|
||||
/**
|
||||
* Recalculate motion paths on all selected objects. This includes bones when recalculating
|
||||
* armature objects.
|
||||
|
|
|
|||
|
|
@ -1281,10 +1281,10 @@ static bool has_pose_motion_paths(Object *ob)
|
|||
return ob->pose && (ob->pose->avs.path_bakeflag & MOTIONPATH_BAKE_HAS_PATHS) != 0;
|
||||
}
|
||||
|
||||
static void motion_paths_recalc(bContext *C,
|
||||
Scene *scene,
|
||||
const eAnimvizCalcRange range,
|
||||
const Span<Object *> objects)
|
||||
void motion_paths_recalc(bContext *C,
|
||||
Scene *scene,
|
||||
const eAnimvizCalcRange range,
|
||||
const Span<Object *> objects)
|
||||
{
|
||||
BLI_assert(C != nullptr);
|
||||
Main *bmain = CTX_data_main(C);
|
||||
|
|
|
|||
|
|
@ -206,10 +206,12 @@ class BreakdownerTestPoseBone(AbstractPoseSlideTest):
|
|||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
bpy.context.preferences.edit.use_keyframe_insert_available = False
|
||||
bpy.context.preferences.edit.use_auto_keyframe_insert_needed = False
|
||||
|
||||
def tearDown(self) -> None:
|
||||
super().tearDown()
|
||||
bpy.context.preferences.edit.use_keyframe_insert_available = True
|
||||
bpy.context.preferences.edit.use_auto_keyframe_insert_needed = True
|
||||
|
||||
def test_no_keys(self):
|
||||
# The case of no keys will produce no interpolation.
|
||||
|
|
@ -250,7 +252,9 @@ class BreakdownerTestPoseBone(AbstractPoseSlideTest):
|
|||
self.assertAlmostEqual(self.pose_bone.location[i], 1, 3)
|
||||
|
||||
# The key count depends on the setting "Only insert available" in the user preferences.
|
||||
self.assertEqual(len(channelbag.fcurves), 10)
|
||||
# Also keys are only inserted into channels that already have keys.
|
||||
self.assertEqual(len(channelbag.fcurves), 3)
|
||||
# This depends on the setting "Only insert needed".
|
||||
self.assertEqual(len(channelbag.fcurves[0].keyframe_points), 2)
|
||||
|
||||
def test_all_properties(self):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue