VisuTwin Canvas
C++ 3D Engine — Metal Backend
Loading...
Searching...
No Matches
elementInput.cpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2025-2026 Arnis Lektauers
3//
4// Created by Arnis Lektauers on 13.10.2025.
5//
6#include "elementInput.h"
7
8#include <algorithm>
9#include <array>
10#include <cmath>
11#include <cstdint>
12#include <cstring>
13#include <sstream>
14#include <string>
15#include <unordered_set>
16#include <vector>
17
18#include <SDL3/SDL.h>
19#include <spdlog/spdlog.h>
20
26#include "framework/engine.h"
27#include "framework/entity.h"
34#include "scene/mesh.h"
35#include "scene/meshInstance.h"
36#include "scene/constants.h"
37
38namespace visutwin::canvas
39{
40 namespace
41 {
42 struct GlyphQuad
43 {
44 float x0 = 0.0f;
45 float y0 = 0.0f;
46 float x1 = 0.0f;
47 float y1 = 0.0f;
48 float u0 = 0.0f;
49 float v0 = 0.0f;
50 float u1 = 0.0f;
51 float v1 = 0.0f;
52 };
53
54 std::vector<std::string> splitLines(const std::string& text)
55 {
56 std::vector<std::string> lines;
57 std::stringstream ss(text);
58 std::string line;
59 while (std::getline(ss, line, '\n')) {
60 lines.push_back(line);
61 }
62 if (text.empty() || text.back() == '\n') {
63 lines.push_back("");
64 }
65 return lines;
66 }
67
68 float lineWidthForText(const std::string& text, const FontResource* font, const float scale)
69 {
70 if (!font || text.empty()) {
71 return 0.0f;
72 }
73
74 float width = 0.0f;
75 int prev = -1;
76 for (char c : text) {
77 const int code = static_cast<unsigned char>(c);
78 const auto it = font->glyphs.find(code);
79 const float advance = it != font->glyphs.end() ? it->second.xadvance * scale : (font->lineHeight * 0.35f * scale);
80 const float kern = prev >= 0 ? font->kerningValue(prev, code) * scale : 0.0f;
81 width += kern + advance;
82 prev = code;
83 }
84 return width;
85 }
86
87 std::vector<std::string> wrapLine(const std::string& line, const FontResource* font, const float scale, const float maxWidth)
88 {
89 if (!font || maxWidth <= 0.0f || line.empty()) {
90 return {line};
91 }
92
93 std::vector<std::string> out;
94 std::string current;
95 size_t i = 0;
96 while (i < line.size()) {
97 size_t j = i;
98 while (j < line.size() && line[j] != ' ') {
99 ++j;
100 }
101 const std::string word = line.substr(i, j - i);
102 const bool hasSpace = (j < line.size() && line[j] == ' ');
103 const std::string token = hasSpace ? (word + " ") : word;
104
105 const std::string candidate = current + token;
106 if (!current.empty() && lineWidthForText(candidate, font, scale) > maxWidth) {
107 out.push_back(current);
108 current.clear();
109 }
110
111 if (current.empty() && lineWidthForText(token, font, scale) > maxWidth) {
112 // Fallback to per-character split for very long words.
113 std::string part;
114 for (char c : token) {
115 const std::string next = part + c;
116 if (!part.empty() && lineWidthForText(next, font, scale) > maxWidth) {
117 out.push_back(part);
118 part.clear();
119 }
120 part.push_back(c);
121 }
122 current += part;
123 } else {
124 current += token;
125 }
126
127 i = hasSpace ? (j + 1) : j;
128 }
129
130 if (!current.empty()) {
131 out.push_back(current);
132 }
133 return out;
134 }
135
136 std::shared_ptr<Mesh> buildTextMesh(const std::shared_ptr<GraphicsDevice>& gd, const ElementComponent* element)
137 {
138 if (!gd || !element || !element->fontResource() || element->text().empty()) {
139 return nullptr;
140 }
141
142 const FontResource* font = element->fontResource();
143 const float lineHeight = std::max(font->lineHeight, 1.0f);
144 const float scale = static_cast<float>(element->fontSize()) / lineHeight;
145
146 std::vector<std::string> lines = splitLines(element->text());
147 if (element->wrapLines()) {
148 std::vector<std::string> wrapped;
149 wrapped.reserve(lines.size());
150 for (const auto& l : lines) {
151 auto sub = wrapLine(l, font, scale, element->width());
152 wrapped.insert(wrapped.end(), sub.begin(), sub.end());
153 }
154 lines = std::move(wrapped);
155 }
156
157 std::vector<float> vertices;
158 std::vector<uint32_t> indices;
159 vertices.reserve(lines.size() * 64u * 14u);
160 indices.reserve(lines.size() * 64u * 6u);
161
162 const float boxW = element->width();
163 const float boxH = element->height();
164 const Vector2 pivot = element->pivot();
165 const float lineStep = lineHeight * scale;
166
167 float yTop = (1.0f - pivot.y) * boxH;
168 uint32_t vbase = 0;
169
170 for (size_t li = 0; li < lines.size(); ++li) {
171 const std::string& line = lines[li];
172
173 float lineWidth = 0.0f;
174 int prevForWidth = -1;
175 for (char c : line) {
176 const int code = static_cast<unsigned char>(c);
177 if (const auto it = font->glyphs.find(code); it != font->glyphs.end()) {
178 lineWidth += (prevForWidth >= 0 ? font->kerningValue(prevForWidth, code) * scale : 0.0f);
179 lineWidth += it->second.xadvance * scale;
180 prevForWidth = code;
181 }
182 }
183
184 float x = -pivot.x * boxW;
185 if (element->horizontalAlign() == ElementHorizontalAlign::Center) {
186 x += (boxW - lineWidth) * 0.5f;
187 } else if (element->horizontalAlign() == ElementHorizontalAlign::Right) {
188 x += (boxW - lineWidth);
189 }
190
191 int prev = -1;
192 for (char c : line) {
193 const int code = static_cast<unsigned char>(c);
194 const auto gIt = font->glyphs.find(code);
195 if (gIt == font->glyphs.end()) {
196 x += lineHeight * 0.35f * scale;
197 prev = code;
198 continue;
199 }
200
201 const FontGlyph& g = gIt->second;
202 x += (prev >= 0 ? font->kerningValue(prev, code) * scale : 0.0f);
203
204 const float gx0 = x + g.xoffset * scale;
205 const float gyTop = yTop - static_cast<float>(li) * lineStep - g.yoffset * scale;
206 const float gx1 = gx0 + g.width * scale;
207 const float gyBot = gyTop - g.height * scale;
208
209 const float atlasW = static_cast<float>(std::max(font->atlasWidth, 1));
210 const float atlasH = static_cast<float>(std::max(font->atlasHeight, 1));
211 const float u0 = g.x / atlasW;
212 const float u1 = (g.x + g.width) / atlasW;
213 // Use native texture-space orientation for this backend.
214 const float v0 = g.y / atlasH;
215 const float v1 = (g.y + g.height) / atlasH;
216
217 // position(3) normal(3) uv0(2) tangent(4) uv1(2)
218 const std::array<float, 56> quadVerts = {
219 gx0, gyTop, 0.0f, 0.0f, 0.0f, 1.0f, u0, v0, 1.0f,0.0f,0.0f,1.0f, u0, v0,
220 gx1, gyTop, 0.0f, 0.0f, 0.0f, 1.0f, u1, v0, 1.0f,0.0f,0.0f,1.0f, u1, v0,
221 gx1, gyBot, 0.0f, 0.0f, 0.0f, 1.0f, u1, v1, 1.0f,0.0f,0.0f,1.0f, u1, v1,
222 gx0, gyBot, 0.0f, 0.0f, 0.0f, 1.0f, u0, v1, 1.0f,0.0f,0.0f,1.0f, u0, v1
223 };
224 vertices.insert(vertices.end(), quadVerts.begin(), quadVerts.end());
225 // Use front-facing winding for UI camera (+Z looking toward origin).
226 indices.insert(indices.end(), {vbase + 0u, vbase + 2u, vbase + 1u, vbase + 0u, vbase + 3u, vbase + 2u});
227 vbase += 4u;
228
229 x += g.xadvance * scale;
230 prev = code;
231 }
232 }
233
234 if (vertices.empty() || indices.empty()) {
235 return nullptr;
236 }
237
238 const int vertexCount = static_cast<int>(vertices.size() / 14u);
239 std::vector<uint8_t> vbData(vertices.size() * sizeof(float));
240 std::memcpy(vbData.data(), vertices.data(), vbData.size());
241 VertexBufferOptions vbOpts;
242 vbOpts.data = std::move(vbData);
243 auto vertexFormat = std::make_shared<VertexFormat>(14 * static_cast<int>(sizeof(float)), true, false);
244 auto vb = gd->createVertexBuffer(vertexFormat, vertexCount, vbOpts);
245
246 std::vector<uint8_t> ibData(indices.size() * sizeof(uint32_t));
247 std::memcpy(ibData.data(), indices.data(), ibData.size());
248 auto ib = gd->createIndexBuffer(INDEXFORMAT_UINT32, static_cast<int>(indices.size()), ibData);
249
250 auto mesh = std::make_shared<Mesh>();
251 mesh->setVertexBuffer(vb);
252 mesh->setIndexBuffer(ib, 0);
253 Primitive prim;
255 prim.base = 0;
256 prim.count = static_cast<int>(indices.size());
257 prim.indexed = true;
258 mesh->setPrimitive(prim, 0);
259
260 BoundingBox bounds;
261 bounds.setCenter(Vector3(0.0f, 0.0f, 0.0f));
262 bounds.setHalfExtents(Vector3(std::max(boxW * 0.5f, 1.0f), std::max(boxH * 0.5f, 1.0f), 1.0f));
263 mesh->setAabb(bounds);
264 return mesh;
265 }
266 }
267
269 {
270 for (auto& [_, visual] : _textVisuals) {
271 if (visual.entity) {
272 visual.entity->remove();
273 }
274 }
275 _textVisuals.clear();
276 _engine.reset();
277 _sdlRenderer = nullptr;
278 }
279
280 bool ElementInput::computeElementRect(const ElementComponent* element, SDL_FRect& outRect) const
281 {
282 if (!element || !element->entity() || !element->enabled() || !element->entity()->enabled()) {
283 return false;
284 }
285
286 const Vector3 pos = element->entity()->position();
287 const float width = std::max(element->width(), 0.0f);
288 const float height = std::max(element->height(), 0.0f);
289 const Vector2 pivot = element->pivot();
290
291 outRect.x = pos.getX() - pivot.x * width;
292 outRect.y = pos.getY() - pivot.y * height;
293 outRect.w = width;
294 outRect.h = height;
295 return outRect.w > 0.0f && outRect.h > 0.0f;
296 }
297
298 bool ElementInput::handleMouseButtonDown(const float x, const float y)
299 {
300 // Front-most element wins.
301 const auto& elements = ElementComponent::instances();
302 for (auto it = elements.rbegin(); it != elements.rend(); ++it) {
303 auto* element = *it;
304 if (!element || !element->useInput() || !element->entity()) {
305 continue;
306 }
307
308 SDL_FRect rect{};
309 if (!computeElementRect(element, rect)) {
310 continue;
311 }
312
313 if (x < rect.x || y < rect.y || x > rect.x + rect.w || y > rect.y + rect.h) {
314 continue;
315 }
316
317 // element receives click first, then button behavior.
318 element->fire("click", x, y);
319 if (auto* button = element->entity()->findComponent<ButtonComponent>()) {
320 button->fire("click", x, y);
321 }
322 return true;
323 }
324 return false;
325 }
326
328 {
329 if (!_sdlRenderer) {
330 return;
331 }
332
333 int windowW = 1;
334 int windowH = 1;
335 if (_engine && _engine->sdlWindow()) {
336 SDL_GetWindowSize(_engine->sdlWindow(), &windowW, &windowH);
337 }
338
339 for (auto* screen : ScreenComponent::instances()) {
340 if (screen && screen->enabled()) {
341 screen->updateScaleFromWindow(windowW, windowH);
342 }
343 }
344
345 SDL_SetRenderDrawBlendMode(_sdlRenderer, SDL_BLENDMODE_BLEND);
346
347 for (auto* element : ElementComponent::instances()) {
348 if (!element || !element->entity() || !element->enabled() || !element->entity()->enabled()) {
349 continue;
350 }
351
352 SDL_FRect rect{};
353 if (!computeElementRect(element, rect)) {
354 continue;
355 }
356
357 const Color c = element->color();
358 const int alpha = static_cast<int>(std::round(std::clamp(element->opacity() * c.a, 0.0f, 1.0f) * 255.0f));
359 const Uint8 r = static_cast<Uint8>(std::round(std::clamp(c.r, 0.0f, 1.0f) * 255.0f));
360 const Uint8 g = static_cast<Uint8>(std::round(std::clamp(c.g, 0.0f, 1.0f) * 255.0f));
361 const Uint8 b = static_cast<Uint8>(std::round(std::clamp(c.b, 0.0f, 1.0f) * 255.0f));
362 const Uint8 a = static_cast<Uint8>(std::clamp(alpha, 0, 255));
363
364 if (element->type() == ElementType::Image) {
365 SDL_SetRenderDrawColor(_sdlRenderer, r, g, b, a);
366 SDL_RenderFillRect(_sdlRenderer, &rect);
367 }
368 }
369 }
370
372 {
373 if (!_engine || !_engine->graphicsDevice()) {
374 return;
375 }
376
377 int windowW = 1;
378 int windowH = 1;
379 if (_engine->sdlWindow()) {
380 SDL_GetWindowSize(_engine->sdlWindow(), &windowW, &windowH);
381 }
382 float uiWidth = static_cast<float>(std::max(windowW, 1));
383 float uiHeight = static_cast<float>(std::max(windowH, 1));
384 for (auto* screen : ScreenComponent::instances()) {
385 if (!screen || !screen->enabled() || !screen->screenSpace()) {
386 continue;
387 }
388 const float scale = std::max(screen->scale(), 1e-6f);
389 uiWidth = screen->resolution().x / scale;
390 uiHeight = screen->resolution().y / scale;
391 break;
392 }
393
394 for (auto& [_, visual] : _textVisuals) {
395 visual.activeFrame = false;
396 }
397
398 for (auto* element : ElementComponent::instances()) {
399 if (!element || element->type() != ElementType::Text || !element->entity()) {
400 continue;
401 }
402 if (!element->fontResource() || !element->fontResource()->texture) {
403 continue;
404 }
405
406 auto& visual = _textVisuals[element];
407 visual.activeFrame = true;
408
409 if (!visual.entity) {
410 visual.entity = new Entity();
411 visual.entity->setEngine(_engine.get());
412 visual.entity->setLocalPosition(0.0f, 0.0f, 5.0f);
413 visual.render = static_cast<RenderComponent*>(visual.entity->addComponent<RenderComponent>());
414 if (visual.render) {
415 visual.render->setLayers({LAYERID_UI});
416 }
417 visual.material = std::make_shared<StandardMaterial>();
418 visual.material->setUseLighting(false);
419 visual.material->setUseSkybox(false);
420 visual.material->setTransparent(true);
421 visual.material->setCullMode(CullMode::CULLFACE_NONE);
422 visual.material->setDiffuse(Color(1.0f, 1.0f, 1.0f, 1.0f));
423 visual.material->setEmissive(Color(1.0f, 1.0f, 1.0f, 1.0f));
424 auto alphaBlend = std::make_shared<BlendState>(BlendState::alphaBlend());
425 visual.material->setBlendState(alphaBlend);
426 auto textDepth = std::make_shared<DepthState>(DepthState::noWrite());
427 textDepth->setDepthTest(false);
428 visual.material->setDepthState(textDepth);
429 visual.material->setDiffuseMap(element->fontResource()->texture);
430 visual.material->setOpacityMap(element->fontResource()->texture);
431 if (visual.render) {
432 visual.render->setMaterial(visual.material.get());
433 }
434 _engine->root()->addChild(visual.entity);
435 }
436
437 const bool needsRebuild = element->textDirty() ||
438 visual.cachedText != element->text() ||
439 visual.cachedFontSize != element->fontSize() ||
440 visual.cachedAlign != element->horizontalAlign() ||
441 visual.cachedWrap != element->wrapLines() ||
442 visual.cachedFont != element->fontResource() ||
443 std::abs(visual.cachedPivot.x - element->pivot().x) > 1e-4f ||
444 std::abs(visual.cachedPivot.y - element->pivot().y) > 1e-4f ||
445 std::abs(visual.cachedWidth - element->width()) > 1e-4f ||
446 std::abs(visual.cachedHeight - element->height()) > 1e-4f;
447
448 if (needsRebuild) {
449 visual.mesh = buildTextMesh(_engine->graphicsDevice(), element);
450 if (visual.render) {
451 auto& mi = visual.render->meshInstances();
452 for (auto* entry : mi) {
453 delete entry;
454 }
455 mi.clear();
456 if (visual.mesh) {
457 visual.material->setDiffuseMap(element->fontResource()->texture);
458 visual.material->setOpacityMap(element->fontResource()->texture);
459 mi.push_back(new MeshInstance(visual.mesh.get(), visual.material.get(), visual.entity));
460 }
461 }
462 visual.cachedText = element->text();
463 visual.cachedFontSize = element->fontSize();
464 visual.cachedWidth = element->width();
465 visual.cachedHeight = element->height();
466 visual.cachedPivot = element->pivot();
467 visual.cachedAlign = element->horizontalAlign();
468 visual.cachedWrap = element->wrapLines();
469 visual.cachedFont = element->fontResource();
470 element->clearTextDirty();
471 }
472
473 static std::unordered_set<const ElementComponent*> logged;
474 if (logged.find(element) == logged.end()) {
475 const Vector3 pos = element->entity()->position();
476 spdlog::info("UI text element '{}': glyphs={}, meshBuilt={}, size=({}, {}), worldPos=({}, {}, {})",
477 element->text(),
478 element->fontResource()->glyphs.size(),
479 visual.mesh ? "true" : "false",
480 element->width(),
481 element->height(),
482 pos.getX(),
483 pos.getY(),
484 pos.getZ());
485 logged.insert(element);
486 }
487
488 const Color c = element->color();
489 visual.material->setDiffuse(c);
490 visual.material->setEmissive(c);
491 visual.material->setOpacity(element->opacity());
492
493 if (visual.entity) {
494 const Vector3 pos = element->entity()->position();
495 const float worldX = pos.getX() - uiWidth * 0.5f;
496 const float worldY = uiHeight * 0.5f - pos.getY();
497 visual.entity->setLocalPosition(worldX, worldY, 5.0f);
498 visual.entity->setEnabled(element->enabled() && element->entity()->enabled());
499 }
500 }
501
502 std::vector<ElementComponent*> toRemove;
503 toRemove.reserve(_textVisuals.size());
504 for (auto& [element, visual] : _textVisuals) {
505 if (!visual.activeFrame) {
506 if (visual.entity) {
507 visual.entity->remove();
508 }
509 toRemove.push_back(element);
510 }
511 }
512 for (auto* element : toRemove) {
513 _textVisuals.erase(element);
514 }
515 }
516}
static BlendState alphaBlend()
Axis-Aligned Bounding Box defined by center and half-extents.
Definition boundingBox.h:21
void setCenter(const Vector3 &center)
Definition boundingBox.h:28
Entity * entity() const
Definition component.cpp:16
virtual bool enabled() const
Definition component.h:49
static DepthState noWrite()
Definition depthState.h:46
FontResource * fontResource() const
static const std::vector< ElementComponent * > & instances()
ElementHorizontalAlign horizontalAlign() const
const std::string & text() const
bool handleMouseButtonDown(float x, float y)
ECS entity — a GraphNode that hosts components defining its behavior.
Definition entity.h:32
EventHandler * fire(const std::string &name, Args &&... args)
Renderable instance of a Mesh with its own material, transform node, and optional GPU instancing.
static const std::vector< ScreenComponent * > & instances()
@ PRIMITIVE_TRIANGLES
Definition mesh.h:23
constexpr int LAYERID_UI
Definition constants.h:24
RGBA color with floating-point components in [0, 1].
Definition color.h:18
std::unordered_map< int, FontGlyph > glyphs
Describes how vertex and index data should be interpreted for a draw call.
Definition mesh.h:33
PrimitiveType type
Definition mesh.h:34
2D vector for UV coordinates, screen positions, and 2D math.
Definition vector2.h:18
3D vector for positions, directions, and normals with multi-backend SIMD acceleration.
Definition vector3.h:29