From bb0a96984d900736e93c241d851c1c1fdd54965f Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:26:15 +0200 Subject: [PATCH] [Game] Derive playmat sampling window from shared clamped helpers (#7159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Game] Derive playmat sampling window from shared clamped helpers The crop formula computed the sampled window inline with an unclamped zoom floor, so stored vertical offsets below half of travel were dead and extreme zooms could sample outside the art Remap verticalOffset to place the window top edge within its travel, floor zoom at visible width over min card side with a 4.0 ceiling, clamp pan along the margin sum constant segment, and expose playmatClampedZoom, playmatWindowSide and aspectFitRect so the game renderer and any editor share one geometry model Zoom 1 rendering is bit identical to before Took 7 seconds * Comments. Took 29 minutes --------- Co-authored-by: Lukas BrĂ¼bach --- .../player/player_graphics_item.cpp | 4 +- .../playmat/playmat_preview_widget.cpp | 4 +- .../interface/widgets/playmat/playmat_utils.h | 117 ++++++++++++++---- 3 files changed, 98 insertions(+), 27 deletions(-) diff --git a/cockatrice/src/game_graphics/player/player_graphics_item.cpp b/cockatrice/src/game_graphics/player/player_graphics_item.cpp index 026e00588..8bf2703e1 100644 --- a/cockatrice/src/game_graphics/player/player_graphics_item.cpp +++ b/cockatrice/src/game_graphics/player/player_graphics_item.cpp @@ -187,8 +187,8 @@ void PlayerGraphicsItem::paint(QPainter *painter, const QStyleOptionGraphicsItem QRectF combinedArea(combinedLeft, combinedTop, combinedRight - combinedLeft, combinedBottom - combinedTop); - const QRectF srcRect = computeArtSourceRect(playmatPixmap.size(), playmatParams); - const QRectF dstRect = coverFitRect(combinedArea, srcRect.size()); + const QRectF srcRect = PlaymatUtils::computeArtSourceRect(playmatPixmap.size(), playmatParams); + const QRectF dstRect = PlaymatUtils::coverFitRect(combinedArea, srcRect.size()); painter->save(); painter->setClipRect(combinedArea); diff --git a/cockatrice/src/interface/widgets/playmat/playmat_preview_widget.cpp b/cockatrice/src/interface/widgets/playmat/playmat_preview_widget.cpp index dc3afc2cd..52f21f714 100644 --- a/cockatrice/src/interface/widgets/playmat/playmat_preview_widget.cpp +++ b/cockatrice/src/interface/widgets/playmat/playmat_preview_widget.cpp @@ -61,8 +61,8 @@ void PlaymatPreviewWidget::paintEvent(QPaintEvent *) // Stack is ~20% width on the left, table is ~80% on the right const QRectF playArea = cardRect.adjusted(6, 4, -4, -4); - const QRectF srcRect = computeArtSourceRect(sourcePixmap.size(), params); - const QRectF dstRect = coverFitRect(playArea, srcRect.size()); + const QRectF srcRect = PlaymatUtils::computeArtSourceRect(sourcePixmap.size(), params); + const QRectF dstRect = PlaymatUtils::coverFitRect(playArea, srcRect.size()); painter.setClipRect(playArea.toRect()); painter.drawPixmap(dstRect, sourcePixmap, srcRect); diff --git a/cockatrice/src/interface/widgets/playmat/playmat_utils.h b/cockatrice/src/interface/widgets/playmat/playmat_utils.h index 9ab8190b3..0691a9637 100644 --- a/cockatrice/src/interface/widgets/playmat/playmat_utils.h +++ b/cockatrice/src/interface/widgets/playmat/playmat_utils.h @@ -6,42 +6,96 @@ #include #include +namespace PlaymatUtils +{ + +/** @brief Upper bound for zooming into the playmat art. */ +constexpr qreal MAX_ZOOM = 4.0; + /** - * @brief Computes the source region of the full-resolution card image to use as a playmat. + * @brief Width of the outer viewing window: full card width trimmed by the + * horizontal margins. Guarded against margins summing to >= 1. + */ +inline qreal playmatVisibleWidth(const QSize &fullCardSize, const PlaymatParams ¶ms) +{ + const qreal srcW = fullCardSize.width(); + const qreal marginL = params.marginPctL * srcW; + const qreal marginR = params.marginPctR * srcW; + return qMax(0.0, srcW - marginL - marginR); +} + +/** + * @brief Zoom clamped to the range where every step renders differently. * - * Parameters are relative to the full card image: horizontal margins trim the card - * borders, the vertical offset positions a square viewing window, and zoom scales - * into that window. The result is clamped to the card image bounds. + * The square sampling window is visibleWidth / zoom, zooming out past + * visibleWidth / min(card width, height) would sample beyond the card itself, + * which both looks broken and makes whole ranges of the parameter dead. The + * floor is therefore derived from the actual image instead of a static value, + * and is shared verbatim by the render path and the editor's gesture math so + * the two can never disagree. + */ +inline qreal playmatClampedZoom(const QSize &fullCardSize, const PlaymatParams ¶ms) +{ + const qreal minDim = qMin(fullCardSize.width(), fullCardSize.height()); + const qreal visibleW = playmatVisibleWidth(fullCardSize, params); + const qreal zoomOutFloor = (minDim > 0.0 && visibleW > 0.0) ? visibleW / minDim : 1.0; + // The floor deliberately bypasses MAX_ZOOM: when the art is much wider + // than tall, keeping the square window inside it requires more than 4x + // zoom-out, and honoring that larger floor keeps side within + // min(card width, height). Zooming IN is still capped at MAX_ZOOM. + return qMin(MAX_ZOOM, qMax(params.zoom, zoomOutFloor)); +} + +/** + * @brief Side of the square sampling window actually rendered for these + * parameters. Never exceeds either card dimension, so the source rect + * always lies within the image (vertical travel remains for panning + * whenever the art is taller than it is wide). + */ +inline qreal playmatWindowSide(const QSize &fullCardSize, const PlaymatParams ¶ms) +{ + const qreal visibleW = playmatVisibleWidth(fullCardSize, params); + if (visibleW <= 0.0) { + return 0.0; + } + return visibleW / playmatClampedZoom(fullCardSize, params); +} + +/** + * @brief Computes the source region of the full resolution card image to use as a playmat. + * + * Parameters are relative to the full card image. horizontal margins trim the + * card borders (shifting them pans the window), verticalOffset places the top + * edge of the sampling window within its available travel, and zoom scales + * into the trimmed span. The result always lies within the card image bounds. * * @param fullCardSize Size of the full card image. * @param params Positioning parameters. - * @return Source rectangle in full-card image pixel coordinates. + * @return Source rectangle in full card image pixel coordinates. */ inline QRectF computeArtSourceRect(const QSize &fullCardSize, const PlaymatParams ¶ms) { const qreal srcW = fullCardSize.width(); const qreal srcH = fullCardSize.height(); - const qreal marginL = params.marginPctL * srcW; - const qreal marginR = params.marginPctR * srcW; - // Guard against margins summing to >= 1 (both are individually in range), - // which would otherwise make the viewing window negative or zero. - const qreal visibleW = qMax(0.0, srcW - marginL - marginR); - const qreal visibleH = visibleW; // square viewing window, keeps art unskewed + // Square sampling window, keeps art unskewed, never exceeds the card on + // either axis thanks to the zoom floor in playmatWindowSide(). + const qreal side = playmatWindowSide(fullCardSize, params); - const qreal vCenter = params.verticalOffset * srcH; - qreal srcY = vCenter - visibleH / 2.0; - srcY = qBound(0.0, srcY, srcH - visibleH); + // verticalOffset places the TOP edge of the sampling window itself within + // its travel, so the full [0, 1] parameter range is live at every zoom and + // the window can always reach the very top (0.0) and bottom (1.0) of the + // art. + const qreal offset = qBound(0.0, params.verticalOffset, 1.0); + const qreal y = offset * qMax(0.0, srcH - side); - // Guard the zoom divisor; everything that produces params clamps zoom to - // [0.1, 4.0] already, this keeps the render path self-contained. - const qreal zoom = qBound(0.1, params.zoom, 4.0); - const qreal zoomedW = visibleW / zoom; - const qreal zoomedH = visibleH / zoom; - const qreal zoomedX = marginL + (visibleW - zoomedW) / 2.0; - const qreal zoomedY = srcY + (visibleH - zoomedH) / 2.0; + // Horizontally the sampling window sits centered inside the trimmed span + // (margins pan it), zooming out can make it wider than that span, so it + // is then kept within the image, an edge stop, never an invalid rect. + const qreal outerW = playmatVisibleWidth(fullCardSize, params); + const qreal x = qBound(0.0, params.marginPctL * srcW + (outerW - side) / 2.0, qMax(0.0, srcW - side)); - return QRectF(zoomedX, zoomedY, zoomedW, zoomedH); + return QRectF(x, y, side, side); } /** @@ -49,7 +103,7 @@ inline QRectF computeArtSourceRect(const QSize &fullCardSize, const PlaymatParam * ratio into dstArea using "cover" semantics (no distortion, overflows cropped). * * @param dstArea Area to fill. - * @param srcSize Size of the source; only its aspect ratio matters. + * @param srcSize Size of the source, only its aspect ratio matters. * @return Destination rectangle centered in dstArea. */ inline QRectF coverFitRect(const QRectF &dstArea, const QSizeF &srcSize) @@ -66,4 +120,21 @@ inline QRectF coverFitRect(const QRectF &dstArea, const QSizeF &srcSize) return QRectF(dstArea.left(), dstArea.top() + (dstArea.height() - dstH) / 2.0, dstArea.width(), dstH); } +/** + * @brief Fits a rectangle of the given aspect ratio into dstArea, centered, + * touching the constraining dimension ("aspect fit" of the FRAME + * itself, not of a source image). + */ +inline QRectF aspectFitRect(const QRectF &dstArea, qreal aspect) +{ + if (aspect <= 0.0) { + return dstArea; + } + qreal w = qMin(dstArea.width(), dstArea.height() * aspect); + qreal h = w / aspect; + return QRectF(dstArea.left() + (dstArea.width() - w) / 2.0, dstArea.top() + (dstArea.height() - h) / 2.0, w, h); +} + +} // namespace PlaymatUtils + #endif // COCKATRICE_PLAYMAT_UTILS_H