From a2124794240663e86dd9c0119d59de157fcb96ce Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 6 May 2026 13:49:02 -0700 Subject: [PATCH] feat(editor): add --import-stl to round-trip STL back into WOM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the WOM <-> STL bridge for the fabrication ecosystem (Cura, PrusaSlicer, TinkerCAD, Meshmixer, SolidWorks, Fusion 360). Designers can now edit prints in any CAD/3D-print tool and bring them back to the engine: asset_extract # M2 -> WOM (open binary) --export-stl # WOM -> STL (universal fabrication format) ... edit in TinkerCAD / sculpt in Meshmixer ... --import-stl # STL -> WOM (back to engine format) --validate-wom # confirm consistency Parser handles ASCII STL only (binary STL is a follow-up): - 'solid ' header — preserves model name - 'facet normal nx ny nz' — sets the face normal for following verts - 'vertex x y z' — accumulates 3 per facet - 'endfacet' — emits the triangle with the face normal applied to all 3 verts (STL doesn't carry per-vertex normals, so the round trip is faceted-shading by design) - Dedupes on (pos, normal) so shared vertices on the same face merge (a 4-vert square base with 2 tris collapses to 4 verts), but verts shared across faces with different normals stay distinct (correct for faceted geometry) - Computes bounds from positions for renderer culling Round-trip cost: a 5-vert/6-tri pyramid round-trips to 16 verts/6 tris. The vertex inflation is structural (STL faceted-shading semantics) — geometry preservation is exact. Verified via WOM -> STL -> WOM -> --validate-wom (PASSED, bounds and tri count match exactly). --- tools/editor/main.cpp | 126 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 125 insertions(+), 1 deletion(-) diff --git a/tools/editor/main.cpp b/tools/editor/main.cpp index a8ed7e0c..7be35d7e 100644 --- a/tools/editor/main.cpp +++ b/tools/editor/main.cpp @@ -476,6 +476,8 @@ static void printUsage(const char* argv0) { std::printf(" Convert a WOM model to glTF 2.0 binary (.glb) — modern industry standard\n"); std::printf(" --export-stl [out.stl]\n"); std::printf(" Convert a WOM model to ASCII STL — works with any 3D printer slicer\n"); + std::printf(" --import-stl [wom-base]\n"); + std::printf(" Convert an ASCII STL back into WOM (round-trips with --export-stl)\n"); std::printf(" --export-wob-glb [out.glb]\n"); std::printf(" Convert a WOB building to glTF 2.0 binary (one mesh, per-group primitives)\n"); std::printf(" --export-whm-glb [out.glb]\n"); @@ -625,7 +627,7 @@ int main(int argc, char* argv[]) { "--export-wob-obj", "--import-wob-obj", "--export-woc-obj", "--export-whm-obj", "--export-glb", "--export-wob-glb", "--export-whm-glb", - "--export-stl", + "--export-stl", "--import-stl", "--convert-m2", "--convert-wmo", "--convert-dbc-json", "--convert-json-dbc", "--convert-blp-png", "--migrate-wom", "--migrate-zone", @@ -4184,6 +4186,128 @@ int main(int argc, char* argv[]) { std::printf(" solid '%s', %u facets\n", solidName.c_str(), triCount); return 0; + } else if (std::strcmp(argv[i], "--import-stl") == 0 && i + 1 < argc) { + // ASCII STL -> WOM. Closes the STL round trip so designers can + // edit prints in TinkerCAD/Meshmixer/SolidWorks and bring them + // back to the engine. Dedupes vertices on (pos, normal) so the + // resulting WOM vertex buffer stays compact. + std::string stlPath = argv[++i]; + std::string womBase; + if (i + 1 < argc && argv[i + 1][0] != '-') womBase = argv[++i]; + if (!std::filesystem::exists(stlPath)) { + std::fprintf(stderr, "STL not found: %s\n", stlPath.c_str()); + return 1; + } + if (womBase.empty()) { + womBase = stlPath; + if (womBase.size() >= 4 && + womBase.substr(womBase.size() - 4) == ".stl") { + womBase = womBase.substr(0, womBase.size() - 4); + } + } + std::ifstream in(stlPath); + if (!in) { + std::fprintf(stderr, "Failed to open STL: %s\n", stlPath.c_str()); + return 1; + } + wowee::pipeline::WoweeModel wom; + wom.version = 1; + // Dedupe key: 6 floats (pos + normal) packed as a string. Loose + // matching, but exact for round-trips since we write the same + // floats back. Real-world STLs from CAD tools rarely benefit + // from looser tolerance — they already share verts at the + // exporter level. + std::unordered_map dedupe; + auto interVert = [&](const glm::vec3& pos, const glm::vec3& nrm) { + char key[128]; + std::snprintf(key, sizeof(key), "%.6f|%.6f|%.6f|%.6f|%.6f|%.6f", + pos.x, pos.y, pos.z, nrm.x, nrm.y, nrm.z); + auto it = dedupe.find(key); + if (it != dedupe.end()) return it->second; + wowee::pipeline::WoweeModel::Vertex v; + v.position = pos; + v.normal = nrm; + v.texCoord = {0, 0}; + uint32_t idx = static_cast(wom.vertices.size()); + wom.vertices.push_back(v); + dedupe[key] = idx; + return idx; + }; + std::string line; + std::string solidName; + // Per-facet state: parsed normal + accumulating vertex queue. + glm::vec3 currentNormal{0, 0, 1}; + std::vector facetVerts; + int facetCount = 0; + while (std::getline(in, line)) { + while (!line.empty() && (line.back() == '\r' || line.back() == ' ')) + line.pop_back(); + std::istringstream ss(line); + std::string tok; + ss >> tok; + if (tok == "solid" && solidName.empty()) { + ss >> solidName; + } else if (tok == "facet") { + std::string normalKw; + ss >> normalKw; + if (normalKw == "normal") { + ss >> currentNormal.x >> currentNormal.y >> currentNormal.z; + } + facetVerts.clear(); + } else if (tok == "vertex") { + glm::vec3 v; + ss >> v.x >> v.y >> v.z; + facetVerts.push_back(v); + } else if (tok == "endfacet") { + if (facetVerts.size() == 3) { + // Use the facet normal for all 3 verts since STL + // doesn't carry per-vertex normals. Glue-points to + // adjacent facets will get distinct verts (which is + // correct for faceted-shading STL geometry). + for (const auto& v : facetVerts) { + wom.indices.push_back(interVert(v, currentNormal)); + } + facetCount++; + } + facetVerts.clear(); + } + // 'outer loop', 'endloop', 'endsolid' ignored — we infer + // from the vertex count per facet. + } + if (wom.vertices.empty() || wom.indices.empty()) { + std::fprintf(stderr, + "import-stl: no geometry parsed from %s\n", stlPath.c_str()); + return 1; + } + wom.name = solidName.empty() + ? std::filesystem::path(stlPath).stem().string() + : solidName; + // Compute bounds — renderer culls by these so wrong values + // make models disappear at distance. + wom.boundMin = wom.vertices[0].position; + wom.boundMax = wom.boundMin; + for (const auto& v : wom.vertices) { + wom.boundMin = glm::min(wom.boundMin, v.position); + wom.boundMax = glm::max(wom.boundMax, v.position); + } + glm::vec3 center = (wom.boundMin + wom.boundMax) * 0.5f; + float r2 = 0; + for (const auto& v : wom.vertices) { + glm::vec3 d = v.position - center; + r2 = std::max(r2, glm::dot(d, d)); + } + wom.boundRadius = std::sqrt(r2); + if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { + std::fprintf(stderr, "import-stl: failed to write %s.wom\n", + womBase.c_str()); + return 1; + } + std::printf("Imported %s -> %s.wom\n", stlPath.c_str(), womBase.c_str()); + std::printf(" %d facets, %zu verts (deduped), bounds [%.2f, %.2f, %.2f] - [%.2f, %.2f, %.2f]\n", + facetCount, wom.vertices.size(), + wom.boundMin.x, wom.boundMin.y, wom.boundMin.z, + wom.boundMax.x, wom.boundMax.y, wom.boundMax.z); + return 0; } else if (std::strcmp(argv[i], "--export-wob-glb") == 0 && i + 1 < argc) { // glTF 2.0 binary export for WOB. Same purpose as --export-glb // for WOM but adapted for buildings: each WOB group becomes