Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
e0bf450
umat: compare the deck's constants by value, not just by count
petlenz Aug 16, 2026
915026c
Merge branch 'fix/umat-props-value-check' into feature/live-deck-cons…
petlenz Aug 16, 2026
e0f7822
materials: props_scalar -- deck constants read per call instead of ba…
petlenz Aug 16, 2026
751f6f5
umat: answer the PROPS-consistency check per slot, and catch an unbou…
petlenz Aug 17, 2026
4420ded
umat: split the PROPS-consistency message; name the constant that dis…
petlenz Aug 17, 2026
56b7c89
Merge branch 'feature/json-model' into feature/live-deck-constants
petlenz Aug 17, 2026
0d60bac
Merge branch 'fix/umat-props-value-check' into feature/live-deck-cons…
petlenz Aug 17, 2026
c26bbbe
umat: resolve a constants target through one function, for both bindi…
petlenz Aug 17, 2026
270c257
umat: shorten the comments
petlenz Aug 17, 2026
2b84ca2
Merge branch 'feature/json-model' into feature/live-deck-constants
petlenz Aug 17, 2026
9a93325
Merge branch 'fix/umat-props-value-check' into feature/live-deck-cons…
petlenz Aug 17, 2026
4355591
umat: shorten the comments
petlenz Aug 17, 2026
0653a71
Merge branch 'feature/json-model' into feature/live-deck-constants
petlenz Aug 17, 2026
e17e5f4
Merge branch 'feature/json-model' into feature/live-deck-constants
petlenz Aug 17, 2026
fa83292
umat: cover the registry's plane-stress bind
petlenz Aug 17, 2026
50d5e47
Merge branch 'feature/json-model' into feature/live-deck-constants
petlenz Aug 18, 2026
c41eddf
Merge branch 'feature/json-model' into feature/live-deck-constants
petlenz Aug 18, 2026
c010efc
materials: drop two comments from props_scalar
petlenz Aug 18, 2026
f67dc5c
Merge branch 'feature/json-model' into feature/live-deck-constants
petlenz Aug 18, 2026
be7e085
Merge branch 'feature/json-model' into feature/live-deck-constants
petlenz Aug 18, 2026
4867de6
Merge branch 'feature/json-model' into feature/live-deck-constants
petlenz Aug 18, 2026
76769d2
Merge branch 'feature/json-model' into feature/live-deck-constants
petlenz Aug 18, 2026
ef4ee2f
Merge branch 'feature/json-model' into feature/live-deck-constants
petlenz Aug 18, 2026
913f51f
Merge branch 'feature/json-model' into feature/live-deck-constants
petlenz Aug 19, 2026
cccfc46
Merge branch 'feature/json-model' into feature/live-deck-constants
petlenz Aug 22, 2026
9f058ad
Merge branch 'feature/json-model' into feature/live-deck-constants
petlenz Aug 22, 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
2 changes: 2 additions & 0 deletions include/numsim-materials/default_materials.h
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#include "numsim-materials/solvers/vector_newton.h"
#include "numsim-materials/materials/scalar_stepper.h"
#include "numsim-materials/materials/constant_scalar.h"
#include "numsim-materials/materials/props_scalar.h"
#include "numsim-materials/materials/isotropic_tangent.h"
#include "numsim-materials/materials/linear_elasticity.h"
#include "numsim-materials/materials/linear_stress.h"
Expand Down Expand Up @@ -73,6 +74,7 @@ void register_default_materials() {
factory.template register_type<scalar_stepper<Traits>>("scalar_stepper");
factory.template register_type<linear_elasticity<Traits>>("linear_elasticity");
factory.template register_type<constant_scalar<Traits>>("constant_scalar");
factory.template register_type<props_scalar<Traits>>("props_scalar");
factory.template register_type<isotropic_tangent<Traits>>("isotropic_tangent");
factory.template register_type<linear_stress<Traits>>("linear_stress");
factory.template register_type<autocatalytic_reaction<Traits>>("autocatalytic_reaction");
Expand Down
1 change: 1 addition & 0 deletions include/numsim-materials/io/json_parameter_converter.h
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ json_reader_registry<JsonType> make_default_json_registry() {
reg.template add<double>();
reg.template add<float>();
reg.template add<int>();
reg.template add<std::size_t>();
reg.template add<bool>();
reg.template add<std::string>();

Expand Down
76 changes: 76 additions & 0 deletions include/numsim-materials/materials/props_scalar.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#ifndef NUMSIM_MATERIALS_PROPS_SCALAR_H
#define NUMSIM_MATERIALS_PROPS_SCALAR_H

#include <cstddef>
#include <span>
#include <stdexcept>
#include <string>

#include "numsim-materials/core/material_base.h"

namespace numsim::materials {

/// A scalar taken from the host's material-constants array on every call.
///
/// constant_scalar bakes its number in at construction, which is right when the
/// constants are fixed for a material name (Abaqus PROPS). This one records only
/// WHICH slot it owns, for hosts whose constants vary — CalculiX interpolates
/// them by temperature.
///
/// Rebuilding the graph per call would also be correct, since nothing is
/// retained between calls, but it measures 22x an evaluation (6.7 us against
/// 302 ns) where reading a slot is free.
///
/// bind() DEREFERENCES and keeps no pointer: a host array may be a per-call
/// temporary, and a kept pointer would read a dead stack slot next call —
/// usually returning the right number, because the slot is commonly reused.
///
/// Plain property, no update callback, as constant_scalar: nothing reaches
/// statev_map, and the value is in place before ctx.update() so ordering
/// cannot matter.
///
/// Parameters:
/// "name": material name
/// "index": which host constant this publishes, 0-based
template <typename Traits>
class props_scalar final : public material_base<props_scalar<Traits>, Traits> {
public:
using base = material_base<props_scalar<Traits>, Traits>;
using value_type = typename base::value_type;
using input_parameter_controller = typename base::input_parameter_controller;

template <typename... Args>
explicit props_scalar(Args&&... args)
: base(std::forward<Args>(args)...),
m_value(base::template add_output<value_type>("value")),
m_index(base::template get_parameter<std::size_t>("index")) {
// Until the first bind(), rather than whatever the storage held.
m_value = value_type{};
}

static input_parameter_controller parameters() {
input_parameter_controller para{base::parameters()};
para.template insert<std::size_t>("index").template add<is_required>();
return para;
}

/// Copy this material's constant out of the host's array.
void bind(std::span<const value_type> props) {
if (m_index >= props.size())
throw std::out_of_range(
"props_scalar '" + base::name() + "': wants constant " +
std::to_string(m_index) + " but only " + std::to_string(props.size()) +
" were supplied");
m_value = props[m_index];
}

[[nodiscard]] std::size_t index() const noexcept { return m_index; }

private:
value_type& m_value;
const std::size_t m_index;
};

} // namespace numsim::materials

#endif // NUMSIM_MATERIALS_PROPS_SCALAR_H
53 changes: 40 additions & 13 deletions include/numsim-materials/umat/json_model.h
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@
///
/// Named "constants", not "props": a PROPERTY here is a graph node, and it is
/// what the deck calls them (*USER MATERIAL, CONSTANTS=).
///
/// An entry naming a props_scalar binds the SLOT instead: that material reads
/// the number from the host each call rather than having it baked in. Swap the
/// type and nothing else in the document changes.
namespace numsim::materials::umat {

/// Host-driven source materials. Kept out of register_default_materials() so
Expand All @@ -69,14 +73,31 @@ void ensure_materials_registered() {
});
}

/// The parameter a binding will actually write.
///
/// Its own function because it is the single place that has to stay in step
/// with the substitution loop below — validating one name and writing another
/// is how a target ends up half-checked.
inline std::string bound_parameter(const nlohmann::json& /*material*/,
const connection_source& binding) {
return binding.property;
/// What a binding writes into the document. One function for both the
/// validation and the substitution below — checking one parameter name and
/// writing another is how a target ends up half-verified.
struct constant_binding_target {
std::string parameter;
/// props_scalar is told its SLOT and reads the number itself each call;
/// everything else has the number written in now.
bool writes_slot{false};
};

inline constant_binding_target bound_parameter(
const nlohmann::json& material, const connection_source& binding) {
if (material.value("type", std::string{}) == "props_scalar") {
// The target names the property the constant arrives on — "value", as for
// constant_scalar — so swapping the type does not force "constants" to be
// rewritten. The parameter written ("index") is therefore not what the
// document says.
if (binding.property != "value")
throw fatal_error(
"json_model: a props_scalar target names the property it publishes, "
"which is \"value\" — got \"" + binding.property +
"\"; the slot comes from the entry's position in \"constants\"");
return {"index", true};
}
return {binding.property, false};
}

/// Reject a target naming a parameter the material does not declare.
Expand All @@ -102,7 +123,7 @@ void require_declared_parameter(const nlohmann::json& material,
// Declared AND numeric. Every material declares "name", and most declare
// *_source strings, so checking mere existence accepts targets that can only
// fail later — with a JSON type error rather than anything about decks.
const auto wanted = bound_parameter(material, binding);
const auto wanted = bound_parameter(material, binding).parameter;
const auto schema = factory.schema(type);
std::vector<std::string> numeric;
bool declared = false, wanted_is_numeric = false;
Expand Down Expand Up @@ -231,10 +252,16 @@ typename umat_registry<Traits>::builder make_json_builder(
// Into a copy, so the registered document stays a template.
nlohmann::json doc = parsed;
for (std::size_t i = 0; i < bindings.size(); ++i)
for (auto& material : doc["materials"])
if (material.contains("name") &&
material["name"].get<std::string>() == bindings[i].material)
material[bindings[i].property] = props[i];
for (auto& material : doc["materials"]) {
if (!material.contains("name") ||
material["name"].get<std::string>() != bindings[i].material)
continue;
// Resolved by the function that validated the target, so the two
// cannot drift.
const auto target = bound_parameter(material, bindings[i]);
material[target.parameter] =
target.writes_slot ? nlohmann::json(i) : nlohmann::json(props[i]);
}

for (const auto& material : doc["materials"])
create_from_json<Traits>(ctx, material);
Expand Down
57 changes: 57 additions & 0 deletions include/numsim-materials/umat/material_point_evaluator.h
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#ifndef NUMSIM_MATERIALS_UMAT_MATERIAL_POINT_EVALUATOR_H
#define NUMSIM_MATERIALS_UMAT_MATERIAL_POINT_EVALUATOR_H

#include <algorithm>
#include <cstddef>
#include <memory>
#include <optional>
Expand All @@ -11,6 +12,7 @@

#include <tmech/tmech.h>
#include "numsim-materials/core/material_context.h"
#include "numsim-materials/materials/props_scalar.h"
#include "numsim-materials/umat/errors.h"
#include "numsim-materials/umat/external_state_source.h"
#include "numsim-materials/umat/statev_map.h"
Expand Down Expand Up @@ -127,6 +129,16 @@ class material_point_evaluator {
}

m_statev = std::make_unique<statev_map<Traits>>(m_ctx, exclusions);

// Collected rather than configured: a hand-listed set is a second thing to
// keep in step with the graph, and a missed entry is a stale modulus.
for (auto* material : m_ctx.materials())
if (auto* reader = dynamic_cast<props_scalar<Traits>*>(material)) {
m_props_readers.push_back(reader);
m_props_needed = std::max(m_props_needed, reader->index() + 1);
}
m_live_slots.assign(m_props_needed, false);
for (const auto* reader : m_props_readers) m_live_slots[reader->index()] = true;
}

/// Doubles this material needs in STATEV — what *DEPVAR must be at least.
Expand Down Expand Up @@ -187,6 +199,16 @@ class material_point_evaluator {
value_type dtime, value_type* stress6,
value_type* tangent36,
std::span<const value_type> drot = {}) {
// Every reader publishes zero until bind_props() runs: moduli of zero, an
// all-zero DDSDDE, and a host that fails to converge with nothing naming
// the cause. Catches never-bound, not stale — the plane-stress solve
// re-runs this per iterate without re-binding, so the two are
// indistinguishable from here. The registry binds on every call.
if (!m_props_readers.empty() && !m_props_bound)
throw fatal_error(
"material_point_evaluator: this model reads its material constants "
"per call — call bind_props() before evaluating");

// STATEV is the only state store: reload it every call, so a repeated call
// on an unconverged iterate starts from t_n, not from the previous trial.
m_statev->unpack(statev);
Expand All @@ -207,6 +229,37 @@ class material_point_evaluator {
tangent_to_buffer<value_type>(*m_tangent, tangent36);
}

/// Copy the host's material constants into the graph, once per host call and
/// before evaluating. Separate from `call` because the plane-stress solve
/// runs the graph repeatedly for one call and the constants do not change
/// between iterates. A no-op without props_scalar materials.
void bind_props(std::span<const value_type> props) {
if (m_props_readers.empty()) return;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Finding 2 (medium): a forgotten bind_props is a singular tangent with no diagnostic.

The early return is right for baked models, but there is no state saying "this model has readers and none of them have been bound yet". A direct C++ caller that never calls bind_props gets moduli of zero:

(A) no bind_props    : C1111=0.000  sigma11=0  -> SINGULAR tangent, no diagnostic

The host then sees an all-zero DDSDDE and fails to converge, with nothing pointing at the cause. PropsScalar.IsZeroBeforeTheFirstBind currently documents this as intended, which I now think is the wrong call — zero is a plausible-looking number for a modulus, and a degenerate one.

The registry path always binds, so this is direct C++ use only. Still cheap to close:

bool m_props_bound{false};   // set in bind_props
// in evaluate()/evaluate_canonical():
if (!m_props_readers.empty() && !m_props_bound)
  throw fatal_error("material_point_evaluator: this model reads its "
                    "constants per call — bind_props() before evaluate()");

One branch on a path that already costs ~300 ns. Alternatively initialise to NaN so it propagates loudly instead of quietly zeroing the stiffness, but an explicit error names the mistake.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 751f6f5.

m_props_bound, checked in evaluate_canonical — the single choke point before ctx.update(), so it covers the plane-stress path too:

if (!m_props_readers.empty() && !m_props_bound)
  throw fatal_error("material_point_evaluator: this model reads its material "
                    "constants per call — call bind_props() before evaluating");

Deliberately catches never-bound, not stale. The plane-stress solve runs the graph repeatedly for one host call and must not re-bind per iterate, so "deliberately the same constants" and "forgot to re-bind" are indistinguishable from here. Under the registry that gap doesn't exist — it binds on every call. Said so in the comment rather than leaving it to be discovered.

I left IsZeroBeforeTheFirstBind in place: the initial value is still worth pinning down, it just can no longer reach a host now that the evaluator refuses.

EvaluatingBeforeBindingIsFatal also asserts that binding afterwards works, so the guard isn't a one-way latch. Verified it fails with the check disabled.

if (props.size() < m_props_needed)
throw fatal_error(
"material_point_evaluator: the model reads " +
std::to_string(m_props_needed) +
" host constants but this call supplied " +
std::to_string(props.size()) + " — check *USER MATERIAL, CONSTANTS=");
for (auto* reader : m_props_readers) reader->bind(props);
m_props_bound = true;
}

/// True when any constant is read per call rather than baked into the graph.
[[nodiscard]] bool has_live_props() const noexcept {
return !m_props_readers.empty();
}

/// True when host constant @p slot is read per call, so a change there is
/// intended rather than a contradiction.
///
/// Per slot, not per model: a document may mix the two binding times, and
/// answering for the whole model would wave the BAKED constants through as
/// well, leaving them silently at their first value.
[[nodiscard]] bool is_live_prop(std::size_t slot) const noexcept {
return slot < m_live_slots.size() && m_live_slots[slot];
}

/// Write the updated history back. No commit(): the host owns the timestep.
void store_statev(value_type* statev) const { m_statev->pack(statev); }

Expand Down Expand Up @@ -344,6 +397,10 @@ class material_point_evaluator {
const numsim_core::history_property<tensor2, property_traits>*
m_plastic_strain{nullptr};
std::unique_ptr<statev_map<Traits>> m_statev;
std::vector<props_scalar<Traits>*> m_props_readers;
std::vector<bool> m_live_slots;
std::size_t m_props_needed{0};
bool m_props_bound{false};
};

} // namespace numsim::materials::umat
Expand Down
10 changes: 10 additions & 0 deletions include/numsim-materials/umat/plane_stress_evaluator.h
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,16 @@ class plane_stress_evaluator {
/// Iterations the last evaluate() needed, for diagnostics.
[[nodiscard]] int last_iterations() const noexcept { return m_last_iters; }

/// Once for the whole out-of-plane solve: the constants do not depend on the
/// iterate.
void bind_props(std::span<const value_type> props) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Finding 3 (low): this forward is untested.

Deleting the body would not fail a single test — every props_scalar test uses the solid path. I did verify by hand that it works:

(B) plane stress     : C1111=118.2609  expected E/(1-nu^2)=118.2609

so this is a coverage gap rather than a defect. But it is the one place where binding interacts with an iterative evaluator, which makes it the most interesting path, not the least: if a future change moved the bind inside the out-of-plane loop, nothing here would notice.

Worth one test that drives plane stress with live constants and asserts the condensed E/(1-v^2).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 751f6f5.

PlaneStressUsesTheLiveConstants drives the plane-stress path with live constants and asserts the condensed modulus computed from the constants the host supplied:

const T E  = 9 * K * G / (3 * K + G);
const T nu = (3 * K - 2 * G) / (2 * (3 * K + G));
EXPECT_NEAR(ddsdde[0], E / (1 - nu * nu), 1e-9);
EXPECT_NEAR(stress[2], 0.0, 1e-10);

Derived rather than hardcoded, so it fails if the constants don't reach the material at all — which is what a deleted forward looks like. Verified: replacing the body with (void)props; fails it.

Also asserts sigma_33 is driven to zero, so the test covers the out-of-plane solve actually converging with host-supplied moduli, not just the tangent value.

m_inner->bind_props(props);
}

[[nodiscard]] bool has_live_props() const noexcept {
return m_inner->has_live_props();
}

void evaluate(const call& c) {
if (c.statev.size() < nstatv())
throw fatal_error(
Expand Down
52 changes: 35 additions & 17 deletions include/numsim-materials/umat/umat_interface.h
Original file line number Diff line number Diff line change
Expand Up @@ -179,10 +179,13 @@ class umat_registry {
void evaluate(std::string_view cmname, std::span<const double> props,
const call& c) {
auto& ts = thread_state_for(cmname, props);
if (c.ec == element_case::plane_stress)
if (c.ec == element_case::plane_stress) {
ts.ps->bind_props(props);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Untested, and it is the production plane-stress path.

Deleting this line passes 214/214. Last review I added PropsScalar.PlaneStressUsesTheLiveConstants, but that drives plane_stress_evaluator directly — so I covered the forward inside the evaluator and left the registry dispatch that calls it uncovered. The half I fixed was the half nothing was actually going to break.

The severity is limited by the guard added in the same round. With the line removed, a plane-stress call through umat_ gives:

FATAL: material_point_evaluator: this model reads its material constants
       per call — call bind_props() before evaluating
C1111=0.0000  fatals=1

So it aborts rather than running with moduli of zero. That is the never-bound check earning its keep on a path I had not considered when I added it — which is the argument for that kind of guard over a comment saying "remember to bind".

Still worth closing: one test driving plane stress (NDI=2, NSHR=1) with live constants through the real entry point. I have the probe already; it returns C1111=118.2609 against the expected E/(1-v^2)=118.2609.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed. PropsScalarJson.PlaneStressBindsThroughTheRegistry drives NDI=2, NSHR=1 through umat_ and asserts the condensed E/(1-v^2) derived from the constants the host supplied.

Verified load-bearing: with ts.ps->bind_props(props) removed the test aborts on the never-bound guard rather than passing. 216 tests.

ts.ps->evaluate(c);
else
} else {
ts.solid->bind_props(props);
ts.solid->evaluate(c);
}
}

/// Drop this thread's cached contexts. Only needed if models are
Expand All @@ -207,10 +210,9 @@ class umat_registry {
std::unique_ptr<context_type> ctx;
std::unique_ptr<evaluator_type> solid;
std::unique_ptr<ps_evaluator_type> ps;
/// How many constants the context was built from. The graph is built once
/// and reused, so a later call arriving with a different count would mean
/// the cached parameters no longer describe this material.
std::size_t nprops{0};
/// What the context was built from; the graph is reused, so different
/// constants on a later call would no longer describe this material.
std::vector<double> props;
};

static std::unordered_map<std::string, thread_state, transparent_string_hash,
Expand All @@ -229,17 +231,33 @@ class umat_registry {
const auto key = normalise_cmname(cmname.data(), cmname.size(), buf);
auto& cache = thread_cache();
if (auto it = cache.find(key); it != cache.end()) {
// PROPS cannot vary for a given material name — two *MATERIAL blocks must
// have distinct names — so a changed count means the deck contradicts the
// cached graph. Checking the size is one comparison; checking the values
// is not worth it per integration point.
if (it->second.nprops != props.size())
// PROPS cannot vary for one material name, so anything different
// contradicts the graph the constants were baked into. Values, not just
// the count: same length with different numbers is the case that reaches
// a material. Two faults, two messages — the check only ever explains.
if (it->second.props.size() != props.size())
throw fatal_error(
"numsim UMAT: material '" + std::string(key) +
"' was built from " + std::to_string(it->second.nprops) +
" constants but this call supplies " +
std::to_string(props.size()) +
" — PROPS must be constant for a given material name");
"numsim UMAT: material '" + std::string(key) + "' was built from " +
std::to_string(it->second.props.size()) +
" constants but this call supplies " + std::to_string(props.size()) +
" — NPROPS cannot vary for a given material name");

// Then per SLOT, skipping the ones the model reads live. Asking
// has_live_props() for the whole model instead would wave a mixed
// document's BAKED constants through as well: they would keep the first
// call's values while the live ones tracked the deck, silently, which is
// the defect this check exists to catch.
for (std::size_t i = 0; i < props.size(); ++i)
if (!it->second.solid->is_live_prop(i) &&
it->second.props[i] != props[i])
throw fatal_error(
"numsim UMAT: material '" + std::string(key) + "' constant " +
std::to_string(i + 1) + " was baked into the graph as " +
std::to_string(it->second.props[i]) + " but this call supplies " +
std::to_string(props[i]) +
" — PROPS must be constant for a given material name; use "
"distinct *MATERIAL names for distinct constants, or a "
"props_scalar for a constant that genuinely varies per call");
return it->second;
}

Expand Down Expand Up @@ -269,7 +287,7 @@ class umat_registry {
thread_state ts;
ts.ctx = std::make_unique<context_type>();
m.build(*ts.ctx, props);
ts.nprops = props.size();
ts.props.assign(props.begin(), props.end());
if (!ts.ctx->is_finalized())
throw fatal_error(
"the builder returned without calling finalize() on the context");
Expand Down
1 change: 1 addition & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ add_numsim_test(test_plane_stress_evaluator test_plane_stress_evaluator.cpp)
add_numsim_test(test_umat_interface test_umat_interface.cpp)
add_numsim_test(test_tangent_generator test_tangent_generator.cpp)
add_numsim_test(test_json_model test_json_model.cpp)
add_numsim_test(test_props_scalar test_props_scalar.cpp)
target_link_libraries(test_umat_interface PRIVATE Threads::Threads)

# Data dumper for plotting (not a test — standalone executable)
Expand Down
Loading
Loading