diff --git a/.github/workflows/conan-package.yml b/.github/workflows/package.yml similarity index 57% rename from .github/workflows/conan-package.yml rename to .github/workflows/package.yml index f5b93d8..6275e86 100644 --- a/.github/workflows/conan-package.yml +++ b/.github/workflows/package.yml @@ -1,4 +1,4 @@ -name: conan-package +name: package on: push: @@ -10,11 +10,13 @@ on: - 'conanfile.py' - 'conandata.yml' - 'CMakeLists.txt' - - '.github/workflows/conan-package.yml' + - '.github/workflows/package.yml' + - 'UvulaJS/**' branches: - main - master - 'CURA-*' + - 'NP-*' - 'PP-*' - '[0-9].[0-9]*' - '[0-9].[0-9][0-9]*' @@ -25,4 +27,13 @@ on: jobs: conan-package: uses: ultimaker/cura-workflows/.github/workflows/conan-package.yml@main + with: + platform_wasm: true + secrets: inherit + + npm-package: + needs: [ conan-package ] + uses: ultimaker/cura-workflows/.github/workflows/npm-package.yml@main + with: + package_version_full: ${{ needs.conan-package.outputs.package_version_full }} secrets: inherit diff --git a/CMakeLists.txt b/CMakeLists.txt index eb079ff..c24ee28 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,6 +14,11 @@ if (WITH_PYTHON_BINDINGS) message(STATUS "Configuring pyUvula version: ${PYUVULA_VERSION}") endif () +option(WITH_JS_BINDINGS "Build with JavaScript/Emscripten bindings: `UvulaJS`" OFF) +if (WITH_JS_BINDINGS) + message(STATUS "Configuring UvulaJS with Emscripten bindings") +endif () + if (NOT DEFINED UVULA_VERSION) message(FATAL_ERROR "UVULA_VERSION is not defined!") endif () @@ -51,8 +56,14 @@ target_compile_definitions(libuvula UVULA_VERSION="${UVULA_VERSION}" ) -use_threads(libuvula) -enable_sanitizers(libuvula) +if(NOT EMSCRIPTEN) + use_threads(libuvula) +endif() +if(EMSCRIPTEN) + # Skip sanitizers and threading for Emscripten builds to avoid shared memory issues +else() + enable_sanitizers(libuvula) +endif() if (${EXTENSIVE_WARNINGS}) set_project_warnings(libuvula) endif () @@ -62,6 +73,11 @@ if (WITH_PYTHON_BINDINGS) add_subdirectory(pyUvula) endif () +# --- Setup JavaScript/Emscripten bindings --- +if (WITH_JS_BINDINGS) + add_subdirectory(UvulaJS) +endif () + # --- Setup command line interface (for testing purposes) --- if (WITH_CLI) add_subdirectory(cli) diff --git a/UvulaJS/CMakeLists.txt b/UvulaJS/CMakeLists.txt new file mode 100644 index 0000000..d7076c4 --- /dev/null +++ b/UvulaJS/CMakeLists.txt @@ -0,0 +1,16 @@ +add_executable(uvula_js UvulaJS.cpp) +target_link_options(uvula_js + PUBLIC + "SHELL:-s USE_ES6_IMPORT_META=1" + "SHELL:-s FORCE_FILESYSTEM=1" + "SHELL:-s EXPORT_NAME=uvula" + "SHELL:-s MODULARIZE=1" + "SHELL:-s EXPORT_ES6=1" + "SHELL:-s SINGLE_FILE=1" + "SHELL:-s ALLOW_MEMORY_GROWTH=1" + "SHELL:-s ERROR_ON_UNDEFINED_SYMBOLS=0" + "SHELL:--bind" + "SHELL:-l embind" + "SHELL: --emit-tsd uvula_js.d.ts" +) +target_link_libraries(uvula_js PUBLIC libuvula) \ No newline at end of file diff --git a/UvulaJS/UvulaJS.cpp b/UvulaJS/UvulaJS.cpp new file mode 100644 index 0000000..f2f557a --- /dev/null +++ b/UvulaJS/UvulaJS.cpp @@ -0,0 +1,260 @@ +// (c) 2025, UltiMaker -- see LICENCE for details + +#include +#include +#include + +#include "Face.h" +#include "Matrix44F.h" +#include "Point2F.h" +#include "Point3F.h" +#include "Vector3F.h" +#include "project.h" +#include "unwrap.h" + +using namespace emscripten; + +// TypeScript type aliases for better type safety +EMSCRIPTEN_DECLARE_VAL_TYPE(Float32Array); +EMSCRIPTEN_DECLARE_VAL_TYPE(Uint32Array); +EMSCRIPTEN_DECLARE_VAL_TYPE(Int32Array); +EMSCRIPTEN_DECLARE_VAL_TYPE(UInt32Array); +EMSCRIPTEN_DECLARE_VAL_TYPE(PolygonArray); + +// Return type for unwrap function +struct UnwrapResult +{ + Float32Array uvCoordinates = Float32Array{emscripten::val::array()}; + uint32_t textureWidth; + uint32_t textureHeight; +}; + +struct Geometry +{ + std::vector vertices; + std::vector indices; + std::vector uvs; + std::vector connectivity; + + Geometry(const Float32Array& vertices_js, const Uint32Array& indices_js, const Float32Array& uvs_js, const Int32Array& connectivity_js) + { + // Convert vertices + auto vertices_length = vertices_js["length"].as(); + vertices.reserve(vertices_length / 3); + for (int i = 0; i < vertices_length; i += 3) { + auto x = vertices_js[i].as(); + auto y = vertices_js[i + 1].as(); + auto z = vertices_js[i + 2].as(); + vertices.emplace_back(x, y, z); + } + + // Convert indices + auto indices_length = indices_js["length"].as(); + indices.reserve(indices_length / 3); + for (int i = 0; i < indices_length; i += 3) + { + auto i1 = indices_js[i].as(); + auto i2 = indices_js[i + 1].as(); + auto i3 = indices_js[i + 2].as(); + indices.push_back({i1, i2, i3}); + } + + // Convert UVs + auto uvs_length = uvs_js["length"].as(); + uvs.reserve(uvs_length / 2); + for (int i = 0; i < uvs_length; i += 2) { + auto u = uvs_js[i].as(); + auto v = uvs_js[i + 1].as(); + uvs.emplace_back(u, v); + } + + // Convert connectivity + auto connectivity_length = connectivity_js["length"].as(); + connectivity.reserve(connectivity_length / 3); + for (int i = 0; i < connectivity_length; i += 3) { + auto i1 = connectivity_js[i].as(); + auto i2 = connectivity_js[i + 1].as(); + auto i3 = connectivity_js[i + 2].as(); + connectivity.push_back({i1, i2, i3}); + } + } +}; + +std::string get_uvula_info() +{ + return { UVULA_VERSION }; +} + +// Typed wrapper functions for better TypeScript generation +UnwrapResult unwrap(const Float32Array& vertices_js, const Uint32Array& indices_js) +{ + // Convert JavaScript arrays to C++ vectors + std::vector vertex_points; + std::vector face_indices; + + // Get array lengths + unsigned vertices_length = vertices_js["length"].as(); + unsigned indices_length = indices_js["length"].as(); + + // Convert vertices (expecting flat array of [x1, y1, z1, x2, y2, z2, ...]) + vertex_points.reserve(vertices_length / 3); + for (unsigned i = 0; i < vertices_length; i += 3) { + float x = vertices_js[i].as(); + float y = vertices_js[i + 1].as(); + float z = vertices_js[i + 2].as(); + vertex_points.emplace_back(x, y, z); + } + + // Convert indices (expecting flat array of [i1, i2, i3, i4, i5, i6, ...]) + face_indices.reserve(indices_length / 3); + for (unsigned i = 0; i < indices_length; i += 3) + { + uint32_t i1 = indices_js[i].as(); + uint32_t i2 = indices_js[i + 1].as(); + uint32_t i3 = indices_js[i + 2].as(); + face_indices.push_back({i1, i2, i3}); + } + + // Prepare output + std::vector uv_coords(vertex_points.size(), {0.0f, 0.0f}); + uint32_t texture_width; + uint32_t texture_height; + + // Perform unwrapping + bool success = smartUnwrap(vertex_points, face_indices, uv_coords, texture_width, texture_height); + + if (!success) + { + throw std::runtime_error("Couldn't unwrap UVs!"); + } + + // Convert result to structured return type + emscripten::val uv_array = emscripten::val::global("Float32Array").new_(uv_coords.size() * 2); + for (size_t i = 0; i < uv_coords.size(); ++i) + { + uv_array.set(i * 2, uv_coords[i].x); + uv_array.set(i * 2 + 1, uv_coords[i].y); + } + + return UnwrapResult{ + .uvCoordinates = Float32Array{uv_array}, + .textureWidth = texture_width, + .textureHeight = texture_height + }; +} + +PolygonArray project( + const Float32Array& stroke_polygon_js, + const Geometry& geometry, + uint32_t texture_width, + uint32_t texture_height, + const Float32Array& camera_projection_matrix_js, + bool is_camera_perspective, + uint32_t viewport_width, + uint32_t viewport_height, + const Vector3F& camera_normal, + uint32_t face_id +) +{ + std::vector stroke_points; + auto stroke_length = stroke_polygon_js["length"].as(); + stroke_points.reserve(stroke_length / 2); + for (int i = 0; i < stroke_length; i += 2) { + auto x = stroke_polygon_js[i].as(); + auto y = stroke_polygon_js[i + 1].as(); + stroke_points.push_back({x, y}); + } + + // Convert camera projection matrix (4x4 matrix as flat array) + float matrix_data[4][4]; + for (int i = 0; i < 16; ++i) { + matrix_data[i % 4][i / 4] = camera_projection_matrix_js[i].as(); + } + Matrix44F projection_matrix(matrix_data); + + // Call the projection function + std::vector result = doProject( + stroke_points, + geometry.vertices, + geometry.indices, + geometry.uvs, + geometry.connectivity, + texture_width, + texture_height, + projection_matrix, + is_camera_perspective, + viewport_width, + viewport_height, + camera_normal, + face_id + ); + + // Convert result to structured return type + emscripten::val result_polygons = emscripten::val::array(); + + for (size_t i = 0; i < result.size(); ++i) + { + emscripten::val polygon_array = emscripten::val::array(); + const auto& polygon = result[i]; + for (size_t j = 0; j < polygon.size(); ++j) + { + polygon_array.set(j, polygon[j]); + } + + result_polygons.set(i, polygon_array); + } + + return PolygonArray{ result_polygons }; +} + +EMSCRIPTEN_BINDINGS(uvula) +{ + // Register TypeScript-style typed arrays + emscripten::register_type("Float32Array"); + emscripten::register_type("Uint32Array"); + emscripten::register_type("Int32Array"); + emscripten::register_type("Point2F[][]"); + + // Register structured return types + value_object("UnwrapResult") + .field("uvCoordinates", &UnwrapResult::uvCoordinates) + .field("textureWidth", &UnwrapResult::textureWidth) + .field("textureHeight", &UnwrapResult::textureHeight); + + // Main typed functions with proper TypeScript signatures + function("unwrap", &unwrap); + function("project", &project); + + function("uvula_info", &get_uvula_info); + + class_("Geometry") + .constructor(); + + // Utility classes for direct access if needed + class_("Point2F") + .constructor<>() + .property("x", &Point2F::x) + .property("y", &Point2F::y); + + class_("Point3F") + .constructor() + .function("x", &Point3F::x) + .function("y", &Point3F::y) + .function("z", &Point3F::z); + + class_("Vector3F") + .constructor() + .function("x", &Vector3F::x) + .function("y", &Vector3F::y) + .function("z", &Vector3F::z); + + value_object("Face") + .field("i1", &Face::i1) + .field("i2", &Face::i2) + .field("i3", &Face::i3); + + value_object("FaceSigned") + .field("i1", &FaceSigned::i1) + .field("i2", &FaceSigned::i2) + .field("i3", &FaceSigned::i3); +} \ No newline at end of file diff --git a/conanfile.py b/conanfile.py index 6d8261b..5254a39 100644 --- a/conanfile.py +++ b/conanfile.py @@ -23,12 +23,14 @@ class UvulaConan(ConanFile): url = "https://github.com/Ultimaker/libUvula" homepage = "https://ultimaker.com" package_type = "library" + python_requires = "npmpackage/[>=1.0.0]" settings = "os", "arch", "compiler", "build_type" options = { "shared": [True, False], "fPIC": [True, False], "enable_extensive_warnings": [True, False], "with_python_bindings": [True, False], + "with_js_bindings": [True, False], "with_cli": [True, False], } default_options = { @@ -36,6 +38,7 @@ class UvulaConan(ConanFile): "fPIC": True, "enable_extensive_warnings": False, "with_python_bindings": True, + "with_js_bindings": False, "with_cli": False, } @@ -66,12 +69,14 @@ def export_sources(self): copy(self, "*", os.path.join(self.recipe_folder, "include"), os.path.join(self.export_sources_folder, "include")) copy(self, "*", os.path.join(self.recipe_folder, "pyUvula"), os.path.join(self.export_sources_folder, "pyUvula")) + copy(self, "*", os.path.join(self.recipe_folder, "UvulaJS"), os.path.join(self.export_sources_folder, "UvulaJS")) def config_options(self): if self.settings.os == "Windows": del self.options.fPIC if self.settings.arch == "wasm" and self.settings.os == "Emscripten": del self.options.with_python_bindings + self.options.with_js_bindings = True def configure(self): if self.options.get_safe("with_python_bindings", False): @@ -88,6 +93,11 @@ def layout(self): self.layouts.build.runenv_info.prepend_path("PYTHONPATH", "pyUvula") self.layouts.package.runenv_info.prepend_path("PYTHONPATH", os.path.join("lib", "pyUvula")) + if self.settings.os == "Emscripten": + self.cpp.build.bin = ["uvula_js.js"] + self.cpp.package.bin = ["uvula_js.js"] + self.cpp.build.bindirs += ["uvula_js"] + def requirements(self): self.requires("spdlog/1.15.1") @@ -125,6 +135,11 @@ def generate(self): if self.options.get_safe("with_python_bindings", False): tc.variables["PYUVULA_VERSION"] = self.version + if self.settings.arch == "wasm" and self.settings.os == "Emscripten": + tc.variables["WITH_JS_BINDINGS"] = True + else: + tc.variables["WITH_JS_BINDINGS"] = self.options.get_safe("with_js_bindings", False) + tc.variables["WITH_CLI"] = self.options.get_safe("with_cli", False) if is_msvc(self): @@ -150,9 +165,13 @@ def build(self): cmake.configure() cmake.build() + def deploy(self): + copy(self, "uvula_js*", src=os.path.join(self.package_folder, "lib"), dst=self.deploy_folder) + def package(self): copy(self, pattern="LICENSE", dst=os.path.join(self.package_folder, "licenses"), src=self.source_folder) copy(self, "*.pyd", src = os.path.join(self.build_folder, "pyUvula"), dst = os.path.join(self.package_folder, "lib", "pyUvula"), keep_path = False) + copy(self, pattern="uvula_js.*", src=os.path.join(self.build_folder, "UvulaJS"), dst=os.path.join(self.package_folder, "lib")) copy(self, f"*.d.ts", src=self.build_folder, dst=os.path.join(self.package_folder, "bin"), keep_path = False) copy(self, f"*.js", src=self.build_folder, dst=os.path.join(self.package_folder, "bin"), keep_path = False) copy(self, pattern="*.h", src=os.path.join(self.source_folder, "include"), dst=os.path.join(self.package_folder, "include")) @@ -166,3 +185,6 @@ def package_info(self): if self.options.get_safe("with_python_bindings", False): self.conf_info.define("user.uvula:pythonpath", os.path.join(self.package_folder, "lib", "pyUvula")) + + if self.settings.os == "Emscripten": + self.python_requires["npmpackage"].module.conf_package_json(self) diff --git a/include/project.h b/include/project.h index aca2c9a..6a936bb 100644 --- a/include/project.h +++ b/include/project.h @@ -34,10 +34,10 @@ using Polygon = std::vector; */ std::vector doProject( const std::span& stroke_polygon, - const std::span& mesh_vertices, - const std::span& mesh_indices, - const std::span& mesh_uv, - const std::span& mesh_faces_connectivity, + const std::span& mesh_vertices, + const std::span& mesh_indices, + const std::span& mesh_uv, + const std::span& mesh_faces_connectivity, const uint32_t texture_width, const uint32_t texture_height, const Matrix44F& camera_projection_matrix, diff --git a/pyUvula/pyUvula.cpp b/pyUvula/pyUvula.cpp index ab01f2f..07c6dcd 100644 --- a/pyUvula/pyUvula.cpp +++ b/pyUvula/pyUvula.cpp @@ -68,19 +68,19 @@ py::list pyProject( const std::span stroke_polygon = std::span(static_cast(stroke_polygon_buffer.ptr), stroke_polygon_buffer.shape[0]); pybind11::buffer_info mesh_vertices_buffer = mesh_vertices_array.request(); - const std::span mesh_vertices = std::span(static_cast(mesh_vertices_buffer.ptr), mesh_vertices_buffer.shape[0]); + const std::span mesh_vertices = std::span(static_cast(mesh_vertices_buffer.ptr), mesh_vertices_buffer.shape[0]); pybind11::buffer_info mesh_indices_buffer = mesh_indices_array.request(); - const std::span mesh_indices = std::span(static_cast(mesh_indices_buffer.ptr), mesh_indices_buffer.shape[0]); + const std::span mesh_indices = std::span(static_cast(mesh_indices_buffer.ptr), mesh_indices_buffer.shape[0]); pybind11::buffer_info mesh_uv_buffer = mesh_uv_array.request(); - const std::span mesh_uv = std::span(static_cast(mesh_uv_buffer.ptr), mesh_uv_buffer.shape[0]); + const std::span mesh_uv = std::span(static_cast(mesh_uv_buffer.ptr), mesh_uv_buffer.shape[0]); pybind11::buffer_info mesh_faces_connectivity_buffer = mesh_faces_connectivity_array.request(); - const std::span mesh_faces_connectivity = std::span(static_cast(mesh_faces_connectivity_buffer.ptr), mesh_faces_connectivity_buffer.shape[0]); + const std::span mesh_faces_connectivity = std::span(static_cast(mesh_faces_connectivity_buffer.ptr), mesh_faces_connectivity_buffer.shape[0]); const pybind11::buffer_info camera_projection_matrix_buf = camera_projection_matrix_array.request(); - const Matrix44F camera_projection_matrix(*static_cast(camera_projection_matrix_buf.ptr)); + const Matrix44F camera_projection_matrix(*static_cast(camera_projection_matrix_buf.ptr)); const pybind11::buffer_info camera_normal_buf = camera_normal_array.request(); const float* camera_normal_ptr = static_cast(camera_normal_buf.ptr); diff --git a/src/project.cpp b/src/project.cpp index 5783b60..195b0dc 100644 --- a/src/project.cpp +++ b/src/project.cpp @@ -18,17 +18,17 @@ static constexpr float CLIPPER_PRECISION = 1000.0; -Face getFace(const std::span& mesh_indices, const uint32_t face_index) +Face getFace(const std::span& mesh_indices, const uint32_t face_index) { return mesh_indices.empty() ? Face{ face_index * 3, face_index * 3 + 1, face_index * 3 + 2 } : mesh_indices[face_index]; } -Triangle3F getFaceTriangle(const std::span& mesh_vertices, const Face& face) +Triangle3F getFaceTriangle(const std::span& mesh_vertices, const Face& face) { return Triangle3F(mesh_vertices[face.i1], mesh_vertices[face.i2], mesh_vertices[face.i3]); } -Triangle2F getFaceUv(const std::span& mesh_uv, const Face& face) +Triangle2F getFaceUv(const std::span& mesh_uv, const Face& face) { return Triangle2F{ mesh_uv[face.i1], mesh_uv[face.i2], mesh_uv[face.i3] }; } @@ -157,10 +157,10 @@ std::vector toPolygons(const ClipperLib::Paths& paths) std::vector doProject( const std::span& stroke_polygon, - const std::span& mesh_vertices, - const std::span& mesh_indices, - const std::span& mesh_uv, - const std::span& mesh_faces_connectivity, + const std::span& mesh_vertices, + const std::span& mesh_indices, + const std::span& mesh_uv, + const std::span& mesh_faces_connectivity, const uint32_t texture_width, const uint32_t texture_height, const Matrix44F& camera_projection_matrix, diff --git a/src/xatlas.cpp b/src/xatlas.cpp index 8e03b55..9fd6afc 100644 --- a/src/xatlas.cpp +++ b/src/xatlas.cpp @@ -62,8 +62,12 @@ Copyright (c) 2012 Brandon Pelfrey #endif #ifndef XA_MULTITHREADED +#ifdef __EMSCRIPTEN__ +#define XA_MULTITHREADED 0 // Disable threading for Emscripten/WASM +#else #define XA_MULTITHREADED 1 #endif +#endif #define XA_STR(x) #x #define XA_XSTR(x) XA_STR(x) @@ -1888,7 +1892,7 @@ class TaskScheduler TaskGroupHandle createTaskGroup(void* userData = nullptr, uint32_t reserveSize = 0) { - TaskGroup* group = XA_NEW(MemTag::Default, TaskGroup); + TaskGroup* group = XA_NEW(TaskGroup); group->queue.reserve(reserveSize); group->userData = userData; m_groups.push_back(group);