Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
286216b
Add ADR 0016 for axis-aligned box constraints
marpaia Aug 17, 2026
1229939
Add axis-aligned box constraints to CPU, Metal, and CUDA mechanics
marpaia Aug 17, 2026
0ca4737
Expose box constraints in Python API with checkpoint schema v8
marpaia Aug 17, 2026
e6fa52b
Build the Danino trap from box constraints instead of sphere rows
marpaia Aug 17, 2026
a3e103f
Add ADR 0017 for signal grid obstacles
marpaia Aug 17, 2026
48a6573
Add signal grid obstacle masks to CPU, Metal, and CUDA transport
marpaia Aug 17, 2026
92ad260
Expose signal grid obstacle masks in Python and checkpoint schema
marpaia Aug 17, 2026
7ed71be
Carry external constraints in scene format v2
marpaia Aug 17, 2026
01bf626
Render scene v2 device constraints in the viewer
marpaia Aug 17, 2026
d0403c0
Add ADR 0018 for axis-aligned cylinder constraints
marpaia Aug 17, 2026
cf542eb
Add axis-aligned cylinder constraints to CPU, Metal, and CUDA mechanics
marpaia Aug 17, 2026
e02eaa4
Expose cylinder constraints in Python, scene v2, and the viewer
marpaia Aug 17, 2026
7a2fe42
Add ADR 0019 for face-staggered signal velocity fields
marpaia Aug 17, 2026
5889f26
Add face-staggered velocity fields to CPU, Metal, and CUDA transport
marpaia Aug 17, 2026
0f16aef
Expose signal velocity fields in Python and checkpoint schema
marpaia Aug 17, 2026
b4d0b3e
Add microfluidics device authoring and a flow-fed trap example
marpaia Aug 17, 2026
3650e72
Type-narrow the microfluidics tests
marpaia Aug 17, 2026
2447233
Add cell removal with StepPlan washout support
marpaia Aug 17, 2026
9f7492c
Add advective flow drift on cells with controller wiring
marpaia Aug 17, 2026
b9179d0
Rebuild Danino and trap models on device flow with washout
marpaia Aug 17, 2026
a5004fb
Add Prindle biopixel trap device, tutorial model, and CAD masks
marpaia Aug 17, 2026
4b56c33
Permit module-internal protected use in microfluidics helpers
marpaia Aug 17, 2026
0941fe4
Extract the biopixel trap array from the mask DXF
marpaia Aug 17, 2026
e9eff2c
Model the biopixel trap under its millimeter-wide flow layer
marpaia Aug 17, 2026
eba48a5
Let the biopixel colony break planarity for trap overflow
marpaia Aug 17, 2026
cda00b3
Guard the biopixel lattice for tilted endpoint sampling
marpaia Aug 17, 2026
d8a1e2c
Read the biopixel trap footprint from the mask and restore its ceiling
marpaia Aug 17, 2026
e3662db
Let a running simulation swap its signal grid velocity field
marpaia Aug 18, 2026
75843a2
Solve steady Hele-Shaw-Brinkman device flow in the authoring layer
marpaia Aug 18, 2026
b958d33
Document numerical flow and Brinkman colony feedback in the tutorial
marpaia Aug 18, 2026
d2f877d
Clamp flow drift endpoint sampling to the signal lattice
marpaia Aug 19, 2026
87a9a29
Add gap-height and per-site base mobility to the flow solver
marpaia Aug 19, 2026
630b9ab
Drive device grids and trap models with the solved Brinkman flow
marpaia Aug 19, 2026
68fa478
Solve staggered MAC Stokes-Brinkman flow in the authoring layer
marpaia Aug 19, 2026
dd69a70
Benchmark both flow solvers against literature references
marpaia Aug 19, 2026
bb1bd82
Let divisions and plan removals share a controller step
marpaia Aug 19, 2026
029bb39
Add the pillar-channel tutorial for the flow solvers
marpaia Aug 19, 2026
e8c7753
Fix and cheapen signal grid sampling on device grids
marpaia Aug 19, 2026
1afaaab
Match ADR 0021 to the drift clamp and rotation limit
marpaia Aug 19, 2026
d6bf3af
Make device model chemistry reach the signal grid
marpaia Aug 19, 2026
30dc0c1
Sharpen the flow benchmarks and cover the solvers' gaps
marpaia Aug 19, 2026
8bbdde5
Correct the flow tutorials and record the solvers' limits
marpaia Aug 19, 2026
5efac3b
Share the flow solvers' internals and drop dead code
marpaia Aug 19, 2026
6955e95
Give every constraint kind one contact pass
marpaia Aug 19, 2026
65059b7
Tidy the dish example and the drift call site
marpaia Aug 19, 2026
d695811
Declare the CUDA coupled grid spec before its buffers reserve
marpaia Aug 19, 2026
592c303
Scatter into a cell's own stencil without reading the mask
marpaia Aug 19, 2026
1c068e1
Judge implicit convergence against the step, not the field
marpaia Aug 19, 2026
3d138da
Share the CUDA grid transport helpers between kernels
marpaia Aug 19, 2026
07161df
Share the Metal grid transport helpers between kernels
marpaia Aug 19, 2026
75bf7c6
Sample no flow where a stencil holds no fluid
marpaia Aug 19, 2026
530657a
Format the native sources with the repo's clang-format
marpaia Aug 19, 2026
c33031f
Add commit hooks for formatting, linting, and typing
marpaia Aug 19, 2026
15f2cb7
Make the Danino clock a clock
marpaia Aug 19, 2026
ac9ec36
Refresh the clock's AHL loss field on a cadence
marpaia Aug 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Checks that run on every commit. They cover the fast, purely local gates:
# formatting and linting for each language in the tree, plus the Python type
# check. Tests, native builds, and backend conformance are not here; they need
# a configured build and hardware, and run through CTest and the conformance
# scripts instead.
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: check-yaml
- id: check-toml
- id: check-merge-conflict
- id: check-added-large-files
# The device photomasks under docs/tutorials/devices are legitimately
# large and are tracked deliberately.
args: [--maxkb=1024]
exclude: ^docs/tutorials/devices/
- id: end-of-file-fixer
exclude: ^docs/tutorials/devices/
- id: trailing-whitespace
exclude: ^docs/tutorials/devices/

# Pinned to the version the project depends on, so a hook run and a local
# `ruff check` enforce the same rule set. Formatting is deliberately absent:
# the linter's rules, including import order, are what this tree follows.
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.3
hooks:
- id: ruff
args: [--fix]

- repo: https://github.com/pre-commit/mirrors-clang-format
rev: v19.1.7
hooks:
- id: clang-format
types_or: [c++, c, cuda, objective-c++, metal]
files: ^cpp/

- repo: local
hooks:
- id: pyright
name: pyright
entry: uv run pyright
language: system
pass_filenames: false
types: [python]
6 changes: 4 additions & 2 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,12 @@ if(CM_ENABLE_METAL)
add_custom_command(
OUTPUT "${CM_METAL_COUPLED_RATES_HEADER}"
COMMAND "${CMAKE_COMMAND}"
"-DINPUT=${CMAKE_CURRENT_SOURCE_DIR}/cpp/metal/kernels/coupled_rates.metal"
"-DINPUT=${CMAKE_CURRENT_SOURCE_DIR}/cpp/metal/kernels/grid_transport.metal;${CMAKE_CURRENT_SOURCE_DIR}/cpp/metal/kernels/coupled_rates.metal"
"-DOUTPUT=${CM_METAL_COUPLED_RATES_HEADER}"
"-DSYMBOL=coupled_rates_source"
-P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/EmbedMetalSource.cmake"
DEPENDS
cpp/metal/kernels/grid_transport.metal
cpp/metal/kernels/coupled_rates.metal
cmake/EmbedMetalSource.cmake
VERBATIM
Expand All @@ -107,11 +108,12 @@ if(CM_ENABLE_METAL)
add_custom_command(
OUTPUT "${CM_METAL_SIGNALS_HEADER}"
COMMAND "${CMAKE_COMMAND}"
"-DINPUT=${CMAKE_CURRENT_SOURCE_DIR}/cpp/metal/kernels/signals.metal"
"-DINPUT=${CMAKE_CURRENT_SOURCE_DIR}/cpp/metal/kernels/grid_transport.metal;${CMAKE_CURRENT_SOURCE_DIR}/cpp/metal/kernels/signals.metal"
"-DOUTPUT=${CM_METAL_SIGNALS_HEADER}"
"-DSYMBOL=signals_source"
-P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/EmbedMetalSource.cmake"
DEPENDS
cpp/metal/kernels/grid_transport.metal
cpp/metal/kernels/signals.metal
cmake/EmbedMetalSource.cmake
VERBATIM
Expand Down
12 changes: 10 additions & 2 deletions cmake/EmbedMetalSource.cmake
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
# INPUT names one Metal source, or several to concatenate in order. A Metal
# library is compiled from source at runtime with no include path, so a source
# that shares helpers with another receives them by concatenation here.
if(NOT DEFINED INPUT OR NOT DEFINED OUTPUT OR NOT DEFINED SYMBOL)
message(FATAL_ERROR "EmbedMetalSource.cmake requires INPUT, OUTPUT, and SYMBOL")
endif()
if(NOT SYMBOL MATCHES "^[A-Za-z_][A-Za-z0-9_]*$")
message(FATAL_ERROR "EmbedMetalSource.cmake received an invalid C++ symbol")
endif()

file(READ "${INPUT}" CM_METAL_SOURCE)
get_filename_component(CM_OUTPUT_DIRECTORY "${OUTPUT}" DIRECTORY)
file(MAKE_DIRECTORY "${CM_OUTPUT_DIRECTORY}")
file(WRITE "${OUTPUT}" "#pragma once\n\nnamespace cm::metal {\ninline constexpr char ${SYMBOL}[] = R\"CM_METAL(")
file(APPEND "${OUTPUT}" "${CM_METAL_SOURCE}")
foreach(CM_METAL_INPUT IN LISTS INPUT)
file(READ "${CM_METAL_INPUT}" CM_METAL_SOURCE)
if(CM_METAL_SOURCE MATCHES "CM_METAL\\(" OR CM_METAL_SOURCE MATCHES "\\)CM_METAL")
message(FATAL_ERROR "Metal source ${CM_METAL_INPUT} contains the embedding delimiter")
endif()
file(APPEND "${OUTPUT}" "${CM_METAL_SOURCE}")
endforeach()
file(APPEND "${OUTPUT}" [=[)CM_METAL";
} // namespace cm::metal
]=])
155 changes: 123 additions & 32 deletions cpp/core/constraints.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#include <cmath>
#include <limits>
#include <stdexcept>
#include <string>
#include <unordered_set>
#include <utility>

Expand Down Expand Up @@ -36,45 +37,82 @@ void validate_sphere(const SphereConstraint& sphere) {
}
validate_coefficient(sphere.coefficient);
switch (sphere.allowed_region) {
case SphereRegion::outside:
case SphereRegion::inside:
case ConstraintRegion::outside:
case ConstraintRegion::inside:
return;
}
throw std::invalid_argument("checkpoint sphere uses an unknown allowed region");
}

bool positive_finite_extents(const Vec3& half_extents) {
return std::isfinite(half_extents.x) && half_extents.x > 0.0F && std::isfinite(half_extents.y) &&
half_extents.y > 0.0F && std::isfinite(half_extents.z) && half_extents.z > 0.0F;
}

void validate_box(const BoxConstraint& box) {
if (box.id == invalid_constraint_id || !finite(box.center) ||
!positive_finite_extents(box.half_extents)) {
throw std::invalid_argument("checkpoint box contains invalid geometry");
}
validate_coefficient(box.coefficient);
switch (box.allowed_region) {
case ConstraintRegion::outside:
case ConstraintRegion::inside:
return;
}
throw std::invalid_argument("checkpoint box uses an unknown allowed region");
}

void validate_cylinder(const CylinderConstraint& cylinder) {
if (cylinder.id == invalid_constraint_id || !finite(cylinder.center) ||
!std::isfinite(cylinder.radius) || cylinder.radius <= 0.0F ||
!std::isfinite(cylinder.half_height) || cylinder.half_height <= 0.0F) {
throw std::invalid_argument("checkpoint cylinder contains invalid geometry");
}
validate_coefficient(cylinder.coefficient);
switch (cylinder.allowed_region) {
case ConstraintRegion::outside:
case ConstraintRegion::inside:
return;
}
throw std::invalid_argument("checkpoint cylinder uses an unknown allowed region");
}

void validate_constraint_state(ConstraintId next_id, std::span<const PlaneConstraint> planes,
std::span<const SphereConstraint> spheres) {
std::span<const SphereConstraint> spheres,
std::span<const BoxConstraint> boxes,
std::span<const CylinderConstraint> cylinders) {
if (next_id == invalid_constraint_id) {
throw std::invalid_argument("checkpoint next constraint identifier is invalid");
}
if (spheres.size() > std::numeric_limits<std::size_t>::max() - planes.size()) {
throw std::overflow_error("checkpoint constraint count overflow");
}
std::unordered_set<ConstraintId> ids;
ids.reserve(planes.size() + spheres.size());
ConstraintId previous_plane = invalid_constraint_id;
for (const auto& plane : planes) {
validate_plane(plane);
if (plane.id <= previous_plane || plane.id >= next_id) {
throw std::invalid_argument("checkpoint plane identifiers are not ordered and allocated");
}
if (!ids.insert(plane.id).second) {
throw std::invalid_argument("checkpoint contains a duplicate constraint identifier");
std::size_t total = planes.size();
for (const auto count : {spheres.size(), boxes.size(), cylinders.size()}) {
if (count > std::numeric_limits<std::size_t>::max() - total) {
throw std::overflow_error("checkpoint constraint count overflow");
}
previous_plane = plane.id;
total += count;
}
ConstraintId previous_sphere = invalid_constraint_id;
for (const auto& sphere : spheres) {
validate_sphere(sphere);
if (sphere.id <= previous_sphere || sphere.id >= next_id) {
throw std::invalid_argument("checkpoint sphere identifiers are not ordered and allocated");
}
if (!ids.insert(sphere.id).second) {
throw std::invalid_argument("checkpoint contains a duplicate constraint identifier");
std::unordered_set<ConstraintId> ids;
ids.reserve(total);
const auto check_ordered = [&ids, next_id](const auto& constraints, auto&& validate,
const char* kind) {
ConstraintId previous = invalid_constraint_id;
for (const auto& constraint : constraints) {
validate(constraint);
if (constraint.id <= previous || constraint.id >= next_id) {
throw std::invalid_argument(std::string("checkpoint ") + kind +
" identifiers are not ordered and allocated");
}
if (!ids.insert(constraint.id).second) {
throw std::invalid_argument("checkpoint contains a duplicate constraint identifier");
}
previous = constraint.id;
}
previous_sphere = sphere.id;
}
};
check_ordered(planes, validate_plane, "plane");
check_ordered(spheres, validate_sphere, "sphere");
check_ordered(boxes, validate_box, "box");
check_ordered(cylinders, validate_cylinder, "cylinder");
}

std::size_t checked_offset_count(std::size_t cell_count) {
Expand Down Expand Up @@ -105,11 +143,15 @@ void validate_contact(const ExternalContact& contact, std::size_t cell_count) {
} // namespace

void ConstraintSetCheckpoint::validate() const {
validate_constraint_state(next_id, planes, spheres);
validate_constraint_state(next_id, planes, spheres, boxes, cylinders);
}

ConstraintSet::ConstraintSet(const ConstraintSetCheckpoint& checkpoint)
: next_id_(checkpoint.next_id), planes_(checkpoint.planes), spheres_(checkpoint.spheres) {
: next_id_(checkpoint.next_id),
planes_(checkpoint.planes),
spheres_(checkpoint.spheres),
boxes_(checkpoint.boxes),
cylinders_(checkpoint.cylinders) {
checkpoint.validate();
}

Expand Down Expand Up @@ -155,25 +197,74 @@ ConstraintId ConstraintSet::add_sphere(const SphereConstraintInit& sphere) {
return id;
}

std::size_t ConstraintSet::size() const noexcept { return planes_.size() + spheres_.size(); }
ConstraintId ConstraintSet::add_box(const BoxConstraintInit& box) {
if (!finite(box.center) || !positive_finite_extents(box.half_extents)) {
throw std::invalid_argument("box geometry must be finite with positive half extents");
}
validate_coefficient(box.coefficient);
const auto id = allocate_id();
boxes_.push_back({
.id = id,
.center = box.center,
.half_extents = box.half_extents,
.coefficient = box.coefficient,
.allowed_region = box.allowed_region,
});
return id;
}

bool ConstraintSet::empty() const noexcept { return planes_.empty() && spheres_.empty(); }
ConstraintId ConstraintSet::add_cylinder(const CylinderConstraintInit& cylinder) {
if (!finite(cylinder.center) || !std::isfinite(cylinder.radius) || cylinder.radius <= 0.0F ||
!std::isfinite(cylinder.half_height) || cylinder.half_height <= 0.0F) {
throw std::invalid_argument(
"cylinder geometry must be finite with a positive radius and half height");
}
validate_coefficient(cylinder.coefficient);
const auto id = allocate_id();
cylinders_.push_back({
.id = id,
.center = cylinder.center,
.radius = cylinder.radius,
.half_height = cylinder.half_height,
.coefficient = cylinder.coefficient,
.allowed_region = cylinder.allowed_region,
});
return id;
}

std::size_t ConstraintSet::size() const noexcept {
return planes_.size() + spheres_.size() + boxes_.size() + cylinders_.size();
}

bool ConstraintSet::empty() const noexcept {
return planes_.empty() && spheres_.empty() && boxes_.empty() && cylinders_.empty();
}

std::span<const PlaneConstraint> ConstraintSet::planes() const& noexcept { return planes_; }

std::span<const SphereConstraint> ConstraintSet::spheres() const& noexcept { return spheres_; }

std::span<const BoxConstraint> ConstraintSet::boxes() const& noexcept { return boxes_; }

std::span<const CylinderConstraint> ConstraintSet::cylinders() const& noexcept {
return cylinders_;
}

ConstraintSetCheckpoint ConstraintSet::checkpoint() const {
ConstraintSetCheckpoint result{
.next_id = next_id_,
.planes = planes_,
.spheres = spheres_,
.boxes = boxes_,
.cylinders = cylinders_,
};
result.validate();
return result;
}

void ConstraintSet::validate() const { validate_constraint_state(next_id_, planes_, spheres_); }
void ConstraintSet::validate() const {
validate_constraint_state(next_id_, planes_, spheres_, boxes_, cylinders_);
}

void validate_constraint_contact_parameters(const ConstraintContactParameters& parameters) {
if (!std::isfinite(parameters.activation_margin) || parameters.activation_margin < 0.0F) {
Expand Down
22 changes: 11 additions & 11 deletions cpp/core/contact_graph.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@ void validate_contact_parameters(const ContactParameters& parameters) {
}
}

std::vector<ContactCandidate> find_cell_contact_candidates(
const WorldState& state, const ContactParameters& parameters) {
std::vector<ContactCandidate> find_cell_contact_candidates(const WorldState& state,
const ContactParameters& parameters) {
validate_contact_parameters(parameters);
const auto geometry = state.geometry_state();
std::vector<CapsuleBounds> bounds;
Expand Down Expand Up @@ -112,10 +112,10 @@ std::vector<ContactCandidate> find_cell_contact_candidates(
});
active.erase(expired.begin(), expired.end());
for (const auto* candidate : active) {
const auto overlaps_y = candidate->maximum_y >= current.minimum_y &&
current.maximum_y >= candidate->minimum_y;
const auto overlaps_z = candidate->maximum_z >= current.minimum_z &&
current.maximum_z >= candidate->minimum_z;
const auto overlaps_y =
candidate->maximum_y >= current.minimum_y && current.maximum_y >= candidate->minimum_y;
const auto overlaps_z =
candidate->maximum_z >= current.minimum_z && current.maximum_z >= candidate->minimum_z;
if (!overlaps_y || !overlaps_z) {
continue;
}
Expand All @@ -125,11 +125,11 @@ std::vector<ContactCandidate> find_cell_contact_candidates(
}
active.push_back(&current);
}
std::ranges::sort(candidates, [&geometry](const ContactCandidate& left,
const ContactCandidate& right) {
return std::tuple{geometry.ids[left.first_slot], geometry.ids[left.second_slot]} <
std::tuple{geometry.ids[right.first_slot], geometry.ids[right.second_slot]};
});
std::ranges::sort(
candidates, [&geometry](const ContactCandidate& left, const ContactCandidate& right) {
return std::tuple{geometry.ids[left.first_slot], geometry.ids[left.second_slot]} <
std::tuple{geometry.ids[right.first_slot], geometry.ids[right.second_slot]};
});
return candidates;
}

Expand Down
11 changes: 5 additions & 6 deletions cpp/core/mechanics_integration.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ bool finite(const Vec3& value) {
return std::isfinite(value.x) && std::isfinite(value.y) && std::isfinite(value.z);
}

} // namespace

Vec3 rotate_axis_angle(Vec3 direction, Vec3 rotation, float max_rotation) {
const auto magnitude = norm(rotation);
if (magnitude <= 1.0e-12F || max_rotation == 0.0F) {
Expand All @@ -33,8 +35,6 @@ Vec3 rotate_axis_angle(Vec3 direction, Vec3 rotation, float max_rotation) {
axis * (dot(axis, direction) * (1.0F - cosine)));
}

} // namespace

void validate_mechanics_integration_parameters(const MechanicsIntegrationParameters& parameters) {
if (!std::isfinite(parameters.max_rotation_radians) || parameters.max_rotation_radians < 0.0F) {
throw std::invalid_argument("mechanics rotation limit must be finite and non-negative");
Expand Down Expand Up @@ -74,10 +74,9 @@ void integrate_mechanics_result(WorldState& state, const MechanicsSolveResult& r
const auto applied_length_increment =
cell.fixed ? desired_increment : std::max(0.0F, desired_increment + correction.length);
const auto new_position = cell.fixed ? cell.position : cell.position + correction.translation;
const auto new_direction =
cell.fixed ? cell.direction
: rotate_axis_angle(cell.direction, correction.rotation,
parameters.max_rotation_radians);
const auto new_direction = cell.fixed ? cell.direction
: rotate_axis_angle(cell.direction, correction.rotation,
parameters.max_rotation_radians);
const auto new_length = cell.length + applied_length_increment;
if (!finite(new_position) || !finite(new_direction) || !std::isfinite(new_length)) {
throw std::overflow_error("mechanics integration produced non-finite geometry");
Expand Down
Loading