mirror of
https://github.com/blender/blender
synced 2026-09-29 04:37:17 +03:00
Animation: Remove the 'Weight Paint: Fix Deforms' operator
The code of the 'Fix Deforms' operator was hard to read and inefficient, doing `O(num_vertices * num_vertex_groups)` evaluations of the mesh. It caused multiple issues and got in the way of improvements to Blender, and seems to be used very little (if ever). It was decided in [last week's module meeting](https://devtalk.blender.org/t/2023-02-23-animation-rigging-meeting/27757#patch-review-decision-time-5) that this operator should be removed. Pull Request #105237
This commit is contained in:
parent
1d3a2fb5bb
commit
f5c56b4cd6
5 changed files with 0 additions and 474 deletions
|
|
@ -1899,7 +1899,6 @@ url_manual_mapping = (
|
|||
("bpy.ops.object.shape_key_remove*", "animation/shape_keys/shape_keys_panel.html#bpy-ops-object-shape-key-remove"),
|
||||
("bpy.ops.object.shape_key_retime*", "animation/shape_keys/shape_keys_panel.html#bpy-ops-object-shape-key-retime"),
|
||||
("bpy.ops.object.vertex_group_add*", "modeling/meshes/properties/vertex_groups/vertex_groups.html#bpy-ops-object-vertex-group-add"),
|
||||
("bpy.ops.object.vertex_group_fix*", "sculpt_paint/weight_paint/editing.html#bpy-ops-object-vertex-group-fix"),
|
||||
("bpy.ops.outliner.collection_new*", "editors/outliner/editing.html#bpy-ops-outliner-collection-new"),
|
||||
("bpy.ops.outliner.show_hierarchy*", "editors/outliner/editing.html#bpy-ops-outliner-show-hierarchy"),
|
||||
("bpy.ops.outliner.show_one_level*", "editors/outliner/editing.html#bpy-ops-outliner-show-one-level"),
|
||||
|
|
|
|||
|
|
@ -3201,7 +3201,6 @@ class VIEW3D_MT_paint_weight(Menu):
|
|||
props.data_type = 'VGROUP_WEIGHTS'
|
||||
|
||||
layout.operator("object.vertex_group_limit_total", text="Limit Total")
|
||||
layout.operator("object.vertex_group_fix", text="Fix Deforms")
|
||||
|
||||
if not is_editmode:
|
||||
layout.separator()
|
||||
|
|
|
|||
|
|
@ -287,7 +287,6 @@ void OBJECT_OT_vertex_group_normalize(struct wmOperatorType *ot);
|
|||
void OBJECT_OT_vertex_group_normalize_all(struct wmOperatorType *ot);
|
||||
void OBJECT_OT_vertex_group_levels(struct wmOperatorType *ot);
|
||||
void OBJECT_OT_vertex_group_lock(struct wmOperatorType *ot);
|
||||
void OBJECT_OT_vertex_group_fix(struct wmOperatorType *ot);
|
||||
void OBJECT_OT_vertex_group_invert(struct wmOperatorType *ot);
|
||||
void OBJECT_OT_vertex_group_smooth(struct wmOperatorType *ot);
|
||||
void OBJECT_OT_vertex_group_clean(struct wmOperatorType *ot);
|
||||
|
|
|
|||
|
|
@ -208,7 +208,6 @@ void ED_operatortypes_object(void)
|
|||
WM_operatortype_append(OBJECT_OT_vertex_group_normalize);
|
||||
WM_operatortype_append(OBJECT_OT_vertex_group_normalize_all);
|
||||
WM_operatortype_append(OBJECT_OT_vertex_group_lock);
|
||||
WM_operatortype_append(OBJECT_OT_vertex_group_fix);
|
||||
WM_operatortype_append(OBJECT_OT_vertex_group_invert);
|
||||
WM_operatortype_append(OBJECT_OT_vertex_group_levels);
|
||||
WM_operatortype_append(OBJECT_OT_vertex_group_smooth);
|
||||
|
|
|
|||
|
|
@ -1219,363 +1219,6 @@ static bool vgroup_normalize(Object *ob)
|
|||
return false;
|
||||
}
|
||||
|
||||
/* This finds all of the vertices face-connected to vert by an edge and returns a
|
||||
* MEM_allocated array of indices of size count.
|
||||
* count is an int passed by reference so it can be assigned the value of the length here. */
|
||||
static blender::Vector<int> getSurroundingVerts(Mesh *me, int vert)
|
||||
{
|
||||
const MPoly *poly = me->polys().data();
|
||||
const MLoop *loops = me->loops().data();
|
||||
int i = me->totpoly;
|
||||
|
||||
blender::Vector<int> verts;
|
||||
|
||||
while (i--) {
|
||||
int j = poly->totloop;
|
||||
int first_l = poly->totloop - 1;
|
||||
const MLoop *ml = &loops[poly->loopstart];
|
||||
while (j--) {
|
||||
/* XXX This assume a vert can only be once in a poly, even though
|
||||
* it seems logical to me, not totally sure of that. */
|
||||
if (ml->v == vert) {
|
||||
int a, b, k;
|
||||
if (j == first_l) {
|
||||
/* We are on the first corner. */
|
||||
a = ml[1].v;
|
||||
b = ml[j].v;
|
||||
}
|
||||
else if (!j) {
|
||||
/* We are on the last corner. */
|
||||
a = (ml - 1)->v;
|
||||
b = loops[poly->loopstart].v;
|
||||
}
|
||||
else {
|
||||
a = (ml - 1)->v;
|
||||
b = (ml + 1)->v;
|
||||
}
|
||||
|
||||
/* Append a and b verts to array, if not yet present. */
|
||||
k = verts.size();
|
||||
/* XXX Maybe a == b is enough? */
|
||||
while (k-- && !(a == b && a == -1)) {
|
||||
if (verts[k] == a) {
|
||||
a = -1;
|
||||
}
|
||||
else if (verts[k] == b) {
|
||||
b = -1;
|
||||
}
|
||||
}
|
||||
if (a != -1) {
|
||||
verts.append(a);
|
||||
}
|
||||
if (b != -1) {
|
||||
verts.append(b);
|
||||
}
|
||||
|
||||
/* Vert found in this poly, we can go to next one! */
|
||||
break;
|
||||
}
|
||||
ml++;
|
||||
}
|
||||
poly++;
|
||||
}
|
||||
|
||||
return verts;
|
||||
}
|
||||
|
||||
/* Get a single point in space by averaging a point cloud (vectors of size 3)
|
||||
* coord is the place the average is stored,
|
||||
* points is the point cloud, count is the number of points in the cloud.
|
||||
*/
|
||||
static void getSingleCoordinate(float3 *points, int count, float coord[3])
|
||||
{
|
||||
int i;
|
||||
zero_v3(coord);
|
||||
for (i = 0; i < count; i++) {
|
||||
add_v3_v3(coord, points[i]);
|
||||
}
|
||||
mul_v3_fl(coord, 1.0f / count);
|
||||
}
|
||||
|
||||
/* given a plane and a start and end position,
|
||||
* compute the amount of vertical distance relative to the plane and store it in dists,
|
||||
* then get the horizontal and vertical change and store them in changes
|
||||
*/
|
||||
static void getVerticalAndHorizontalChange(const float norm[3],
|
||||
float d,
|
||||
const float coord[3],
|
||||
const float start[3],
|
||||
float distToStart,
|
||||
float *end,
|
||||
float (*changes)[2],
|
||||
float *dists,
|
||||
int index)
|
||||
{
|
||||
/* A = Q - ((Q - P).N)N
|
||||
* D = (a * x0 + b * y0 +c * z0 + d) */
|
||||
float projA[3], projB[3];
|
||||
float plane[4];
|
||||
|
||||
plane_from_point_normal_v3(plane, coord, norm);
|
||||
|
||||
closest_to_plane_normalized_v3(projA, plane, start);
|
||||
closest_to_plane_normalized_v3(projB, plane, end);
|
||||
/* (vertical and horizontal refer to the plane's y and xz respectively)
|
||||
* vertical distance */
|
||||
dists[index] = dot_v3v3(norm, end) + d;
|
||||
/* vertical change */
|
||||
changes[index][0] = dists[index] - distToStart;
|
||||
// printf("vc %f %f\n", distance(end, projB, 3) - distance(start, projA, 3), changes[index][0]);
|
||||
/* horizontal change */
|
||||
changes[index][1] = len_v3v3(projA, projB);
|
||||
}
|
||||
|
||||
/**
|
||||
* By changing nonzero weights, try to move a vertex in `me->mverts` with index 'index' to
|
||||
* `distToBe` distance away from the provided plane strength can change `distToBe` so that it moves
|
||||
* towards `distToBe` by that percentage `cp` changes how much the weights are adjusted
|
||||
* to check the distance
|
||||
*
|
||||
* `index` is the index of the vertex being moved.
|
||||
* `norm` and `d` are the plane's properties for the equation: `ax + by + cz + d = 0`.
|
||||
* `coord` is a point on the plane.
|
||||
*/
|
||||
static void moveCloserToDistanceFromPlane(Depsgraph *depsgraph,
|
||||
Scene * /*scene*/,
|
||||
Object *ob,
|
||||
Mesh *me,
|
||||
int index,
|
||||
const float norm[3],
|
||||
const float coord[3],
|
||||
float d,
|
||||
float distToBe,
|
||||
float strength,
|
||||
float cp)
|
||||
{
|
||||
Scene *scene_eval = DEG_get_evaluated_scene(depsgraph);
|
||||
Object *object_eval = DEG_get_evaluated_object(depsgraph, ob);
|
||||
Mesh *mesh_eval = (Mesh *)object_eval->data;
|
||||
|
||||
Mesh *me_deform;
|
||||
MDeformWeight *dw, *dw_eval;
|
||||
float3 m;
|
||||
MDeformVert *dvert = me->deform_verts_for_write().data() + index;
|
||||
MDeformVert *dvert_eval = mesh_eval->deform_verts_for_write().data() + index;
|
||||
int totweight = dvert->totweight;
|
||||
float oldw = 0;
|
||||
float oldPos[3] = {0};
|
||||
float vc, hc, dist = 0.0f;
|
||||
int i, k;
|
||||
float(*changes)[2] = static_cast<float(*)[2]>(
|
||||
MEM_mallocN(sizeof(float[2]) * totweight, "vertHorzChange"));
|
||||
float *dists = static_cast<float *>(MEM_mallocN(sizeof(float) * totweight, "distance"));
|
||||
|
||||
/* track if up or down moved it closer for each bone */
|
||||
bool *upDown = static_cast<bool *>(MEM_callocN(sizeof(bool) * totweight, "upDownTracker"));
|
||||
|
||||
int *dwIndices = static_cast<int *>(MEM_callocN(sizeof(int) * totweight, "dwIndexTracker"));
|
||||
float distToStart;
|
||||
int bestIndex = 0;
|
||||
bool wasChange;
|
||||
bool wasUp;
|
||||
int lastIndex = -1;
|
||||
float originalDistToBe = distToBe;
|
||||
do {
|
||||
wasChange = false;
|
||||
me_deform = mesh_get_eval_deform(depsgraph, scene_eval, object_eval, &CD_MASK_BAREMESH);
|
||||
const Span<float3> positions = me_deform->vert_positions();
|
||||
m = positions[index];
|
||||
copy_v3_v3(oldPos, m);
|
||||
distToStart = dot_v3v3(norm, oldPos) + d;
|
||||
|
||||
if (distToBe == originalDistToBe) {
|
||||
distToBe += distToStart - distToStart * strength;
|
||||
}
|
||||
for (i = 0; i < totweight; i++) {
|
||||
dwIndices[i] = i;
|
||||
dw = (dvert->dw + i);
|
||||
dw_eval = (dvert_eval->dw + i);
|
||||
vc = hc = 0;
|
||||
if (!dw->weight) {
|
||||
changes[i][0] = 0;
|
||||
changes[i][1] = 0;
|
||||
dists[i] = distToStart;
|
||||
continue;
|
||||
}
|
||||
for (k = 0; k < 2; k++) {
|
||||
if (me_deform) {
|
||||
/* DO NOT try to do own cleanup here, this is call for dramatic failures and bugs!
|
||||
* Better to over-free and recompute a bit. */
|
||||
BKE_object_free_derived_caches(object_eval);
|
||||
}
|
||||
oldw = dw->weight;
|
||||
if (k) {
|
||||
dw->weight *= 1 + cp;
|
||||
}
|
||||
else {
|
||||
dw->weight /= 1 + cp;
|
||||
}
|
||||
if (dw->weight == oldw) {
|
||||
changes[i][0] = 0;
|
||||
changes[i][1] = 0;
|
||||
dists[i] = distToStart;
|
||||
break;
|
||||
}
|
||||
if (dw->weight > 1) {
|
||||
dw->weight = 1;
|
||||
}
|
||||
dw_eval->weight = dw->weight;
|
||||
me_deform = mesh_get_eval_deform(depsgraph, scene_eval, object_eval, &CD_MASK_BAREMESH);
|
||||
m = positions[index];
|
||||
getVerticalAndHorizontalChange(norm, d, coord, oldPos, distToStart, m, changes, dists, i);
|
||||
dw->weight = oldw;
|
||||
dw_eval->weight = oldw;
|
||||
if (!k) {
|
||||
vc = changes[i][0];
|
||||
hc = changes[i][1];
|
||||
dist = dists[i];
|
||||
}
|
||||
else {
|
||||
if (fabsf(dist - distToBe) < fabsf(dists[i] - distToBe)) {
|
||||
upDown[i] = false;
|
||||
changes[i][0] = vc;
|
||||
changes[i][1] = hc;
|
||||
dists[i] = dist;
|
||||
}
|
||||
else {
|
||||
upDown[i] = true;
|
||||
}
|
||||
if (fabsf(dists[i] - distToBe) > fabsf(distToStart - distToBe)) {
|
||||
changes[i][0] = 0;
|
||||
changes[i][1] = 0;
|
||||
dists[i] = distToStart;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/* sort the changes by the vertical change */
|
||||
for (k = 0; k < totweight; k++) {
|
||||
bestIndex = k;
|
||||
for (i = k + 1; i < totweight; i++) {
|
||||
dist = dists[i];
|
||||
|
||||
if (fabsf(dist) > fabsf(dists[i])) {
|
||||
bestIndex = i;
|
||||
}
|
||||
}
|
||||
/* switch with k */
|
||||
if (bestIndex != k) {
|
||||
std::swap(upDown[k], upDown[bestIndex]);
|
||||
std::swap(dwIndices[k], dwIndices[bestIndex]);
|
||||
swap_v2_v2(changes[k], changes[bestIndex]);
|
||||
std::swap(dists[k], dists[bestIndex]);
|
||||
}
|
||||
}
|
||||
bestIndex = -1;
|
||||
/* find the best change with an acceptable horizontal change */
|
||||
for (i = 0; i < totweight; i++) {
|
||||
if (fabsf(changes[i][0]) > fabsf(changes[i][1] * 2.0f)) {
|
||||
bestIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (bestIndex != -1) {
|
||||
wasChange = true;
|
||||
/* it is a good place to stop if it tries to move the opposite direction
|
||||
* (relative to the plane) of last time */
|
||||
if (lastIndex != -1) {
|
||||
if (wasUp != upDown[bestIndex]) {
|
||||
wasChange = false;
|
||||
}
|
||||
}
|
||||
lastIndex = bestIndex;
|
||||
wasUp = upDown[bestIndex];
|
||||
dw = (dvert->dw + dwIndices[bestIndex]);
|
||||
oldw = dw->weight;
|
||||
if (upDown[bestIndex]) {
|
||||
dw->weight *= 1 + cp;
|
||||
}
|
||||
else {
|
||||
dw->weight /= 1 + cp;
|
||||
}
|
||||
if (dw->weight > 1) {
|
||||
dw->weight = 1;
|
||||
}
|
||||
if (oldw == dw->weight) {
|
||||
wasChange = false;
|
||||
}
|
||||
if (me_deform) {
|
||||
/* DO NOT try to do own cleanup here, this is call for dramatic failures and bugs!
|
||||
* Better to over-free and recompute a bit. */
|
||||
BKE_object_free_derived_caches(object_eval);
|
||||
}
|
||||
}
|
||||
} while (wasChange && ((distToStart - distToBe) / fabsf(distToStart - distToBe) ==
|
||||
(dists[bestIndex] - distToBe) / fabsf(dists[bestIndex] - distToBe)));
|
||||
|
||||
MEM_freeN(upDown);
|
||||
MEM_freeN(changes);
|
||||
MEM_freeN(dists);
|
||||
MEM_freeN(dwIndices);
|
||||
}
|
||||
|
||||
/* this is used to try to smooth a surface by only adjusting the nonzero weights of a vertex
|
||||
* but it could be used to raise or lower an existing 'bump.' */
|
||||
static void vgroup_fix(
|
||||
const bContext *C, Scene * /*scene*/, Object *ob, float distToBe, float strength, float cp)
|
||||
{
|
||||
using namespace blender;
|
||||
Depsgraph *depsgraph = CTX_data_ensure_evaluated_depsgraph(C);
|
||||
Scene *scene_eval = DEG_get_evaluated_scene(depsgraph);
|
||||
Object *object_eval = DEG_get_evaluated_object(depsgraph, ob);
|
||||
int i;
|
||||
|
||||
Mesh *me = static_cast<Mesh *>(ob->data);
|
||||
if (!(me->editflag & ME_EDIT_PAINT_VERT_SEL)) {
|
||||
return;
|
||||
}
|
||||
const bke::AttributeAccessor attributes = me->attributes();
|
||||
const VArray<bool> select_vert = attributes.lookup_or_default<bool>(
|
||||
".select_vert", ATTR_DOMAIN_POINT, false);
|
||||
for (i = 0; i < me->totvert; i++) {
|
||||
if (select_vert[i]) {
|
||||
blender::Vector<int> verts = getSurroundingVerts(me, i);
|
||||
const int count = verts.size();
|
||||
if (!verts.is_empty()) {
|
||||
float3 m;
|
||||
float3 *p = static_cast<float3 *>(MEM_callocN(sizeof(float3) * (count), "deformedPoints"));
|
||||
int k;
|
||||
|
||||
Mesh *me_deform = mesh_get_eval_deform(
|
||||
depsgraph, scene_eval, object_eval, &CD_MASK_BAREMESH);
|
||||
const Span<float3> positions_deform = me_deform->vert_positions();
|
||||
k = count;
|
||||
while (k--) {
|
||||
p[k] = positions_deform[verts[k]];
|
||||
}
|
||||
|
||||
if (count >= 3) {
|
||||
float d /*, dist */ /* UNUSED */, mag;
|
||||
float coord[3];
|
||||
float norm[3];
|
||||
getSingleCoordinate(p, count, coord);
|
||||
m = positions_deform[i];
|
||||
sub_v3_v3v3(norm, m, coord);
|
||||
mag = normalize_v3(norm);
|
||||
if (mag) { /* zeros fix */
|
||||
d = -dot_v3v3(norm, coord);
|
||||
// dist = (dot_v3v3(norm, m.co) + d); /* UNUSED */
|
||||
moveCloserToDistanceFromPlane(
|
||||
depsgraph, scene_eval, object_eval, me, i, norm, coord, d, distToBe, strength, cp);
|
||||
}
|
||||
}
|
||||
|
||||
MEM_freeN(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void vgroup_levels_subset(Object *ob,
|
||||
const bool *vgroup_validmap,
|
||||
const int vgroup_tot,
|
||||
|
|
@ -2732,36 +2375,6 @@ static bool vertex_group_poll(bContext *C)
|
|||
return vertex_group_poll_ex(C, ob);
|
||||
}
|
||||
|
||||
static bool vertex_group_mesh_poll_ex(bContext *C, Object *ob)
|
||||
{
|
||||
if (!vertex_group_poll_ex(C, ob)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ob->type != OB_MESH) {
|
||||
CTX_wm_operator_poll_msg_set(C, "Only mesh objects are supported");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool vertex_group_mesh_with_dvert_poll(bContext *C)
|
||||
{
|
||||
Object *ob = ED_object_context(C);
|
||||
if (!vertex_group_mesh_poll_ex(C, ob)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Mesh *me = static_cast<Mesh *>(ob->data);
|
||||
if (me->deform_verts().is_empty()) {
|
||||
CTX_wm_operator_poll_msg_set(C, "The active mesh object has no vertex group data");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool UNUSED_FUNCTION(vertex_group_poll_edit)(bContext *C)
|
||||
{
|
||||
Object *ob = ED_object_context(C);
|
||||
|
|
@ -3338,89 +2951,6 @@ void OBJECT_OT_vertex_group_normalize_all(wmOperatorType *ot)
|
|||
|
||||
/** \} */
|
||||
|
||||
/* -------------------------------------------------------------------- */
|
||||
/** \name Vertex Group Fix Position Operator
|
||||
* \{ */
|
||||
|
||||
static int vertex_group_fix_exec(bContext *C, wmOperator *op)
|
||||
{
|
||||
Object *ob = CTX_data_active_object(C);
|
||||
Scene *scene = CTX_data_scene(C);
|
||||
|
||||
float distToBe = RNA_float_get(op->ptr, "dist");
|
||||
float strength = RNA_float_get(op->ptr, "strength");
|
||||
float cp = RNA_float_get(op->ptr, "accuracy");
|
||||
ModifierData *md = static_cast<ModifierData *>(ob->modifiers.first);
|
||||
|
||||
while (md) {
|
||||
if (md->type == eModifierType_Mirror && (md->mode & eModifierMode_Realtime)) {
|
||||
break;
|
||||
}
|
||||
md = md->next;
|
||||
}
|
||||
|
||||
if (md && md->type == eModifierType_Mirror) {
|
||||
BKE_report(op->reports,
|
||||
RPT_ERROR_INVALID_CONTEXT,
|
||||
"This operator does not support an active mirror modifier");
|
||||
return OPERATOR_CANCELLED;
|
||||
}
|
||||
vgroup_fix(C, scene, ob, distToBe, strength, cp);
|
||||
|
||||
DEG_id_tag_update(&ob->id, ID_RECALC_GEOMETRY);
|
||||
WM_event_add_notifier(C, NC_OBJECT | ND_DRAW, ob);
|
||||
WM_event_add_notifier(C, NC_GEOM | ND_DATA, ob->data);
|
||||
|
||||
return OPERATOR_FINISHED;
|
||||
}
|
||||
|
||||
void OBJECT_OT_vertex_group_fix(wmOperatorType *ot)
|
||||
{
|
||||
/* identifiers */
|
||||
ot->name = "Fix Vertex Group Deform";
|
||||
ot->idname = "OBJECT_OT_vertex_group_fix";
|
||||
ot->description =
|
||||
"Modify the position of selected vertices by changing only their respective "
|
||||
"groups' weights (this tool may be slow for many vertices)";
|
||||
|
||||
/* api callbacks */
|
||||
ot->poll = vertex_group_mesh_with_dvert_poll;
|
||||
ot->exec = vertex_group_fix_exec;
|
||||
|
||||
/* flags */
|
||||
ot->flag = OPTYPE_REGISTER | OPTYPE_UNDO;
|
||||
RNA_def_float(ot->srna,
|
||||
"dist",
|
||||
0.0f,
|
||||
-FLT_MAX,
|
||||
FLT_MAX,
|
||||
"Distance",
|
||||
"The distance to move to",
|
||||
-10.0f,
|
||||
10.0f);
|
||||
RNA_def_float(ot->srna,
|
||||
"strength",
|
||||
1.0f,
|
||||
-2.0f,
|
||||
FLT_MAX,
|
||||
"Strength",
|
||||
"The distance moved can be changed by this multiplier",
|
||||
-2.0f,
|
||||
2.0f);
|
||||
RNA_def_float(
|
||||
ot->srna,
|
||||
"accuracy",
|
||||
1.0f,
|
||||
0.05f,
|
||||
FLT_MAX,
|
||||
"Change Sensitivity",
|
||||
"Change the amount weights are altered with each iteration: lower values are slower",
|
||||
0.05f,
|
||||
1.0f);
|
||||
}
|
||||
|
||||
/** \} */
|
||||
|
||||
/* -------------------------------------------------------------------- */
|
||||
/** \name Vertex Group Lock Operator
|
||||
* \{ */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue