VisuTwin Canvas
C++ 3D Engine — Metal Backend
Loading...
Searching...
No Matches
stlParser.cpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2025-2026 Arnis Lektauers
3//
4// STL (Stereolithography) file parser for VisuTwin Canvas.
5//
6// Supports both binary and ASCII STL formats. Binary is auto-detected by
7// validating the file size against the header triangle count formula:
8// fileSize == 80 + 4 + triangleCount * 50
9//
10// Key design decisions:
11// - Reuses GlbContainerResource for unified instantiateRenderEntity() path
12// - No external library — STL binary parsing is ~50 lines of C++
13// - Flat shading by default (face normals); optional crease-angle smooth normals
14// - Always recomputes normals from geometry (many exporters write (0,0,0))
15// - Vertex welding only when smooth normals are requested
16// - Single default PBR material (STL has no material data)
17// - Same 56-byte PackedVertex layout as GlbParser and ObjParser
18//
19// Custom loader (not derived from upstream).
20//
21#include "stlParser.h"
22
23#include <algorithm>
24#include <cmath>
25#include <cstring>
26#include <filesystem>
27#include <fstream>
28#include <limits>
29#include <sstream>
30#include <unordered_map>
31#include <vector>
32
33#include "core/math/vector3.h"
39#include "spdlog/spdlog.h"
40
41namespace visutwin::canvas
42{
43 namespace
44 {
45 // ── Vertex layout (must match GlbParser::PackedVertex and ObjParser) ──
46
47 struct PackedVertex
48 {
49 float px, py, pz; // position
50 float nx, ny, nz; // normal
51 float u, v; // uv0
52 float tx, ty, tz, tw; // tangent + handedness
53 float u1, v1; // uv1
54 };
55
56 static_assert(sizeof(PackedVertex) == 56, "PackedVertex must be 56 bytes (14 floats)");
57
58 // ── Raw triangle as read from binary STL ──────────────────────────
59
60 struct StlTriangle
61 {
62 float nx, ny, nz; // face normal
63 float v0x, v0y, v0z; // vertex 0
64 float v1x, v1y, v1z; // vertex 1
65 float v2x, v2y, v2z; // vertex 2
66 };
67
68 // ── Tangent-from-normal fallback (no UVs in STL) ─────────────────
69
70 void tangentFromNormal(float nx, float ny, float nz,
71 float& tx, float& ty, float& tz, float& tw)
72 {
73 Vector3 n(nx, ny, nz);
74 Vector3 up = std::abs(ny) < 0.999f ? Vector3(0.0f, 1.0f, 0.0f) : Vector3(1.0f, 0.0f, 0.0f);
75 Vector3 t = n.cross(up).normalized();
76 tx = t.getX();
77 ty = t.getY();
78 tz = t.getZ();
79 tw = 1.0f;
80 }
81
82 // ── Compute geometric face normal from 3 vertices ────────────────
83
84 Vector3 computeFaceNormal(float v0x, float v0y, float v0z,
85 float v1x, float v1y, float v1z,
86 float v2x, float v2y, float v2z)
87 {
88 Vector3 e1(v1x - v0x, v1y - v0y, v1z - v0z);
89 Vector3 e2(v2x - v0x, v2y - v0y, v2z - v0z);
90 Vector3 n = e1.cross(e2);
91 float len = n.length();
92 if (len > 1e-8f) {
93 return n * (1.0f / len);
94 }
95 return Vector3(0.0f, 1.0f, 0.0f); // degenerate triangle fallback
96 }
97
98 // ── Binary format detection ──────────────────────────────────────
99
100 bool isBinaryStl(const std::vector<uint8_t>& data)
101 {
102 if (data.size() < 84) return false;
103
104 // Read triangle count from offset 80
105 uint32_t triCount = 0;
106 std::memcpy(&triCount, data.data() + 80, sizeof(uint32_t));
107
108 // Validate file size: 80 header + 4 count + triCount * 50
109 const size_t expectedSize = 80 + 4 + static_cast<size_t>(triCount) * 50;
110 return data.size() == expectedSize;
111 }
112
113 // ── Parse binary STL ─────────────────────────────────────────────
114
115 bool parseBinaryStl(const std::vector<uint8_t>& data,
116 std::vector<StlTriangle>& triangles,
117 std::string& solidName)
118 {
119 if (data.size() < 84) {
120 spdlog::error("STL binary file too small ({} bytes)", data.size());
121 return false;
122 }
123
124 // Extract solid name from header (first 80 bytes, null-terminated or trimmed)
125 solidName.assign(reinterpret_cast<const char*>(data.data()), 80);
126 // Remove trailing nulls and whitespace
127 auto end = solidName.find_last_not_of(std::string("\0 \t\r\n", 5));
128 solidName = (end != std::string::npos) ? solidName.substr(0, end + 1) : "";
129 // Strip "solid " prefix if present
130 if (solidName.size() >= 6 && solidName.substr(0, 6) == "solid ") {
131 solidName = solidName.substr(6);
132 }
133
134 uint32_t triCount = 0;
135 std::memcpy(&triCount, data.data() + 80, sizeof(uint32_t));
136
137 const size_t expectedSize = 80 + 4 + static_cast<size_t>(triCount) * 50;
138 if (data.size() < expectedSize) {
139 spdlog::error("STL binary file truncated: expected {} bytes, got {}", expectedSize, data.size());
140 return false;
141 }
142
143 triangles.resize(triCount);
144 const uint8_t* ptr = data.data() + 84;
145
146 for (uint32_t i = 0; i < triCount; ++i) {
147 // 12 floats = normal(3) + v0(3) + v1(3) + v2(3) = 48 bytes
148 std::memcpy(&triangles[i], ptr, 48);
149 ptr += 50; // 48 data bytes + 2 attribute bytes (skipped)
150 }
151
152 return true;
153 }
154
155 // ── Parse ASCII STL ──────────────────────────────────────────────
156
157 bool parseAsciiStl(const std::vector<uint8_t>& data,
158 std::vector<StlTriangle>& triangles,
159 std::string& solidName)
160 {
161 std::string text(reinterpret_cast<const char*>(data.data()), data.size());
162 std::istringstream stream(text);
163 std::string token;
164
165 // Read "solid <name>"
166 stream >> token; // "solid"
167 if (token != "solid") {
168 spdlog::error("STL ASCII: expected 'solid', got '{}'", token);
169 return false;
170 }
171 std::getline(stream, solidName);
172 // Trim leading/trailing whitespace
173 auto start = solidName.find_first_not_of(" \t\r\n");
174 auto end = solidName.find_last_not_of(" \t\r\n");
175 solidName = (start != std::string::npos) ? solidName.substr(start, end - start + 1) : "";
176
177 while (stream >> token) {
178 if (token == "endsolid") break;
179
180 if (token == "facet") {
181 StlTriangle tri{};
182
183 // "facet normal nx ny nz"
184 stream >> token; // "normal"
185 stream >> tri.nx >> tri.ny >> tri.nz;
186
187 // "outer loop"
188 stream >> token >> token; // "outer" "loop"
189
190 // 3 vertices
191 stream >> token >> tri.v0x >> tri.v0y >> tri.v0z; // "vertex" x y z
192 stream >> token >> tri.v1x >> tri.v1y >> tri.v1z;
193 stream >> token >> tri.v2x >> tri.v2y >> tri.v2z;
194
195 // "endloop"
196 stream >> token;
197
198 // "endfacet"
199 stream >> token;
200
201 triangles.push_back(tri);
202 }
203 }
204
205 return !triangles.empty();
206 }
207
208 // ── Spatial hash key for vertex welding ──────────────────────────
209
210 struct PositionKey
211 {
212 int32_t ix, iy, iz;
213
214 bool operator==(const PositionKey& o) const
215 {
216 return ix == o.ix && iy == o.iy && iz == o.iz;
217 }
218 };
219
220 struct PositionKeyHash
221 {
222 size_t operator()(const PositionKey& k) const
223 {
224 size_t h = std::hash<int32_t>()(k.ix);
225 h ^= std::hash<int32_t>()(k.iy) + 0x9e3779b9 + (h << 6) + (h >> 2);
226 h ^= std::hash<int32_t>()(k.iz) + 0x9e3779b9 + (h << 6) + (h >> 2);
227 return h;
228 }
229 };
230
231 PositionKey quantize(float x, float y, float z, float invEpsilon)
232 {
233 return {
234 static_cast<int32_t>(std::round(x * invEpsilon)),
235 static_cast<int32_t>(std::round(y * invEpsilon)),
236 static_cast<int32_t>(std::round(z * invEpsilon))
237 };
238 }
239
240 // ── Build flat-shaded mesh (no vertex welding) ───────────────────
241
242 void buildFlatMesh(
243 const std::vector<StlTriangle>& triangles,
244 const StlParserConfig& config,
245 std::vector<PackedVertex>& outVertices,
246 std::vector<uint32_t>& outIndices,
247 float& minX, float& minY, float& minZ,
248 float& maxX, float& maxY, float& maxZ)
249 {
250 const size_t triCount = triangles.size();
251 outVertices.reserve(triCount * 3);
252 outIndices.reserve(triCount * 3);
253
254 for (size_t i = 0; i < triCount; ++i) {
255 const auto& tri = triangles[i];
256
257 // Scale vertices
258 float positions[3][3] = {
259 {tri.v0x * config.uniformScale, tri.v0y * config.uniformScale, tri.v0z * config.uniformScale},
260 {tri.v1x * config.uniformScale, tri.v1y * config.uniformScale, tri.v1z * config.uniformScale},
261 {tri.v2x * config.uniformScale, tri.v2y * config.uniformScale, tri.v2z * config.uniformScale}
262 };
263
264 // FlipYZ
265 if (config.flipYZ) {
266 for (auto& p : positions) {
267 std::swap(p[1], p[2]);
268 p[2] = -p[2];
269 }
270 }
271
272 // Recompute face normal from geometry (don't trust stored normal)
273 Vector3 faceN = computeFaceNormal(
274 positions[0][0], positions[0][1], positions[0][2],
275 positions[1][0], positions[1][1], positions[1][2],
276 positions[2][0], positions[2][1], positions[2][2]);
277
278 float fnx = faceN.getX();
279 float fny = faceN.getY();
280 float fnz = faceN.getZ();
281
282 // Tangent from normal
283 float tx, ty, tz, tw;
284 if (config.generateTangents) {
285 tangentFromNormal(fnx, fny, fnz, tx, ty, tz, tw);
286 } else {
287 tx = 1.0f; ty = 0.0f; tz = 0.0f; tw = 1.0f;
288 }
289
290 // Determine triangle vertex order
291 int order[3] = {0, 1, 2};
292 if (config.flipWinding) {
293 std::swap(order[1], order[2]);
294 }
295
296 // Emit 3 vertices with face normal
297 for (int j = 0; j < 3; ++j) {
298 int oi = order[j];
299 PackedVertex vert{};
300 vert.px = positions[oi][0];
301 vert.py = positions[oi][1];
302 vert.pz = positions[oi][2];
303 vert.nx = fnx;
304 vert.ny = fny;
305 vert.nz = fnz;
306 vert.u = 0.0f;
307 vert.v = 0.0f;
308 vert.tx = tx;
309 vert.ty = ty;
310 vert.tz = tz;
311 vert.tw = tw;
312 vert.u1 = 0.0f;
313 vert.v1 = 0.0f;
314
315 auto idx = static_cast<uint32_t>(outVertices.size());
316 outVertices.push_back(vert);
317 outIndices.push_back(idx);
318
319 minX = std::min(minX, vert.px);
320 minY = std::min(minY, vert.py);
321 minZ = std::min(minZ, vert.pz);
322 maxX = std::max(maxX, vert.px);
323 maxY = std::max(maxY, vert.py);
324 maxZ = std::max(maxZ, vert.pz);
325 }
326 }
327 }
328
329 // ── Build smooth-shaded mesh (with vertex welding + crease angle) ──
330
331 void buildSmoothMesh(
332 const std::vector<StlTriangle>& triangles,
333 const StlParserConfig& config,
334 std::vector<PackedVertex>& outVertices,
335 std::vector<uint32_t>& outIndices,
336 float& minX, float& minY, float& minZ,
337 float& maxX, float& maxY, float& maxZ)
338 {
339 const size_t triCount = triangles.size();
340 const float cosCrease = std::cos(config.creaseAngle * 3.14159265358979f / 180.0f);
341
342 // Step 1: Transform all positions and compute face normals
343 struct TransformedTri {
344 float pos[3][3]; // 3 vertices × (x,y,z)
345 Vector3 faceNormal;
346 };
347
348 std::vector<TransformedTri> tris(triCount);
349
350 for (size_t i = 0; i < triCount; ++i) {
351 const auto& src = triangles[i];
352
353 tris[i].pos[0][0] = src.v0x * config.uniformScale;
354 tris[i].pos[0][1] = src.v0y * config.uniformScale;
355 tris[i].pos[0][2] = src.v0z * config.uniformScale;
356 tris[i].pos[1][0] = src.v1x * config.uniformScale;
357 tris[i].pos[1][1] = src.v1y * config.uniformScale;
358 tris[i].pos[1][2] = src.v1z * config.uniformScale;
359 tris[i].pos[2][0] = src.v2x * config.uniformScale;
360 tris[i].pos[2][1] = src.v2y * config.uniformScale;
361 tris[i].pos[2][2] = src.v2z * config.uniformScale;
362
363 if (config.flipYZ) {
364 for (auto& p : tris[i].pos) {
365 std::swap(p[1], p[2]);
366 p[2] = -p[2];
367 }
368 }
369
370 tris[i].faceNormal = computeFaceNormal(
371 tris[i].pos[0][0], tris[i].pos[0][1], tris[i].pos[0][2],
372 tris[i].pos[1][0], tris[i].pos[1][1], tris[i].pos[1][2],
373 tris[i].pos[2][0], tris[i].pos[2][1], tris[i].pos[2][2]);
374 }
375
376 // Step 2: Weld vertices by quantized position
377 // Epsilon: use uniformScale to adapt — if scale is 0.001 (mm→m), positions
378 // are small; if scale is 1.0, positions may be in hundreds.
379 const float epsilon = 1e-5f;
380 const float invEpsilon = 1.0f / epsilon;
381
382 // Map: quantized position → welded vertex index
383 // One welded vertex can appear in many triangles
384 std::unordered_map<PositionKey, uint32_t, PositionKeyHash> weldMap;
385 // Per welded vertex: list of (triIndex, cornerIndex) pairs
386 struct WeldedVertex {
387 float px, py, pz;
388 std::vector<std::pair<size_t, int>> incidents; // (triIdx, corner 0/1/2)
389 };
390 std::vector<WeldedVertex> welded;
391
392 // Per-triangle corner → welded index
393 std::vector<std::array<uint32_t, 3>> triWeldedIdx(triCount);
394
395 for (size_t i = 0; i < triCount; ++i) {
396 for (int c = 0; c < 3; ++c) {
397 float px = tris[i].pos[c][0];
398 float py = tris[i].pos[c][1];
399 float pz = tris[i].pos[c][2];
400
401 PositionKey key = quantize(px, py, pz, invEpsilon);
402 auto it = weldMap.find(key);
403 uint32_t wIdx;
404 if (it != weldMap.end()) {
405 wIdx = it->second;
406 } else {
407 wIdx = static_cast<uint32_t>(welded.size());
408 WeldedVertex wv;
409 wv.px = px; wv.py = py; wv.pz = pz;
410 welded.push_back(std::move(wv));
411 weldMap[key] = wIdx;
412 }
413 welded[wIdx].incidents.emplace_back(i, c);
414 triWeldedIdx[i][c] = wIdx;
415 }
416 }
417
418 spdlog::debug("STL smooth normals: {} triangles, {} raw vertices, {} welded vertices",
419 triCount, triCount * 3, welded.size());
420
421 // Step 3: For each welded vertex, compute smooth normal per crease group.
422 // Group incident triangles: two triangles at the same welded vertex are in the
423 // same smooth group if the angle between their face normals < creaseAngle.
424 // We use a simple greedy approach: for each incident triangle, find a group
425 // whose representative normal has angle < threshold; if none, start a new group.
426
427 // Per-triangle-corner: the smooth normal to use
428 // Indexed as [triIdx * 3 + corner]
429 std::vector<Vector3> cornerNormals(triCount * 3, Vector3(0.0f, 1.0f, 0.0f));
430
431 for (auto& wv : welded) {
432 if (wv.incidents.empty()) continue;
433
434 if (wv.incidents.size() == 1) {
435 // Single triangle — just use face normal
436 auto [ti, ci] = wv.incidents[0];
437 cornerNormals[ti * 3 + ci] = tris[ti].faceNormal;
438 continue;
439 }
440
441 // Group incident triangles by crease angle
442 struct SmoothGroup {
443 Vector3 accumulated{0.0f, 0.0f, 0.0f};
444 std::vector<std::pair<size_t, int>> members;
445 };
446 std::vector<SmoothGroup> groups;
447
448 for (auto [ti, ci] : wv.incidents) {
449 const Vector3& fn = tris[ti].faceNormal;
450 bool assigned = false;
451
452 for (auto& group : groups) {
453 // Compare with the averaged direction of this group
454 Vector3 groupDir = group.accumulated.normalized();
455 float dot = groupDir.dot(fn);
456 if (dot >= cosCrease) {
457 // Area-weighted accumulation (face normal is already unit,
458 // but we keep the magnitude proportional to triangle area
459 // by using the cross product magnitude implicitly)
460 group.accumulated += fn;
461 group.members.emplace_back(ti, ci);
462 assigned = true;
463 break;
464 }
465 }
466
467 if (!assigned) {
468 SmoothGroup newGroup;
469 newGroup.accumulated = fn;
470 newGroup.members.emplace_back(ti, ci);
471 groups.push_back(std::move(newGroup));
472 }
473 }
474
475 // Assign normalized group normal to each member
476 for (auto& group : groups) {
477 Vector3 smoothN = group.accumulated.normalized();
478 for (auto [ti, ci] : group.members) {
479 cornerNormals[ti * 3 + ci] = smoothN;
480 }
481 }
482 }
483
484 // Step 4: Emit vertices with smooth normals. We need to deduplicate
485 // (position, normal) pairs since a welded vertex might have different
486 // normals for different smooth groups.
487
488 struct VertexKey {
489 uint32_t weldedIdx;
490 // Quantized normal to 16-bit for dedup
491 int16_t qnx, qny, qnz;
492
493 bool operator==(const VertexKey& o) const {
494 return weldedIdx == o.weldedIdx &&
495 qnx == o.qnx && qny == o.qny && qnz == o.qnz;
496 }
497 };
498
499 struct VertexKeyHash {
500 size_t operator()(const VertexKey& k) const {
501 size_t h = std::hash<uint32_t>()(k.weldedIdx);
502 h ^= std::hash<int16_t>()(k.qnx) + 0x9e3779b9 + (h << 6) + (h >> 2);
503 h ^= std::hash<int16_t>()(k.qny) + 0x9e3779b9 + (h << 6) + (h >> 2);
504 h ^= std::hash<int16_t>()(k.qnz) + 0x9e3779b9 + (h << 6) + (h >> 2);
505 return h;
506 }
507 };
508
509 auto quantizeNormal = [](float v) -> int16_t {
510 return static_cast<int16_t>(std::round(v * 32767.0f));
511 };
512
513 std::unordered_map<VertexKey, uint32_t, VertexKeyHash> vertexMap;
514 outVertices.reserve(welded.size());
515 outIndices.reserve(triCount * 3);
516
517 for (size_t i = 0; i < triCount; ++i) {
518 int order[3] = {0, 1, 2};
519 if (config.flipWinding) {
520 std::swap(order[1], order[2]);
521 }
522
523 for (int j = 0; j < 3; ++j) {
524 int c = order[j];
525 uint32_t wIdx = triWeldedIdx[i][c];
526 const Vector3& n = cornerNormals[i * 3 + c];
527
528 VertexKey key{wIdx,
529 quantizeNormal(n.getX()),
530 quantizeNormal(n.getY()),
531 quantizeNormal(n.getZ())};
532
533 auto it = vertexMap.find(key);
534 if (it != vertexMap.end()) {
535 outIndices.push_back(it->second);
536 } else {
537 auto idx = static_cast<uint32_t>(outVertices.size());
538
539 float nx = n.getX(), ny = n.getY(), nz = n.getZ();
540 float tx, ty, tz, tw;
541 if (config.generateTangents) {
542 tangentFromNormal(nx, ny, nz, tx, ty, tz, tw);
543 } else {
544 tx = 1.0f; ty = 0.0f; tz = 0.0f; tw = 1.0f;
545 }
546
547 const auto& wv = welded[wIdx];
548
549 PackedVertex vert{};
550 vert.px = wv.px; vert.py = wv.py; vert.pz = wv.pz;
551 vert.nx = nx; vert.ny = ny; vert.nz = nz;
552 vert.u = 0.0f; vert.v = 0.0f;
553 vert.tx = tx; vert.ty = ty; vert.tz = tz; vert.tw = tw;
554 vert.u1 = 0.0f; vert.v1 = 0.0f;
555
556 outVertices.push_back(vert);
557 vertexMap[key] = idx;
558 outIndices.push_back(idx);
559
560 minX = std::min(minX, vert.px);
561 minY = std::min(minY, vert.py);
562 minZ = std::min(minZ, vert.pz);
563 maxX = std::max(maxX, vert.px);
564 maxY = std::max(maxY, vert.py);
565 maxZ = std::max(maxZ, vert.pz);
566 }
567 }
568 }
569 }
570
571 } // anonymous namespace
572
573 // ── StlParser::parse ─────────────────────────────────────────────────
574
575 std::unique_ptr<GlbContainerResource> StlParser::parse(
576 const std::string& path,
577 const std::shared_ptr<GraphicsDevice>& device,
578 const StlParserConfig& config)
579 {
580 if (!device) {
581 spdlog::error("STL parse failed: graphics device is null");
582 return nullptr;
583 }
584
585 // Read entire file into memory
586 std::ifstream file(path, std::ios::binary | std::ios::ate);
587 if (!file.is_open()) {
588 spdlog::error("STL parse failed: cannot open '{}'", path);
589 return nullptr;
590 }
591
592 const auto fileSize = file.tellg();
593 if (fileSize <= 0) {
594 spdlog::error("STL parse failed: empty file '{}'", path);
595 return nullptr;
596 }
597
598 std::vector<uint8_t> data(static_cast<size_t>(fileSize));
599 file.seekg(0);
600 file.read(reinterpret_cast<char*>(data.data()), fileSize);
601 file.close();
602
603 // Parse triangles (auto-detect binary vs ASCII)
604 std::vector<StlTriangle> triangles;
605 std::string solidName;
606
607 if (isBinaryStl(data)) {
608 if (!parseBinaryStl(data, triangles, solidName)) {
609 spdlog::error("STL binary parse failed: '{}'", path);
610 return nullptr;
611 }
612 } else {
613 if (!parseAsciiStl(data, triangles, solidName)) {
614 spdlog::error("STL ASCII parse failed: '{}'", path);
615 return nullptr;
616 }
617 }
618
619 if (triangles.empty()) {
620 spdlog::warn("STL file has no triangles: '{}'", path);
621 return nullptr;
622 }
623
624 spdlog::info("STL loaded [{}]: {} triangles, format={}, solid='{}'",
625 path, triangles.size(),
626 isBinaryStl(data) ? "binary" : "ASCII",
627 solidName);
628
629 // Build vertex/index data
630 std::vector<PackedVertex> vertices;
631 std::vector<uint32_t> indices;
632 float minX = std::numeric_limits<float>::max();
633 float minY = std::numeric_limits<float>::max();
634 float minZ = std::numeric_limits<float>::max();
635 float maxX = std::numeric_limits<float>::lowest();
636 float maxY = std::numeric_limits<float>::lowest();
637 float maxZ = std::numeric_limits<float>::lowest();
638
639 if (config.generateSmoothNormals) {
640 buildSmoothMesh(triangles, config, vertices, indices,
641 minX, minY, minZ, maxX, maxY, maxZ);
642 } else {
643 buildFlatMesh(triangles, config, vertices, indices,
644 minX, minY, minZ, maxX, maxY, maxZ);
645 }
646
647 if (vertices.empty()) {
648 spdlog::error("STL parse produced no vertices: '{}'", path);
649 return nullptr;
650 }
651
652 spdlog::info("STL mesh [{}]: {} vertices, {} indices ({}x reduction from raw)",
653 path, vertices.size(), indices.size(),
654 static_cast<float>(triangles.size() * 3) / static_cast<float>(vertices.size()));
655
656 // ── Create GPU buffers ───────────────────────────────────────────
657
658 constexpr int BYTES_PER_VERTEX = static_cast<int>(sizeof(PackedVertex));
659 auto vertexFormat = std::make_shared<VertexFormat>(BYTES_PER_VERTEX, true, false);
660
661 const int vertexCount = static_cast<int>(vertices.size());
662 std::vector<uint8_t> vertexBytes(vertices.size() * sizeof(PackedVertex));
663 std::memcpy(vertexBytes.data(), vertices.data(), vertexBytes.size());
664
665 VertexBufferOptions vbOpts;
666 vbOpts.usage = BUFFER_STATIC;
667 vbOpts.data = std::move(vertexBytes);
668 auto vb = device->createVertexBuffer(vertexFormat, vertexCount, vbOpts);
669
670 const int indexCount = static_cast<int>(indices.size());
671 IndexFormat idxFmt = (vertexCount <= 65535) ? INDEXFORMAT_UINT16 : INDEXFORMAT_UINT32;
672
673 std::vector<uint8_t> indexBytes;
674 if (idxFmt == INDEXFORMAT_UINT16) {
675 indexBytes.resize(indices.size() * sizeof(uint16_t));
676 auto* dst = reinterpret_cast<uint16_t*>(indexBytes.data());
677 for (size_t i = 0; i < indices.size(); ++i) {
678 dst[i] = static_cast<uint16_t>(indices[i]);
679 }
680 } else {
681 indexBytes.resize(indices.size() * sizeof(uint32_t));
682 std::memcpy(indexBytes.data(), indices.data(), indexBytes.size());
683 }
684 auto ib = device->createIndexBuffer(idxFmt, indexCount, indexBytes);
685
686 // ── Assemble Mesh ────────────────────────────────────────────────
687
688 auto meshResource = std::make_shared<Mesh>();
689 meshResource->setVertexBuffer(vb);
690 meshResource->setIndexBuffer(ib, 0);
691
692 Primitive prim;
694 prim.base = 0;
695 prim.baseVertex = 0;
696 prim.count = indexCount;
697 prim.indexed = true;
698 meshResource->setPrimitive(prim, 0);
699
700 BoundingBox bounds;
701 bounds.setCenter(
702 (minX + maxX) * 0.5f,
703 (minY + maxY) * 0.5f,
704 (minZ + maxZ) * 0.5f);
705 bounds.setHalfExtents(
706 (maxX - minX) * 0.5f,
707 (maxY - minY) * 0.5f,
708 (maxZ - minZ) * 0.5f);
709 meshResource->setAabb(bounds);
710
711 // ── Create default PBR material ──────────────────────────────────
712
713 auto material = std::make_shared<StandardMaterial>();
714 material->setName(solidName.empty() ? "stl-default" : solidName);
715 material->setDiffuse(Color(config.diffuseR, config.diffuseG, config.diffuseB, 1.0f));
716 material->setBaseColorFactor(Color(config.diffuseR, config.diffuseG, config.diffuseB, 1.0f));
717 material->setMetalness(config.metalness);
718 material->setMetallicFactor(config.metalness);
719 material->setGloss(1.0f - config.roughness);
720 material->setRoughnessFactor(config.roughness);
721 material->setUseMetalness(true);
722 material->setCullMode(CullMode::CULLFACE_BACK);
723
724 // ── Package into GlbContainerResource ────────────────────────────
725
726 auto container = std::make_unique<GlbContainerResource>();
727
728 GlbMeshPayload payload;
729 payload.mesh = meshResource;
730 payload.material = material;
731 container->addMeshPayload(payload);
732
733 // STL is always a single flat mesh — one root node
734 GlbNodePayload rootNode;
735 rootNode.name = solidName.empty()
736 ? std::filesystem::path(path).stem().string()
737 : solidName;
738 rootNode.scale = Vector3(1.0f, 1.0f, 1.0f);
739 rootNode.meshPayloadIndices.push_back(0);
740 container->addNodePayload(rootNode);
741 container->addRootNodeIndex(0);
742
743 spdlog::info("STL parse complete [{}]: {} vertices, {} indices, bounds=[{:.3f},{:.3f},{:.3f}]-[{:.3f},{:.3f},{:.3f}]",
744 path, vertices.size(), indices.size(),
745 minX, minY, minZ, maxX, maxY, maxZ);
746
747 return container;
748 }
749
750} // namespace visutwin::canvas
Axis-Aligned Bounding Box defined by center and half-extents.
Definition boundingBox.h:21
void setHalfExtents(const Vector3 &halfExtents)
Definition boundingBox.h:36
void setCenter(const Vector3 &center)
Definition boundingBox.h:28
static std::unique_ptr< GlbContainerResource > parse(const std::string &path, const std::shared_ptr< GraphicsDevice > &device, const StlParserConfig &config=StlParserConfig{})
@ PRIMITIVE_TRIANGLES
Definition mesh.h:23
RGBA color with floating-point components in [0, 1].
Definition color.h:18
std::shared_ptr< Material > material
Describes how vertex and index data should be interpreted for a draw call.
Definition mesh.h:33
PrimitiveType type
Definition mesh.h:34
Configuration options for STL loading.
Definition stlParser.h:30
float diffuseR
Default material diffuse color (STL has no material data).
Definition stlParser.h:54
float metalness
Default material metalness (0 = dielectric, 1 = metal).
Definition stlParser.h:59
float roughness
Default material roughness (0 = mirror, 1 = matte).
Definition stlParser.h:62
3D vector for positions, directions, and normals with multi-backend SIMD acceleration.
Definition vector3.h:29
Vector3 cross(const Vector3 &other) const
Vector3 normalized() const
float dot(const Vector3 &other) const
float length() const
Definition vector3.h:224