Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
8 changes: 8 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,11 @@ jobs:
with:
files: ${{ matrix.artifact_name }}.zip
token: ${{ secrets.GITHUB_TOKEN }}

swift-package:
name: Swift Package (macOS)
runs-on: macos-15
steps:
- uses: actions/checkout@v5
- name: Run package tests
run: swift test
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ Makefile
# Object files
*.o
*.obj
# ...but Wavefront OBJ test fixtures are not object files
!Tests/MeshDenoiserKitTests/Fixtures/*.obj

# macOS
.DS_Store

# Swift Package Manager
.build/
.swiftpm/
47 changes: 47 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// swift-tools-version:5.9
import PackageDescription

let package = Package(
name: "MeshDenoiserKit",
platforms: [.iOS(.v17), .macOS(.v14)],
products: [
.library(name: "MeshDenoiserKit", targets: ["MeshDenoiserKit"]),
],
targets: [
// OpenMesh headers deliberately live in "Headers", not the SPM default
// "include": they must stay textual includes, not a Clang module —
// some of them #include each other inside open namespaces, which
// breaks when clang maps the #include to a module import.
.target(
name: "OpenMeshCore",
path: "Sources/OpenMeshCore",
cxxSettings: [
.define("OM_STATIC_BUILD"),
.headerSearchPath("Headers"),
]
),
.target(
name: "CMeshDenoiserCore",
dependencies: ["OpenMeshCore"],
path: "Sources/CMeshDenoiserCore",
publicHeadersPath: "include",
cxxSettings: [
.headerSearchPath("../../src"),
.headerSearchPath("../../Vendor/eigen"),
.headerSearchPath("../OpenMeshCore/Headers"),
]
),
.target(
name: "MeshDenoiserKit",
dependencies: ["CMeshDenoiserCore"],
path: "Sources/MeshDenoiserKit"
),
.testTarget(
name: "MeshDenoiserKitTests",
dependencies: ["MeshDenoiserKit"],
path: "Tests/MeshDenoiserKitTests",
resources: [.copy("Fixtures")]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add the declared golden-test fixtures

The test target declares Fixtures as a copied resource, but the committed tree under Tests/MeshDenoiserKitTests contains only the Swift test/helper files and no Fixtures directory; swift test already warns Invalid Resource 'Fixtures': File not found. Once the package compiles, GoldenParityTests force-loads noisy_icosphere.obj and golden_denoised.obj from that resource directory, so the package test suite will fail unless those OBJ fixtures are committed or the resource/test is adjusted.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fd637b8. Root cause: the repo's pre-existing *.obj gitignore rule (intended for MSVC object files) silently excluded the Wavefront fixtures from git add. Added a negation for Tests/MeshDenoiserKitTests/Fixtures/*.obj and committed both OBJ files; swift test passes 8/8 from a clean checkout state.

),
],
cxxLanguageStandard: .cxx17
)
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,34 @@ MeshDenoiser --help
| `--deterministic` | Force single-threaded execution for reproducible output |
| `--write-default-options PATH` | Write the default options template to a file and exit |

## Using from Swift (iOS / macOS)

This repo is also a Swift Package (`MeshDenoiserKit`) targeting iOS 17+ / macOS 14+.
It exposes the denoiser as a buffers-in/buffers-out API — file I/O stays in your app
(ModelIO/SceneKit read USDZ natively). Vertex count and order are preserved, so UVs
and materials on the original asset remain valid; recompute normals after denoising.

```swift
import MeshDenoiserKit

var params = MeshDenoiseParameters() // tuned defaults
params.outerIterations = 2 // more smoothing

let denoised = try await MeshDenoiser.denoise(
positions: positions, // [SIMD3<Float>]
indices: indices, // [UInt32], 3 per triangle
parameters: params
) { progress in
print("Progress: \(progress)")
}
```

Cancellation: cancel the surrounding `Task`; the call throws `CancellationError`
(checked after each outer iteration). For very large meshes (>~500k vertices) set
`params.linearSolver = .conjugateGradient` to reduce memory use.

Vendored dependencies (Eigen, OpenMesh Core) are pinned by `scripts/vendor_dependencies.sh`.

## Denoising Parameters

The defaults are tuned for detail-preserving cleanup. Generate a commented template with
Expand Down
117 changes: 117 additions & 0 deletions Sources/CMeshDenoiserCore/CMeshDenoiserCore.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
#include "CMeshDenoiserCore.h"

#include "MeshTypes.h"
#include "MeshNormalDenoising.h"

#include <Eigen/Core>
#include <cmath>
#include <vector>

void md_params_init_defaults(md_params *params)
{
params->lambda = 0.15;
params->eta = 2.2;
params->mu = 0.2;
params->nu = 0.25;
params->mesh_update_closeness_weight = 0.001;
params->mesh_update_iterations = 20;
params->mesh_update_displacement_eps = 0.1;
params->outer_iterations = 1;
params->linear_solver_type = 1;
params->deterministic = 0;
}

md_status md_denoise(const float *vertices, size_t vertex_count,
const uint32_t *indices, size_t triangle_count,
const md_params *params,
float *out_vertices,
md_progress_fn progress, void *ctx)
{
if(vertices == nullptr || indices == nullptr || params == nullptr || out_vertices == nullptr ||
vertex_count == 0 || triangle_count == 0){
return MD_ERR_INVALID_INPUT;
}

for(size_t i = 0; i < vertex_count * 3; ++i){
if(!std::isfinite(vertices[i])){
return MD_ERR_INVALID_INPUT;
}
}
for(size_t i = 0; i < triangle_count * 3; ++i){
if(indices[i] >= vertex_count){
return MD_ERR_INVALID_INPUT;
}
}

SDFilter::MeshDenoisingParameters param;
param.lambda = params->lambda;
param.eta = params->eta;
param.mu = params->mu;
param.nu = params->nu;
param.mesh_update_closeness_weight = params->mesh_update_closeness_weight;
param.mesh_update_iter = params->mesh_update_iterations;
param.mesh_update_disp_eps = params->mesh_update_displacement_eps;
param.outer_iterations = params->outer_iterations;
param.deterministic_mode = (params->deterministic != 0);
switch(params->linear_solver_type){
case 0: param.linear_solver_type = SDFilter::Parameters::CG; break;
case 1: param.linear_solver_type = SDFilter::Parameters::LDLT; break;
default: return MD_ERR_INVALID_PARAMS;
}
if(!param.valid_parameters()){
return MD_ERR_INVALID_PARAMS;
}

if(param.deterministic_mode){
Eigen::setNbThreads(1);
}

TriMesh mesh;
std::vector<TriMesh::VertexHandle> vhandles;
vhandles.reserve(vertex_count);
for(size_t i = 0; i < vertex_count; ++i){
vhandles.push_back(mesh.add_vertex(TriMesh::Point(
static_cast<double>(vertices[3 * i]),
static_cast<double>(vertices[3 * i + 1]),
static_cast<double>(vertices[3 * i + 2]))));
}
for(size_t i = 0; i < triangle_count; ++i){
TriMesh::FaceHandle fh = mesh.add_face(
vhandles[indices[3 * i]],
vhandles[indices[3 * i + 1]],
vhandles[indices[3 * i + 2]]);
if(!fh.is_valid()){
return MD_ERR_INVALID_INPUT; // degenerate or non-manifold face
}
}

// Same pipeline as the CLI: normalize -> denoise -> restore.
Eigen::Vector3d original_center;
double original_scale = 0.0;
SDFilter::normalize_mesh(mesh, original_center, original_scale);

SDFilter::MeshNormalDenoising denoiser(mesh);
if(progress != nullptr){
denoiser.set_progress_callback([progress, ctx](int completed, int total) -> bool {
return progress(completed, total, ctx);
});
}

TriMesh output_mesh;
if(!denoiser.denoise(param, output_mesh)){
return denoiser.cancelled() ? MD_CANCELLED : MD_ERR_SOLVER_FAILED;
}

SDFilter::restore_mesh(output_mesh, original_center, original_scale);

for(TriMesh::ConstVertexIter cv_it = output_mesh.vertices_begin();
cv_it != output_mesh.vertices_end(); ++cv_it){
const TriMesh::Point &pt = output_mesh.point(*cv_it);
const int idx = cv_it->idx();
out_vertices[3 * idx] = static_cast<float>(pt[0]);
out_vertices[3 * idx + 1] = static_cast<float>(pt[1]);
out_vertices[3 * idx + 2] = static_cast<float>(pt[2]);
}

return MD_OK;
}
55 changes: 55 additions & 0 deletions Sources/CMeshDenoiserCore/include/CMeshDenoiserCore.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// C API for the SD-filter mesh denoiser (algorithm: Zhang et al., arXiv:1712.03574).
// Buffers in / buffers out; vertex count and order are preserved.
#ifndef CMESHDENOISERCORE_H
#define CMESHDENOISERCORE_H

#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>

#ifdef __cplusplus
extern "C" {
#endif

typedef struct {
double lambda; // regularization weight (> 0)
double eta; // spatial Gaussian sigma, scaled by avg face-centroid distance (> 0)
double mu; // guidance normal difference weight (> 0)
double nu; // signal normal difference weight (> 0)
double mesh_update_closeness_weight; // vertex position fidelity (>= 0)
int mesh_update_iterations; // max vertex-update iterations per outer iteration (> 0)
double mesh_update_displacement_eps; // early-stop RMS displacement threshold (<= 0 disables)
int outer_iterations; // full filtering passes (> 0)
int linear_solver_type; // 0 = conjugate gradient, 1 = LDLT (default)
int deterministic; // nonzero forces single-threaded Eigen for reproducible output
} md_params;

typedef enum {
MD_OK = 0,
MD_ERR_INVALID_INPUT = 1, // NULL/empty buffers, NaN/Inf coords, bad indices, non-manifold face
MD_ERR_INVALID_PARAMS = 2,
MD_ERR_SOLVER_FAILED = 3,
MD_CANCELLED = 4
} md_status;

// Called after each completed outer iteration. Return false to cancel.
typedef bool (*md_progress_fn)(int completed_outer, int total_outer, void *ctx);

// Fills params with the tuned defaults from MeshDenoiserDefaults.txt.
void md_params_init_defaults(md_params *params);

// vertices: xyz triples, vertex_count points.
// indices: 3 per triangle, triangle_count triangles, each index < vertex_count.
// out_vertices: caller-allocated, 3 * vertex_count floats. Written only on MD_OK.
// progress may be NULL; ctx is passed through to it.
md_status md_denoise(const float *vertices, size_t vertex_count,
const uint32_t *indices, size_t triangle_count,
const md_params *params,
float *out_vertices,
md_progress_fn progress, void *ctx);

#ifdef __cplusplus
}
#endif

#endif
10 changes: 10 additions & 0 deletions Sources/MeshDenoiserKit/MeshDenoiseError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/// Errors thrown by ``MeshDenoiser/denoise(positions:indices:parameters:progress:)``.
/// Cancellation is reported as `CancellationError`, not through this enum.
public enum MeshDenoiseError: Error, Equatable {
/// Empty buffers, NaN/Inf coordinates, out-of-range indices, or a degenerate/non-manifold face.
case invalidInput
/// A parameter is out of its documented range.
case invalidParameters
/// The sparse solver failed to factorize or converge.
case solverFailed
}
50 changes: 50 additions & 0 deletions Sources/MeshDenoiserKit/MeshDenoiseParameters.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import CMeshDenoiserCore

/// SD-filter denoising parameters. Defaults are the tuned values from MeshDenoiserDefaults.txt.
public struct MeshDenoiseParameters: Sendable, Equatable {

public enum LinearSolver: Int32, Sendable {
/// Iterative conjugate gradient — lower memory, use for very large meshes (>~500k vertices).
case conjugateGradient = 0
/// Direct sparse Cholesky (default) — fastest for typical mesh sizes.
case ldlt = 1
}

/// Regularization weight. Higher = more smoothing per iteration. Must be > 0.
public var lambda: Double = 0.15
/// Spatial Gaussian sigma, scaled by average face-centroid distance. Must be > 0.
public var eta: Double = 2.2
/// Guidance normal difference weight. Must be > 0.
public var mu: Double = 0.2
/// Signal normal difference weight. Must be > 0.
public var nu: Double = 0.25
/// Vertex position fidelity during mesh update. Must be >= 0.
public var meshUpdateClosenessWeight: Double = 0.001
/// Max vertex-update iterations per outer iteration. Must be > 0.
public var meshUpdateIterations: Int = 20
/// Early-stop RMS displacement threshold for mesh update; <= 0 disables.
public var meshUpdateDisplacementEps: Double = 0.1
/// Number of full filtering passes. More = more smoothing. Must be > 0.
public var outerIterations: Int = 1
public var linearSolver: LinearSolver = .ldlt
/// Forces single-threaded execution for bit-reproducible output.
public var deterministic: Bool = false

public init() {}

var cParams: md_params {
var c = md_params()
md_params_init_defaults(&c)
c.lambda = lambda
c.eta = eta
c.mu = mu
c.nu = nu
c.mesh_update_closeness_weight = meshUpdateClosenessWeight
c.mesh_update_iterations = Int32(meshUpdateIterations)
c.mesh_update_displacement_eps = meshUpdateDisplacementEps
c.outer_iterations = Int32(outerIterations)
c.linear_solver_type = linearSolver.rawValue
c.deterministic = deterministic ? 1 : 0
return c
}
}
Loading
Loading