| 1 | // Copyright (C) 2024 The Qt Company Ltd. |
| 2 | // Copyright (C) 2017 Klaralvdalens Datakonsult AB (KDAB). |
| 3 | // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only |
| 4 | |
| 5 | #include "extrudedtextgeometry_p.h" |
| 6 | #include <QPainterPath> |
| 7 | #include <QtGui/private/qtriangulator_p.h> |
| 8 | #include <QVector3D> |
| 9 | |
| 10 | #if QT_CONFIG(concurrent) |
| 11 | #include <QtConcurrentRun> |
| 12 | #endif |
| 13 | |
| 14 | QT_BEGIN_NAMESPACE |
| 15 | |
| 16 | namespace { |
| 17 | |
| 18 | static float edgeSplitAngle = 90.f * 0.1f; |
| 19 | |
| 20 | struct TriangulationData { |
| 21 | struct Outline { |
| 22 | int begin; |
| 23 | int end; |
| 24 | }; |
| 25 | |
| 26 | std::vector<QVector3D> vertices; |
| 27 | std::vector<ExtrudedTextGeometry::IndexType> indices; |
| 28 | std::vector<Outline> outlines; |
| 29 | std::vector<ExtrudedTextGeometry::IndexType> outlineIndices; |
| 30 | bool inverted; |
| 31 | }; |
| 32 | |
| 33 | TriangulationData triangulate(const QString &text, const QFont &font, float scale) |
| 34 | { |
| 35 | TriangulationData result; |
| 36 | int beginOutline = 0; |
| 37 | |
| 38 | // Initialize path with text and extract polygons |
| 39 | QPainterPath path; |
| 40 | path.setFillRule(Qt::WindingFill); |
| 41 | path.addText(x: 0, y: 0, f: font, text); |
| 42 | QList<QPolygonF> polygons = path.toSubpathPolygons(matrix: QTransform().scale(sx: 1., sy: -1.)); |
| 43 | |
| 44 | // maybe glyph has no geometry |
| 45 | if (polygons.empty()) |
| 46 | return result; |
| 47 | |
| 48 | const size_t prevNumIndices = result.indices.size(); |
| 49 | |
| 50 | // Reset path and add previously extracted polygons (which where spatially transformed) |
| 51 | path = QPainterPath(); |
| 52 | path.setFillRule(Qt::WindingFill); |
| 53 | for (QPolygonF &p : polygons) |
| 54 | path.addPolygon(polygon: p); |
| 55 | |
| 56 | // Extract polylines out of the path, this allows us to retrieve indices for each glyph outline |
| 57 | QPolylineSet polylines = qPolyline(path); |
| 58 | std::vector<ExtrudedTextGeometry::IndexType> tmpIndices; |
| 59 | tmpIndices.resize(new_size: size_t(polylines.indices.size())); |
| 60 | memcpy(dest: tmpIndices.data(), src: polylines.indices.data(), n: size_t(polylines.indices.size()) * sizeof(ExtrudedTextGeometry::IndexType)); |
| 61 | |
| 62 | int lastIndex = 0; |
| 63 | for (const ExtrudedTextGeometry::IndexType idx : tmpIndices) { |
| 64 | if (idx == std::numeric_limits<ExtrudedTextGeometry::IndexType>::max()) { |
| 65 | const int endOutline = lastIndex; |
| 66 | result.outlines.push_back(x: {.begin: beginOutline, .end: endOutline}); |
| 67 | beginOutline = endOutline; |
| 68 | } else { |
| 69 | result.outlineIndices.push_back(x: idx); |
| 70 | ++lastIndex; |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | // Triangulate path |
| 75 | QTransform transform; |
| 76 | transform.scale(sx: scale, sy: scale); |
| 77 | const QTriangleSet triangles = qTriangulate(path, matrix: transform); |
| 78 | |
| 79 | // Append new indices to result.indices buffer |
| 80 | result.indices.resize(new_size: result.indices.size() + size_t(triangles.indices.size())); |
| 81 | memcpy(dest: &result.indices[prevNumIndices], src: triangles.indices.data(), n: size_t(triangles.indices.size()) * sizeof(ExtrudedTextGeometry::IndexType)); |
| 82 | for (size_t i = prevNumIndices, m = result.indices.size(); i < m; ++i) |
| 83 | result.indices[i] += ExtrudedTextGeometry::IndexType(result.vertices.size()); |
| 84 | |
| 85 | // Append new triangles to result.vertices |
| 86 | result.vertices.reserve(n: size_t(triangles.vertices.size()) / 2); |
| 87 | for (qsizetype i = 0, m = triangles.vertices.size(); i < m; i += 2) |
| 88 | result.vertices.push_back(x: QVector3D(triangles.vertices[i] / font.pointSizeF(), triangles.vertices[i + 1] / font.pointSizeF(), 0.0f)); |
| 89 | |
| 90 | return result; |
| 91 | } |
| 92 | |
| 93 | inline QVector3D mix(const QVector3D &a, const QVector3D &b, float ratio) |
| 94 | { |
| 95 | return a + (b - a) * ratio; |
| 96 | } |
| 97 | |
| 98 | } // anonymous namespace |
| 99 | |
| 100 | |
| 101 | /*! |
| 102 | \qmltype ExtrudedTextGeometry |
| 103 | \inqmlmodule QtQuick3D.Helpers |
| 104 | \inherits Geometry |
| 105 | \since 6.9 |
| 106 | \brief Provides geometry for extruded text. |
| 107 | |
| 108 | ExtrudedTextGeometry provides geometry for extruded text. The text is extruded along the z-axis. |
| 109 | The text and font can be set, and the depth of the extrusion can be controlled. |
| 110 | The size of the generated geometry is controlled by the scale and depth properties. The topology |
| 111 | of the geometry is defined by the font.pointSize. |
| 112 | |
| 113 | The origin of the mesh is the rear left end of the text's baseline. |
| 114 | */ |
| 115 | |
| 116 | /*! |
| 117 | \qmlproperty string ExtrudedTextGeometry::text |
| 118 | |
| 119 | This property holds the text that will be extruded. |
| 120 | */ |
| 121 | |
| 122 | /*! |
| 123 | \qmlproperty font ExtrudedTextGeometry::font |
| 124 | |
| 125 | This property holds the font that will be used to render the text. |
| 126 | |
| 127 | \note The mesh geometry is normalized by the font's pointSize, so a larger pointSize |
| 128 | will result in smoother, rather than larger, text. pixelSize should not |
| 129 | be used. |
| 130 | */ |
| 131 | |
| 132 | /*! |
| 133 | \qmlproperty real ExtrudedTextGeometry::depth |
| 134 | |
| 135 | This property holds the depth of the extrusion. |
| 136 | */ |
| 137 | |
| 138 | /*! |
| 139 | \qmlproperty real ExtrudedTextGeometry::scale |
| 140 | |
| 141 | This property holds a scalar value of how the geometry should be scaled. |
| 142 | This property only affects the size of the text, not the depth of the extrusion. |
| 143 | */ |
| 144 | |
| 145 | /*! |
| 146 | \qmlproperty bool ExtrudedTextGeometry::asynchronous |
| 147 | |
| 148 | This property holds whether the geometry generation should be asynchronous. |
| 149 | */ |
| 150 | |
| 151 | /*! |
| 152 | \qmlproperty bool ExtrudedTextGeometry::status |
| 153 | \readonly |
| 154 | |
| 155 | This property holds the status of the geometry generation when asynchronous is true. |
| 156 | |
| 157 | \value ExtrudedTextGeometry.Null The geometry generation has not started |
| 158 | \value ExtrudedTextGeometry.Ready The geometry generation is complete. |
| 159 | \value ExtrudedTextGeometry.Loading The geometry generation is in progress. |
| 160 | \value ExtrudedTextGeometry.Error The geometry generation failed. |
| 161 | */ |
| 162 | |
| 163 | |
| 164 | ExtrudedTextGeometry::ExtrudedTextGeometry(QQuick3DObject *parent) |
| 165 | : QQuick3DGeometry(parent) |
| 166 | { |
| 167 | #if QT_CONFIG(concurrent) |
| 168 | connect(sender: &m_geometryDataWatcher, signal: &QFutureWatcher<GeometryData>::finished, context: this, slot: &ExtrudedTextGeometry::requestFinished); |
| 169 | #endif |
| 170 | scheduleGeometryUpdate(); |
| 171 | } |
| 172 | |
| 173 | ExtrudedTextGeometry::~ExtrudedTextGeometry() |
| 174 | { |
| 175 | |
| 176 | } |
| 177 | |
| 178 | QString ExtrudedTextGeometry::text() const |
| 179 | { |
| 180 | return m_text; |
| 181 | } |
| 182 | |
| 183 | void ExtrudedTextGeometry::setText(const QString &newText) |
| 184 | { |
| 185 | if (m_text == newText) |
| 186 | return; |
| 187 | m_text = newText; |
| 188 | emit textChanged(); |
| 189 | scheduleGeometryUpdate(); |
| 190 | } |
| 191 | |
| 192 | QFont ExtrudedTextGeometry::font() const |
| 193 | { |
| 194 | return m_font; |
| 195 | } |
| 196 | |
| 197 | void ExtrudedTextGeometry::setFont(const QFont &newFont) |
| 198 | { |
| 199 | if (m_font == newFont) |
| 200 | return; |
| 201 | m_font = newFont; |
| 202 | emit fontChanged(); |
| 203 | scheduleGeometryUpdate(); |
| 204 | } |
| 205 | |
| 206 | float ExtrudedTextGeometry::depth() const |
| 207 | { |
| 208 | return m_depth; |
| 209 | } |
| 210 | |
| 211 | void ExtrudedTextGeometry::setDepth(float newDepth) |
| 212 | { |
| 213 | if (qFuzzyCompare(p1: m_depth, p2: newDepth)) |
| 214 | return; |
| 215 | m_depth = newDepth; |
| 216 | emit depthChanged(); |
| 217 | scheduleGeometryUpdate(); |
| 218 | } |
| 219 | |
| 220 | float ExtrudedTextGeometry::scale() const |
| 221 | { |
| 222 | return m_scale; |
| 223 | } |
| 224 | |
| 225 | void ExtrudedTextGeometry::setScale(float newScale) |
| 226 | { |
| 227 | if (qFuzzyCompare(p1: m_scale, p2: newScale)) |
| 228 | return; |
| 229 | m_scale = newScale; |
| 230 | emit scaleChanged(); |
| 231 | scheduleGeometryUpdate(); |
| 232 | } |
| 233 | |
| 234 | bool ExtrudedTextGeometry::asynchronous() const |
| 235 | { |
| 236 | return m_asynchronous; |
| 237 | } |
| 238 | |
| 239 | void ExtrudedTextGeometry::setAsynchronous(bool newAsynchronous) |
| 240 | { |
| 241 | if (m_asynchronous == newAsynchronous) |
| 242 | return; |
| 243 | m_asynchronous = newAsynchronous; |
| 244 | emit asynchronousChanged(); |
| 245 | } |
| 246 | |
| 247 | ExtrudedTextGeometry::Status ExtrudedTextGeometry::status() const |
| 248 | { |
| 249 | return m_status; |
| 250 | } |
| 251 | |
| 252 | void ExtrudedTextGeometry::doUpdateGeometry() |
| 253 | { |
| 254 | // reset the flag since we are processing the update |
| 255 | m_geometryUpdateRequested = false; |
| 256 | |
| 257 | #if QT_CONFIG(concurrent) |
| 258 | if (m_geometryDataFuture.isRunning()) { |
| 259 | m_pendingAsyncUpdate = true; |
| 260 | return; |
| 261 | } |
| 262 | #endif |
| 263 | |
| 264 | // If text is empty, clear the geometry |
| 265 | // Note this happens after we check if we are already running an update |
| 266 | // asynchronously. |
| 267 | if (m_text.isEmpty()) { |
| 268 | clear(); |
| 269 | update(); |
| 270 | return; |
| 271 | } |
| 272 | |
| 273 | #if QT_CONFIG(concurrent) |
| 274 | |
| 275 | if (m_asynchronous) { |
| 276 | m_geometryDataFuture = QtConcurrent::run(f&: generateExtrudedTextGeometryAsync, |
| 277 | args&: m_text, |
| 278 | args&: m_font, |
| 279 | args&: m_depth, |
| 280 | args&: m_scale); |
| 281 | m_geometryDataWatcher.setFuture(m_geometryDataFuture); |
| 282 | m_status = Status::Loading; |
| 283 | Q_EMIT statusChanged(); |
| 284 | } else { |
| 285 | #else |
| 286 | { |
| 287 | |
| 288 | #endif // QT_CONFIG(concurrent) |
| 289 | updateGeometry(geometryData: generateExtrudedTextGeometry(text: m_text, font: m_font, depth: m_depth, scale: m_scale)); |
| 290 | } |
| 291 | } |
| 292 | |
| 293 | void ExtrudedTextGeometry::requestFinished() |
| 294 | { |
| 295 | #if QT_CONFIG(concurrent) |
| 296 | const auto output = m_geometryDataFuture.takeResult(); |
| 297 | updateGeometry(geometryData: output); |
| 298 | #endif |
| 299 | } |
| 300 | |
| 301 | void ExtrudedTextGeometry::scheduleGeometryUpdate() |
| 302 | { |
| 303 | if (!m_geometryUpdateRequested) { |
| 304 | QMetaObject::invokeMethod(obj: this, member: "doUpdateGeometry" , c: Qt::QueuedConnection); |
| 305 | m_geometryUpdateRequested = true; |
| 306 | } |
| 307 | } |
| 308 | |
| 309 | void ExtrudedTextGeometry::updateGeometry(const GeometryData &geometryData) |
| 310 | { |
| 311 | // clear(); |
| 312 | setStride(sizeof(float) * 6); // 3 for position, 3 for normal |
| 313 | setPrimitiveType(QQuick3DGeometry::PrimitiveType::Triangles); |
| 314 | addAttribute(semantic: QQuick3DGeometry::Attribute::PositionSemantic, |
| 315 | offset: 0, |
| 316 | componentType: QQuick3DGeometry::Attribute::F32Type); |
| 317 | addAttribute(semantic: QQuick3DGeometry::Attribute::NormalSemantic, |
| 318 | offset: 3 * sizeof(float), |
| 319 | componentType: QQuick3DGeometry::Attribute::F32Type); |
| 320 | addAttribute(semantic: QQuick3DGeometry::Attribute::IndexSemantic, |
| 321 | offset: 0, |
| 322 | componentType: QQuick3DGeometry::Attribute::U32Type); |
| 323 | |
| 324 | setBounds(min: geometryData.boundsMin, max: geometryData.boundsMax); |
| 325 | setVertexData(geometryData.vertexData); |
| 326 | setIndexData(geometryData.indexData); |
| 327 | |
| 328 | // If the geometry update was requested while the geometry was being generated asynchronously, |
| 329 | // we need to schedule another geometry update now that the geometry is ready. |
| 330 | if (m_pendingAsyncUpdate) { |
| 331 | m_pendingAsyncUpdate = false; |
| 332 | scheduleGeometryUpdate(); |
| 333 | } else { |
| 334 | m_status = Status::Ready; |
| 335 | Q_EMIT statusChanged(); |
| 336 | } |
| 337 | update(); |
| 338 | } |
| 339 | |
| 340 | ExtrudedTextGeometry::GeometryData ExtrudedTextGeometry::generateExtrudedTextGeometry(const QString &text, |
| 341 | const QFont &font, |
| 342 | float depth, |
| 343 | float scale) |
| 344 | { |
| 345 | GeometryData output; |
| 346 | |
| 347 | struct Vertex { |
| 348 | QVector3D position; |
| 349 | QVector3D normal; |
| 350 | }; |
| 351 | |
| 352 | std::vector<IndexType> indices; |
| 353 | std::vector<Vertex> vertices; |
| 354 | |
| 355 | TriangulationData data = triangulate(text, font, scale); |
| 356 | |
| 357 | const IndexType numVertices = IndexType(data.vertices.size()); |
| 358 | const size_t numIndices = data.indices.size(); |
| 359 | |
| 360 | vertices.reserve(n: data.vertices.size() * 2); |
| 361 | for (QVector3D &v : data.vertices) // front face |
| 362 | vertices.push_back(x: { .position: v, .normal: QVector3D(0.0f, 0.0f, -1.0f) }); |
| 363 | for (QVector3D &v : data.vertices) // back face |
| 364 | vertices.push_back(x: { .position: QVector3D(v.x(), v.y(), depth), .normal: QVector3D(0.0f, 0.0f, 1.0f) }); |
| 365 | |
| 366 | int verticesIndex = int(vertices.size()); |
| 367 | for (size_t i = 0; i < data.outlines.size(); ++i) { |
| 368 | const int begin = data.outlines[i].begin; |
| 369 | const int end = data.outlines[i].end; |
| 370 | const int verticesIndexBegin = verticesIndex; |
| 371 | |
| 372 | if (begin == end) |
| 373 | continue; |
| 374 | |
| 375 | QVector3D prevNormal = QVector3D::crossProduct( |
| 376 | v1: vertices[data.outlineIndices[end - 1] + numVertices].position - vertices[data.outlineIndices[end - 1]].position, |
| 377 | v2: vertices[data.outlineIndices[begin]].position - vertices[data.outlineIndices[end - 1]].position).normalized(); |
| 378 | |
| 379 | for (int j = begin; j < end; ++j) { |
| 380 | const bool isLastIndex = (j == end - 1); |
| 381 | const IndexType cur = data.outlineIndices[j]; |
| 382 | const IndexType next = data.outlineIndices[((j - begin + 1) % (end - begin)) + begin]; // normalize, bring in range and adjust |
| 383 | const QVector3D normal = QVector3D::crossProduct(v1: vertices[cur + numVertices].position - vertices[cur].position, v2: vertices[next].position - vertices[cur].position).normalized(); |
| 384 | |
| 385 | // use smooth normals in case of a short angle |
| 386 | const bool smooth = QVector3D::dotProduct(v1: prevNormal, v2: normal) > (90.0f - edgeSplitAngle) / 90.0f; |
| 387 | const QVector3D resultNormal = smooth ? mix(a: prevNormal, b: normal, ratio: 0.5f) : normal; |
| 388 | if (!smooth) { |
| 389 | vertices.push_back(x: {.position: vertices[cur].position, .normal: prevNormal}); |
| 390 | vertices.push_back(x: {.position: vertices[cur + numVertices].position, .normal: prevNormal}); |
| 391 | verticesIndex += 2; |
| 392 | } |
| 393 | |
| 394 | vertices.push_back(x: {.position: vertices[cur].position, .normal: resultNormal}); |
| 395 | vertices.push_back(x: {.position: vertices[cur + numVertices].position, .normal: resultNormal}); |
| 396 | |
| 397 | const int v0 = verticesIndex; |
| 398 | const int v1 = verticesIndex + 1; |
| 399 | const int v2 = isLastIndex ? verticesIndexBegin : verticesIndex + 2; |
| 400 | const int v3 = isLastIndex ? verticesIndexBegin + 1 : verticesIndex + 3; |
| 401 | |
| 402 | indices.push_back(x: v0); |
| 403 | indices.push_back(x: v1); |
| 404 | indices.push_back(x: v2); |
| 405 | indices.push_back(x: v2); |
| 406 | indices.push_back(x: v1); |
| 407 | indices.push_back(x: v3); |
| 408 | |
| 409 | verticesIndex += 2; |
| 410 | prevNormal = normal; |
| 411 | } |
| 412 | } |
| 413 | |
| 414 | // Indices for the front and back faces |
| 415 | const int indicesOffset = int(indices.size()); |
| 416 | indices.resize(new_size: indices.size() + numIndices * 2); |
| 417 | |
| 418 | // copy values for back faces |
| 419 | IndexType *indicesFaces = indices.data() + indicesOffset; |
| 420 | memcpy(dest: indicesFaces, src: data.indices.data(), n: numIndices * sizeof(IndexType)); |
| 421 | |
| 422 | // insert values for front face and flip triangles |
| 423 | for (size_t j = 0; j < numIndices; j += 3) { |
| 424 | indicesFaces[numIndices + j ] = indicesFaces[j ] + numVertices; |
| 425 | indicesFaces[numIndices + j + 1] = indicesFaces[j + 2] + numVertices; |
| 426 | indicesFaces[numIndices + j + 2] = indicesFaces[j + 1] + numVertices; |
| 427 | } |
| 428 | |
| 429 | for (const auto &vertex : vertices) { |
| 430 | const auto &p = vertex.position; |
| 431 | output.boundsMin = QVector3D(qMin(a: output.boundsMin.x(), b: p.x()), qMin(a: output.boundsMin.y(), b: p.y()), qMin(a: output.boundsMin.z(), b: p.z())); |
| 432 | output.boundsMax = QVector3D(qMax(a: output.boundsMax.x(), b: p.x()), qMax(a: output.boundsMax.y(), b: p.y()), qMax(a: output.boundsMax.z(), b: p.z())); |
| 433 | } |
| 434 | |
| 435 | |
| 436 | output.vertexData.resize(size: vertices.size() * sizeof(Vertex)); |
| 437 | memcpy(dest: output.vertexData.data(), src: vertices.data(), n: vertices.size() * sizeof(Vertex)); |
| 438 | |
| 439 | |
| 440 | output.indexData.resize(size: indices.size() * sizeof(IndexType)); |
| 441 | memcpy(dest: output.indexData.data(), src: indices.data(), n: indices.size() * sizeof(IndexType)); |
| 442 | |
| 443 | |
| 444 | return output; |
| 445 | } |
| 446 | #if QT_CONFIG(concurrent) |
| 447 | void ExtrudedTextGeometry::generateExtrudedTextGeometryAsync(QPromise<GeometryData> &promise, |
| 448 | const QString &text, |
| 449 | const QFont &font, |
| 450 | float depth, |
| 451 | float scale) |
| 452 | { |
| 453 | GeometryData output = generateExtrudedTextGeometry(text, font, depth, scale); |
| 454 | promise.addResult(result&: output); |
| 455 | } |
| 456 | #endif |
| 457 | |
| 458 | QT_END_NAMESPACE |
| 459 | |