Skip to content

Commit 9da047f

Browse files
committed
Zoom and rotate segment by @Brandon502 & @blazoncek
- see #5202 for original implementation - does not include mirroring and always wraps - implemented as Segment property - reduced index.js footprint by coalescing several similar functions - slightly optimised push transition logic
1 parent 304c59e commit 9da047f

File tree

4 files changed

+187
-26
lines changed

4 files changed

+187
-26
lines changed

wled00/FX.h

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -421,7 +421,7 @@ typedef enum mapping1D2D {
421421

422422
class WS2812FX;
423423

424-
// segment, 76 bytes
424+
// segment, 80 bytes
425425
class Segment {
426426
public:
427427
uint32_t colors[NUM_COLORS];
@@ -460,9 +460,12 @@ class Segment {
460460
bool check1 : 1; // checkmark 1
461461
bool check2 : 1; // checkmark 2
462462
bool check3 : 1; // checkmark 3
463-
//uint8_t blendMode : 4; // segment blending modes: top, bottom, add, subtract, difference, multiply, divide, lighten, darken, screen, overlay, hardlight, softlight, dodge, burn
464463
};
465464
uint8_t blendMode; // segment blending modes: top, bottom, add, subtract, difference, multiply, divide, lighten, darken, screen, overlay, hardlight, softlight, dodge, burn
465+
struct {
466+
uint8_t zoomAmount : 4; // zoom amount (0-15); 8 == no zoom
467+
uint8_t rotateSpeed : 4; // rotation speed (0-15); 0 == no rotation
468+
};
466469
char *name; // segment name
467470

468471
// runtime data
@@ -488,6 +491,7 @@ class Segment {
488491
bool _manualW : 1;
489492
};
490493
};
494+
mutable uint16_t rotatedAngle; // current rotation angle (2D)
491495

492496
// static variables are use to speed up effect calculations by stashing common pre-calculated values
493497
static unsigned _usedSegmentData; // amount of data used by all segments
@@ -591,6 +595,8 @@ class Segment {
591595
, check2(false)
592596
, check3(false)
593597
, blendMode(0)
598+
, zoomAmount(8)
599+
, rotateSpeed(0)
594600
, name(nullptr)
595601
, next_time(0)
596602
, step(0)

wled00/FX_fcn.cpp

Lines changed: 111 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -563,7 +563,7 @@ Segment &Segment::setMode(uint8_t fx, bool loadDefaults) {
563563
sOpt = extractModeDefaults(fx, "ix"); intensity = (sOpt >= 0) ? sOpt : DEFAULT_INTENSITY;
564564
sOpt = extractModeDefaults(fx, "c1"); custom1 = (sOpt >= 0) ? sOpt : DEFAULT_C1;
565565
sOpt = extractModeDefaults(fx, "c2"); custom2 = (sOpt >= 0) ? sOpt : DEFAULT_C2;
566-
sOpt = extractModeDefaults(fx, "c3"); custom3 = (sOpt >= 0) ? sOpt : DEFAULT_C3;
566+
sOpt = extractModeDefaults(fx, "c3"); custom3 = (sOpt >= 0) ? constrain(sOpt, 0, 31) : DEFAULT_C3;
567567
sOpt = extractModeDefaults(fx, "o1"); check1 = (sOpt >= 0) ? (bool)sOpt : false;
568568
sOpt = extractModeDefaults(fx, "o2"); check2 = (sOpt >= 0) ? (bool)sOpt : false;
569569
sOpt = extractModeDefaults(fx, "o3"); check3 = (sOpt >= 0) ? (bool)sOpt : false;
@@ -573,6 +573,8 @@ Segment &Segment::setMode(uint8_t fx, bool loadDefaults) {
573573
sOpt = extractModeDefaults(fx, "mi"); if (sOpt >= 0) mirror = (bool)sOpt; // NOTE: setting this option is a risky business
574574
sOpt = extractModeDefaults(fx, "rY"); if (sOpt >= 0) reverse_y = (bool)sOpt;
575575
sOpt = extractModeDefaults(fx, "mY"); if (sOpt >= 0) mirror_y = (bool)sOpt; // NOTE: setting this option is a risky business
576+
sOpt = extractModeDefaults(fx, "rS"); if (sOpt >= 0) rotateSpeed = constrain(sOpt, 0, 15); // 0 = no rotation
577+
sOpt = extractModeDefaults(fx, "zA"); if (sOpt >= 0) zoomAmount = constrain(sOpt, 0, 15); // 8 = no zoom
576578
}
577579
sOpt = extractModeDefaults(fx, "pal"); // always extract 'pal' to set _default_palette
578580
if (sOpt >= 0 && loadDefaults) setPalette(sOpt);
@@ -1474,9 +1476,109 @@ void WS2812FX::blendSegment(const Segment &topSegment) const {
14741476
}
14751477
};
14761478

1479+
// zooming and rotation
1480+
auto RotateAndZoom = [](uint32_t *srcPixels, uint32_t *destPixels, int midX, int midY, int cols, int rows, int shearAngle, int zoomOffset) {
1481+
for (int i = 0; i < cols * rows; i++) destPixels[i] = BLACK; // fill black
1482+
1483+
constexpr uint8_t Scale_Shift = 10;
1484+
constexpr int Fixed_Scale = (1 << Scale_Shift);
1485+
constexpr int RoundVal = (1 << (Scale_Shift - 1));
1486+
constexpr int zoomRange = (Fixed_Scale * 3) / 4; // 768
1487+
int zoomScale = Fixed_Scale + (zoomOffset * zoomRange) / 8; // zoomOffset: -8 .. +7 -> zoomScale: 256 .. 1696
1488+
if (zoomScale <= 0) zoomScale = 1; // avoid divide-by-zero and negative zoom
1489+
1490+
const bool flip = (shearAngle > 90 && shearAngle < 270); // Flip to avoid instability near 180°
1491+
if (flip) shearAngle = (shearAngle + 180) % 360;
1492+
1493+
// Calculate shearX and shearY
1494+
const float angleRadians = radians(shearAngle);
1495+
int shearX = -tan_t(angleRadians / 2) * Fixed_Scale;
1496+
int shearY = sin_t(angleRadians) * Fixed_Scale;
1497+
1498+
const int WRAP_PAD_X = cols << 5; // ×32
1499+
const int WRAP_PAD_Y = rows << 5; // Ensures wrap works with large negative coordinates when zoomed out
1500+
1501+
// Use inverse mapping: iterate destination pixels, find source coordinates
1502+
for (int destY = 0; destY < rows; destY++) {
1503+
for (int destX = 0; destX < cols; destX++) {
1504+
// Translate destination to origin
1505+
int dx = destX - midX;
1506+
int dy = destY - midY;
1507+
1508+
// Inverse shear transformations (reverse order)
1509+
int x1 = dx - ((shearX * dy + RoundVal) >> Scale_Shift);
1510+
int y0 = dy - ((shearY * x1 + RoundVal) >> Scale_Shift);
1511+
int x0 = x1 - ((shearX * y0 + RoundVal) >> Scale_Shift);
1512+
1513+
// Apply zoom to source coordinates
1514+
x0 = (x0 * Fixed_Scale) / zoomScale;
1515+
y0 = (y0 * Fixed_Scale) / zoomScale;
1516+
1517+
// Handle flip
1518+
int srcX = flip ? (midX - x0) : (midX + x0);
1519+
int srcY = flip ? (midY - y0) : (midY + y0);
1520+
1521+
// Bounds check or wrap
1522+
//if (wrap) { // Wrap around
1523+
srcX = (srcX + WRAP_PAD_X); while (srcX >= cols) srcX -= cols; // positive modulo since % is slow
1524+
srcY = (srcY + WRAP_PAD_Y); while (srcY >= rows) srcY -= rows; // positive modulo since % is slow
1525+
//}
1526+
//else if (wrap_and_mirror) { // Wrap plus mirror
1527+
// int tileX = (srcX + WRAP_PAD_X) / cols;
1528+
// int tileY = (srcY + WRAP_PAD_Y) / rows;
1529+
1530+
// // Wrap src
1531+
// srcX = (srcX + WRAP_PAD_X); while (srcX >= cols) srcX -= cols; // positive modulo since % is slow
1532+
// srcY = (srcY + WRAP_PAD_Y); while (srcY >= rows) srcY -= rows; // positive modulo since % is slow
1533+
1534+
// // Flip on odd tiles
1535+
// if (tileX & 1) srcX = cols - 1 - srcX;
1536+
// if (tileY & 1) srcY = rows - 1 - srcY;
1537+
//}
1538+
//else
1539+
if ((unsigned)srcX >= (unsigned)cols || (unsigned)srcY >= (unsigned)rows) continue;
1540+
1541+
// Sample from source & write to destination
1542+
destPixels[destX + destY * cols] = srcPixels[srcX + srcY * cols];
1543+
}
1544+
}
1545+
};
1546+
1547+
uint32_t *_pixelsN = topSegment.getPixels(); // we will use this pointer as a source later insetad of getPixelColorRaw()
1548+
if (topSegment.rotateSpeed || topSegment.zoomAmount) {
1549+
_pixelsN = new uint32_t[nCols * nRows]; // may use allocateBuffer() if needed
1550+
const int midX = nCols / 2;
1551+
const int midY = nRows / 2;
1552+
if (topSegment.rotateSpeed != 0) {
1553+
topSegment.rotatedAngle += topSegment.rotateSpeed;
1554+
while (topSegment.rotatedAngle > 3600) topSegment.rotatedAngle -= 3600;
1555+
} else {
1556+
topSegment.rotatedAngle = 0; // reset angle if no rotation
1557+
}
1558+
RotateAndZoom(topSegment.getPixels(), _pixelsN, midX, midY, nCols, nRows, topSegment.rotatedAngle/10, topSegment.zoomAmount - 8);
1559+
}
1560+
uint32_t *_pixelsO = topSegment.getPixels(); // we will use this pointer as a source (old segment during transition) later insetad of getPixelColorRaw()
1561+
if (segO) {
1562+
_pixelsO = segO->getPixels(); // default to unmodified old segment pixels
1563+
if (segO->rotateSpeed || segO->zoomAmount) {
1564+
_pixelsO = new uint32_t[oCols * oRows]; // may use allocateBuffer() if needed
1565+
const int midXo = oCols / 2;
1566+
const int midYo = oRows / 2;
1567+
if (topSegment.rotateSpeed != 0) {
1568+
segO->rotatedAngle += segO->rotateSpeed;
1569+
while (segO->rotatedAngle > 3600) segO->rotatedAngle -= 3600;
1570+
} else {
1571+
segO->rotatedAngle = 0;
1572+
}
1573+
RotateAndZoom(segO->getPixels(), _pixelsO, midXo, midYo, oCols, oRows, segO->rotatedAngle/10, segO->zoomAmount - 8);
1574+
}
1575+
}
1576+
14771577
// if we blend using "push" style we need to "shift" canvas to left/right/up/down
14781578
unsigned offsetX = (blendingStyle == BLEND_STYLE_PUSH_UP || blendingStyle == BLEND_STYLE_PUSH_DOWN) ? 0 : progInv * nCols / 0xFFFFU;
14791579
unsigned offsetY = (blendingStyle == BLEND_STYLE_PUSH_LEFT || blendingStyle == BLEND_STYLE_PUSH_RIGHT) ? 0 : progInv * nRows / 0xFFFFU;
1580+
if (blendingStyle == BLEND_STYLE_PUSH_RIGHT) offsetX = nCols - offsetX;
1581+
if (blendingStyle == BLEND_STYLE_PUSH_UP) offsetY = nRows - offsetY;
14801582

14811583
// we only traverse new segment, not old one
14821584
for (int r = 0; r < nRows; r++) for (int c = 0; c < nCols; c++) {
@@ -1485,22 +1587,19 @@ void WS2812FX::blendSegment(const Segment &topSegment) const {
14851587
const Segment *seg = clipped && segO ? segO : &topSegment; // pixel is never clipped for FADE
14861588
int vCols = seg == segO ? oCols : nCols; // old segment may have different dimensions
14871589
int vRows = seg == segO ? oRows : nRows; // old segment may have different dimensions
1590+
uint32_t *_pixelsR = seg == segO ? _pixelsO : _pixelsN;
14881591
int x = c;
14891592
int y = r;
14901593
// if we blend using "push" style we need to "shift" canvas to left/right/up/down
1491-
switch (blendingStyle) {
1492-
case BLEND_STYLE_PUSH_RIGHT: x = (x + offsetX) % nCols; break;
1493-
case BLEND_STYLE_PUSH_LEFT: x = (x - offsetX + nCols) % nCols; break;
1494-
case BLEND_STYLE_PUSH_DOWN: y = (y + offsetY) % nRows; break;
1495-
case BLEND_STYLE_PUSH_UP: y = (y - offsetY + nRows) % nRows; break;
1496-
}
1594+
if (offsetX != 0) { x = (x + offsetX); while (x >= nCols) x -= nCols; }
1595+
if (offsetY != 0) { y = (y + offsetY); while (y >= nRows) y -= nRows; }
14971596
uint32_t c_a = BLACK;
1498-
if (x < vCols && y < vRows) c_a = seg->getPixelColorRaw(x + y*vCols); // will get clipped pixel from old segment or unclipped pixel from new segment
1597+
if (x < vCols && y < vRows) c_a = _pixelsR[x + y*vCols]; // will get clipped pixel from old segment or unclipped pixel from new segment
14991598
if (segO && blendingStyle == BLEND_STYLE_FADE
15001599
&& (topSegment.mode != segO->mode || (segO->name != topSegment.name && segO->name && topSegment.name && strncmp(segO->name, topSegment.name, WLED_MAX_SEGNAME_LEN) != 0))
15011600
&& x < oCols && y < oRows) {
15021601
// we need to blend old segment using fade as pixels are not clipped
1503-
c_a = color_blend16(c_a, segO->getPixelColorRaw(x + y*oCols), progInv);
1602+
c_a = color_blend16(c_a, _pixelsO[x + y*oCols], progInv);
15041603
} else if (blendingStyle != BLEND_STYLE_FADE) {
15051604
// if we have global brightness change (not On/Off change) we will ignore transition style and just fade brightness (see led.cpp)
15061605
// workaround for On/Off transition
@@ -1531,6 +1630,9 @@ void WS2812FX::blendSegment(const Segment &topSegment) const {
15311630
}
15321631
}
15331632
}
1633+
// clean up
1634+
if (topSegment.rotateSpeed || topSegment.zoomAmount) delete[] _pixelsN;
1635+
if (segO && (segO->rotateSpeed || segO->zoomAmount)) delete[] _pixelsO;
15341636
#endif
15351637
} else {
15361638
const int nLen = topSegment.virtualLength();

wled00/data/index.js

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -741,7 +741,21 @@ function populateSegments(s)
741741
let segp = `<div id="segp${i}" class="sbs">`+
742742
`<i class="icons slider-icon pwr ${inst.on ? "act":""}" id="seg${i}pwr" title="Power" onclick="setSegPwr(${i})">&#xe08f;</i>`+
743743
`<div class="sliderwrap il" title="Opacity/Brightness">`+
744-
`<input id="seg${i}bri" class="noslide" onchange="setSegBri(${i})" oninput="updateTrail(this)" max="255" min="1" type="range" value="${inst.bri}" />`+
744+
`<input id="seg${i}bri" class="noslide" onchange="setSegProp(${i},'bri')" oninput="updateTrail(this)" max="255" min="1" type="range" value="${inst.bri}" />`+
745+
`<div class="sliderdisplay"></div>`+
746+
`</div>`+
747+
`</div>`;
748+
let zoom = `<div id="segzm${i}" class="lbl-l">`+
749+
`Zoom<br>`+
750+
`<div class="sliderwrap il" title="Zoom amount">`+
751+
`<input id="seg${i}zA" class="noslide" onchange="setSegProp(${i},'zA')" oninput="updateTrail(this)" max="15" min="0" type="range" value="${inst.zA}" />`+
752+
`<div class="sliderdisplay"></div>`+
753+
`</div>`+
754+
`</div>`;
755+
let rotate =`<div id="segrt${i}" class="lbl-l">`+
756+
`Rotation<br>`+
757+
`<div class="sliderwrap il" title="Rotation speed">`+
758+
`<input id="seg${i}rS" class="noslide" onchange="setSegProp(${i},'rS')" oninput="updateTrail(this)" max="15" min="0" type="range" value="${inst.rS}" />`+
745759
`<div class="sliderdisplay"></div>`+
746760
`</div>`+
747761
`</div>`;
@@ -750,16 +764,16 @@ function populateSegments(s)
750764
let staY = inst.startY;
751765
let stoY = inst.stopY;
752766
let isMSeg = isM && staX<mw*mh; // 2D matrix segment
753-
let rvXck = `<label class="check revchkl">Reverse ${isM?'':'direction'}<input type="checkbox" id="seg${i}rev" onchange="setRev(${i})" ${inst.rev?"checked":""}><span class="checkmark"></span></label>`;
754-
let miXck = `<label class="check revchkl">Mirror<input type="checkbox" id="seg${i}mi" onchange="setMi(${i})" ${inst.mi?"checked":""}><span class="checkmark"></span></label>`;
767+
let rvXck = `<label class="check revchkl">Reverse ${isM?'':'direction'}<input type="checkbox" id="seg${i}rev" onchange="setSegProp(${i},'rev')" ${inst.rev?"checked":""}><span class="checkmark"></span></label>`;
768+
let miXck = `<label class="check revchkl">Mirror<input type="checkbox" id="seg${i}mi" onchange="setSegProp(${i},'mi')" ${inst.mi?"checked":""}><span class="checkmark"></span></label>`;
755769
let rvYck = "", miYck ="";
756770
let smpl = simplifiedUI ? 'hide' : '';
757771
if (isMSeg) {
758-
rvYck = `<label class="check revchkl">Reverse<input type="checkbox" id="seg${i}rY" onchange="setRevY(${i})" ${inst.rY?"checked":""}><span class="checkmark"></span></label>`;
759-
miYck = `<label class="check revchkl">Mirror<input type="checkbox" id="seg${i}mY" onchange="setMiY(${i})" ${inst.mY?"checked":""}><span class="checkmark"></span></label>`;
772+
rvYck = `<label class="check revchkl">Reverse<input type="checkbox" id="seg${i}rY" onchange="setSegProp(${i},'rY')" ${inst.rY?"checked":""}><span class="checkmark"></span></label>`;
773+
miYck = `<label class="check revchkl">Mirror<input type="checkbox" id="seg${i}mY" onchange="setSegProp(${i},'mY')" ${inst.mY?"checked":""}><span class="checkmark"></span></label>`;
760774
}
761-
let map2D = `<div id="seg${i}map2D" data-map="map2D" class="lbl-s hide">Expand 1D FX<br>`+
762-
`<div class="sel-p"><select class="sel-p" id="seg${i}m12" onchange="setM12(${i})">`+
775+
let map2D = `<div id="seg${i}map2D" data-map="map2D" data-fx="${inst.fx}" class="lbl-s hide">Expand 1D FX<br>`+
776+
`<div class="sel-p"><select class="sel-p" id="seg${i}m12" onchange="setSegProp(${i},'m12')">`+
763777
`<option value="0" ${inst.m12==0?' selected':''}>Pixels</option>`+
764778
`<option value="1" ${inst.m12==1?' selected':''}>Bar</option>`+
765779
`<option value="2" ${inst.m12==2?' selected':''}>Arc</option>`+
@@ -768,7 +782,7 @@ function populateSegments(s)
768782
`</select></div>`+
769783
`</div>`;
770784
let blend = `<div class="lbl-l">Blend mode<br>`+
771-
`<div class="sel-p"><select class="sel-ple" id="seg${i}bm" onchange="setBm(${i})">`+
785+
`<div class="sel-p"><select class="sel-ple" id="seg${i}bm" onchange="setSegProp(${i},'bm')">`+
772786
`<option value="0" ${inst.bm==0?' selected':''}>Top/Default</option>`+
773787
`<option value="1" ${inst.bm==1?' selected':''}>Bottom/None</option>`+
774788
`<option value="2" ${inst.bm==2?' selected':''}>Add</option>`+
@@ -788,7 +802,7 @@ function populateSegments(s)
788802
`</select></div>`+
789803
`</div>`;
790804
let sndSim = `<div data-snd="si" class="lbl-s hide">Sound sim<br>`+
791-
`<div class="sel-p"><select class="sel-p" id="seg${i}si" onchange="setSi(${i})">`+
805+
`<div class="sel-p"><select class="sel-p" id="seg${i}si" onchange="setSegProp(${i},'si')">`+
792806
`<option value="0" ${inst.si==0?' selected':''}>BeatSin</option>`+
793807
`<option value="1" ${inst.si==1?' selected':''}>WeWillRockYou</option>`+
794808
`<option value="2" ${inst.si==2?' selected':''}>10/13</option>`+
@@ -844,12 +858,14 @@ function populateSegments(s)
844858
`<div class="h bp" id="seg${i}len"></div>`+
845859
blend +
846860
(!isMSeg ? rvXck : '') +
861+
(isMSeg?zoom:'')+
862+
(isMSeg?rotate:'')+
847863
(isMSeg&&stoY-staY>1&&stoX-staX>1 ? map2D : '') +
848864
(s.AudioReactive && s.AudioReactive.on ? "" : sndSim) +
849865
`<label class="check revchkl" id="seg${i}lbtm">`+
850866
(isMSeg?'Transpose':'Mirror effect') + (isMSeg ?
851-
'<input type="checkbox" id="seg'+i+'tp" onchange="setTp('+i+')" '+(inst.tp?"checked":"")+'>':
852-
'<input type="checkbox" id="seg'+i+'mi" onchange="setMi('+i+')" '+(inst.mi?"checked":"")+'>') +
867+
'<input type="checkbox" id="seg'+i+'tp" onchange="setSegProp('+i+',\'tp\')" '+(inst.tp?"checked":"")+'>':
868+
'<input type="checkbox" id="seg'+i+'mi" onchange="setSegProp('+i+',\'mi\')" '+(inst.mi?"checked":"")+'>') +
853869
`<span class="checkmark"></span>`+
854870
`</label>`+
855871
`<div class="del">`+
@@ -870,6 +886,8 @@ function populateSegments(s)
870886
if (!gId(`seg${i}`)) continue;
871887
updateLen(i);
872888
updateTrail(gId(`seg${i}bri`));
889+
let r = gId(`seg${i}rS`); if (r) updateTrail(r);
890+
let z = gId(`seg${i}zA`); if (z) updateTrail(z);
873891
gId(`segr${i}`).classList.add("hide");
874892
}
875893
if (segCount < 2) {
@@ -2265,6 +2283,14 @@ function delSeg(s)
22652283
requestJson(obj);
22662284
}
22672285

2286+
function setSegProp(s,p)
2287+
{
2288+
let o = gId(`seg${s}${p}`);
2289+
let val = o.type === "checkbox" ? o.checked : parseInt(o.value);
2290+
var obj = {"seg": {"id": s, [p]: val}};
2291+
requestJson(obj);
2292+
}
2293+
/*
22682294
function setRev(s)
22692295
{
22702296
var rev = gId(`seg${s}rev`).checked;
@@ -2320,28 +2346,40 @@ function setTp(s)
23202346
var obj = {"seg": {"id": s, "tp": tp}};
23212347
requestJson(obj);
23222348
}
2323-
2349+
*/
23242350
function setGrp(s, g)
23252351
{
23262352
event.preventDefault();
23272353
event.stopPropagation();
23282354
var obj = {"seg": {"id": s, "set": g}};
23292355
requestJson(obj);
23302356
}
2357+
/*
2358+
function setZoom(s)
2359+
{
2360+
var obj = {"seg": {"id": s, "zA": parseInt(gId(`seg${s}za`).value)}};
2361+
requestJson(obj);
2362+
}
23312363
2364+
function setRotation(s)
2365+
{
2366+
var obj = {"seg": {"id": s, "rS": parseInt(gId(`seg${s}rs`).value)}};
2367+
requestJson(obj);
2368+
}
2369+
*/
23322370
function setSegPwr(s)
23332371
{
23342372
var pwr = gId(`seg${s}pwr`).classList.contains('act');
23352373
var obj = {"seg": {"id": s, "on": !pwr}};
23362374
requestJson(obj);
23372375
}
2338-
2376+
/*
23392377
function setSegBri(s)
23402378
{
23412379
var obj = {"seg": {"id": s, "bri": parseInt(gId(`seg${s}bri`).value)}};
23422380
requestJson(obj);
23432381
}
2344-
2382+
*/
23452383
function tglFreeze(s=null)
23462384
{
23472385
var obj = {"seg": {"frz": "t"}}; // toggle

0 commit comments

Comments
 (0)