Browse Source

Merge pull request #6248 from thinkyhead/rc_marlin_scad_mesh

Add hidden option to output Bilinear grids in JSON
pull/1/head
Scott Lahteine 8 years ago
committed by GitHub
parent
commit
034e912c85
  1. 3
      .gitattributes
  2. 163
      Marlin/Marlin_main.cpp
  3. 256
      buildroot/share/scripts/MarlinMesh.scad

3
.gitattributes

@ -3,11 +3,12 @@
# Files with Unix line endings # Files with Unix line endings
*.c text eol=lf *.c text eol=lf
# *.cpp text eol=lf *.cpp text eol=lf
*.h text eol=lf *.h text eol=lf
*.ino text eol=lf *.ino text eol=lf
*.py text eol=lf *.py text eol=lf
*.sh text eol=lf *.sh text eol=lf
*.scad text eol=lf
# Files with native line endings # Files with native line endings
# *.sln text # *.sln text

163
Marlin/Marlin_main.cpp

@ -399,11 +399,11 @@ int feedrate_percentage = 100, saved_feedrate_percentage,
bool axis_relative_modes[] = AXIS_RELATIVE_MODES, bool axis_relative_modes[] = AXIS_RELATIVE_MODES,
volumetric_enabled = volumetric_enabled =
#if ENABLED(VOLUMETRIC_DEFAULT_ON) #if ENABLED(VOLUMETRIC_DEFAULT_ON)
true true
#else #else
false false
#endif #endif
; ;
float filament_size[EXTRUDERS] = ARRAY_BY_EXTRUDERS1(DEFAULT_NOMINAL_FILAMENT_DIA), float filament_size[EXTRUDERS] = ARRAY_BY_EXTRUDERS1(DEFAULT_NOMINAL_FILAMENT_DIA),
volumetric_multiplier[EXTRUDERS] = ARRAY_BY_EXTRUDERS1(1.0); volumetric_multiplier[EXTRUDERS] = ARRAY_BY_EXTRUDERS1(1.0);
@ -588,7 +588,6 @@ static uint8_t target_extruder;
#endif #endif
#if ENABLED(AUTO_BED_LEVELING_BILINEAR) #if ENABLED(AUTO_BED_LEVELING_BILINEAR)
#define UNPROBED 9999.0f
int bilinear_grid_spacing[2], bilinear_start[2]; int bilinear_grid_spacing[2], bilinear_start[2];
float bed_level_grid[ABL_GRID_MAX_POINTS_X][ABL_GRID_MAX_POINTS_Y]; float bed_level_grid[ABL_GRID_MAX_POINTS_X][ABL_GRID_MAX_POINTS_Y];
#endif #endif
@ -2344,7 +2343,7 @@ static void clean_up_after_endstop_or_probe_move() {
bilinear_grid_spacing[X_AXIS] = bilinear_grid_spacing[Y_AXIS] = 0; bilinear_grid_spacing[X_AXIS] = bilinear_grid_spacing[Y_AXIS] = 0;
for (uint8_t x = 0; x < ABL_GRID_MAX_POINTS_X; x++) for (uint8_t x = 0; x < ABL_GRID_MAX_POINTS_X; x++)
for (uint8_t y = 0; y < ABL_GRID_MAX_POINTS_Y; y++) for (uint8_t y = 0; y < ABL_GRID_MAX_POINTS_Y; y++)
bed_level_grid[x][y] = UNPROBED; bed_level_grid[x][y] = NAN;
#elif ENABLED(AUTO_BED_LEVELING_UBL) #elif ENABLED(AUTO_BED_LEVELING_UBL)
ubl.reset(); ubl.reset();
#endif #endif
@ -2353,6 +2352,76 @@ static void clean_up_after_endstop_or_probe_move() {
#endif // PLANNER_LEVELING #endif // PLANNER_LEVELING
#if ENABLED(AUTO_BED_LEVELING_BILINEAR) || ENABLED(MESH_BED_LEVELING)
//
// Enable if you prefer your output in JSON format
// suitable for SCAD or JavaScript mesh visualizers.
//
// Visualize meshes in OpenSCAD using the included script.
//
// buildroot/shared/scripts/MarlinMesh.scad
//
//#define SCAD_MESH_OUTPUT
/**
* Print calibration results for plotting or manual frame adjustment.
*/
static void print_2d_array(const uint8_t sx, const uint8_t sy, const uint8_t precision, float (*fn)(const uint8_t, const uint8_t)) {
#ifndef SCAD_MESH_OUTPUT
for (uint8_t x = 0; x < sx; x++) {
for (uint8_t i = 0; i < precision + 2 + (x < 10 ? 1 : 0); i++)
SERIAL_PROTOCOLCHAR(' ');
SERIAL_PROTOCOL((int)x);
}
SERIAL_EOL;
#endif
#ifdef SCAD_MESH_OUTPUT
SERIAL_PROTOCOLLNPGM("measured_z = ["); // open 2D array
#endif
for (uint8_t y = 0; y < sy; y++) {
#ifdef SCAD_MESH_OUTPUT
SERIAL_PROTOCOLLNPGM(" ["); // open sub-array
#else
if (y < 10) SERIAL_PROTOCOLCHAR(' ');
SERIAL_PROTOCOL((int)y);
#endif
for (uint8_t x = 0; x < sx; x++) {
SERIAL_PROTOCOLCHAR(' ');
const float offset = fn(x, y);
if (offset != NAN) {
if (offset >= 0) SERIAL_PROTOCOLCHAR('+');
SERIAL_PROTOCOL_F(offset, precision);
}
else {
#ifdef SCAD_MESH_OUTPUT
for (uint8_t i = 3; i < precision + 3; i++)
SERIAL_PROTOCOLCHAR(' ');
SERIAL_PROTOCOLPGM("NAN");
#else
for (uint8_t i = 0; i < precision + 3; i++)
SERIAL_PROTOCOLCHAR(i ? '=' : ' ');
#endif
}
#ifdef SCAD_MESH_OUTPUT
if (x < sx - 1) SERIAL_PROTOCOLCHAR(',');
#endif
}
#ifdef SCAD_MESH_OUTPUT
SERIAL_PROTOCOLCHAR(' ');
SERIAL_PROTOCOLCHAR(']'); // close sub-array
if (y < sy - 1) SERIAL_PROTOCOLCHAR(',');
#endif
SERIAL_EOL;
}
#ifdef SCAD_MESH_OUTPUT
SERIAL_PROTOCOLPGM("\n];"); // close 2D array
#endif
SERIAL_EOL;
}
#endif
#if ENABLED(AUTO_BED_LEVELING_BILINEAR) #if ENABLED(AUTO_BED_LEVELING_BILINEAR)
/** /**
@ -2372,7 +2441,7 @@ static void clean_up_after_endstop_or_probe_move() {
SERIAL_CHAR(']'); SERIAL_CHAR(']');
} }
#endif #endif
if (bed_level_grid[x][y] != UNPROBED) { if (bed_level_grid[x][y] != NAN) {
#if ENABLED(DEBUG_LEVELING_FEATURE) #if ENABLED(DEBUG_LEVELING_FEATURE)
if (DEBUGGING(LEVELING)) SERIAL_ECHOLNPGM(" (done)"); if (DEBUGGING(LEVELING)) SERIAL_ECHOLNPGM(" (done)");
#endif #endif
@ -2386,9 +2455,9 @@ static void clean_up_after_endstop_or_probe_move() {
c1 = bed_level_grid[x + xdir][y + ydir], c2 = bed_level_grid[x + xdir * 2][y + ydir * 2]; c1 = bed_level_grid[x + xdir][y + ydir], c2 = bed_level_grid[x + xdir * 2][y + ydir * 2];
// Treat far unprobed points as zero, near as equal to far // Treat far unprobed points as zero, near as equal to far
if (a2 == UNPROBED) a2 = 0.0; if (a1 == UNPROBED) a1 = a2; if (a2 == NAN) a2 = 0.0; if (a1 == NAN) a1 = a2;
if (b2 == UNPROBED) b2 = 0.0; if (b1 == UNPROBED) b1 = b2; if (b2 == NAN) b2 = 0.0; if (b1 == NAN) b1 = b2;
if (c2 == UNPROBED) c2 = 0.0; if (c1 == UNPROBED) c1 = c2; if (c2 == NAN) c2 = 0.0; if (c1 == NAN) c1 = c2;
const float a = 2 * a1 - a2, b = 2 * b1 - b2, c = 2 * c1 - c2; const float a = 2 * a1 - a2, b = 2 * b1 - b2, c = 2 * c1 - c2;
@ -2453,39 +2522,10 @@ static void clean_up_after_endstop_or_probe_move() {
} }
/**
* Print calibration results for plotting or manual frame adjustment.
*/
static void print_2d_array(const uint8_t sx, const uint8_t sy, const uint8_t precision, float (*fn)(const uint8_t, const uint8_t)) {
for (uint8_t x = 0; x < sx; x++) {
for (uint8_t i = 0; i < precision + 2 + (x < 10 ? 1 : 0); i++)
SERIAL_PROTOCOLCHAR(' ');
SERIAL_PROTOCOL((int)x);
}
SERIAL_EOL;
for (uint8_t y = 0; y < sy; y++) {
if (y < 10) SERIAL_PROTOCOLCHAR(' ');
SERIAL_PROTOCOL((int)y);
for (uint8_t x = 0; x < sx; x++) {
SERIAL_PROTOCOLCHAR(' ');
float offset = fn(x, y);
if (offset != UNPROBED) {
if (offset >= 0) SERIAL_PROTOCOLCHAR('+');
SERIAL_PROTOCOL_F(offset, precision);
}
else
for (uint8_t i = 0; i < precision + 3; i++)
SERIAL_PROTOCOLCHAR(i ? '=' : ' ');
}
SERIAL_EOL;
}
SERIAL_EOL;
}
static void print_bilinear_leveling_grid() { static void print_bilinear_leveling_grid() {
SERIAL_ECHOLNPGM("Bilinear Leveling Grid:"); SERIAL_ECHOLNPGM("Bilinear Leveling Grid:");
print_2d_array(ABL_GRID_MAX_POINTS_X, ABL_GRID_MAX_POINTS_Y, 2, print_2d_array(ABL_GRID_MAX_POINTS_X, ABL_GRID_MAX_POINTS_Y, 3,
[](const uint8_t x, const uint8_t y) { return bed_level_grid[x][y]; } [](const uint8_t ix, const uint8_t iy) { return bed_level_grid[ix][iy]; }
); );
} }
@ -2501,7 +2541,7 @@ static void clean_up_after_endstop_or_probe_move() {
static void bed_level_virt_print() { static void bed_level_virt_print() {
SERIAL_ECHOLNPGM("Subdivided with CATMULL ROM Leveling Grid:"); SERIAL_ECHOLNPGM("Subdivided with CATMULL ROM Leveling Grid:");
print_2d_array(ABL_GRID_POINTS_VIRT_X, ABL_GRID_POINTS_VIRT_Y, 5, print_2d_array(ABL_GRID_POINTS_VIRT_X, ABL_GRID_POINTS_VIRT_Y, 5,
[](const uint8_t x, const uint8_t y) { return bed_level_grid_virt[x][y]; } [](const uint8_t ix, const uint8_t iy) { return bed_level_grid_virt[ix][iy]; }
); );
} }
@ -3715,13 +3755,9 @@ inline void gcode_G28() {
SERIAL_PROTOCOLLNPGM("Num X,Y: " STRINGIFY(MESH_NUM_X_POINTS) "," STRINGIFY(MESH_NUM_Y_POINTS)); SERIAL_PROTOCOLLNPGM("Num X,Y: " STRINGIFY(MESH_NUM_X_POINTS) "," STRINGIFY(MESH_NUM_Y_POINTS));
SERIAL_PROTOCOLPGM("Z offset: "); SERIAL_PROTOCOL_F(mbl.z_offset, 5); SERIAL_PROTOCOLPGM("Z offset: "); SERIAL_PROTOCOL_F(mbl.z_offset, 5);
SERIAL_PROTOCOLLNPGM("\nMeasured points:"); SERIAL_PROTOCOLLNPGM("\nMeasured points:");
for (uint8_t py = 0; py < MESH_NUM_Y_POINTS; py++) { print_2d_array(MESH_NUM_X_POINTS, MESH_NUM_Y_POINTS, 5,
for (uint8_t px = 0; px < MESH_NUM_X_POINTS; px++) { [](const uint8_t ix, const uint8_t iy) { return mbl.z_values[ix][iy]; }
SERIAL_PROTOCOLPGM(" "); );
SERIAL_PROTOCOL_F(mbl.z_values[py][px], 5);
}
SERIAL_EOL;
}
} }
/** /**
@ -6440,6 +6476,13 @@ inline void gcode_M115() {
SERIAL_PROTOCOLLNPGM("Cap:Z_PROBE:0"); SERIAL_PROTOCOLLNPGM("Cap:Z_PROBE:0");
#endif #endif
// MESH_REPORT (M420 V)
#if PLANNER_LEVELING
SERIAL_PROTOCOLLNPGM("Cap:LEVELING_DATA:1");
#else
SERIAL_PROTOCOLLNPGM("Cap:LEVELING_DATA:0");
#endif
// SOFTWARE_POWER (G30) // SOFTWARE_POWER (G30)
#if HAS_POWER_SWITCH #if HAS_POWER_SWITCH
SERIAL_PROTOCOLLNPGM("Cap:SOFTWARE_POWER:1"); SERIAL_PROTOCOLLNPGM("Cap:SOFTWARE_POWER:1");
@ -7479,9 +7522,9 @@ void quickstop_stepper() {
* Z[height] Sets the Z fade height (0 or none to disable) * Z[height] Sets the Z fade height (0 or none to disable)
* V[bool] Verbose - Print the leveling grid * V[bool] Verbose - Print the leveling grid
* *
* With AUTO_BED_LEVELING_UBL only: * With AUTO_BED_LEVELING_UBL only:
* *
* L[index] Load UBL mesh from index (0 is default) * L[index] Load UBL mesh from index (0 is default)
*/ */
inline void gcode_M420() { inline void gcode_M420() {
@ -7498,9 +7541,6 @@ void quickstop_stepper() {
ubl.load_mesh(storage_slot); ubl.load_mesh(storage_slot);
if (storage_slot != ubl.state.eeprom_storage_slot) ubl.store_state(); if (storage_slot != ubl.state.eeprom_storage_slot) ubl.store_state();
ubl.state.eeprom_storage_slot = storage_slot; ubl.state.eeprom_storage_slot = storage_slot;
ubl.display_map(0); // Right now, we only support one type of map
SERIAL_ECHOLNPAIR("UBL_MESH_VALID = ", UBL_MESH_VALID);
SERIAL_ECHOLNPAIR("eeprom_storage_slot = ", ubl.state.eeprom_storage_slot);
} }
#endif // AUTO_BED_LEVELING_UBL #endif // AUTO_BED_LEVELING_UBL
@ -7515,10 +7555,6 @@ void quickstop_stepper() {
bed_level_virt_print(); bed_level_virt_print();
#endif #endif
} }
#elif ENABLED(AUTO_BED_LEVELING_UBL)
ubl.display_map(0); // Currently only supports one map type
SERIAL_ECHOLNPAIR("UBL_MESH_VALID = ", UBL_MESH_VALID);
SERIAL_ECHOLNPAIR("eeprom_storage_slot = ", ubl.state.eeprom_storage_slot);
#elif ENABLED(MESH_BED_LEVELING) #elif ENABLED(MESH_BED_LEVELING)
if (mbl.has_mesh()) { if (mbl.has_mesh()) {
SERIAL_ECHOLNPGM("Mesh Bed Level data:"); SERIAL_ECHOLNPGM("Mesh Bed Level data:");
@ -7527,6 +7563,15 @@ void quickstop_stepper() {
#endif #endif
} }
#if ENABLED(AUTO_BED_LEVELING_UBL)
// L to load a mesh from the EEPROM
if (code_seen('L') || code_seen('V')) {
ubl.display_map(0); // Currently only supports one map type
SERIAL_ECHOLNPAIR("UBL_MESH_VALID = ", UBL_MESH_VALID);
SERIAL_ECHOLNPAIR("eeprom_storage_slot = ", ubl.state.eeprom_storage_slot);
}
#endif
bool to_enable = false; bool to_enable = false;
if (code_seen('S')) { if (code_seen('S')) {
to_enable = code_value_bool(); to_enable = code_value_bool();

256
buildroot/share/scripts/MarlinMesh.scad

@ -0,0 +1,256 @@
/**************************************\
* *
* OpenSCAD Mesh Display *
* by Thinkyhead - April 2017 *
* *
* Copy the grid output from Marlin, *
* paste below as shown, and use *
* OpenSCAD to see a visualization *
* of your mesh. *
* *
\**************************************/
//$t = 0.15; // comment out during animation
//
// Mesh info and points
//
mesh_width = 200; // X Size in mm of the probed area
mesh_height = 200; // Y Size...
zprobe_offset = 0; // Added to the points
NAN = 0; // Z to use for un-measured points
measured_z = [
[ -1.20, -1.13, -1.09, -1.03, -1.19 ],
[ -1.16, -1.25, -1.27, -1.25, -1.08 ],
[ -1.13, -1.26, -1.39, -1.31, -1.18 ],
[ -1.09, -1.20, -1.26, -1.21, -1.18 ],
[ -1.13, -0.99, -1.03, -1.06, -1.32 ]
];
//
// Geometry
//
max_z_scale = 100; // Scale at Time 0.5
min_z_scale = 10; // Scale at Time 0.0 and 1.0
thickness = 0.5; // thickness of the mesh triangles
tesselation = 1; // levels of tesselation from 0-2
alternation = 2; // direction change modulus (try it)
//
// Appearance
//
show_plane = true;
show_labels = true;
arrow_length = 5;
label_font_lg = "Arial";
label_font_sm = "Arial";
mesh_color = [1,1,1,0.5];
plane_color = [0.4,0.6,0.9,0.6];
//================================================ Derive useful values
big_z = max_2D(measured_z,0);
lil_z = min_2D(measured_z,0);
mean_value = (big_z + lil_z) / 2.0;
mesh_points_y = len(measured_z);
mesh_points_x = len(measured_z[0]);
xspace = mesh_width / (mesh_points_x - 1);
yspace = mesh_height / (mesh_points_y - 1);
// At $t=0 and $t=1 scale will be 100%
z_scale_factor = min_z_scale + (($t > 0.5) ? 1.0 - $t : $t) * (max_z_scale - min_z_scale) * 2;
//
// Min and max recursive functions for 1D and 2D arrays
// Return the smallest or largest value in the array
//
function min_1D(b,i) = (i<len(b)-1) ? min(b[i], min_1D(b,i+1)) : b[i];
function min_2D(a,j) = (j<len(a)-1) ? min_2D(a,j+1) : min_1D(a[j], 0);
function max_1D(b,i) = (i<len(b)-1) ? max(b[i], max_1D(b,i+1)) : b[i];
function max_2D(a,j) = (j<len(a)-1) ? max_2D(a,j+1) : max_1D(a[j], 0);
//
// Get the corner probe points of a grid square.
//
// Input : x,y grid indexes
// Output : An array of the 4 corner points
//
function grid_square(x,y) = [
[x * xspace, y * yspace, z_scale_factor * (measured_z[y][x] - mean_value)],
[x * xspace, (y+1) * yspace, z_scale_factor * (measured_z[y+1][x] - mean_value)],
[(x+1) * xspace, (y+1) * yspace, z_scale_factor * (measured_z[y+1][x+1] - mean_value)],
[(x+1) * xspace, y * yspace, z_scale_factor * (measured_z[y][x+1] - mean_value)]
];
// The corner point of a grid square with Z centered on the mean
function pos(x,y,z) = [x * xspace, y * yspace, z_scale_factor * (z - mean_value)];
//
// Draw the point markers and labels
//
module point_markers(show_home=true) {
// Mark the home position 0,0
color([0,0,0,0.25]) translate([1,1]) cylinder(r=1, h=z_scale_factor, center=true);
for (x=[0:mesh_points_x-1], y=[0:mesh_points_y-1]) {
z = measured_z[y][x];
down = z < mean_value;
translate(pos(x, y, z)) {
// Label each point with the Z
if (show_labels) {
v = z - mean_value;
color(abs(v) < 0.1 ? [0,0.5,0] : [0.25,0,0])
translate([0,0,down?-10:10]) {
$fn=8;
rotate([90,0])
text(str(z), 6, label_font_lg, halign="center", valign="center");
translate([0,0,down?-6:6]) rotate([90,0])
text(str(down ? "" : "+", v), 3, label_font_sm, halign="center", valign="center");
}
}
// Show an arrow pointing up or down
rotate([0, down ? 180 : 0]) translate([0,0,-1])
cylinder(
r1=0.5,
r2=0.1,
h=arrow_length, $fn=12, center=1
);
}
}
}
//
// Split a square on the diagonal into
// two triangles and render them.
//
// s : a square
// alt : a flag to split on the other diagonal
//
module tesselated_square(s, alt=false) {
add = [0,0,thickness];
p1 = [
s[0], s[1], s[2], s[3],
s[0]+add, s[1]+add, s[2]+add, s[3]+add
];
f1 = alt
? [ [0,1,3], [4,5,1,0], [4,7,5], [5,7,3,1], [7,4,0,3] ]
: [ [0,1,2], [4,5,1,0], [4,6,5], [5,6,2,1], [6,4,0,2] ];
f2 = alt
? [ [1,2,3], [5,6,2,1], [5,6,7], [6,7,3,2], [7,5,1,3] ]
: [ [0,2,3], [4,6,2,0], [4,7,6], [6,7,3,2], [7,4,0,3] ];
// Use the other diagonal
polyhedron(points=p1, faces=f1);
polyhedron(points=p1, faces=f2);
}
/**
* The simplest mesh display
*/
module simple_mesh(show_plane=show_plane) {
if (show_plane) color(plane_color) cube([mesh_width, mesh_height, thickness]);
color(mesh_color)
for (x=[0:mesh_points_x-2], y=[0:mesh_points_y-2])
tesselated_square(grid_square(x, y));
}
/**
* Subdivide the mesh into smaller squares.
*/
module bilinear_mesh(show_plane=show_plane,tesselation=tesselation) {
if (show_plane) color(plane_color) translate([-5,-5]) cube([mesh_width+10, mesh_height+10, thickness]);
tesselation = tesselation % 4;
color(mesh_color)
for (x=[0:mesh_points_x-2], y=[0:mesh_points_y-2]) {
square = grid_square(x, y);
if (tesselation < 1) {
tesselated_square(square,(x%alternation)-(y%alternation));
}
else {
subdiv_4 = subdivided_square(square);
if (tesselation < 2) {
for (i=[0:3]) tesselated_square(subdiv_4[i],i%alternation);
}
else {
for (i=[0:3]) {
subdiv_16 = subdivided_square(subdiv_4[i]);
if (tesselation < 3) {
for (j=[0:3]) tesselated_square(subdiv_16[j],j%alternation);
}
else {
for (j=[0:3]) {
subdiv_64 = subdivided_square(subdiv_16[j]);
if (tesselation < 4) {
for (k=[0:3]) tesselated_square(subdiv_64[k]);
}
}
}
}
}
}
}
}
//
// Subdivision helpers
//
function ctrz(a) = (a[0][2]+a[1][2]+a[3][2]+a[2][2])/4;
function avgx(a,i) = (a[i][0]+a[(i+1)%4][0])/2;
function avgy(a,i) = (a[i][1]+a[(i+1)%4][1])/2;
function avgz(a,i) = (a[i][2]+a[(i+1)%4][2])/2;
//
// Convert one square into 4, applying bilinear averaging
//
// Input : 1 square (4 points)
// Output : An array of 4 squares
//
function subdivided_square(a) = [
[ // SW square
a[0], // SW
[a[0][0],avgy(a,0),avgz(a,0)], // CW
[avgx(a,1),avgy(a,0),ctrz(a)], // CC
[avgx(a,1),a[0][1],avgz(a,3)] // SC
],
[ // NW square
[a[0][0],avgy(a,0),avgz(a,0)], // CW
a[1], // NW
[avgx(a,1),a[1][1],avgz(a,1)], // NC
[avgx(a,1),avgy(a,0),ctrz(a)] // CC
],
[ // NE square
[avgx(a,1),avgy(a,0),ctrz(a)], // CC
[avgx(a,1),a[1][1],avgz(a,1)], // NC
a[2], // NE
[a[2][0],avgy(a,0),avgz(a,2)] // CE
],
[ // SE square
[avgx(a,1),a[0][1],avgz(a,3)], // SC
[avgx(a,1),avgy(a,0),ctrz(a)], // CC
[a[2][0],avgy(a,0),avgz(a,2)], // CE
a[3] // SE
]
];
//================================================ Run the plan
translate([-mesh_width / 2, -mesh_height / 2]) {
$fn = 12;
point_markers();
bilinear_mesh();
}
Loading…
Cancel
Save