#include "blobgroup2.hpp" #include "blobinvertedrect2.hpp" #include "blobshape2.hpp" #include #include #include #include #include static float cpuSdBox( float px, float py, float cx, float cy, float hw, float hh) { const float dx = std::abs(px - cx) - hw; const float dy = std::abs(py - cy) - hh; const float mdx = std::max(dx, 0.0f); const float mdy = std::max(dy, 0.0f); return std::sqrt(mdx * mdx + mdy * mdy) + std::min(std::max(dx, dy), 0.0f); } static float cpuSmoothstep(float edge0, float edge1, float x) { const float t = std::clamp((x - edge0) / (edge1 - edge0), 0.0f, 1.0f); return t * t * (3.0f - 2.0f * t); } static float cornerFillFactor(float sd, float smoothFactor) { const float outside = cpuSmoothstep(0.0f, smoothFactor, sd); const float inside = cpuSmoothstep(0.0f, -smoothFactor, sd); return std::max(outside, inside); } BlobGroup2::BlobGroup2(QQuickItem* parent) : QQuickItem(parent) { setFlag(ItemHasContents); } BlobGroup2::~BlobGroup2() { for (auto* shape : std::as_const(m_shapes)) shape->m_group = nullptr; if (m_invertedRect) static_cast(m_invertedRect)->m_group = nullptr; } void BlobGroup2::setSmoothing(qreal s) { if (qFuzzyCompare(m_smoothing, s)) return; m_smoothing = s; emit smoothingChanged(); markDirty(); } void BlobGroup2::setColor(const QColor& c) { if (m_color == c) return; m_color = c; emit colorChanged(); markDirty(); } void BlobGroup2::setCornerFill(bool e) { if (m_cornerFill == e) return; m_cornerFill = e; emit cornerFillChanged(); markDirty(); } void BlobGroup2::setShadowColor(const QColor& c) { if (m_shadowColor == c) return; m_shadowColor = c; emit shadowColorChanged(); markDirty(); } void BlobGroup2::setShadowRadius(qreal r) { if (qFuzzyCompare(m_shadowRadius, r)) return; m_shadowRadius = r; emit shadowRadiusChanged(); markDirty(); } void BlobGroup2::addShape(BlobShape2* shape) { if (!shape || m_shapes.contains(shape)) return; m_shapes.append(shape); markDirty(); } void BlobGroup2::removeShape(BlobShape2* shape) { m_shapes.removeOne(shape); markDirty(); } void BlobGroup2::setInvertedRect(BlobInvertedRect2* rect) { if (m_invertedRect == rect) return; m_invertedRect = rect; markDirty(); } void BlobGroup2::clearInvertedRect(BlobInvertedRect2* rect) { if (m_invertedRect != rect) return; m_invertedRect = nullptr; markDirty(); } void BlobGroup2::markDirty() { m_physicsUpdated = false; m_dataBuilt = false; // Physics runs during polish, before the render pass. The panel content is // transformed by deformMatrix from QML, so the matrix has to be final for this // frame; building the data in updatePaintNode would let the transform lag one // frame behind the material. polish(); update(); } void BlobGroup2::updatePolish() { // The group is the only thing that draws, so it also drives the shapes' // physics. ensureDataBuilt(); } void BlobGroup2::ensurePhysicsUpdated() { if (m_physicsUpdated) return; m_physicsUpdated = true; for (auto* shape : std::as_const(m_shapes)) shape->updatePhysics(); } void BlobGroup2::ensureDataBuilt() { if (m_dataBuilt) return; ensurePhysicsUpdated(); // Physics can request the next frame through markDirty(); the data is // refreshed here anyway, so the flag is set after it runs. m_dataBuilt = true; m_data.rects.clear(); m_bounds = QRectF(); const float smoothFactor = static_cast(m_smoothing); // The same reach the shader uses to decide whether a rect can contribute, plus // the shadow, so every pixel the shader can give a value to is inside the node. const double extent = static_cast(smoothFactor) * 1.5 + static_cast(m_shadowRadius); // Group order, no pruning: the shader sees this list for every pixel. // entryShapes maps a shared entry back to the shape it came from. QVector entryShapes; for (auto* shape : std::as_const(m_shapes)) { if (shape->width() <= 0 || shape->height() <= 0) continue; if (static_cast(m_data.rects.size()) >= BlobGroupData::kMaxRects) continue; const QPointF scene = shape->mapToScene(QPointF(0, 0)); const QMatrix4x4& dm = shape->m_deformMatrix; BlobRectData r; r.cx = static_cast(scene.x() + shape->width() / 2.0); r.cy = static_cast(scene.y() + shape->height() / 2.0); r.hw = static_cast(shape->width()) * 0.5f; r.hh = static_cast(shape->height()) * 0.5f; shape->cornerRadii(r.radius); r.offsetX = dm(0, 3); r.offsetY = dm(1, 3); const float a = dm(0, 0), b = dm(1, 0); const float c = dm(0, 1), e = dm(1, 1); const float det = a * e - c * b; const float invDet = std::abs(det) > 1e-6f ? 1.0f / det : 1.0f; r.invDeform[0] = e * invDet; r.invDeform[1] = -b * invDet; r.invDeform[2] = -c * invDet; r.invDeform[3] = a * invDet; const float halfTr = 0.5f * (a + e); const float halfDiff = 0.5f * (a - e); r.minEig = halfTr - std::sqrt(halfDiff * halfDiff + c * c); r.screenHalfX = std::abs(a) * r.hw + std::abs(c) * r.hh; r.screenHalfY = std::abs(b) * r.hw + std::abs(e) * r.hh; m_data.rects.append(r); m_bounds = m_bounds.united(QRectF( static_cast(r.cx) + static_cast(r.offsetX) - static_cast(r.screenHalfX) - extent, static_cast(r.cy) + static_cast(r.offsetY) - static_cast(r.screenHalfY) - extent, 2.0 * (static_cast(r.screenHalfX) + extent), 2.0 * (static_cast(r.screenHalfY) + extent))); entryShapes.append(shape); } const int count = static_cast(m_data.rects.size()); // Which shape is the source for the bar edge. resolveSources(entryShapes); // Exclusion gates only the smooth blend. for (int i = 0; i < count; ++i) { int mask = 0; BlobShape2* const si = entryShapes[i]; for (int j = 0; j < count; ++j) { if (j == i) continue; BlobShape2* const sj = entryShapes[j]; if (si->isExcluded(sj) || sj->isExcluded(si)) mask |= (1 << j); } m_data.rects[i].excludeMask = mask; } constexpr float minR = 2.0f; const bool cornerFill = m_cornerFill; for (int i = 0; i < count; ++i) { auto& ri = m_data.rects[i]; BlobShape2* const si = entryShapes[i]; float fTr = 1.0f, fBr = 1.0f, fBl = 1.0f, fTl = 1.0f; const float cTrX = ri.cx + ri.hw, cTrY = ri.cy - ri.hh; const float cBrX = ri.cx + ri.hw, cBrY = ri.cy + ri.hh; const float cBlX = ri.cx - ri.hw, cBlY = ri.cy + ri.hh; const float cTlX = ri.cx - ri.hw, cTlY = ri.cy - ri.hh; if (cornerFill) { for (int j = 0; j < count; ++j) { if (j == i) continue; if (ri.excludeMask & (1 << j)) continue; BlobShape2* const sj = entryShapes[j]; if (si->isCornerExcluded(sj) || sj->isCornerExcluded(si)) continue; const auto& rj = m_data.rects[j]; fTr = std::min( fTr, cornerFillFactor( cpuSdBox(cTrX, cTrY, rj.cx, rj.cy, rj.hw, rj.hh), smoothFactor)); fBr = std::min( fBr, cornerFillFactor( cpuSdBox(cBrX, cBrY, rj.cx, rj.cy, rj.hw, rj.hh), smoothFactor)); fBl = std::min( fBl, cornerFillFactor( cpuSdBox(cBlX, cBlY, rj.cx, rj.cy, rj.hw, rj.hh), smoothFactor)); fTl = std::min( fTl, cornerFillFactor( cpuSdBox(cTlX, cTlY, rj.cx, rj.cy, rj.hw, rj.hh), smoothFactor)); } } ri.radius[0] = std::max(ri.radius[0] * fTr, minR); ri.radius[1] = std::max(ri.radius[1] * fBr, minR); ri.radius[2] = std::max(ri.radius[2] * fBl, minR); ri.radius[3] = std::max(ri.radius[3] * fTl, minR); } // Inverted rect. The frame term is evaluated for every pixel in the bounds, so the // group flag cannot be gated per shape. if (!m_invertedRect) return; auto* inv = static_cast(m_invertedRect); if (inv->width() <= 0 || inv->height() <= 0) return; const QPointF invScene = inv->mapToScene(QPointF(0, 0)); const float outerCX = static_cast(invScene.x() + inv->width() / 2.0); const float outerCY = static_cast(invScene.y() + inv->height() / 2.0); const float outerHW = static_cast(inv->width()) * 0.5f; const float outerHH = static_cast(inv->height()) * 0.5f; const auto* invRect = m_invertedRect; const float innerCX = outerCX + static_cast( (invRect->borderLeft() - invRect->borderRight()) / 2.0); const float innerCY = outerCY + static_cast( (invRect->borderTop() - invRect->borderBottom()) / 2.0); const float innerHW = outerHW - static_cast( (invRect->borderLeft() + invRect->borderRight()) / 2.0); const float innerHH = outerHH - static_cast( (invRect->borderTop() + invRect->borderBottom()) / 2.0); m_data.hasInverted = true; m_data.invertedRadius = static_cast(invRect->radius()); m_data.invertedOuter[0] = outerCX; m_data.invertedOuter[1] = outerCY; m_data.invertedOuter[2] = outerHW; m_data.invertedOuter[3] = outerHH; m_data.invertedInner[0] = innerCX; m_data.invertedInner[1] = innerCY; m_data.invertedInner[2] = innerHW; m_data.invertedInner[3] = innerHH; m_bounds = m_bounds.united(QRectF( static_cast(outerCX) - static_cast(outerHW) - extent, static_cast(outerCY) - static_cast(outerHH) - extent, 2.0 * (static_cast(outerHW) + extent), 2.0 * (static_cast(outerHH) + extent))); } // A point on the bar edge can only have one source. A shape that is not pushing is not // the source: it still fills its own area, but inside the border strip it must not add // its own outline to a span another shape is already shaping. That is the case that // matters — one panel at rest next to one that is bouncing — where the edge showed // several steps instead of one smooth indent of the closing panel. Only one shape can be // pushing at a time, so the flag is just that, and the shader gates the strip with it. void BlobGroup2::resolveSources(const QVector& entryShapes) { const int count = static_cast(m_data.rects.size()); // With nothing pushing there is no span being shaped, so a shape at rest keeps its // own outline and nothing is gated. bool anyPushing = false; for (int i = 0; i < count; ++i) if (entryShapes[i]->isMoving()) { anyPushing = true; break; } for (int i = 0; i < count; ++i) m_data.rects[i].source = !anyPushing || entryShapes[i]->isMoving(); } void BlobGroup2::fillMaterial(BlobMaterial* material) { material->m_paddedX = static_cast(m_bounds.x()); material->m_paddedY = static_cast(m_bounds.y()); material->m_paddedW = static_cast(m_bounds.width()); material->m_paddedH = static_cast(m_bounds.height()); material->m_smoothFactor = static_cast(m_smoothing); // The single group draw has no owner, so the shader skips the ownership test. material->m_myIndex = -2; material->m_color = m_color; material->m_hasInverted = m_data.hasInverted ? 1 : 0; material->m_invertedRadius = data().invertedRadius; memcpy( material->m_invertedOuter, data().invertedOuter, sizeof(data().invertedOuter)); memcpy( material->m_invertedInner, data().invertedInner, sizeof(data().invertedInner)); material->m_shadowRadius = static_cast(m_shadowRadius); material->m_shadowColor[0] = static_cast(m_shadowColor.redF()); material->m_shadowColor[1] = static_cast(m_shadowColor.greenF()); material->m_shadowColor[2] = static_cast(m_shadowColor.blueF()); material->m_shadowColor[3] = static_cast(m_shadowColor.alphaF()); const int count = static_cast(qMin(data().rects.size(), qsizetype(16))); material->m_rectCount = count; for (int i = 0; i < count; ++i) material->m_rects[i] = data().rects[i]; } QSGNode* BlobGroup2::updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData*) { // Data is built during polish. This only covers a render pass that happens // without one. ensureDataBuilt(); if (m_data.rects.isEmpty() && !m_data.hasInverted) { delete oldNode; return nullptr; } auto* node = static_cast(oldNode); if (!node) { node = new QSGGeometryNode; auto* geometry = new QSGGeometry(QSGGeometry::defaultAttributes_TexturedPoint2D(), 4); geometry->setDrawingMode(QSGGeometry::DrawTriangleStrip); node->setGeometry(geometry); node->setFlag(QSGNode::OwnsGeometry); auto* material = new BlobMaterial; material->setFlag(QSGMaterial::Blending); node->setMaterial(material); node->setFlag(QSGNode::OwnsMaterial); } // The shader evaluates the field at the scene position a vertex maps to, so // the group item itself must not be transformed. The vertex positions are the // bounds in this item's frame; the material carries the same rect in scene // coordinates. auto* v = node->geometry()->vertexDataAsTexturedPoint2D(); const QPointF p0 = mapFromScene(m_bounds.topLeft()); const QPointF p1 = mapFromScene(m_bounds.bottomRight()); v[0].set(static_cast(p0.x()), static_cast(p0.y()), 0.0f, 0.0f); v[1].set(static_cast(p1.x()), static_cast(p0.y()), 1.0f, 0.0f); v[2].set(static_cast(p0.x()), static_cast(p1.y()), 0.0f, 1.0f); v[3].set(static_cast(p1.x()), static_cast(p1.y()), 1.0f, 1.0f); node->markDirty(QSGNode::DirtyGeometry); fillMaterial(static_cast(node->material())); node->markDirty(QSGNode::DirtyMaterial); return node; }