morphit-rs 0.2.0__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/Cargo.lock +8 -8
  2. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/Cargo.toml +1 -1
  3. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/PKG-INFO +1 -1
  4. {morphit_rs-0.2.0/crates/morphit-robot → morphit_rs-0.2.1/crates/morphit}/README.md +13 -4
  5. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/config.rs +8 -0
  6. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/mesh_prep.rs +8 -5
  7. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/state.rs +38 -0
  8. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/trainer.rs +63 -5
  9. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-py/src/session.rs +3 -2
  10. {morphit_rs-0.2.0/crates/morphit → morphit_rs-0.2.1/crates/morphit-robot}/README.md +13 -4
  11. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-robot/src/config.rs +11 -4
  12. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/python/morphit_rs/compat.py +2 -1
  13. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/README.md +0 -0
  14. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/Cargo.toml +0 -0
  15. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/contains.rs +0 -0
  16. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/density.rs +0 -0
  17. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/device.rs +0 -0
  18. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/distances.rs +0 -0
  19. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/error.rs +0 -0
  20. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/exec.rs +0 -0
  21. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/lib.rs +0 -0
  22. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/loss/boundary.rs +0 -0
  23. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/loss/containment.rs +0 -0
  24. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/loss/coverage.rs +0 -0
  25. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/loss/fd.rs +0 -0
  26. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/loss/hausdorff.rs +0 -0
  27. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/loss/mesh_containment.rs +0 -0
  28. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/loss/mod.rs +0 -0
  29. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/loss/overlap.rs +0 -0
  30. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/loss/physics.rs +0 -0
  31. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/loss/sqem.rs +0 -0
  32. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/loss/surface.rs +0 -0
  33. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/math.rs +0 -0
  34. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/mesh.rs +0 -0
  35. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/mesh_io/dae.rs +0 -0
  36. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/mesh_io/mod.rs +0 -0
  37. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/mesh_io/ply.rs +0 -0
  38. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/mesh_io/write.rs +0 -0
  39. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/metrics.rs +0 -0
  40. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/optim.rs +0 -0
  41. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/par.rs +0 -0
  42. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/quality.rs +0 -0
  43. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/result.rs +0 -0
  44. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/sampling.rs +0 -0
  45. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/search/gpu/mod.rs +0 -0
  46. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/search/gpu/search.wgsl +0 -0
  47. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/search/gpu/session.rs +0 -0
  48. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/search/gpu/tests.rs +0 -0
  49. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/search/mod.rs +0 -0
  50. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/src/shapes.rs +0 -0
  51. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/tests/fixtures/link0.obj +0 -0
  52. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/tests/fixtures/mesh_prep/disjoint.obj +0 -0
  53. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/tests/fixtures/mesh_prep/groups_box.obj +0 -0
  54. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/tests/fixtures/mesh_prep/inward_inner_shell.obj +0 -0
  55. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/tests/fixtures/mesh_prep/open_overlap.obj +0 -0
  56. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/tests/fixtures/mesh_prep/sliver.obj +0 -0
  57. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/tests/fixtures/mesh_prep/three_bodies.obj +0 -0
  58. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/tests/fixtures/mesh_prep/two_overlapping.obj +0 -0
  59. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/tests/fixtures/py_link0_b_n16.json +0 -0
  60. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/tests/fixtures/py_link0_obj_n16.json +0 -0
  61. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/tests/fixtures/py_link0_objmass_n16.json +0 -0
  62. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/tests/fixtures/py_mesh_prep.json +0 -0
  63. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/tests/mesh_prep_parity.rs +0 -0
  64. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/tests/python_parity.rs +0 -0
  65. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit/tests/web_examples.rs +0 -0
  66. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-py/Cargo.toml +0 -0
  67. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-py/README.md +0 -0
  68. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-py/src/config.rs +0 -0
  69. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-py/src/convert.rs +0 -0
  70. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-py/src/errors.rs +0 -0
  71. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-py/src/export.rs +0 -0
  72. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-py/src/lib.rs +0 -0
  73. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-py/src/logging.rs +0 -0
  74. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-py/src/mesh.rs +0 -0
  75. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-py/src/result.rs +0 -0
  76. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-py/src/robot.rs +0 -0
  77. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-py/tests/conftest.py +0 -0
  78. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-py/tests/test_compat.py +0 -0
  79. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-py/tests/test_config.py +0 -0
  80. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-py/tests/test_export.py +0 -0
  81. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-py/tests/test_logging.py +0 -0
  82. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-py/tests/test_mesh.py +0 -0
  83. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-py/tests/test_robot.py +0 -0
  84. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-py/tests/test_session.py +0 -0
  85. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-robot/Cargo.toml +0 -0
  86. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-robot/src/assemble.rs +0 -0
  87. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-robot/src/color.rs +0 -0
  88. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-robot/src/examples.rs +0 -0
  89. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-robot/src/history.rs +0 -0
  90. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-robot/src/inspect.rs +0 -0
  91. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-robot/src/kinematics.rs +0 -0
  92. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-robot/src/lib.rs +0 -0
  93. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-robot/src/object_model.rs +0 -0
  94. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-robot/src/pack.rs +0 -0
  95. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-robot/src/paths.rs +0 -0
  96. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-robot/src/quality.rs +0 -0
  97. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-robot/src/vfs.rs +0 -0
  98. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-robot/src/xml.rs +0 -0
  99. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-robot/tests/assemble_parity.rs +0 -0
  100. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-robot/tests/fixtures/py_assemble.json +0 -0
  101. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-robot/tests/fixtures/py_inspect.json +0 -0
  102. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-robot/tests/fixtures/py_object_model.json +0 -0
  103. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-robot/tests/inspect_parity.rs +0 -0
  104. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-robot/tests/object_model_parity.rs +0 -0
  105. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/crates/morphit-robot/tests/vfs_parity.rs +0 -0
  106. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/pyproject.toml +0 -0
  107. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/python/morphit_rs/__init__.py +0 -0
  108. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/python/morphit_rs/_native.pyi +0 -0
  109. {morphit_rs-0.2.0 → morphit_rs-0.2.1}/python/morphit_rs/py.typed +0 -0
@@ -4178,7 +4178,7 @@ dependencies = [
4178
4178
 
4179
4179
  [[package]]
4180
4180
  name = "morphit"
4181
- version = "0.2.0"
4181
+ version = "0.2.1"
4182
4182
  dependencies = [
4183
4183
  "bytemuck",
4184
4184
  "futures-channel",
@@ -4204,7 +4204,7 @@ dependencies = [
4204
4204
 
4205
4205
  [[package]]
4206
4206
  name = "morphit-capi"
4207
- version = "0.2.0"
4207
+ version = "0.2.1"
4208
4208
  dependencies = [
4209
4209
  "cbindgen",
4210
4210
  "morphit",
@@ -4218,7 +4218,7 @@ dependencies = [
4218
4218
 
4219
4219
  [[package]]
4220
4220
  name = "morphit-cli"
4221
- version = "0.2.0"
4221
+ version = "0.2.1"
4222
4222
  dependencies = [
4223
4223
  "assert_cmd",
4224
4224
  "clap",
@@ -4233,7 +4233,7 @@ dependencies = [
4233
4233
 
4234
4234
  [[package]]
4235
4235
  name = "morphit-py"
4236
- version = "0.2.0"
4236
+ version = "0.2.1"
4237
4237
  dependencies = [
4238
4238
  "morphit",
4239
4239
  "morphit-robot",
@@ -4249,7 +4249,7 @@ dependencies = [
4249
4249
 
4250
4250
  [[package]]
4251
4251
  name = "morphit-robot"
4252
- version = "0.2.0"
4252
+ version = "0.2.1"
4253
4253
  dependencies = [
4254
4254
  "morphit",
4255
4255
  "rand 0.9.5",
@@ -4265,7 +4265,7 @@ dependencies = [
4265
4265
 
4266
4266
  [[package]]
4267
4267
  name = "morphit-server"
4268
- version = "0.2.0"
4268
+ version = "0.2.1"
4269
4269
  dependencies = [
4270
4270
  "axum",
4271
4271
  "clap",
@@ -4287,7 +4287,7 @@ dependencies = [
4287
4287
 
4288
4288
  [[package]]
4289
4289
  name = "morphit-studio"
4290
- version = "0.2.0"
4290
+ version = "0.2.1"
4291
4291
  dependencies = [
4292
4292
  "bevy",
4293
4293
  "bevy_editor_cam",
@@ -4312,7 +4312,7 @@ dependencies = [
4312
4312
 
4313
4313
  [[package]]
4314
4314
  name = "morphit-wasm"
4315
- version = "0.2.0"
4315
+ version = "0.2.1"
4316
4316
  dependencies = [
4317
4317
  "console_error_panic_hook",
4318
4318
  "js-sys",
@@ -3,7 +3,7 @@ members = ["crates/morphit", "crates/morphit-robot", "crates/morphit-py"]
3
3
  resolver = "3"
4
4
 
5
5
  [workspace.package]
6
- version = "0.2.0"
6
+ version = "0.2.1"
7
7
  edition = "2024"
8
8
  license = "MIT"
9
9
  rust-version = "1.85"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: morphit-rs
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Intended Audience :: Science/Research
6
6
  Classifier: Operating System :: Microsoft :: Windows
@@ -217,7 +217,7 @@ result.save("bunny.json")?;
217
217
  ([details](#using-the-release-package-from-cmake)):
218
218
 
219
219
  ```cmake
220
- find_package(morphit 0.1 CONFIG REQUIRED)
220
+ find_package(morphit 0.2 CONFIG REQUIRED)
221
221
  target_link_libraries(app PRIVATE morphit::morphit_cpp) # C: morphit::morphit
222
222
  ```
223
223
 
@@ -626,7 +626,7 @@ lib/cmake/morphit/morphit-config.cmake (+ version file, system libraries of
626
626
  Unpack it anywhere and point `CMAKE_PREFIX_PATH` at the folder:
627
627
 
628
628
  ```cmake
629
- find_package(morphit 0.1 CONFIG REQUIRED)
629
+ find_package(morphit 0.2 CONFIG REQUIRED)
630
630
  target_link_libraries(app PRIVATE morphit::morphit) # shared library
631
631
  # target_link_libraries(app PRIVATE morphit::morphit_static) # static, with its system libraries
632
632
  # target_link_libraries(app PRIVATE morphit::morphit_cpp) # C++17 wrapper (also _cpp_static)
@@ -638,7 +638,7 @@ cmake -S . -B build -DCMAKE_PREFIX_PATH=/path/to/morphit-capi-0.2.0-x86_64-unkno
638
638
 
639
639
  On Windows copy `morphit_capi.dll` next to the executable
640
640
  (`$<TARGET_RUNTIME_DLLS:app>` lists it); the static library there is built
641
- against the DLL runtime (`/MD`). A `0.1` request accepts any `0.1.x`. The
641
+ against the DLL runtime (`/MD`). A `0.2` request accepts any `0.2.x`. The
642
642
  examples in `crates/morphit-capi/examples/c` use the package when
643
643
  `CMAKE_PREFIX_PATH` points at one (`-DMORPHIT_STATIC=ON` for the static
644
644
  library), and `tools/ci/test_capi_package.sh <archive>` builds and runs them
@@ -831,6 +831,9 @@ exits 0 when it answers, which is what the Docker health check runs.
831
831
  packs the mesh exactly as loaded (the `X-Morphit-Mesh-Prep` report then says
832
832
  `disabled`), and `convex_hull=true` packs the convex hull of each body. The
833
833
  web UI has both under Advanced settings, Mesh preparation.
834
+ `advanced` also accepts `prune_contained_spheres` (default `true`; see
835
+ [Differences from the Python code](#differences-from-the-python-code)), under
836
+ Advanced settings, Final prune.
834
837
 
835
838
  Robot sessions live in the server's memory and in the session directory, and
836
839
  expire after the TTL. Run a single instance. Uploads are capped at 100 MB per
@@ -1133,6 +1136,12 @@ one-time device setup per process; ¹ optimizer loop only.
1133
1136
  - Mesh preparation can be switched off (`model.union_overlapping_bodies`) and
1134
1137
  extended with convex hulls (`model.convex_hull`); Python always merges and
1135
1138
  never builds hulls. Both keys are stored in the result's config.
1139
+ - The final prune also removes spheres that lie entirely inside another
1140
+ sphere (`training.prune_contained_spheres`, on by default; `false` keeps
1141
+ them as Python does). They add nothing to the covered volume, so coverage
1142
+ and surface distance are unchanged, but they no longer count twice in the
1143
+ per-sphere masses. The key is stored in the result's config, and the HTTP
1144
+ API accepts it in `advanced`.
1136
1145
  - Float64 throughout. GPU support is a search accelerator with CPU-identical
1137
1146
  results rather than a float32 port; `model.device` therefore never changes
1138
1147
  the output.
@@ -1206,7 +1215,7 @@ token. For PyPI, add a *pending trusted publisher* on pypi.org (project
1206
1215
  and create an environment named `pypi`; no token is needed. npm publishes
1207
1216
  only through trusted publishing (2FA-bypass tokens are being deprecated), and
1208
1217
  a trusted publisher can only be added to an existing package: publish the
1209
- first version by hand (`npm publish morphit-rs-<version>.tgz --access public`
1218
+ first version by hand (`npm publish ./morphit-rs-<version>.tgz --access public`
1210
1219
  with the tarball from the GitHub Release; the release's `npm-publish` job
1211
1220
  fails until then and can be re-run afterwards), then add a trusted publisher
1212
1221
  for `morphit-rs` on npmjs.com (workflow `release.yml`, environment `npm`,
@@ -257,6 +257,10 @@ pub struct TrainingConfig {
257
257
  pub density_control_warmup_steps: usize,
258
258
  pub density_control_cooling_factor: f64,
259
259
  pub density_control_min_radius_fraction: f64,
260
+ /// The final prune also removes spheres lying entirely inside another
261
+ /// sphere; they add nothing to the covered volume. Not in Python; on by
262
+ /// default.
263
+ pub prune_contained_spheres: bool,
260
264
  }
261
265
 
262
266
  impl Default for TrainingConfig {
@@ -291,6 +295,7 @@ impl Default for TrainingConfig {
291
295
  density_control_warmup_steps: 10,
292
296
  density_control_cooling_factor: 0.85,
293
297
  density_control_min_radius_fraction: 0.001,
298
+ prune_contained_spheres: true,
294
299
  }
295
300
  }
296
301
  }
@@ -668,6 +673,9 @@ mod tests {
668
673
  assert_eq!(c.random_seed, None);
669
674
  c.set("model.per_sphere_mass", json!(true)).unwrap();
670
675
  assert!(c.model.per_sphere_mass);
676
+ assert_eq!(c.get("training.prune_contained_spheres").unwrap(), json!(true));
677
+ c.set("training.prune_contained_spheres", json!(false)).unwrap();
678
+ assert!(!c.training.prune_contained_spheres);
671
679
  assert_eq!(c.get("training.iterations").unwrap(), json!(120));
672
680
  }
673
681
 
@@ -267,7 +267,7 @@ fn prepare(
267
267
  }
268
268
 
269
269
  let n = kept.len();
270
- let (uv, uf) = match union(&kept) {
270
+ let (uv, uf) = match union(&kept, !hulled) {
271
271
  Ok(u) => u,
272
272
  Err(UnionError::Unavailable(why)) => {
273
273
  report.skip(
@@ -631,9 +631,12 @@ enum UnionError {
631
631
 
632
632
  /// Boolean union of closed bodies, each converted to a Manifold from f32
633
633
  /// positions (as Python's manifold3d `Mesh`), combined left to right. The
634
- /// result's duplicate positions are merged like trimesh's `process=True`.
634
+ /// result's duplicate positions are merged like trimesh's `process=True` when
635
+ /// `weld` is set. Without it, Manifold's own indexing is kept: where two
636
+ /// inputs only touch, Manifold leaves separate vertices at one position, and
637
+ /// welding them would make the result non-manifold there.
635
638
  #[cfg(feature = "union")]
636
- fn union(bodies: &[Body]) -> Result<(Vec<DVec3>, Vec<[u32; 3]>), UnionError> {
639
+ fn union(bodies: &[Body], weld: bool) -> Result<(Vec<DVec3>, Vec<[u32; 3]>), UnionError> {
637
640
  use manifold_rust::manifold::Manifold;
638
641
  use manifold_rust::types::{Error as MfError, MeshGL};
639
642
 
@@ -675,11 +678,11 @@ fn union(bodies: &[Body]) -> Result<(Vec<DVec3>, Vec<[u32; 3]>), UnionError> {
675
678
  .map(|p| DVec3::new(p[0] as f64, p[1] as f64, p[2] as f64))
676
679
  .collect();
677
680
  let faces: Vec<[u32; 3]> = out.tri_verts.chunks_exact(3).map(|t| [t[0], t[1], t[2]]).collect();
678
- Ok(merge_positions(&vertices, &faces))
681
+ Ok(if weld { merge_positions(&vertices, &faces) } else { (vertices, faces) })
679
682
  }
680
683
 
681
684
  #[cfg(not(feature = "union"))]
682
- fn union(_bodies: &[Body]) -> Result<(Vec<DVec3>, Vec<[u32; 3]>), UnionError> {
685
+ fn union(_bodies: &[Body], _weld: bool) -> Result<(Vec<DVec3>, Vec<[u32; 3]>), UnionError> {
683
686
  Err(UnionError::Unavailable("mesh union support is not built in (feature `union`)".into()))
684
687
  }
685
688
 
@@ -70,10 +70,48 @@ impl Spheres {
70
70
  }
71
71
  }
72
72
 
73
+ /// Spheres lying entirely inside another one (`|c_i - c_j| + r_j <= r_i`;
74
+ /// touching from inside counts). Of spheres that contain each other
75
+ /// (identical ones) the first is kept, so the largest always survives and the
76
+ /// union of the kept spheres equals the union of all.
77
+ pub fn contained_in_another(centers: &[DVec3], radii: &[f64]) -> Vec<bool> {
78
+ assert_eq!(centers.len(), radii.len());
79
+ let n = centers.len();
80
+ (0..n)
81
+ .map(|j| {
82
+ (0..n).any(|i| {
83
+ if i == j {
84
+ return false;
85
+ }
86
+ // Same argument order for (i,j) and (j,i), as in `distances::pairwise`.
87
+ let d = crate::distances::dist(centers[i.min(j)], centers[i.max(j)]);
88
+ d + radii[j] <= radii[i] && (i < j || d + radii[i] > radii[j])
89
+ })
90
+ })
91
+ .collect()
92
+ }
93
+
73
94
  #[cfg(test)]
74
95
  mod tests {
75
96
  use super::*;
76
97
 
98
+ #[test]
99
+ fn contained_spheres_are_found() {
100
+ let x = |v: f64| DVec3::new(v, 0.0, 0.0);
101
+ // Nested chain: all but the outermost go.
102
+ assert_eq!(contained_in_another(&[x(0.0), x(0.1), x(0.15)], &[1.0, 0.5, 0.2]), [false, true, true]);
103
+ // Partial overlap: both stay.
104
+ assert_eq!(contained_in_another(&[x(0.0), x(0.8)], &[0.5, 0.5]), [false, false]);
105
+ // Touching from inside counts as contained.
106
+ assert_eq!(contained_in_another(&[x(0.0), x(0.5)], &[1.0, 0.5]), [false, true]);
107
+ // Identical spheres: the first is kept.
108
+ assert_eq!(contained_in_another(&[x(1.0); 3], &[0.3; 3]), [false, true, true]);
109
+ // Order does not matter: the largest is kept.
110
+ assert_eq!(contained_in_another(&[x(0.15), x(0.1), x(0.0)], &[0.2, 0.5, 1.0]), [true, true, false]);
111
+ assert!(contained_in_another(&[], &[]).is_empty());
112
+ assert_eq!(contained_in_another(&[x(0.0)], &[0.1]), [false]);
113
+ }
114
+
77
115
  #[test]
78
116
  fn real_round_trip() {
79
117
  let s = Spheres::from_real(vec![DVec3::ZERO, DVec3::ONE], &[0.1, 0.25], Some(&[2.0, 3.0]));
@@ -21,7 +21,7 @@ use crate::metrics::{DensityEvent, History, IterationRecord};
21
21
  use crate::optim::{Optimizer, clip_grad_norm, clip_grad_norm_vec3};
22
22
  use crate::result::PackResult;
23
23
  use crate::sampling::{MorphRng, lognormal_radii, sample_inside, sample_surface, voxel_grid_centers};
24
- use crate::state::Spheres;
24
+ use crate::state::{Spheres, contained_in_another};
25
25
 
26
26
  /// Lifecycle of a session.
27
27
  #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -32,7 +32,7 @@ pub enum SessionState {
32
32
  Converged,
33
33
  /// All configured iterations ran.
34
34
  Completed,
35
- /// The final escaped-sphere prune ran; no more steps.
35
+ /// The final prune (escaped and nested spheres) ran; no more steps.
36
36
  Finalized,
37
37
  }
38
38
 
@@ -400,8 +400,10 @@ impl Session {
400
400
  }
401
401
 
402
402
  /// Remove spheres whose centers ended outside the mesh
403
- /// (`_finalize_training`) and end the session. Idempotent; returns the
404
- /// number of spheres removed. At least one sphere is always kept.
403
+ /// (`_finalize_training`), then, with `training.prune_contained_spheres`
404
+ /// (not in Python), spheres lying entirely inside another one, and end the
405
+ /// session. Idempotent; returns the number of spheres removed. At least
406
+ /// one sphere is always kept.
405
407
  pub fn finalize(&mut self) -> usize {
406
408
  if self.state == SessionState::Finalized {
407
409
  return self.pruned;
@@ -412,10 +414,26 @@ impl Session {
412
414
  tracing::warn!("every sphere center is outside the mesh; keeping the first sphere");
413
415
  keep.push(0);
414
416
  }
417
+ let escaped = self.spheres.len() - keep.len();
418
+ if escaped > 0 {
419
+ tracing::info!(removed = escaped, "final prune removed spheres whose centers escaped the mesh");
420
+ }
421
+ if self.config.training.prune_contained_spheres {
422
+ // Only among the survivors: an escaped container does not take its contents along.
423
+ let radii = self.spheres.radii();
424
+ let centers: Vec<_> = keep.iter().map(|&i| self.spheres.centers[i]).collect();
425
+ let r: Vec<f64> = keep.iter().map(|&i| radii[i]).collect();
426
+ let nested = contained_in_another(&centers, &r);
427
+ let before = keep.len();
428
+ keep = keep.into_iter().zip(nested).filter_map(|(i, n)| (!n).then_some(i)).collect();
429
+ let contained = before - keep.len();
430
+ if contained > 0 {
431
+ tracing::info!(removed = contained, "final prune removed spheres inside another sphere");
432
+ }
433
+ }
415
434
  let removed = self.spheres.len() - keep.len();
416
435
  if removed > 0 {
417
436
  self.spheres = self.spheres.select(&keep);
418
- tracing::info!(removed, "final prune removed spheres whose centers escaped the mesh");
419
437
  }
420
438
  self.pruned = removed;
421
439
  self.state = SessionState::Finalized;
@@ -745,6 +763,46 @@ mod tests {
745
763
  assert_eq!(s.spheres().len(), 1);
746
764
  }
747
765
 
766
+ /// A session on `cube()` whose spheres are replaced by `spheres`.
767
+ fn with_spheres(prune_contained: bool, spheres: &[([f64; 3], f64)]) -> Session {
768
+ let mut cfg = small_config(Preset::B, 5, spheres.len());
769
+ cfg.training.prune_contained_spheres = prune_contained;
770
+ let mut s = Session::new(cfg, cube()).unwrap();
771
+ let centers = spheres.iter().map(|(c, _)| DVec3::from_array(*c)).collect();
772
+ let radii: Vec<f64> = spheres.iter().map(|(_, r)| *r).collect();
773
+ s.spheres = Spheres::from_real(centers, &radii, None);
774
+ s
775
+ }
776
+
777
+ #[test]
778
+ fn finalize_prunes_spheres_inside_another() {
779
+ let spheres = [
780
+ ([0.3, 0.4, 0.3], 0.25), // big
781
+ ([0.35, 0.4, 0.3], 0.1), // inside the big one
782
+ ([0.7, 0.4, 0.3], 0.2), // overlaps the big one partly
783
+ ([0.3, 0.4, 0.3], 0.25), // duplicate of the big one
784
+ ];
785
+ let mut s = with_spheres(true, &spheres);
786
+ let radii = s.spheres.radii();
787
+ assert_eq!(s.finalize(), 2);
788
+ assert_eq!(s.spheres().centers, vec![DVec3::new(0.3, 0.4, 0.3), DVec3::new(0.7, 0.4, 0.3)]);
789
+ assert_eq!(s.spheres().radii(), vec![radii[0], radii[2]]);
790
+ assert_eq!(s.finalize(), 2);
791
+ assert_eq!(s.result().num_spheres, 2);
792
+
793
+ let mut s = with_spheres(false, &spheres);
794
+ assert_eq!(s.finalize(), 0);
795
+ assert_eq!(s.spheres().len(), 4);
796
+ }
797
+
798
+ #[test]
799
+ fn escaped_container_keeps_its_contents() {
800
+ // The container's center is outside the box; the nested sphere's is inside.
801
+ let mut s = with_spheres(true, &[([1.1, 0.4, 0.3], 0.5), ([0.9, 0.4, 0.3], 0.1)]);
802
+ assert_eq!(s.finalize(), 1);
803
+ assert_eq!(s.spheres().centers, vec![DVec3::new(0.9, 0.4, 0.3)]);
804
+ }
805
+
748
806
  #[test]
749
807
  fn projection_moves_escaped_centers_inside() {
750
808
  let mut s = Session::new(small_config(Preset::B, 5, 4), cube()).unwrap();
@@ -351,8 +351,9 @@ impl PySession {
351
351
  self.core.cancel.store(true, Ordering::Release);
352
352
  }
353
353
 
354
- /// Remove spheres whose centers ended outside the mesh and end the
355
- /// session; returns how many were removed. Idempotent.
354
+ /// Remove spheres whose centers ended outside the mesh and spheres inside
355
+ /// another sphere (unless `training.prune_contained_spheres` is false),
356
+ /// and end the session; returns how many were removed. Idempotent.
356
357
  fn finalize(&self, py: Python<'_>) -> PyResult<usize> {
357
358
  let _running = self.core.claim()?;
358
359
  let core = Arc::clone(&self.core);
@@ -217,7 +217,7 @@ result.save("bunny.json")?;
217
217
  ([details](#using-the-release-package-from-cmake)):
218
218
 
219
219
  ```cmake
220
- find_package(morphit 0.1 CONFIG REQUIRED)
220
+ find_package(morphit 0.2 CONFIG REQUIRED)
221
221
  target_link_libraries(app PRIVATE morphit::morphit_cpp) # C: morphit::morphit
222
222
  ```
223
223
 
@@ -626,7 +626,7 @@ lib/cmake/morphit/morphit-config.cmake (+ version file, system libraries of
626
626
  Unpack it anywhere and point `CMAKE_PREFIX_PATH` at the folder:
627
627
 
628
628
  ```cmake
629
- find_package(morphit 0.1 CONFIG REQUIRED)
629
+ find_package(morphit 0.2 CONFIG REQUIRED)
630
630
  target_link_libraries(app PRIVATE morphit::morphit) # shared library
631
631
  # target_link_libraries(app PRIVATE morphit::morphit_static) # static, with its system libraries
632
632
  # target_link_libraries(app PRIVATE morphit::morphit_cpp) # C++17 wrapper (also _cpp_static)
@@ -638,7 +638,7 @@ cmake -S . -B build -DCMAKE_PREFIX_PATH=/path/to/morphit-capi-0.2.0-x86_64-unkno
638
638
 
639
639
  On Windows copy `morphit_capi.dll` next to the executable
640
640
  (`$<TARGET_RUNTIME_DLLS:app>` lists it); the static library there is built
641
- against the DLL runtime (`/MD`). A `0.1` request accepts any `0.1.x`. The
641
+ against the DLL runtime (`/MD`). A `0.2` request accepts any `0.2.x`. The
642
642
  examples in `crates/morphit-capi/examples/c` use the package when
643
643
  `CMAKE_PREFIX_PATH` points at one (`-DMORPHIT_STATIC=ON` for the static
644
644
  library), and `tools/ci/test_capi_package.sh <archive>` builds and runs them
@@ -831,6 +831,9 @@ exits 0 when it answers, which is what the Docker health check runs.
831
831
  packs the mesh exactly as loaded (the `X-Morphit-Mesh-Prep` report then says
832
832
  `disabled`), and `convex_hull=true` packs the convex hull of each body. The
833
833
  web UI has both under Advanced settings, Mesh preparation.
834
+ `advanced` also accepts `prune_contained_spheres` (default `true`; see
835
+ [Differences from the Python code](#differences-from-the-python-code)), under
836
+ Advanced settings, Final prune.
834
837
 
835
838
  Robot sessions live in the server's memory and in the session directory, and
836
839
  expire after the TTL. Run a single instance. Uploads are capped at 100 MB per
@@ -1133,6 +1136,12 @@ one-time device setup per process; ¹ optimizer loop only.
1133
1136
  - Mesh preparation can be switched off (`model.union_overlapping_bodies`) and
1134
1137
  extended with convex hulls (`model.convex_hull`); Python always merges and
1135
1138
  never builds hulls. Both keys are stored in the result's config.
1139
+ - The final prune also removes spheres that lie entirely inside another
1140
+ sphere (`training.prune_contained_spheres`, on by default; `false` keeps
1141
+ them as Python does). They add nothing to the covered volume, so coverage
1142
+ and surface distance are unchanged, but they no longer count twice in the
1143
+ per-sphere masses. The key is stored in the result's config, and the HTTP
1144
+ API accepts it in `advanced`.
1136
1145
  - Float64 throughout. GPU support is a search accelerator with CPU-identical
1137
1146
  results rather than a float32 port; `model.device` therefore never changes
1138
1147
  the output.
@@ -1206,7 +1215,7 @@ token. For PyPI, add a *pending trusted publisher* on pypi.org (project
1206
1215
  and create an environment named `pypi`; no token is needed. npm publishes
1207
1216
  only through trusted publishing (2FA-bypass tokens are being deprecated), and
1208
1217
  a trusted publisher can only be added to an existing package: publish the
1209
- first version by hand (`npm publish morphit-rs-<version>.tgz --access public`
1218
+ first version by hand (`npm publish ./morphit-rs-<version>.tgz --access public`
1210
1219
  with the tarball from the GitHub Release; the release's `npm-publish` job
1211
1220
  fails until then and can be re-run afterwards), then add a trusted publisher
1212
1221
  for `morphit-rs` on npmjs.com (workflow `release.yml`, environment `npm`,
@@ -10,8 +10,9 @@ use crate::{Error, Result};
10
10
  pub const ALLOWED_VARIANTS: [&str; 3] = ["MorphIt-V", "MorphIt-S", "MorphIt-B"];
11
11
 
12
12
  /// Flat `advanced` keys the UI sends, and the dotted config keys they set.
13
- /// Anything else is dropped silently, as in the Python API.
14
- pub const ADVANCED_OVERRIDE_MAP: [(&str, &str); 13] = [
13
+ /// Anything else is dropped silently, as in the Python API
14
+ /// (`prune_contained_spheres` is not in Python).
15
+ pub const ADVANCED_OVERRIDE_MAP: [(&str, &str); 14] = [
15
16
  ("num_inside_samples", "model.num_inside_samples"),
16
17
  ("num_surface_samples", "model.num_surface_samples"),
17
18
  ("density_control_enabled", "training.density_control_enabled"),
@@ -25,6 +26,7 @@ pub const ADVANCED_OVERRIDE_MAP: [(&str, &str); 13] = [
25
26
  ("sqem_weight", "training.sqem_weight"),
26
27
  ("hausdorff_weight", "training.hausdorff_weight"),
27
28
  ("mesh_containment_weight", "training.mesh_containment_weight"),
29
+ ("prune_contained_spheres", "training.prune_contained_spheres"),
28
30
  ];
29
31
 
30
32
  /// `variant must be one of ('MorphIt-V', 'MorphIt-S', 'MorphIt-B')`.
@@ -135,7 +137,7 @@ mod tests {
135
137
  #[test]
136
138
  fn advanced_is_filtered_and_mapped() {
137
139
  let a = parse_advanced(
138
- r#"{"coverage_weight": 2.5, "bogus": 1, "sqem_weight": null, "num_inside_samples": 100}"#,
140
+ r#"{"coverage_weight": 2.5, "bogus": 1, "sqem_weight": null, "num_inside_samples": 100, "prune_contained_spheres": false}"#,
139
141
  )
140
142
  .unwrap();
141
143
  assert_eq!(
@@ -143,6 +145,7 @@ mod tests {
143
145
  vec![
144
146
  ("training.coverage_weight".to_string(), Value::from(2.5)),
145
147
  ("model.num_inside_samples".to_string(), Value::from(100)),
148
+ ("training.prune_contained_spheres".to_string(), Value::from(false)),
146
149
  ]
147
150
  );
148
151
  assert!(parse_advanced("").unwrap().is_empty());
@@ -183,7 +186,10 @@ mod tests {
183
186
  fn config_applies_everything() {
184
187
  let mut p = params();
185
188
  p.seed = Some(7);
186
- p.advanced = parse_advanced(r#"{"coverage_weight": 3, "density_control_enabled": false}"#).unwrap();
189
+ p.advanced = parse_advanced(
190
+ r#"{"coverage_weight": 3, "density_control_enabled": false, "prune_contained_spheres": false}"#,
191
+ )
192
+ .unwrap();
187
193
  let c = p.config("cpu", "/tmp/x", "a.json").unwrap();
188
194
  assert_eq!(c.model.num_spheres, 20);
189
195
  assert_eq!(c.training.iterations, 200);
@@ -191,6 +197,7 @@ mod tests {
191
197
  assert_eq!(c.model.device, "cpu");
192
198
  assert_eq!(c.training.coverage_weight, 3.0);
193
199
  assert!(!c.training.density_control_enabled);
200
+ assert!(!c.training.prune_contained_spheres);
194
201
  assert_eq!(c.output_filename, "a.json");
195
202
  p.advanced = vec![("model.num_inside_samples".into(), Value::from("many"))];
196
203
  assert!(matches!(p.config("cpu", "", ""), Err(Error::Invalid(_))));
@@ -435,7 +435,8 @@ def train_morphit(
435
435
  print(f"Stopping at iteration {step.iteration}")
436
436
  removed = session.finalize()
437
437
  if removed:
438
- print(f"\n[final prune] removed {removed} sphere(s) whose centers escaped the mesh")
438
+ print(f"\n[final prune] removed {removed} sphere(s) whose centers escaped the mesh "
439
+ f"or that lay inside another sphere")
439
440
  model.num_spheres = session.num_spheres
440
441
  print("\n=== Training Complete ===")
441
442
  print(f"Density control operations: {len(tracker.metrics['density_control_events'])}")
File without changes
File without changes